@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +57 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +72 -9
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +54 -40
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +82 -6
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -0
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -1,16 +1,98 @@
|
|
|
1
1
|
import type { BootstrapData } from './types'
|
|
2
|
+
import type { AppDiRegistrar } from '../di/container'
|
|
2
3
|
import { findAppRoot, type AppRoot } from './appResolver'
|
|
3
4
|
import { registerEntityIds } from '../encryption/entityIds'
|
|
5
|
+
import { createLogger } from '../logger'
|
|
4
6
|
import {
|
|
5
7
|
ensureMikroOrmV7GeneratedCacheCompatibility,
|
|
6
8
|
recoverMikroOrmV7GeneratedCacheFromImportError,
|
|
7
9
|
} from './generatedCacheRecovery'
|
|
10
|
+
import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './clientOnlyModules'
|
|
8
11
|
import path from 'node:path'
|
|
9
12
|
import fs from 'node:fs'
|
|
10
13
|
import crypto from 'node:crypto'
|
|
11
14
|
import { createRequire } from 'node:module'
|
|
12
15
|
import { pathToFileURL } from 'node:url'
|
|
13
16
|
|
|
17
|
+
const logger = createLogger('shared').child({ component: 'bootstrap' })
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when an expected generated source file is absent.
|
|
21
|
+
*
|
|
22
|
+
* Optional registries treat this as the supported compatibility case (an app
|
|
23
|
+
* that never generated the file), which is what makes it distinguishable from
|
|
24
|
+
* a file that exists but fails to compile or import.
|
|
25
|
+
*/
|
|
26
|
+
class GeneratedFileNotFoundError extends Error {
|
|
27
|
+
readonly filePath: string
|
|
28
|
+
|
|
29
|
+
constructor(filePath: string) {
|
|
30
|
+
super(`Generated file not found: ${filePath}`)
|
|
31
|
+
this.name = 'GeneratedFileNotFoundError'
|
|
32
|
+
this.filePath = filePath
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
|
|
38
|
+
* first so it wins over the alias and external plugins for `*.client` dynamic imports.
|
|
39
|
+
*
|
|
40
|
+
* Exported so the wiring itself is testable: a test that only exercises
|
|
41
|
+
* `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
|
|
42
|
+
* list, which would silently reintroduce #4623.
|
|
43
|
+
*/
|
|
44
|
+
export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
|
|
45
|
+
// Plugin to resolve the @/ alias the way the app tsconfig maps it:
|
|
46
|
+
// `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
|
|
47
|
+
const aliasPlugin: import('esbuild').Plugin = {
|
|
48
|
+
name: 'alias-resolver',
|
|
49
|
+
setup(build) {
|
|
50
|
+
build.onResolve({ filter: /^@\// }, (args) => {
|
|
51
|
+
const rest = args.path.slice('@/'.length)
|
|
52
|
+
const bases = rest.startsWith('.mercato/')
|
|
53
|
+
? [path.join(appRoot, rest)]
|
|
54
|
+
: [path.join(appRoot, 'src', rest), path.join(appRoot, rest)]
|
|
55
|
+
for (const base of bases) {
|
|
56
|
+
if (fs.existsSync(base) && fs.statSync(base).isFile()) {
|
|
57
|
+
return { path: base }
|
|
58
|
+
}
|
|
59
|
+
for (const suffix of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
|
|
60
|
+
if (fs.existsSync(base + suffix)) {
|
|
61
|
+
return { path: base + suffix }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Nothing matched — hand esbuild the literal mapping so it reports the
|
|
66
|
+
// missing file against the path the app author actually wrote.
|
|
67
|
+
return { path: path.join(appRoot, rest) }
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Plugin to mark non-JSON package imports as external
|
|
73
|
+
const externalNonJsonPlugin: import('esbuild').Plugin = {
|
|
74
|
+
name: 'external-non-json',
|
|
75
|
+
setup(build) {
|
|
76
|
+
// Mark all package imports as external EXCEPT JSON files
|
|
77
|
+
// Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
|
|
78
|
+
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
79
|
+
// Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
|
|
80
|
+
if (/^[a-zA-Z]:/.test(args.path)) {
|
|
81
|
+
return null // Let esbuild handle it
|
|
82
|
+
}
|
|
83
|
+
// If it's a JSON file, let esbuild bundle it
|
|
84
|
+
if (args.path.endsWith('.json')) {
|
|
85
|
+
return null // Let esbuild handle it
|
|
86
|
+
}
|
|
87
|
+
// Otherwise mark as external
|
|
88
|
+
return { path: args.path, external: true }
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
|
|
94
|
+
}
|
|
95
|
+
|
|
14
96
|
const DYNAMIC_LOADER_CACHE_VERSION = 4
|
|
15
97
|
|
|
16
98
|
type DynamicLoaderCacheMetadata = {
|
|
@@ -167,6 +249,7 @@ function collectDependencyHashes(
|
|
|
167
249
|
): Record<string, string> {
|
|
168
250
|
return Object.fromEntries(
|
|
169
251
|
Object.keys(inputs)
|
|
252
|
+
.filter((inputPath) => !inputPath.startsWith(`${CLIENT_ONLY_STUB_NAMESPACE}:`))
|
|
170
253
|
.map((inputPath) => {
|
|
171
254
|
const absolutePath = path.isAbsolute(inputPath)
|
|
172
255
|
? inputPath
|
|
@@ -221,14 +304,33 @@ function cacheIsValid(
|
|
|
221
304
|
&& dependenciesAreValid(appRoot, metadata.dependencies)
|
|
222
305
|
}
|
|
223
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Options for `compileAndImport`.
|
|
309
|
+
*
|
|
310
|
+
* Both paths default to the generated-registry layout (`<appRoot>/.mercato/generated/<file>.ts`
|
|
311
|
+
* compiled to a `.mjs` sibling). Sources that live elsewhere in the app — `src/di.ts` — MUST pass
|
|
312
|
+
* both explicitly: the default app root is derived by walking three directories up from the source,
|
|
313
|
+
* which only holds inside `.mercato/generated`.
|
|
314
|
+
*/
|
|
315
|
+
type CompileAndImportOptions = {
|
|
316
|
+
appRoot?: string
|
|
317
|
+
outFile?: string
|
|
318
|
+
allowRecovery?: boolean
|
|
319
|
+
}
|
|
320
|
+
|
|
224
321
|
/**
|
|
225
322
|
* Compile a TypeScript file to JavaScript using esbuild bundler.
|
|
226
323
|
* This bundles the file and all its dependencies, handling JSON imports properly.
|
|
227
|
-
* The compiled file is written next to the source file with a .mjs extension
|
|
324
|
+
* The compiled file is written next to the source file with a .mjs extension unless
|
|
325
|
+
* `outFile` says otherwise.
|
|
228
326
|
*/
|
|
229
|
-
async function compileAndImport(
|
|
230
|
-
|
|
231
|
-
|
|
327
|
+
async function compileAndImport(
|
|
328
|
+
tsPath: string,
|
|
329
|
+
options: CompileAndImportOptions = {},
|
|
330
|
+
): Promise<Record<string, unknown>> {
|
|
331
|
+
const allowRecovery = options.allowRecovery ?? true
|
|
332
|
+
const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
|
|
333
|
+
const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
|
|
232
334
|
const appTsconfig = path.join(appRoot, 'tsconfig.json')
|
|
233
335
|
const metadataPath = cacheMetadataPath(jsPath)
|
|
234
336
|
|
|
@@ -236,7 +338,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
236
338
|
const tsconfigExists = fs.existsSync(appTsconfig)
|
|
237
339
|
|
|
238
340
|
if (!tsExists) {
|
|
239
|
-
throw new
|
|
341
|
+
throw new GeneratedFileNotFoundError(tsPath)
|
|
240
342
|
}
|
|
241
343
|
if (!tsconfigExists) {
|
|
242
344
|
throw new Error(`App TypeScript config not found: ${appTsconfig}`)
|
|
@@ -247,50 +349,10 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
247
349
|
const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
|
|
248
350
|
|
|
249
351
|
if (needsCompile) {
|
|
352
|
+
fs.mkdirSync(path.dirname(jsPath), { recursive: true })
|
|
250
353
|
// Dynamically import esbuild only when needed
|
|
251
354
|
const esbuild = await import('esbuild')
|
|
252
355
|
|
|
253
|
-
// Plugin to resolve @/ alias to app root (works for @app modules)
|
|
254
|
-
const aliasPlugin: import('esbuild').Plugin = {
|
|
255
|
-
name: 'alias-resolver',
|
|
256
|
-
setup(build) {
|
|
257
|
-
// Resolve @/ alias to app root
|
|
258
|
-
build.onResolve({ filter: /^@\// }, (args) => {
|
|
259
|
-
const resolved = path.join(appRoot, args.path.slice(2))
|
|
260
|
-
// Try with .ts extension if base path doesn't exist
|
|
261
|
-
if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
|
|
262
|
-
return { path: resolved + '.ts' }
|
|
263
|
-
}
|
|
264
|
-
// Also check for /index.ts if it's a directory
|
|
265
|
-
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
|
|
266
|
-
return { path: path.join(resolved, 'index.ts') }
|
|
267
|
-
}
|
|
268
|
-
return { path: resolved }
|
|
269
|
-
})
|
|
270
|
-
},
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Plugin to mark non-JSON package imports as external
|
|
274
|
-
const externalNonJsonPlugin: import('esbuild').Plugin = {
|
|
275
|
-
name: 'external-non-json',
|
|
276
|
-
setup(build) {
|
|
277
|
-
// Mark all package imports as external EXCEPT JSON files
|
|
278
|
-
// Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
|
|
279
|
-
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
280
|
-
// Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
|
|
281
|
-
if (/^[a-zA-Z]:/.test(args.path)) {
|
|
282
|
-
return null // Let esbuild handle it
|
|
283
|
-
}
|
|
284
|
-
// If it's a JSON file, let esbuild bundle it
|
|
285
|
-
if (args.path.endsWith('.json')) {
|
|
286
|
-
return null // Let esbuild handle it
|
|
287
|
-
}
|
|
288
|
-
// Otherwise mark as external
|
|
289
|
-
return { path: args.path, external: true }
|
|
290
|
-
})
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
|
|
294
356
|
// Use esbuild.build with bundling to handle JSON imports
|
|
295
357
|
const result = await esbuild.build({
|
|
296
358
|
entryPoints: [tsPath],
|
|
@@ -302,7 +364,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
302
364
|
platform: 'node',
|
|
303
365
|
target: 'node18',
|
|
304
366
|
tsconfig: appTsconfig,
|
|
305
|
-
plugins:
|
|
367
|
+
plugins: createCliBundlePlugins(appRoot),
|
|
306
368
|
// Allow JSON imports
|
|
307
369
|
loader: { '.json': 'json' },
|
|
308
370
|
})
|
|
@@ -333,25 +395,45 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
333
395
|
throw error
|
|
334
396
|
}
|
|
335
397
|
|
|
336
|
-
return compileAndImport(tsPath, false)
|
|
398
|
+
return compileAndImport(tsPath, { ...options, allowRecovery: false })
|
|
337
399
|
}
|
|
338
400
|
}
|
|
339
401
|
|
|
340
402
|
|
|
341
403
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
* IMPORTANT: This only works in unbundled contexts (CLI, tsx).
|
|
345
|
-
* Do NOT use this in Next.js bundled code - use static imports instead.
|
|
404
|
+
* Load a generated registry that older apps may not have generated yet.
|
|
346
405
|
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
406
|
+
* An absent source file is the supported compatibility case and resolves to
|
|
407
|
+
* `fallback` quietly. Any other failure — a compile error, a broken import, a
|
|
408
|
+
* runtime throw at module scope — still resolves to `fallback` so bootstrap
|
|
409
|
+
* keeps working, but is reported at error level: a registry that silently
|
|
410
|
+
* degrades to nothing is exactly how command interceptors stopped applying in
|
|
411
|
+
* worker/CLI processes (#4327, #4491).
|
|
353
412
|
*/
|
|
354
|
-
|
|
413
|
+
async function loadOptionalGeneratedModule(
|
|
414
|
+
tsPath: string,
|
|
415
|
+
fallback: Record<string, unknown>,
|
|
416
|
+
): Promise<Record<string, unknown>> {
|
|
417
|
+
try {
|
|
418
|
+
return await compileAndImport(tsPath)
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error instanceof GeneratedFileNotFoundError) {
|
|
421
|
+
logger.debug('Optional generated registry not present, using empty fallback', {
|
|
422
|
+
file: path.basename(tsPath),
|
|
423
|
+
})
|
|
424
|
+
return fallback
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
logger.error('Failed to load generated registry, continuing without its entries', {
|
|
428
|
+
file: path.basename(tsPath),
|
|
429
|
+
filePath: tsPath,
|
|
430
|
+
err: error,
|
|
431
|
+
})
|
|
432
|
+
return fallback
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveAppRootOrThrow(appRoot?: string): AppRoot {
|
|
355
437
|
const resolved: AppRoot | null = appRoot
|
|
356
438
|
? {
|
|
357
439
|
generatedDir: path.join(appRoot, '.mercato', 'generated'),
|
|
@@ -368,6 +450,68 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
368
450
|
)
|
|
369
451
|
}
|
|
370
452
|
|
|
453
|
+
return resolved
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Load the app-level DI registrar (`src/di.ts`) for the dynamic bootstrap path.
|
|
458
|
+
*
|
|
459
|
+
* The Next.js runtime imports `@/di` statically from its own `src/bootstrap.ts` and hands the
|
|
460
|
+
* registrar to `createBootstrap`. Worker, scheduler and CLI processes bootstrap through
|
|
461
|
+
* `bootstrapFromAppRoot` instead, where the `@/` alias does not exist — so without this the app's
|
|
462
|
+
* DI registrations silently never ran there, and every request container paid a failed
|
|
463
|
+
* `import('@/di')` resolution (the compatibility fallback in `lib/di/container.ts`).
|
|
464
|
+
*
|
|
465
|
+
* An absent `src/di.ts` is the supported case and resolves to `null` quietly. A file that exists
|
|
466
|
+
* but cannot be compiled, imported, or does not export `register` is reported at error level and
|
|
467
|
+
* still resolves to `null`, so a broken app DI module degrades the same way a broken generated
|
|
468
|
+
* registry does (#4327, #4491) instead of taking the whole process down.
|
|
469
|
+
*/
|
|
470
|
+
async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null> {
|
|
471
|
+
const tsPath = path.join(appDir, 'src', 'di.ts')
|
|
472
|
+
if (!fs.existsSync(tsPath)) {
|
|
473
|
+
logger.debug('App-level DI module not present, skipping its registrations', { filePath: tsPath })
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const appDiModule = await compileAndImport(tsPath, {
|
|
479
|
+
appRoot: appDir,
|
|
480
|
+
outFile: path.join(appDir, '.mercato', 'generated', 'app-di.compiled.mjs'),
|
|
481
|
+
})
|
|
482
|
+
const register = appDiModule.register
|
|
483
|
+
if (typeof register !== 'function') {
|
|
484
|
+
logger.error('App-level DI module exports no register(); its registrations are skipped', {
|
|
485
|
+
filePath: tsPath,
|
|
486
|
+
})
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
return register as AppDiRegistrar
|
|
490
|
+
} catch (error) {
|
|
491
|
+
logger.error('Failed to load the app-level DI module; its registrations are skipped', {
|
|
492
|
+
filePath: tsPath,
|
|
493
|
+
err: error,
|
|
494
|
+
})
|
|
495
|
+
return null
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Dynamically load bootstrap data from a resolved app directory.
|
|
501
|
+
*
|
|
502
|
+
* IMPORTANT: This only works in unbundled contexts (CLI, tsx).
|
|
503
|
+
* Do NOT use this in Next.js bundled code - use static imports instead.
|
|
504
|
+
*
|
|
505
|
+
* For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
|
|
506
|
+
* CLI commands are discovered separately via the CLI module system.
|
|
507
|
+
*
|
|
508
|
+
* @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
|
|
509
|
+
* @returns The loaded bootstrap data
|
|
510
|
+
* @throws Error if app root cannot be found or generated files are missing
|
|
511
|
+
*/
|
|
512
|
+
export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
|
|
513
|
+
const resolved = resolveAppRootOrThrow(appRoot)
|
|
514
|
+
|
|
371
515
|
const { generatedDir } = resolved
|
|
372
516
|
|
|
373
517
|
ensureMikroOrmV7GeneratedCacheCompatibility(resolved.appDir)
|
|
@@ -392,10 +536,12 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
392
536
|
compileAndImport(path.join(generatedDir, 'modules.cli.generated.ts')),
|
|
393
537
|
compileAndImport(path.join(generatedDir, 'entities.generated.ts')),
|
|
394
538
|
compileAndImport(path.join(generatedDir, 'di.generated.ts')),
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
539
|
+
loadOptionalGeneratedModule(path.join(generatedDir, 'search.generated.ts'), { searchModuleConfigs: [] }),
|
|
540
|
+
loadOptionalGeneratedModule(path.join(generatedDir, 'command-loaders.generated.ts'), { commandLoaderEntries: [] }),
|
|
541
|
+
loadOptionalGeneratedModule(path.join(generatedDir, 'command-interceptors.generated.ts'), {
|
|
542
|
+
commandInterceptorEntries: [],
|
|
543
|
+
}),
|
|
544
|
+
loadOptionalGeneratedModule(path.join(generatedDir, 'workflows.generated.ts'), { allCodeWorkflows: [] }),
|
|
399
545
|
])
|
|
400
546
|
|
|
401
547
|
return {
|
|
@@ -437,8 +583,10 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
437
583
|
*/
|
|
438
584
|
export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
|
|
439
585
|
const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
|
|
440
|
-
const
|
|
441
|
-
const
|
|
586
|
+
const resolved = resolveAppRootOrThrow(appRoot)
|
|
587
|
+
const data = await loadBootstrapData(resolved.appDir)
|
|
588
|
+
const appDiRegistrar = await loadAppDiRegistrar(resolved.appDir)
|
|
589
|
+
const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
|
|
442
590
|
bootstrap()
|
|
443
591
|
// In CLI context, wait for async registrations (UI widgets, search configs, etc.)
|
|
444
592
|
await waitForAsyncRegistration()
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
} from '../command-interceptor-runner'
|
|
8
8
|
import type { CommandInterceptor, CommandInterceptorContext, CommandInterceptorUndoContext } from '../command-interceptor'
|
|
9
9
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
10
|
+
import {
|
|
11
|
+
applyAclFeatureOverrides,
|
|
12
|
+
resetModuleContractOverridesForTests,
|
|
13
|
+
} from '@open-mercato/shared/modules/overrides'
|
|
10
14
|
|
|
11
15
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
12
16
|
const mocked = {
|
|
@@ -59,6 +63,10 @@ describe('matchesCommandPattern', () => {
|
|
|
59
63
|
})
|
|
60
64
|
|
|
61
65
|
describe('runCommandInterceptorsBefore', () => {
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
resetModuleContractOverridesForTests()
|
|
68
|
+
})
|
|
69
|
+
|
|
62
70
|
it('returns ok when no interceptors match', async () => {
|
|
63
71
|
const interceptor = makeInterceptor({
|
|
64
72
|
id: 'i1',
|
|
@@ -158,6 +166,26 @@ describe('runCommandInterceptorsBefore', () => {
|
|
|
158
166
|
expect(withWildcard.ok).toBe(false)
|
|
159
167
|
expect(withWildcard.error?.message).toBe('Blocked by wildcard')
|
|
160
168
|
})
|
|
169
|
+
|
|
170
|
+
it('does not run an interceptor gated by a nulled ACL feature', async () => {
|
|
171
|
+
applyAclFeatureOverrides({ 'premium.audit': null })
|
|
172
|
+
const interceptor = makeInterceptor({
|
|
173
|
+
id: 'i1',
|
|
174
|
+
features: ['premium.audit'],
|
|
175
|
+
beforeExecute: jest.fn().mockResolvedValue({ ok: false }),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await runCommandInterceptorsBefore(
|
|
179
|
+
[interceptor],
|
|
180
|
+
'customers.create-person',
|
|
181
|
+
{},
|
|
182
|
+
baseContext,
|
|
183
|
+
['*', 'premium.audit'],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(result.ok).toBe(true)
|
|
187
|
+
expect(interceptor.beforeExecute).not.toHaveBeenCalled()
|
|
188
|
+
})
|
|
161
189
|
})
|
|
162
190
|
|
|
163
191
|
describe('runCommandInterceptorsAfter', () => {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './command-interceptor-runner'
|
|
32
32
|
import type { CommandInterceptorContext } from './command-interceptor'
|
|
33
33
|
import { CommandInterceptorError } from './errors'
|
|
34
|
+
import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
|
|
34
35
|
import { createLogger } from '../logger'
|
|
35
36
|
|
|
36
37
|
const logger = createLogger('shared').child({ component: 'commands' })
|
|
@@ -697,7 +698,10 @@ export class CommandBus {
|
|
|
697
698
|
try {
|
|
698
699
|
const dataEngine = (container.resolve('dataEngine') as DataEngine)
|
|
699
700
|
await dataEngine.flushOrmEntityChanges(suppress)
|
|
700
|
-
} catch {
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (isReadProjectionAlwaysConsistent()) {
|
|
703
|
+
throw error
|
|
704
|
+
}
|
|
701
705
|
// best-effort: failures should not block command execution
|
|
702
706
|
}
|
|
703
707
|
}
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
CommandInterceptorContext,
|
|
4
4
|
CommandInterceptorUndoContext,
|
|
5
5
|
} from './command-interceptor'
|
|
6
|
-
import {
|
|
6
|
+
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
7
7
|
import { createLogger } from '../logger'
|
|
8
8
|
|
|
9
9
|
const logger = createLogger('shared').child({ component: 'commands' })
|
|
@@ -33,7 +33,7 @@ function collectMatching(
|
|
|
33
33
|
): CommandInterceptor[] {
|
|
34
34
|
return interceptors
|
|
35
35
|
.filter((i) => matchesCommandPattern(i.targetCommand, commandId))
|
|
36
|
-
.filter((i) =>
|
|
36
|
+
.filter((i) => authorizeFeatures(i.features ?? [], { grantedFeatures: userFeatures }))
|
|
37
37
|
.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Regression coverage for the CRUD list cache key missing a caller-identity
|
|
2
|
+
// segment: two callers in the same tenant/org scope requesting the identical
|
|
3
|
+
// URL must NOT share cache entries, because list payloads can vary per caller —
|
|
4
|
+
// buildFilters may narrow by ctx.auth (e.g. ?mine=true on staff time-projects,
|
|
5
|
+
// the isSuperAdmin branch on scheduler jobs), before-interceptor query rewrites
|
|
6
|
+
// are feature-gated per user, and afterList/after-interceptor output is
|
|
7
|
+
// embedded in the stored payload.
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/cache', () => ({
|
|
10
|
+
runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
|
|
11
|
+
}), { virtual: true })
|
|
12
|
+
|
|
13
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
14
|
+
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
|
|
17
|
+
const defaultOrganizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
18
|
+
const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
|
|
19
|
+
|
|
20
|
+
let mockAuthSub = 'user-a'
|
|
21
|
+
let mockAuthKeyId: string | null = null
|
|
22
|
+
|
|
23
|
+
const em = {}
|
|
24
|
+
|
|
25
|
+
// Returns rows derived from the per-caller filter buildFilters produced, so a
|
|
26
|
+
// cross-user cache hit is observable as one user's rows in another's response.
|
|
27
|
+
const queryEngine = {
|
|
28
|
+
query: jest.fn(async (_entity: string, opts: Record<string, any>) => {
|
|
29
|
+
const owner = opts?.filters?.owner_user_id?.$eq ?? 'unfiltered'
|
|
30
|
+
return {
|
|
31
|
+
items: [{ id: `todo-of-${owner}`, title: `Owned by ${owner}`, organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
|
|
32
|
+
total: 1,
|
|
33
|
+
}
|
|
34
|
+
}),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const store = new Map<string, unknown>()
|
|
38
|
+
const cache = {
|
|
39
|
+
get: jest.fn(async (key: string) => (store.has(key) ? store.get(key) : null)),
|
|
40
|
+
set: jest.fn(async (key: string, value: unknown) => { store.set(key, value) }),
|
|
41
|
+
delete: jest.fn(async (key: string) => { store.delete(key) }),
|
|
42
|
+
deleteByTags: jest.fn(async () => 0),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const accessLogService = { log: jest.fn(async () => {}) }
|
|
46
|
+
|
|
47
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
48
|
+
createRequestContainer: async () => ({
|
|
49
|
+
resolve: (name: string) => ({
|
|
50
|
+
em,
|
|
51
|
+
queryEngine,
|
|
52
|
+
cache,
|
|
53
|
+
accessLogService,
|
|
54
|
+
} as any)[name],
|
|
55
|
+
}),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => {
|
|
59
|
+
const buildAuth = () => ({
|
|
60
|
+
sub: mockAuthSub,
|
|
61
|
+
keyId: mockAuthKeyId ?? undefined,
|
|
62
|
+
orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
63
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
64
|
+
roles: ['admin'],
|
|
65
|
+
})
|
|
66
|
+
return {
|
|
67
|
+
getAuthFromCookies: async () => buildAuth(),
|
|
68
|
+
getAuthFromRequest: async () => buildAuth(),
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
|
|
73
|
+
resolveOrganizationScopeForRequest: jest.fn(async () => ({
|
|
74
|
+
selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
75
|
+
filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
76
|
+
allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
77
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
78
|
+
})),
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
|
|
82
|
+
setRecordCustomFields: jest.fn(async () => {}),
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
class Todo {}
|
|
86
|
+
|
|
87
|
+
const querySchema = z.object({
|
|
88
|
+
page: z.coerce.number().default(1),
|
|
89
|
+
pageSize: z.coerce.number().default(50),
|
|
90
|
+
sortField: z.string().default('id'),
|
|
91
|
+
sortDir: z.enum(['asc', 'desc']).default('asc'),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const route = makeCrudRoute({
|
|
95
|
+
metadata: { GET: { requireAuth: true } },
|
|
96
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
97
|
+
indexer: { entityType: 'example.todo' },
|
|
98
|
+
list: {
|
|
99
|
+
schema: querySchema,
|
|
100
|
+
entityId: 'example.todo',
|
|
101
|
+
fields: ['id', 'title'],
|
|
102
|
+
sortFieldMap: { id: 'id', title: 'title' },
|
|
103
|
+
buildFilters: (_query: any, ctx: any) => ({ owner_user_id: { $eq: ctx.auth?.sub ?? null } } as any),
|
|
104
|
+
transformItem: (i: any) => ({ id: i.id, title: i.title }),
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const url = 'http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'
|
|
109
|
+
|
|
110
|
+
describe('CRUD Factory — list cache is partitioned per caller identity', () => {
|
|
111
|
+
const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
|
|
112
|
+
|
|
113
|
+
beforeAll(() => {
|
|
114
|
+
process.env.ENABLE_CRUD_API_CACHE = 'true'
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
|
|
119
|
+
else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
jest.clearAllMocks()
|
|
124
|
+
store.clear()
|
|
125
|
+
mockAuthSub = 'user-a'
|
|
126
|
+
mockAuthKeyId = null
|
|
127
|
+
registerApiInterceptors([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('caches per user and still serves same-user repeat requests from cache', async () => {
|
|
131
|
+
const first = await route.GET(new Request(url))
|
|
132
|
+
expect(first.status).toBe(200)
|
|
133
|
+
expect(first.headers.get('x-om-cache')).toBe('miss')
|
|
134
|
+
expect((await first.json()).items[0].id).toBe('todo-of-user-a')
|
|
135
|
+
|
|
136
|
+
const second = await route.GET(new Request(url))
|
|
137
|
+
expect(second.headers.get('x-om-cache')).toBe('hit')
|
|
138
|
+
expect((await second.json()).items[0].id).toBe('todo-of-user-a')
|
|
139
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not serve one user\'s cached rows to another user on the identical URL', async () => {
|
|
143
|
+
mockAuthSub = 'user-a'
|
|
144
|
+
const aRes = await route.GET(new Request(url))
|
|
145
|
+
expect((await aRes.json()).items[0].id).toBe('todo-of-user-a')
|
|
146
|
+
expect(store.size).toBe(1)
|
|
147
|
+
|
|
148
|
+
mockAuthSub = 'user-b'
|
|
149
|
+
const bRes = await route.GET(new Request(url))
|
|
150
|
+
expect(bRes.headers.get('x-om-cache')).toBe('miss')
|
|
151
|
+
expect((await bRes.json()).items[0].id).toBe('todo-of-user-b')
|
|
152
|
+
expect(store.size).toBe(2)
|
|
153
|
+
|
|
154
|
+
mockAuthSub = 'user-a'
|
|
155
|
+
const aAgain = await route.GET(new Request(url))
|
|
156
|
+
expect(aAgain.headers.get('x-om-cache')).toBe('hit')
|
|
157
|
+
expect((await aAgain.json()).items[0].id).toBe('todo-of-user-a')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('keys every entry with an identity segment, preferring the API key id over the user id', async () => {
|
|
161
|
+
await route.GET(new Request(url))
|
|
162
|
+
const userKey = Array.from(store.keys())[0] as string
|
|
163
|
+
expect(userKey).toContain('user:user-a')
|
|
164
|
+
|
|
165
|
+
store.clear()
|
|
166
|
+
mockAuthKeyId = 'api-key-9'
|
|
167
|
+
await route.GET(new Request(url))
|
|
168
|
+
const apiKeyKey = Array.from(store.keys())[0] as string
|
|
169
|
+
expect(apiKeyKey).toContain('user:api-key-9')
|
|
170
|
+
})
|
|
171
|
+
})
|