@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +35 -2
  3. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  4. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  5. package/dist/lib/bootstrap/dynamicLoader.js +35 -30
  6. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  7. package/dist/lib/encryption/tenantDataEncryptionService.js +15 -2
  8. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  9. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  10. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  11. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  12. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  13. package/dist/lib/query/engine.js +31 -0
  14. package/dist/lib/query/engine.js.map +2 -2
  15. package/dist/lib/search/auto-indexing.js +14 -0
  16. package/dist/lib/search/auto-indexing.js.map +7 -0
  17. package/dist/lib/search/config.js +38 -1
  18. package/dist/lib/search/config.js.map +2 -2
  19. package/dist/lib/search/tokenLookup.js +46 -0
  20. package/dist/lib/search/tokenLookup.js.map +7 -0
  21. package/dist/lib/version.js +1 -1
  22. package/dist/lib/version.js.map +1 -1
  23. package/dist/modules/overrides.js +50 -1
  24. package/dist/modules/overrides.js.map +2 -2
  25. package/package.json +6 -2
  26. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  27. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  28. package/src/lib/bootstrap/dynamicLoader.ts +55 -42
  29. package/src/lib/encryption/tenantDataEncryptionService.ts +16 -2
  30. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  31. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  32. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  33. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  34. package/src/lib/query/engine.ts +41 -0
  35. package/src/lib/search/__tests__/config.test.ts +118 -0
  36. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  37. package/src/lib/search/auto-indexing.ts +22 -0
  38. package/src/lib/search/config.ts +78 -8
  39. package/src/lib/search/tokenLookup.ts +133 -0
  40. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  41. package/src/modules/navigation/backendChrome.ts +14 -0
  42. 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: [aliasPlugin, externalNonJsonPlugin],
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 (!this.isEnabled()) return []
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
+ }