@open-mercato/shared 0.6.7-develop.6706.1.b3a4c759bb → 0.6.7-develop.6726.1.983ae8a07e
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 +35 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +35 -30
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +15 -2
- package/dist/lib/encryption/tenantDataEncryptionService.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 +31 -0
- package/dist/lib/query/engine.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/config.js +38 -1
- 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/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/package.json +6 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +55 -42
- package/src/lib/encryption/tenantDataEncryptionService.ts +16 -2
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -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/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +41 -0
- package/src/lib/search/__tests__/config.test.ts +118 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/config.ts +78 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/navigation/backendChrome.ts +14 -0
- package/src/modules/overrides.ts +103 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createClientOnlyStubPlugin,
|
|
7
|
+
encodeJsStringLiteral,
|
|
8
|
+
isClientOnlyModulePath,
|
|
9
|
+
renderClientOnlyModuleStub,
|
|
10
|
+
} from '../clientOnlyModules'
|
|
11
|
+
import { createCliBundlePlugins } from '../dynamicLoader'
|
|
12
|
+
|
|
13
|
+
const CHART_IMPORT = '@open-mercato/ui/backend/charts'
|
|
14
|
+
|
|
15
|
+
function createServerHelperFixture(tempDir: string): string {
|
|
16
|
+
const moduleDir = path.join(tempDir, 'src', 'modules', 'demo')
|
|
17
|
+
fs.mkdirSync(moduleDir, { recursive: true })
|
|
18
|
+
|
|
19
|
+
fs.writeFileSync(
|
|
20
|
+
path.join(moduleDir, 'http.client.ts'),
|
|
21
|
+
['export const httpClient = { fetchOrders: async () => [] }', ''].join('\n'),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const entry = path.join(tempDir, 'modules.cli.generated.ts')
|
|
25
|
+
fs.writeFileSync(
|
|
26
|
+
entry,
|
|
27
|
+
[
|
|
28
|
+
"import { httpClient } from './src/modules/demo/http.client'",
|
|
29
|
+
'export const modules = [{',
|
|
30
|
+
" id: 'demo',",
|
|
31
|
+
' workers: [{ handler: () => httpClient.fetchOrders() }],',
|
|
32
|
+
'}]',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n'),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return entry
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createAppModuleFixture(tempDir: string): string {
|
|
41
|
+
const widgetDir = path.join(tempDir, 'src', 'modules', 'demo', 'widgets', 'dashboard', 'sales')
|
|
42
|
+
fs.mkdirSync(widgetDir, { recursive: true })
|
|
43
|
+
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(widgetDir, 'widget.client.tsx'),
|
|
46
|
+
[
|
|
47
|
+
'"use client"',
|
|
48
|
+
`import { BarChart } from '${CHART_IMPORT}'`,
|
|
49
|
+
'export default function DemoWidget() { return BarChart }',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n'),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const entry = path.join(tempDir, 'modules.cli.generated.ts')
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
entry,
|
|
57
|
+
[
|
|
58
|
+
'export const modules = [{',
|
|
59
|
+
" id: 'demo',",
|
|
60
|
+
' dashboardWidgets: [{',
|
|
61
|
+
" moduleId: 'demo',",
|
|
62
|
+
" key: 'demo:sales:widget',",
|
|
63
|
+
" loader: () => import('./src/modules/demo/widgets/dashboard/sales/widget.client').then((mod) => mod.default ?? mod),",
|
|
64
|
+
' }],',
|
|
65
|
+
'}]',
|
|
66
|
+
'',
|
|
67
|
+
].join('\n'),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return entry
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function bundle(entry: string, outfile: string, withStubPlugin: boolean): Promise<string> {
|
|
74
|
+
const esbuild = await import('esbuild')
|
|
75
|
+
await esbuild.build({
|
|
76
|
+
entryPoints: [entry],
|
|
77
|
+
outfile,
|
|
78
|
+
bundle: true,
|
|
79
|
+
format: 'esm',
|
|
80
|
+
platform: 'node',
|
|
81
|
+
target: 'node18',
|
|
82
|
+
external: ['@open-mercato/*'],
|
|
83
|
+
plugins: withStubPlugin ? [createClientOnlyStubPlugin()] : [],
|
|
84
|
+
})
|
|
85
|
+
return fs.readFileSync(outfile, 'utf8')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('isClientOnlyModulePath', () => {
|
|
89
|
+
it('matches local client modules with and without an extension', () => {
|
|
90
|
+
expect(isClientOnlyModulePath('./widget.client')).toBe(true)
|
|
91
|
+
expect(isClientOnlyModulePath('./widget.client.tsx')).toBe(true)
|
|
92
|
+
expect(isClientOnlyModulePath('../dashboard/sales/widget.client.ts')).toBe(true)
|
|
93
|
+
expect(isClientOnlyModulePath('@/modules/demo/widgets/dashboard/sales/widget.client')).toBe(true)
|
|
94
|
+
expect(isClientOnlyModulePath('./notifications.client.ts')).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('leaves bare package specifiers to the external-import plugin', () => {
|
|
98
|
+
expect(isClientOnlyModulePath('@open-mercato/ui/backend/charts')).toBe(false)
|
|
99
|
+
expect(isClientOnlyModulePath('@open-mercato/core/modules/demo/widget.client')).toBe(false)
|
|
100
|
+
expect(isClientOnlyModulePath('react')).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does not match server modules that merely mention client', () => {
|
|
104
|
+
expect(isClientOnlyModulePath('./widget.ts')).toBe(false)
|
|
105
|
+
expect(isClientOnlyModulePath('./clientFactory.ts')).toBe(false)
|
|
106
|
+
expect(isClientOnlyModulePath('./client/index.ts')).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not match the generated notification-renderer registry', () => {
|
|
110
|
+
expect(isClientOnlyModulePath('@/.mercato/generated/notifications.client.generated')).toBe(false)
|
|
111
|
+
expect(isClientOnlyModulePath('@/.mercato/generated/notifications.client.generated.ts')).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('encodeJsStringLiteral', () => {
|
|
116
|
+
it('escapes quotes and backslashes so the literal cannot be terminated early', () => {
|
|
117
|
+
expect(encodeJsStringLiteral('say "hi"')).toBe('"say \\"hi\\""')
|
|
118
|
+
expect(encodeJsStringLiteral('a\\b')).toBe('"a\\\\b"')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('escapes every character outside printable ASCII, including the separators JSON leaves raw', () => {
|
|
122
|
+
expect(encodeJsStringLiteral('a\u2028b')).toBe('"a\\u2028b"')
|
|
123
|
+
expect(encodeJsStringLiteral('a\u2029b')).toBe('"a\\u2029b"')
|
|
124
|
+
expect(encodeJsStringLiteral('a\nb')).toBe('"a\\u000ab"')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('leaves no unescaped delimiter that could break out of the literal', () => {
|
|
128
|
+
const hostile = './widget.client"); globalThis.pwned = true; ("'
|
|
129
|
+
const encoded = encodeJsStringLiteral(hostile)
|
|
130
|
+
|
|
131
|
+
expect(encoded.startsWith('"')).toBe(true)
|
|
132
|
+
expect(encoded.endsWith('"')).toBe(true)
|
|
133
|
+
expect(encoded.slice(1, -1)).not.toMatch(/(^|[^\\])"/)
|
|
134
|
+
expect(JSON.parse(encoded)).toBe(hostile)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('renderClientOnlyModuleStub', () => {
|
|
139
|
+
it('exports a default that throws when the browser component is actually used', () => {
|
|
140
|
+
const stub = renderClientOnlyModuleStub('./widget.client')
|
|
141
|
+
expect(stub).toContain('export default clientOnlyModuleUnavailable')
|
|
142
|
+
expect(stub).toContain('./widget.client')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('createCliBundlePlugins', () => {
|
|
147
|
+
it('registers the client-only stub ahead of the alias and external plugins', () => {
|
|
148
|
+
expect(createCliBundlePlugins('/tmp/app-root').map((plugin) => plugin.name)).toEqual([
|
|
149
|
+
'client-only-stub',
|
|
150
|
+
'alias-resolver',
|
|
151
|
+
'external-non-json',
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('CLI bundle graph', () => {
|
|
157
|
+
let tempDir: string
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-mercato-client-only-'))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('keeps browser-only widget imports out of the CLI bundle', async () => {
|
|
168
|
+
const entry = createAppModuleFixture(tempDir)
|
|
169
|
+
const output = await bundle(entry, path.join(tempDir, 'stubbed.mjs'), true)
|
|
170
|
+
|
|
171
|
+
expect(output).not.toContain(CHART_IMPORT)
|
|
172
|
+
expect(output).toContain('clientOnlyModuleUnavailable')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('without the plugin the same fixture hoists the browser-only import (regression guard)', async () => {
|
|
176
|
+
const entry = createAppModuleFixture(tempDir)
|
|
177
|
+
const output = await bundle(entry, path.join(tempDir, 'plain.mjs'), false)
|
|
178
|
+
|
|
179
|
+
expect(output).toContain(CHART_IMPORT)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves statically imported server helpers alone even when they are named *.client', async () => {
|
|
183
|
+
const entry = createServerHelperFixture(tempDir)
|
|
184
|
+
const output = await bundle(entry, path.join(tempDir, 'server-helper.mjs'), true)
|
|
185
|
+
|
|
186
|
+
expect(output).toContain('fetchOrders')
|
|
187
|
+
expect(output).not.toContain('clientOnlyModuleUnavailable')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-only modules (`*.client.tsx`) hold browser components and must never enter the
|
|
3
|
+
* CLI bundle graph. App modules are bundled from source by `compileAndImport`, and esbuild
|
|
4
|
+
* inlines dynamic imports into the single output file, hoisting the client file's static
|
|
5
|
+
* imports to the top of the bundle. A wrapper such as `@open-mercato/ui/backend/charts`
|
|
6
|
+
* would then execute on every CLI start and fail on bare Next.js specifiers that Node's
|
|
7
|
+
* ESM resolver cannot resolve.
|
|
8
|
+
*
|
|
9
|
+
* Resolving these files to an inert stub keeps the owning `widget.ts` importable (the CLI
|
|
10
|
+
* reads its metadata when seeding dashboards) while cutting the browser-only subgraph.
|
|
11
|
+
*
|
|
12
|
+
* Only `import()` expressions are stubbed. That is the documented way a server-side
|
|
13
|
+
* `widget.ts` reaches its browser component (`lazyDashboardWidget(() => import('./widget.client'))`),
|
|
14
|
+
* and its result is consumed as a namespace object, so a default-only stub is sufficient.
|
|
15
|
+
* Static imports are left to the bundler: they may request named bindings the stub cannot
|
|
16
|
+
* provide, which esbuild rejects with `No matching export`, failing the whole CLI bundle —
|
|
17
|
+
* the exact class of breakage this module exists to prevent. Restricting the rewrite to
|
|
18
|
+
* dynamic imports also keeps server-side helpers that merely follow a `*.client.ts` naming
|
|
19
|
+
* convention working untouched.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const CLIENT_ONLY_STUB_NAMESPACE = 'om-client-only-stub'
|
|
23
|
+
|
|
24
|
+
const LOCAL_IMPORT_PATTERN = /^(\.{1,2}\/|@\/)/
|
|
25
|
+
const CLIENT_ONLY_SUFFIX_PATTERN = /(^|[\\/])[^\\/]+\.client(\.[cm]?[jt]sx?)?$/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Local (relative or `@/` aliased) imports of a `*.client` module. Bare package specifiers
|
|
29
|
+
* are left alone because the bundler already marks them external, so they never get inlined.
|
|
30
|
+
*/
|
|
31
|
+
export function isClientOnlyModulePath(importPath: string): boolean {
|
|
32
|
+
if (!LOCAL_IMPORT_PATTERN.test(importPath)) return false
|
|
33
|
+
return CLIENT_ONLY_SUFFIX_PATTERN.test(importPath)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render a value as a JavaScript string literal safe to embed in generated source.
|
|
38
|
+
* Everything outside printable ASCII is escaped to a `\uXXXX` sequence, so no character
|
|
39
|
+
* of the input can terminate the literal or be re-interpreted as code — `JSON.stringify`
|
|
40
|
+
* alone is a JSON encoder, not a JavaScript-source escaper.
|
|
41
|
+
*/
|
|
42
|
+
export function encodeJsStringLiteral(value: string): string {
|
|
43
|
+
let encoded = ''
|
|
44
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
45
|
+
const char = value[index]
|
|
46
|
+
if (char === '"' || char === '\\') {
|
|
47
|
+
encoded += `\\${char}`
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const code = value.charCodeAt(index)
|
|
51
|
+
if (code < 0x20 || code > 0x7e) {
|
|
52
|
+
encoded += `\\u${code.toString(16).padStart(4, '0')}`
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
encoded += char
|
|
56
|
+
}
|
|
57
|
+
return `"${encoded}"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderClientOnlyModuleStub(importPath: string): string {
|
|
61
|
+
const message =
|
|
62
|
+
`[internal] Client-only module ${importPath} is not available in the CLI runtime. ` +
|
|
63
|
+
'It is excluded from the CLI bundle because it renders browser components.'
|
|
64
|
+
return [
|
|
65
|
+
`function clientOnlyModuleUnavailable() { throw new Error(${encodeJsStringLiteral(message)}) }`,
|
|
66
|
+
'export default clientOnlyModuleUnavailable',
|
|
67
|
+
].join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createClientOnlyStubPlugin(): import('esbuild').Plugin {
|
|
71
|
+
return {
|
|
72
|
+
name: 'client-only-stub',
|
|
73
|
+
setup(build) {
|
|
74
|
+
build.onResolve({ filter: CLIENT_ONLY_SUFFIX_PATTERN }, (args) => {
|
|
75
|
+
if (args.kind !== 'dynamic-import') return null
|
|
76
|
+
if (!isClientOnlyModulePath(args.path)) return null
|
|
77
|
+
return { path: args.path, namespace: CLIENT_ONLY_STUB_NAMESPACE }
|
|
78
|
+
})
|
|
79
|
+
build.onLoad({ filter: /.*/, namespace: CLIENT_ONLY_STUB_NAMESPACE }, (args) => ({
|
|
80
|
+
contents: renderClientOnlyModuleStub(args.path),
|
|
81
|
+
loader: 'js',
|
|
82
|
+
}))
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ensureMikroOrmV7GeneratedCacheCompatibility,
|
|
7
7
|
recoverMikroOrmV7GeneratedCacheFromImportError,
|
|
8
8
|
} from './generatedCacheRecovery'
|
|
9
|
+
import { createClientOnlyStubPlugin } from './clientOnlyModules'
|
|
9
10
|
import path from 'node:path'
|
|
10
11
|
import fs from 'node:fs'
|
|
11
12
|
import { pathToFileURL } from 'node:url'
|
|
@@ -29,6 +30,59 @@ class GeneratedFileNotFoundError extends Error {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
|
|
35
|
+
* first so it wins over the alias and external plugins for `*.client` dynamic imports.
|
|
36
|
+
*
|
|
37
|
+
* Exported so the wiring itself is testable: a test that only exercises
|
|
38
|
+
* `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
|
|
39
|
+
* list, which would silently reintroduce #4623.
|
|
40
|
+
*/
|
|
41
|
+
export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
|
|
42
|
+
// Plugin to resolve @/ alias to app root (works for @app modules)
|
|
43
|
+
const aliasPlugin: import('esbuild').Plugin = {
|
|
44
|
+
name: 'alias-resolver',
|
|
45
|
+
setup(build) {
|
|
46
|
+
// Resolve @/ alias to app root
|
|
47
|
+
build.onResolve({ filter: /^@\// }, (args) => {
|
|
48
|
+
const resolved = path.join(appRoot, args.path.slice(2))
|
|
49
|
+
// Try with .ts extension if base path doesn't exist
|
|
50
|
+
if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
|
|
51
|
+
return { path: resolved + '.ts' }
|
|
52
|
+
}
|
|
53
|
+
// Also check for /index.ts if it's a directory
|
|
54
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
|
|
55
|
+
return { path: path.join(resolved, 'index.ts') }
|
|
56
|
+
}
|
|
57
|
+
return { path: resolved }
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Plugin to mark non-JSON package imports as external
|
|
63
|
+
const externalNonJsonPlugin: import('esbuild').Plugin = {
|
|
64
|
+
name: 'external-non-json',
|
|
65
|
+
setup(build) {
|
|
66
|
+
// Mark all package imports as external EXCEPT JSON files
|
|
67
|
+
// Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
|
|
68
|
+
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
69
|
+
// Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
|
|
70
|
+
if (/^[a-zA-Z]:/.test(args.path)) {
|
|
71
|
+
return null // Let esbuild handle it
|
|
72
|
+
}
|
|
73
|
+
// If it's a JSON file, let esbuild bundle it
|
|
74
|
+
if (args.path.endsWith('.json')) {
|
|
75
|
+
return null // Let esbuild handle it
|
|
76
|
+
}
|
|
77
|
+
// Otherwise mark as external
|
|
78
|
+
return { path: args.path, external: true }
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
|
|
84
|
+
}
|
|
85
|
+
|
|
32
86
|
/**
|
|
33
87
|
* Compile a TypeScript file to JavaScript using esbuild bundler.
|
|
34
88
|
* This bundles the file and all its dependencies, handling JSON imports properly.
|
|
@@ -53,47 +107,6 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
53
107
|
// Dynamically import esbuild only when needed
|
|
54
108
|
const esbuild = await import('esbuild')
|
|
55
109
|
|
|
56
|
-
// Plugin to resolve @/ alias to app root (works for @app modules)
|
|
57
|
-
const aliasPlugin: import('esbuild').Plugin = {
|
|
58
|
-
name: 'alias-resolver',
|
|
59
|
-
setup(build) {
|
|
60
|
-
// Resolve @/ alias to app root
|
|
61
|
-
build.onResolve({ filter: /^@\// }, (args) => {
|
|
62
|
-
const resolved = path.join(appRoot, args.path.slice(2))
|
|
63
|
-
// Try with .ts extension if base path doesn't exist
|
|
64
|
-
if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
|
|
65
|
-
return { path: resolved + '.ts' }
|
|
66
|
-
}
|
|
67
|
-
// Also check for /index.ts if it's a directory
|
|
68
|
-
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
|
|
69
|
-
return { path: path.join(resolved, 'index.ts') }
|
|
70
|
-
}
|
|
71
|
-
return { path: resolved }
|
|
72
|
-
})
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Plugin to mark non-JSON package imports as external
|
|
77
|
-
const externalNonJsonPlugin: import('esbuild').Plugin = {
|
|
78
|
-
name: 'external-non-json',
|
|
79
|
-
setup(build) {
|
|
80
|
-
// Mark all package imports as external EXCEPT JSON files
|
|
81
|
-
// Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
|
|
82
|
-
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
83
|
-
// Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
|
|
84
|
-
if (/^[a-zA-Z]:/.test(args.path)) {
|
|
85
|
-
return null // Let esbuild handle it
|
|
86
|
-
}
|
|
87
|
-
// If it's a JSON file, let esbuild bundle it
|
|
88
|
-
if (args.path.endsWith('.json')) {
|
|
89
|
-
return null // Let esbuild handle it
|
|
90
|
-
}
|
|
91
|
-
// Otherwise mark as external
|
|
92
|
-
return { path: args.path, external: true }
|
|
93
|
-
})
|
|
94
|
-
},
|
|
95
|
-
}
|
|
96
|
-
|
|
97
110
|
// Use esbuild.build with bundling to handle JSON imports
|
|
98
111
|
await esbuild.build({
|
|
99
112
|
entryPoints: [tsPath],
|
|
@@ -102,7 +115,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
102
115
|
format: 'esm',
|
|
103
116
|
platform: 'node',
|
|
104
117
|
target: 'node18',
|
|
105
|
-
plugins:
|
|
118
|
+
plugins: createCliBundlePlugins(appRoot),
|
|
106
119
|
// Allow JSON imports
|
|
107
120
|
loader: { '.json': 'json' },
|
|
108
121
|
})
|
|
@@ -336,12 +336,26 @@ export class TenantDataEncryptionService {
|
|
|
336
336
|
this.kms.invalidateDek?.(tenantId)
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Lists the fields an encryption map marks as encrypted at rest.
|
|
341
|
+
*
|
|
342
|
+
* `isEnabled()` folds the environment toggle together with KMS health, so by default an
|
|
343
|
+
* unhealthy KMS reports "nothing is encrypted" — which is safe for write paths but wrong for
|
|
344
|
+
* readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`
|
|
345
|
+
* answers the on-disk question instead: it consults the map even when the KMS cannot currently
|
|
346
|
+
* resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).
|
|
347
|
+
*/
|
|
339
348
|
async getEncryptedFieldNames(
|
|
340
349
|
entityId: string,
|
|
341
350
|
tenantId: string | null | undefined,
|
|
342
|
-
organizationId?: string | null
|
|
351
|
+
organizationId?: string | null,
|
|
352
|
+
options?: { ignoreRuntimeHealth?: boolean }
|
|
343
353
|
): Promise<string[]> {
|
|
344
|
-
if (
|
|
354
|
+
if (options?.ignoreRuntimeHealth) {
|
|
355
|
+
if (!isTenantDataEncryptionEnabled()) return []
|
|
356
|
+
} else if (!this.isEnabled()) {
|
|
357
|
+
return []
|
|
358
|
+
}
|
|
345
359
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
346
360
|
const fields = new Set(normalizeEncryptedFieldNames(map?.fields))
|
|
347
361
|
if (organizationId == null) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Module, BackendRouteManifestEntry } from '../../../modules/registry'
|
|
2
|
+
import { getBackendRouteManifests } from '../../../modules/registry'
|
|
3
|
+
import { getModules } from '../registry'
|
|
4
|
+
import { getModuleSurfaceFingerprint } from '../surfaceFingerprint'
|
|
5
|
+
|
|
6
|
+
jest.mock('../registry', () => ({
|
|
7
|
+
getModules: jest.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
jest.mock('../../../modules/registry', () => ({
|
|
11
|
+
getBackendRouteManifests: jest.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const mockGetModules = jest.mocked(getModules)
|
|
15
|
+
const mockGetBackendRouteManifests = jest.mocked(getBackendRouteManifests)
|
|
16
|
+
|
|
17
|
+
function route(pattern: string, overrides: Partial<BackendRouteManifestEntry> = {}): BackendRouteManifestEntry {
|
|
18
|
+
return {
|
|
19
|
+
moduleId: 'auth',
|
|
20
|
+
pattern,
|
|
21
|
+
title: 'Page',
|
|
22
|
+
load: async () => null,
|
|
23
|
+
...overrides,
|
|
24
|
+
} as BackendRouteManifestEntry
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ModuleSurface = string | { id: string; features: Array<{ id: string; module?: string }> }
|
|
28
|
+
|
|
29
|
+
function setSurface(modules: ModuleSurface[], routes: BackendRouteManifestEntry[]): void {
|
|
30
|
+
mockGetModules.mockReturnValue(modules.map((mod) => (typeof mod === 'string' ? { id: mod } : mod) as Module))
|
|
31
|
+
mockGetBackendRouteManifests.mockReturnValue(routes)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('getModuleSurfaceFingerprint', () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
jest.resetAllMocks()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('is stable for the same enabled modules and route manifest', () => {
|
|
40
|
+
setSurface(['auth', 'search'], [route('/backend/dashboard')])
|
|
41
|
+
const first = getModuleSurfaceFingerprint()
|
|
42
|
+
|
|
43
|
+
setSurface(['auth', 'search'], [route('/backend/dashboard')])
|
|
44
|
+
expect(getModuleSurfaceFingerprint()).toBe(first)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('changes when a module is enabled — the deploy that used to serve a stale nav', () => {
|
|
48
|
+
setSurface(['auth'], [route('/backend/dashboard')])
|
|
49
|
+
const before = getModuleSurfaceFingerprint()
|
|
50
|
+
|
|
51
|
+
setSurface(['auth', 'search'], [route('/backend/dashboard')])
|
|
52
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('changes when a backend route is added', () => {
|
|
56
|
+
setSurface(['auth'], [route('/backend/dashboard')])
|
|
57
|
+
const before = getModuleSurfaceFingerprint()
|
|
58
|
+
|
|
59
|
+
setSurface(['auth'], [route('/backend/dashboard'), route('/backend/search')])
|
|
60
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('changes when only a route metadata field changes', () => {
|
|
64
|
+
setSurface(['auth'], [route('/backend/dashboard', { title: 'Dashboard' })])
|
|
65
|
+
const before = getModuleSurfaceFingerprint()
|
|
66
|
+
|
|
67
|
+
setSurface(['auth'], [route('/backend/dashboard', { title: 'Overview' })])
|
|
68
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('changes when a module declares a new feature and no route moves', () => {
|
|
72
|
+
const routes = [route('/backend/dashboard')]
|
|
73
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.view' }] }], routes)
|
|
74
|
+
const before = getModuleSurfaceFingerprint()
|
|
75
|
+
|
|
76
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.view' }, { id: 'auth.manage' }] }], routes)
|
|
77
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('changes when a feature declares a different owning module', () => {
|
|
81
|
+
const routes = [route('/backend/dashboard')]
|
|
82
|
+
setSurface([{ id: 'auth', features: [{ id: 'analytics.view' }] }], routes)
|
|
83
|
+
const before = getModuleSurfaceFingerprint()
|
|
84
|
+
|
|
85
|
+
setSurface([{ id: 'auth', features: [{ id: 'analytics.view', module: 'reporting' }] }], routes)
|
|
86
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('ignores feature ordering within a module', () => {
|
|
90
|
+
const routes = [route('/backend/dashboard')]
|
|
91
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.view' }, { id: 'auth.manage' }] }], routes)
|
|
92
|
+
const forward = getModuleSurfaceFingerprint()
|
|
93
|
+
|
|
94
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.manage' }, { id: 'auth.view' }] }], routes)
|
|
95
|
+
expect(getModuleSurfaceFingerprint()).toBe(forward)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('ignores route manifest ordering', () => {
|
|
99
|
+
setSurface(['auth'], [route('/backend/a'), route('/backend/b')])
|
|
100
|
+
const forward = getModuleSurfaceFingerprint()
|
|
101
|
+
|
|
102
|
+
setSurface(['auth'], [route('/backend/b'), route('/backend/a')])
|
|
103
|
+
expect(getModuleSurfaceFingerprint()).toBe(forward)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('falls back to the route manifest alone when the module registry is not populated', () => {
|
|
107
|
+
mockGetModules.mockImplementation(() => {
|
|
108
|
+
throw new Error('[Bootstrap] Modules not registered.')
|
|
109
|
+
})
|
|
110
|
+
mockGetBackendRouteManifests.mockReturnValue([route('/backend/dashboard')])
|
|
111
|
+
|
|
112
|
+
expect(getModuleSurfaceFingerprint()).toMatch(/^[0-9a-f]{12}$/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('degrades to a constant rather than throwing when route metadata cannot be serialized', () => {
|
|
116
|
+
const circular = route('/backend/dashboard') as BackendRouteManifestEntry & { self?: unknown }
|
|
117
|
+
circular.self = circular
|
|
118
|
+
setSurface(['auth'], [circular])
|
|
119
|
+
|
|
120
|
+
expect(getModuleSurfaceFingerprint()).toBe('unknown')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-time fingerprint of the registered module surface.
|
|
3
|
+
*
|
|
4
|
+
* Some cached payloads are derived from state that only changes when the app
|
|
5
|
+
* is redeployed — the enabled module set and the backend route manifest. That
|
|
6
|
+
* state has no database write to hang a tag invalidation off, so a cache key
|
|
7
|
+
* that omits it keeps serving the pre-deploy payload to anyone with a warm
|
|
8
|
+
* entry (see `/api/auth/admin/nav`, whose payload embeds
|
|
9
|
+
* `filterGrantsByEnabledModules(...)` computed at write time).
|
|
10
|
+
*
|
|
11
|
+
* Mixing this fingerprint into such keys makes new processes write to new
|
|
12
|
+
* keys, so old and new pods coexist during a rolling deploy without a purge
|
|
13
|
+
* step and without any ordering constraint between purge and rollout.
|
|
14
|
+
*
|
|
15
|
+
* Covered inputs: the enabled module ids, each module's declared `features`
|
|
16
|
+
* (which drive `filterGrantsByEnabledModules`, including the `*` superadmin
|
|
17
|
+
* expansion and off-convention prefixes), and every JSON-serializable field
|
|
18
|
+
* of the backend route manifest.
|
|
19
|
+
*
|
|
20
|
+
* NOT covered: a route's `icon`. It is a React element whose `type` is a
|
|
21
|
+
* function, which `JSON.stringify` drops — two different icon components
|
|
22
|
+
* serialize identically. Swapping an icon heals on the caller's TTL, not on
|
|
23
|
+
* the fingerprint. Callers MUST therefore still pass a `ttl`.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import type { Module } from '../../modules/registry'
|
|
27
|
+
import { getBackendRouteManifests } from '../../modules/registry'
|
|
28
|
+
import { getModules } from './registry'
|
|
29
|
+
|
|
30
|
+
const UNKNOWN_FINGERPRINT = 'unknown'
|
|
31
|
+
|
|
32
|
+
let cachedFingerprint: string | null = null
|
|
33
|
+
let cachedManifestsRef: unknown = null
|
|
34
|
+
let cachedModulesRef: unknown = null
|
|
35
|
+
|
|
36
|
+
function readModulesRef(): readonly Module[] | null {
|
|
37
|
+
try {
|
|
38
|
+
return getModules()
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byCodeUnit(a: string, b: string): number {
|
|
45
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function describeModules(modules: readonly Module[] | null): string[] {
|
|
49
|
+
if (!modules) return []
|
|
50
|
+
return modules
|
|
51
|
+
.map((mod) => {
|
|
52
|
+
const features = (Array.isArray(mod.features) ? mod.features : [])
|
|
53
|
+
.filter((feature) => feature && typeof feature.id === 'string' && feature.id.length > 0)
|
|
54
|
+
.map((feature) => `${feature.id}:${feature.module || mod.id}`)
|
|
55
|
+
.sort(byCodeUnit)
|
|
56
|
+
return JSON.stringify({ id: mod.id, features })
|
|
57
|
+
})
|
|
58
|
+
.sort(byCodeUnit)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getModuleSurfaceFingerprint(): string {
|
|
62
|
+
const manifests = getBackendRouteManifests()
|
|
63
|
+
const modulesRef = readModulesRef()
|
|
64
|
+
if (cachedFingerprint !== null && cachedManifestsRef === manifests && cachedModulesRef === modulesRef) {
|
|
65
|
+
return cachedFingerprint
|
|
66
|
+
}
|
|
67
|
+
// The manifest carries third-party route metadata and module-scope React
|
|
68
|
+
// elements, so serialization is not fully under this repo's control. Every
|
|
69
|
+
// other fallible step in the nav handler degrades rather than throws, and a
|
|
70
|
+
// throw here would blank the entire admin chrome — fall back to a constant,
|
|
71
|
+
// which is exactly the pre-fingerprint behavior.
|
|
72
|
+
let fingerprint: string
|
|
73
|
+
try {
|
|
74
|
+
const modules = describeModules(modulesRef)
|
|
75
|
+
const routes = manifests.map((route) => JSON.stringify(route)).sort(byCodeUnit)
|
|
76
|
+
fingerprint = createHash('sha1')
|
|
77
|
+
.update(JSON.stringify({ modules, routes }))
|
|
78
|
+
.digest('hex')
|
|
79
|
+
.slice(0, 12)
|
|
80
|
+
} catch {
|
|
81
|
+
fingerprint = UNKNOWN_FINGERPRINT
|
|
82
|
+
}
|
|
83
|
+
cachedManifestsRef = manifests
|
|
84
|
+
cachedModulesRef = modulesRef
|
|
85
|
+
cachedFingerprint = fingerprint
|
|
86
|
+
return fingerprint
|
|
87
|
+
}
|