@open-mercato/shared 0.6.2-develop.3467.1.2a1818709d → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +24 -0
- package/dist/lib/bootstrap/factory.js +3 -1
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-interceptor-store.js +3 -1
- package/dist/lib/commands/command-interceptor-store.js.map +2 -2
- package/dist/lib/crud/enricher-registry.js +3 -1
- package/dist/lib/crud/enricher-registry.js.map +2 -2
- package/dist/lib/crud/factory.js.map +1 -1
- package/dist/lib/crud/interceptor-registry.js +3 -1
- package/dist/lib/crud/interceptor-registry.js.map +2 -2
- package/dist/lib/crud/sync-event-runner.js +2 -8
- package/dist/lib/crud/sync-event-runner.js.map +2 -2
- package/dist/lib/db/mikro.js +27 -3
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/di/container.js +16 -0
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/events/patterns.js +2 -10
- package/dist/lib/events/patterns.js.map +2 -2
- package/dist/lib/middleware/page-executor.js +2 -1
- package/dist/lib/middleware/page-executor.js.map +2 -2
- package/dist/lib/modules/registry.js +2 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/notifications/handler-registry.js +3 -1
- package/dist/lib/notifications/handler-registry.js.map +2 -2
- package/dist/lib/patterns/wildcard.js +42 -0
- package/dist/lib/patterns/wildcard.js.map +7 -0
- package/dist/lib/regex/linear.js +31 -0
- package/dist/lib/regex/linear.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/entities/validation.js +15 -7
- package/dist/modules/entities/validation.js.map +2 -2
- package/dist/modules/overrides.js +857 -0
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/registry.js +17 -4
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +12 -6
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/jest.config.cjs +3 -1
- package/package.json +8 -8
- package/src/lib/bootstrap/factory.ts +3 -1
- package/src/lib/commands/command-interceptor-store.ts +3 -1
- package/src/lib/crud/__tests__/sync-event-runner.test.ts +5 -0
- package/src/lib/crud/enricher-registry.ts +3 -1
- package/src/lib/crud/factory.ts +2 -2
- package/src/lib/crud/interceptor-registry.ts +3 -1
- package/src/lib/crud/sync-event-runner.ts +2 -11
- package/src/lib/db/mikro.ts +27 -2
- package/src/lib/di/container.ts +24 -4
- package/src/lib/events/__tests__/patterns.test.ts +5 -0
- package/src/lib/events/patterns.ts +3 -16
- package/src/lib/middleware/page-executor.ts +2 -1
- package/src/lib/modules/registry.ts +2 -1
- package/src/lib/notifications/handler-registry.ts +3 -1
- package/src/lib/patterns/__tests__/wildcard.test.ts +27 -0
- package/src/lib/patterns/wildcard.ts +59 -0
- package/src/lib/regex/linear.ts +48 -0
- package/src/modules/__tests__/contract-overrides.test.ts +332 -0
- package/src/modules/__tests__/overrides.test.ts +23 -22
- package/src/modules/__tests__/route-overrides.test.ts +441 -0
- package/src/modules/entities/__tests__/validation.test.ts +97 -2
- package/src/modules/entities/validation.ts +14 -9
- package/src/modules/overrides.ts +1297 -27
- package/src/modules/registry.ts +27 -4
- package/src/modules/widgets/__tests__/injection-loader.required-modules.test.ts +21 -0
- package/src/modules/widgets/injection-loader.ts +12 -6
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2/3 — unified `entry.overrides.routes.*` wiring.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - The dispatcher routes `overrides.routes.api` to the wired applier
|
|
6
|
+
* (no "Domain routes not yet wired" warning).
|
|
7
|
+
* - The dispatcher routes `overrides.routes.pages` to the page-route
|
|
8
|
+
* composer and the manifest registries apply it.
|
|
9
|
+
* - `applyApiOverridesToManifests` drops disabled methods, drops fully
|
|
10
|
+
* disabled entries, and wraps `load()` for replacement handlers.
|
|
11
|
+
* - Programmatic overrides supersede `modules.ts` overrides.
|
|
12
|
+
* - `registerApiRouteManifests` consults the override composer.
|
|
13
|
+
* - Stale override keys emit a warning so operators notice.
|
|
14
|
+
*
|
|
15
|
+
* Spec: `.ai/specs/2026-05-04-modules-ts-unified-overrides.md`.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
applyApiOverridesToManifests,
|
|
19
|
+
applyApiRouteOverrides,
|
|
20
|
+
applyModuleOverridesFromEnabledModules,
|
|
21
|
+
applyPageOverridesToManifests,
|
|
22
|
+
applyPageRouteOverrides,
|
|
23
|
+
composeApiRouteOverrides,
|
|
24
|
+
composePageRouteOverrides,
|
|
25
|
+
resetApiRouteOverridesForTests,
|
|
26
|
+
resetModuleContractOverridesForTests,
|
|
27
|
+
type ApiRouteOverridesMap,
|
|
28
|
+
} from '../overrides'
|
|
29
|
+
import {
|
|
30
|
+
getApiRouteManifests,
|
|
31
|
+
getBackendRouteManifests,
|
|
32
|
+
getFrontendRouteManifests,
|
|
33
|
+
registerApiRouteManifests,
|
|
34
|
+
registerBackendRouteManifests,
|
|
35
|
+
registerFrontendRouteManifests,
|
|
36
|
+
type ApiHandler,
|
|
37
|
+
type ApiRouteManifestEntry,
|
|
38
|
+
type BackendRouteManifestEntry,
|
|
39
|
+
type FrontendRouteManifestEntry,
|
|
40
|
+
type HttpMethod,
|
|
41
|
+
} from '../registry'
|
|
42
|
+
|
|
43
|
+
function makeEntry(
|
|
44
|
+
moduleId: string,
|
|
45
|
+
path: string,
|
|
46
|
+
methods: HttpMethod[],
|
|
47
|
+
loadResult: Record<string, unknown> = {},
|
|
48
|
+
): ApiRouteManifestEntry {
|
|
49
|
+
return {
|
|
50
|
+
moduleId,
|
|
51
|
+
kind: 'route-file',
|
|
52
|
+
path,
|
|
53
|
+
methods,
|
|
54
|
+
load: jest.fn(async () => ({ ...loadResult })),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeHandler(label: string): ApiHandler {
|
|
59
|
+
return () => new Response(label)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeBackendPage(path: string): BackendRouteManifestEntry {
|
|
63
|
+
return {
|
|
64
|
+
moduleId: 'example',
|
|
65
|
+
path,
|
|
66
|
+
pattern: path,
|
|
67
|
+
title: 'Original',
|
|
68
|
+
load: jest.fn(async () => function OriginalPage() { return null }),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeFrontendPage(path: string): FrontendRouteManifestEntry {
|
|
73
|
+
return {
|
|
74
|
+
moduleId: 'example',
|
|
75
|
+
path,
|
|
76
|
+
pattern: path,
|
|
77
|
+
title: 'Original',
|
|
78
|
+
load: jest.fn(async () => function OriginalPage() { return null }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
resetModuleContractOverridesForTests()
|
|
84
|
+
resetApiRouteOverridesForTests()
|
|
85
|
+
registerApiRouteManifests([])
|
|
86
|
+
registerBackendRouteManifests([])
|
|
87
|
+
registerFrontendRouteManifests([])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
registerApiRouteManifests([])
|
|
92
|
+
registerBackendRouteManifests([])
|
|
93
|
+
registerFrontendRouteManifests([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('applyApiOverridesToManifests', () => {
|
|
97
|
+
it('returns input unchanged when overrides map is empty', () => {
|
|
98
|
+
const entries = [makeEntry('a', '/api/foo', ['GET'])]
|
|
99
|
+
const result = applyApiOverridesToManifests(entries, {})
|
|
100
|
+
expect(result).toHaveLength(1)
|
|
101
|
+
expect(result[0]).toBe(entries[0])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('drops a single method when override is null', () => {
|
|
105
|
+
const entries = [makeEntry('a', '/api/foo', ['GET', 'POST'])]
|
|
106
|
+
const result = applyApiOverridesToManifests(entries, {
|
|
107
|
+
'GET /api/foo': null,
|
|
108
|
+
})
|
|
109
|
+
expect(result).toHaveLength(1)
|
|
110
|
+
expect(result[0].methods).toEqual(['POST'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('matches public /api override keys against generated manifest paths', () => {
|
|
114
|
+
const entries = [makeEntry('a', '/example/override-probe', ['GET', 'POST'])]
|
|
115
|
+
const result = applyApiOverridesToManifests(entries, {
|
|
116
|
+
'GET /api/example/override-probe': null,
|
|
117
|
+
})
|
|
118
|
+
expect(result).toHaveLength(1)
|
|
119
|
+
expect(result[0].methods).toEqual(['POST'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('drops the entry entirely when every method is disabled', () => {
|
|
123
|
+
const entries = [makeEntry('a', '/api/foo', ['GET', 'POST'])]
|
|
124
|
+
const result = applyApiOverridesToManifests(entries, {
|
|
125
|
+
'GET /api/foo': null,
|
|
126
|
+
'POST /api/foo': null,
|
|
127
|
+
})
|
|
128
|
+
expect(result).toHaveLength(0)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('wraps load() to swap the handler for replaced methods', async () => {
|
|
132
|
+
const originalHandler = makeHandler('original')
|
|
133
|
+
const overrideHandler = makeHandler('override')
|
|
134
|
+
const entries = [
|
|
135
|
+
makeEntry('a', '/api/foo', ['GET', 'POST'], {
|
|
136
|
+
GET: originalHandler,
|
|
137
|
+
POST: originalHandler,
|
|
138
|
+
metadata: { GET: { requireAuth: true } },
|
|
139
|
+
}),
|
|
140
|
+
]
|
|
141
|
+
const result = applyApiOverridesToManifests(entries, {
|
|
142
|
+
'GET /api/foo': { handler: overrideHandler, metadata: { requireAuth: false } },
|
|
143
|
+
})
|
|
144
|
+
expect(result).toHaveLength(1)
|
|
145
|
+
expect(result[0].methods).toEqual(['GET', 'POST'])
|
|
146
|
+
|
|
147
|
+
const loaded = (await result[0].load()) as Record<string, unknown>
|
|
148
|
+
expect(loaded.GET).toBe(overrideHandler)
|
|
149
|
+
expect(loaded.POST).toBe(originalHandler)
|
|
150
|
+
expect(loaded.metadata).toEqual({
|
|
151
|
+
GET: { requireAuth: false },
|
|
152
|
+
// POST untouched.
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not mutate the input array or entries', () => {
|
|
157
|
+
const entry = makeEntry('a', '/api/foo', ['GET'])
|
|
158
|
+
const original = entry.methods
|
|
159
|
+
const result = applyApiOverridesToManifests([entry], {
|
|
160
|
+
'GET /api/foo': null,
|
|
161
|
+
})
|
|
162
|
+
expect(entry.methods).toBe(original)
|
|
163
|
+
expect(entry.methods).toEqual(['GET'])
|
|
164
|
+
expect(result).toHaveLength(0)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('warns when an override key does not match any manifest entry', () => {
|
|
168
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
169
|
+
applyApiOverridesToManifests([makeEntry('a', '/api/foo', ['GET'])], {
|
|
170
|
+
'GET /api/missing': null,
|
|
171
|
+
})
|
|
172
|
+
const staleCalls = warnSpy.mock.calls.filter((args) =>
|
|
173
|
+
typeof args[0] === 'string' && args[0].includes('did not match any registered API route'),
|
|
174
|
+
)
|
|
175
|
+
expect(staleCalls).toHaveLength(1)
|
|
176
|
+
expect(staleCalls[0][0]).toContain('GET /api/missing')
|
|
177
|
+
warnSpy.mockRestore()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('skips malformed override values (not null and not a definition)', () => {
|
|
181
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
182
|
+
const entries = [makeEntry('a', '/api/foo', ['GET'])]
|
|
183
|
+
const result = applyApiOverridesToManifests(entries, {
|
|
184
|
+
// @ts-expect-error intentionally malformed
|
|
185
|
+
'GET /api/foo': { metadata: { requireAuth: false } },
|
|
186
|
+
})
|
|
187
|
+
expect(result).toHaveLength(1)
|
|
188
|
+
expect(result[0].methods).toEqual(['GET'])
|
|
189
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
190
|
+
warnSpy.mockRestore()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('applyApiRouteOverrides (programmatic)', () => {
|
|
195
|
+
it('normalizes the key format (case-insensitive method, leading-slash optional)', () => {
|
|
196
|
+
applyApiRouteOverrides({
|
|
197
|
+
'get api/foo': null,
|
|
198
|
+
'Post /api/bar/': null,
|
|
199
|
+
})
|
|
200
|
+
const composed = composeApiRouteOverrides()
|
|
201
|
+
expect(composed['GET /api/foo']).toBeNull()
|
|
202
|
+
expect(composed['POST /api/bar']).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('warns on malformed keys and skips them', () => {
|
|
206
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
207
|
+
applyApiRouteOverrides({
|
|
208
|
+
'NOT_A_METHOD /api/foo': null,
|
|
209
|
+
'GET': null,
|
|
210
|
+
'': null,
|
|
211
|
+
})
|
|
212
|
+
expect(Object.keys(composeApiRouteOverrides())).toHaveLength(0)
|
|
213
|
+
expect(warnSpy.mock.calls.filter((c) =>
|
|
214
|
+
typeof c[0] === 'string' && c[0].includes('malformed routes.api key'),
|
|
215
|
+
)).toHaveLength(3)
|
|
216
|
+
warnSpy.mockRestore()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('supersedes modules.ts inline overrides for the same key', () => {
|
|
220
|
+
applyModuleOverridesFromEnabledModules([
|
|
221
|
+
{
|
|
222
|
+
id: 'app',
|
|
223
|
+
overrides: { routes: { api: { 'GET /api/foo': null } } },
|
|
224
|
+
},
|
|
225
|
+
])
|
|
226
|
+
const override = { handler: makeHandler('replacement') }
|
|
227
|
+
applyApiRouteOverrides({ 'GET /api/foo': override })
|
|
228
|
+
expect(composeApiRouteOverrides()['GET /api/foo']).toBe(override)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('dispatcher → routes applier', () => {
|
|
233
|
+
it('routes `overrides.routes.api` to the wired applier without the "not yet wired" warning', () => {
|
|
234
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
235
|
+
applyModuleOverridesFromEnabledModules([
|
|
236
|
+
{
|
|
237
|
+
id: 'app',
|
|
238
|
+
overrides: { routes: { api: { 'GET /api/foo': null } } },
|
|
239
|
+
},
|
|
240
|
+
])
|
|
241
|
+
const unwiredCalls = warnSpy.mock.calls.filter((args) =>
|
|
242
|
+
typeof args[0] === 'string' && args[0].includes('Domain "routes" not yet wired'),
|
|
243
|
+
)
|
|
244
|
+
expect(unwiredCalls).toHaveLength(0)
|
|
245
|
+
expect(composeApiRouteOverrides()['GET /api/foo']).toBeNull()
|
|
246
|
+
warnSpy.mockRestore()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('routes `overrides.routes.pages` to the page override composer', () => {
|
|
250
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
251
|
+
applyModuleOverridesFromEnabledModules([
|
|
252
|
+
{
|
|
253
|
+
id: 'first',
|
|
254
|
+
overrides: {
|
|
255
|
+
routes: {
|
|
256
|
+
pages: {
|
|
257
|
+
'/backend/foo': null,
|
|
258
|
+
'/frontend/store': { metadata: { title: 'Store Override' } },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'second',
|
|
265
|
+
overrides: {
|
|
266
|
+
routes: {
|
|
267
|
+
pages: {
|
|
268
|
+
'/backend/bar/': null,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
])
|
|
274
|
+
const unwiredCalls = warnSpy.mock.calls.filter((args) =>
|
|
275
|
+
typeof args[0] === 'string' && args[0].includes('not yet wired'),
|
|
276
|
+
)
|
|
277
|
+
expect(unwiredCalls).toHaveLength(0)
|
|
278
|
+
expect(composePageRouteOverrides()).toMatchObject({
|
|
279
|
+
'backend:/backend/foo': null,
|
|
280
|
+
'backend:/backend/bar': null,
|
|
281
|
+
'frontend:/store': { metadata: { title: 'Store Override' } },
|
|
282
|
+
})
|
|
283
|
+
warnSpy.mockRestore()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('preserves module load order when multiple entries override the same key (last wins)', () => {
|
|
287
|
+
const firstHandler = makeHandler('first')
|
|
288
|
+
const secondHandler = makeHandler('second')
|
|
289
|
+
applyModuleOverridesFromEnabledModules([
|
|
290
|
+
{
|
|
291
|
+
id: 'first',
|
|
292
|
+
overrides: { routes: { api: { 'GET /api/foo': { handler: firstHandler } } } },
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'second',
|
|
296
|
+
overrides: { routes: { api: { 'GET /api/foo': { handler: secondHandler } } } },
|
|
297
|
+
},
|
|
298
|
+
])
|
|
299
|
+
const composed = composeApiRouteOverrides()
|
|
300
|
+
expect(composed['GET /api/foo']).toEqual({ handler: secondHandler })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('ignores malformed sub-keys but still processes well-formed ones', () => {
|
|
304
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
305
|
+
const handler = makeHandler('ok')
|
|
306
|
+
applyModuleOverridesFromEnabledModules([
|
|
307
|
+
{
|
|
308
|
+
id: 'app',
|
|
309
|
+
overrides: {
|
|
310
|
+
routes: {
|
|
311
|
+
api: {
|
|
312
|
+
'GET /api/ok': { handler },
|
|
313
|
+
'BADMETHOD /api/x': null,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
])
|
|
319
|
+
expect(composeApiRouteOverrides()['GET /api/ok']).toEqual({ handler })
|
|
320
|
+
expect(warnSpy.mock.calls.filter((c) =>
|
|
321
|
+
typeof c[0] === 'string' && c[0].includes('malformed routes.api key'),
|
|
322
|
+
)).toHaveLength(1)
|
|
323
|
+
warnSpy.mockRestore()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('applyPageOverridesToManifests', () => {
|
|
328
|
+
it('drops disabled backend pages and replaces metadata/loader for frontend pages', async () => {
|
|
329
|
+
const Replacement = function ReplacementPage() { return null }
|
|
330
|
+
const backendEntries = [
|
|
331
|
+
makeBackendPage('/backend/example'),
|
|
332
|
+
makeBackendPage('/backend/keep'),
|
|
333
|
+
]
|
|
334
|
+
const frontendEntries = [
|
|
335
|
+
makeFrontendPage('/store'),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
const backendResult = applyPageOverridesToManifests(backendEntries, {
|
|
339
|
+
'/backend/example': null,
|
|
340
|
+
}, 'backend')
|
|
341
|
+
const frontendResult = applyPageOverridesToManifests(frontendEntries, {
|
|
342
|
+
'/frontend/store': {
|
|
343
|
+
Component: Replacement,
|
|
344
|
+
metadata: { title: 'Store Override', navHidden: true },
|
|
345
|
+
},
|
|
346
|
+
}, 'frontend')
|
|
347
|
+
|
|
348
|
+
expect(backendResult.map((entry) => entry.path)).toEqual(['/backend/keep'])
|
|
349
|
+
expect(frontendResult).toHaveLength(1)
|
|
350
|
+
expect(frontendResult[0].title).toBe('Store Override')
|
|
351
|
+
expect(frontendResult[0].navHidden).toBe(true)
|
|
352
|
+
await expect(frontendResult[0].load()).resolves.toBe(Replacement)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('lets programmatic page overrides supersede modules.ts inline overrides', () => {
|
|
356
|
+
applyModuleOverridesFromEnabledModules([
|
|
357
|
+
{
|
|
358
|
+
id: 'app',
|
|
359
|
+
overrides: { routes: { pages: { '/backend/foo': null } } },
|
|
360
|
+
},
|
|
361
|
+
])
|
|
362
|
+
const loader = async () => function RestoredPage() { return null }
|
|
363
|
+
applyPageRouteOverrides({
|
|
364
|
+
'/backend/foo': { load: loader, metadata: { title: 'Restored' } },
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const result = applyPageOverridesToManifests([makeBackendPage('/backend/foo')], composePageRouteOverrides(), 'backend')
|
|
368
|
+
expect(result).toHaveLength(1)
|
|
369
|
+
expect(result[0].title).toBe('Restored')
|
|
370
|
+
expect(result[0].load).toBe(loader)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe('registerApiRouteManifests consults overrides', () => {
|
|
375
|
+
it('drops disabled methods at registration time', () => {
|
|
376
|
+
applyModuleOverridesFromEnabledModules([
|
|
377
|
+
{
|
|
378
|
+
id: 'app',
|
|
379
|
+
overrides: { routes: { api: { 'GET /api/foo': null } } },
|
|
380
|
+
},
|
|
381
|
+
])
|
|
382
|
+
registerApiRouteManifests([makeEntry('a', '/api/foo', ['GET', 'POST'])])
|
|
383
|
+
const registered = getApiRouteManifests()
|
|
384
|
+
expect(registered).toHaveLength(1)
|
|
385
|
+
expect(registered[0].methods).toEqual(['POST'])
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('returns an unmodified manifest when no overrides are registered', () => {
|
|
389
|
+
const entries = [makeEntry('a', '/api/foo', ['GET'])]
|
|
390
|
+
registerApiRouteManifests(entries)
|
|
391
|
+
const registered = getApiRouteManifests()
|
|
392
|
+
expect(registered).toHaveLength(1)
|
|
393
|
+
expect(registered[0]).toBe(entries[0])
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('replaces the handler via the wrapped load() when override is a definition', async () => {
|
|
397
|
+
const originalHandler = makeHandler('original')
|
|
398
|
+
const overrideHandler = makeHandler('override')
|
|
399
|
+
applyApiRouteOverrides({
|
|
400
|
+
'GET /api/foo': { handler: overrideHandler, metadata: { requireAuth: false } },
|
|
401
|
+
})
|
|
402
|
+
registerApiRouteManifests([
|
|
403
|
+
makeEntry('a', '/api/foo', ['GET'], { GET: originalHandler }),
|
|
404
|
+
])
|
|
405
|
+
const registered = getApiRouteManifests()
|
|
406
|
+
expect(registered).toHaveLength(1)
|
|
407
|
+
const loaded = (await registered[0].load()) as Record<string, unknown>
|
|
408
|
+
expect(loaded.GET).toBe(overrideHandler)
|
|
409
|
+
expect(loaded.metadata).toEqual({ GET: { requireAuth: false } })
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
describe('page manifest registries consult overrides', () => {
|
|
414
|
+
it('applies backend and frontend page overrides at registration time', () => {
|
|
415
|
+
applyModuleOverridesFromEnabledModules([
|
|
416
|
+
{
|
|
417
|
+
id: 'app',
|
|
418
|
+
overrides: {
|
|
419
|
+
routes: {
|
|
420
|
+
pages: {
|
|
421
|
+
'/backend/example': null,
|
|
422
|
+
'/frontend/store': { metadata: { title: 'Store Override' } },
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
])
|
|
428
|
+
|
|
429
|
+
registerBackendRouteManifests([
|
|
430
|
+
makeBackendPage('/backend/example'),
|
|
431
|
+
makeBackendPage('/backend/keep'),
|
|
432
|
+
])
|
|
433
|
+
registerFrontendRouteManifests([
|
|
434
|
+
makeFrontendPage('/store'),
|
|
435
|
+
])
|
|
436
|
+
|
|
437
|
+
expect(getBackendRouteManifests().map((entry) => entry.path)).toEqual(['/backend/keep'])
|
|
438
|
+
expect(getFrontendRouteManifests()).toHaveLength(1)
|
|
439
|
+
expect(getFrontendRouteManifests()[0].title).toBe('Store Override')
|
|
440
|
+
})
|
|
441
|
+
})
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** @jest-environment node */
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MAX_CUSTOM_FIELD_REGEX_PATTERN_LENGTH,
|
|
4
|
+
MAX_CUSTOM_FIELD_REGEX_INPUT_LENGTH,
|
|
5
|
+
validateValuesAgainstDefs,
|
|
6
|
+
} from '../validation'
|
|
3
7
|
|
|
4
8
|
describe('validateValuesAgainstDefs', () => {
|
|
5
9
|
it('validates required and integer/float comparisons', () => {
|
|
@@ -48,5 +52,96 @@ describe('validateValuesAgainstDefs', () => {
|
|
|
48
52
|
r = validateValuesAgainstDefs({ code: 'ok', status: 'open' }, defs as any)
|
|
49
53
|
expect(r.ok).toBe(true)
|
|
50
54
|
})
|
|
51
|
-
})
|
|
52
55
|
|
|
56
|
+
it('evaluates dangerous backtracking regex rules in bounded time', () => {
|
|
57
|
+
const defs = [
|
|
58
|
+
{
|
|
59
|
+
key: 'eu_vat_number',
|
|
60
|
+
kind: 'text',
|
|
61
|
+
configJson: {
|
|
62
|
+
validation: [
|
|
63
|
+
{
|
|
64
|
+
rule: 'regex',
|
|
65
|
+
param: '^(GB|FR|DE|IT|ES|PL|NL|BE|SE|AT|DK|FI|PT|IE|GR|CZ|RO|HU|SK|BG|HR|SI|LT|LV|EE|LU|MT|CY)?([0-9A-Za-z]+)*$',
|
|
66
|
+
message: 'bad vat',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const startedAt = Date.now()
|
|
74
|
+
const result = validateValuesAgainstDefs(
|
|
75
|
+
{ eu_vat_number: `${'A'.repeat(64)}!` },
|
|
76
|
+
defs as any,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
|
80
|
+
expect(result.ok).toBe(false)
|
|
81
|
+
expect(result.fieldErrors['cf_eu_vat_number']).toBe('bad vat')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('fails closed for unsupported regex syntax', () => {
|
|
85
|
+
const defs = [
|
|
86
|
+
{
|
|
87
|
+
key: 'code',
|
|
88
|
+
kind: 'text',
|
|
89
|
+
configJson: {
|
|
90
|
+
validation: [
|
|
91
|
+
{ rule: 'regex', param: '(?=safe)safe', message: 'unsupported regex' },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const result = validateValuesAgainstDefs({ code: 'safe' }, defs as any)
|
|
98
|
+
|
|
99
|
+
expect(result.ok).toBe(false)
|
|
100
|
+
expect(result.fieldErrors['cf_code']).toBe('unsupported regex')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('fails closed before testing oversized regex input values', () => {
|
|
104
|
+
const defs = [
|
|
105
|
+
{
|
|
106
|
+
key: 'body',
|
|
107
|
+
kind: 'multiline',
|
|
108
|
+
configJson: {
|
|
109
|
+
validation: [
|
|
110
|
+
{ rule: 'regex', param: '^a+$', message: 'body too large' },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
const result = validateValuesAgainstDefs(
|
|
117
|
+
{ body: 'a'.repeat(MAX_CUSTOM_FIELD_REGEX_INPUT_LENGTH + 1) },
|
|
118
|
+
defs as any,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
expect(result.ok).toBe(false)
|
|
122
|
+
expect(result.fieldErrors['cf_body']).toBe('body too large')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('fails closed before testing oversized regex patterns', () => {
|
|
126
|
+
const defs = [
|
|
127
|
+
{
|
|
128
|
+
key: 'code',
|
|
129
|
+
kind: 'text',
|
|
130
|
+
configJson: {
|
|
131
|
+
validation: [
|
|
132
|
+
{
|
|
133
|
+
rule: 'regex',
|
|
134
|
+
param: `^${'a'.repeat(MAX_CUSTOM_FIELD_REGEX_PATTERN_LENGTH)}$`,
|
|
135
|
+
message: 'pattern too large',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
const result = validateValuesAgainstDefs({ code: 'a' }, defs as any)
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(false)
|
|
145
|
+
expect(result.fieldErrors['cf_code']).toBe('pattern too large')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { testLinearRegex } from '../../lib/regex/linear'
|
|
3
|
+
|
|
4
|
+
export const MAX_CUSTOM_FIELD_REGEX_PATTERN_LENGTH = 500
|
|
5
|
+
export const MAX_CUSTOM_FIELD_REGEX_INPUT_LENGTH = 10_000
|
|
2
6
|
|
|
3
7
|
// Supported rule types for custom fields validation
|
|
4
8
|
export const VALIDATION_RULES = [
|
|
@@ -28,7 +32,11 @@ export const validationRuleSchema = z.discriminatedUnion('rule', [
|
|
|
28
32
|
z.object({ rule: z.literal('gte'), param: z.number(), message: z.string().min(1) }),
|
|
29
33
|
z.object({ rule: z.literal('eq'), param: z.any(), message: z.string().min(1) }),
|
|
30
34
|
z.object({ rule: z.literal('ne'), param: z.any(), message: z.string().min(1) }),
|
|
31
|
-
z.object({
|
|
35
|
+
z.object({
|
|
36
|
+
rule: z.literal('regex'),
|
|
37
|
+
param: z.string().min(1),
|
|
38
|
+
message: z.string().min(1),
|
|
39
|
+
}),
|
|
32
40
|
])
|
|
33
41
|
|
|
34
42
|
export type ValidationRule = z.infer<typeof validationRuleSchema>
|
|
@@ -83,13 +91,11 @@ function evalRule(rule: ValidationRule, value: any, kind: string): string | null
|
|
|
83
91
|
return value !== (rule as any).param ? null : rule.message
|
|
84
92
|
case 'regex':
|
|
85
93
|
if (isEmpty(value)) return null
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return rule.message
|
|
92
|
-
}
|
|
94
|
+
const regexResult = testLinearRegex(String((rule as any).param), String(value), {
|
|
95
|
+
maxPatternLength: MAX_CUSTOM_FIELD_REGEX_PATTERN_LENGTH,
|
|
96
|
+
maxInputLength: MAX_CUSTOM_FIELD_REGEX_INPUT_LENGTH,
|
|
97
|
+
})
|
|
98
|
+
return regexResult.ok && regexResult.matched ? null : rule.message
|
|
93
99
|
default:
|
|
94
100
|
return null
|
|
95
101
|
}
|
|
@@ -115,4 +121,3 @@ export function validateValuesAgainstDefs(
|
|
|
115
121
|
}
|
|
116
122
|
return { ok: Object.keys(errors).length === 0, fieldErrors: errors }
|
|
117
123
|
}
|
|
118
|
-
|