@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
package/src/lib/search/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
2
|
import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
|
|
3
|
+
import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
|
|
3
4
|
|
|
4
5
|
export type SearchConfig = {
|
|
5
6
|
enabled: boolean
|
|
@@ -8,12 +9,15 @@ export type SearchConfig = {
|
|
|
8
9
|
hashAlgorithm: 'sha256' | 'sha1' | 'md5'
|
|
9
10
|
storeRawTokens: boolean
|
|
10
11
|
blocklistedFields: string[]
|
|
12
|
+
entityBlocklistedFields?: Record<string, string[]>
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3
|
|
14
16
|
|
|
15
17
|
const DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']
|
|
16
18
|
|
|
19
|
+
const ENTITY_BLOCKLIST_SEPARATOR = '@'
|
|
20
|
+
|
|
17
21
|
function parseBoolean(raw: string | undefined, fallback: boolean): boolean {
|
|
18
22
|
return parseBooleanWithDefault(raw, fallback)
|
|
19
23
|
}
|
|
@@ -29,24 +33,90 @@ function parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5'
|
|
|
29
33
|
return 'sha256'
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.
|
|
38
|
+
*
|
|
39
|
+
* Why: a deployment often needs to keep one large free-text column out of the token
|
|
40
|
+
* index (e-mail bodies on `customers:customer_interaction`) while still indexing the
|
|
41
|
+
* same-named column elsewhere. A flat global list cannot express that.
|
|
42
|
+
*
|
|
43
|
+
* How to apply: entries are comma-separated; an entry may carry an optional
|
|
44
|
+
* `entityType@` prefix — `body` blocks the field everywhere, while
|
|
45
|
+
* `customers:customer_interaction@body` blocks it only for that entity type. Entries
|
|
46
|
+
* whose field part is empty are ignored so malformed env input cannot break indexing.
|
|
47
|
+
*/
|
|
48
|
+
function parseFieldBlocklist(raw: string | undefined): {
|
|
49
|
+
global: string[]
|
|
50
|
+
byEntity: Record<string, string[]>
|
|
51
|
+
} {
|
|
52
|
+
const global: string[] = []
|
|
53
|
+
const byEntity = new Map<string, string[]>()
|
|
54
|
+
|
|
55
|
+
for (const rawEntry of parseCommaSeparatedList(raw)) {
|
|
56
|
+
const entry = rawEntry.toLowerCase()
|
|
57
|
+
const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)
|
|
58
|
+
const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''
|
|
59
|
+
const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry
|
|
60
|
+
if (!field.length) continue
|
|
61
|
+
|
|
62
|
+
if (!entityType.length) {
|
|
63
|
+
if (!global.includes(field)) global.push(field)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const scoped = byEntity.get(entityType) ?? []
|
|
68
|
+
if (!scoped.includes(field)) scoped.push(field)
|
|
69
|
+
byEntity.set(entityType, scoped)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const fallback of DEFAULT_BLOCKLIST) {
|
|
73
|
+
if (!global.includes(fallback)) global.push(fallback)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const scopedBlocklist = Object.create(null) as Record<string, string[]>
|
|
77
|
+
for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields
|
|
78
|
+
|
|
79
|
+
return { global, byEntity: scopedBlocklist }
|
|
80
|
+
}
|
|
81
|
+
|
|
32
82
|
export function resolveSearchConfig(): SearchConfig {
|
|
83
|
+
const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)
|
|
33
84
|
return {
|
|
34
85
|
enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),
|
|
35
86
|
minTokenLength: resolveSearchMinTokenLength(),
|
|
36
87
|
enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),
|
|
37
88
|
hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),
|
|
38
89
|
storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
|
|
39
|
-
blocklistedFields:
|
|
40
|
-
|
|
41
|
-
.map((entry) => entry.trim())
|
|
42
|
-
.filter((entry) => entry.length > 0)
|
|
43
|
-
.filter((value, index, arr) => arr.indexOf(value) === index)
|
|
44
|
-
.map((entry) => entry.toLowerCase())
|
|
45
|
-
.concat(DEFAULT_BLOCKLIST)
|
|
46
|
-
.filter((value, index, arr) => arr.indexOf(value) === index),
|
|
90
|
+
blocklistedFields: blocklist.global,
|
|
91
|
+
entityBlocklistedFields: blocklist.byEntity,
|
|
47
92
|
}
|
|
48
93
|
}
|
|
49
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Single matcher for "should this field be kept out of the search index?".
|
|
97
|
+
*
|
|
98
|
+
* Why: the per-field token path and the `search_text` aggregate previously each
|
|
99
|
+
* decided this on their own, and the aggregate simply never consulted the config —
|
|
100
|
+
* so a blocklisted column's text came back into the index under the aggregate's
|
|
101
|
+
* field name (#4624). Both paths now share this function so they cannot drift.
|
|
102
|
+
*
|
|
103
|
+
* How to apply: pass the document's field name and the entity type being indexed;
|
|
104
|
+
* `entityType` may be omitted when unknown, in which case only global entries apply.
|
|
105
|
+
* Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).
|
|
106
|
+
*/
|
|
107
|
+
export function isSearchFieldBlocklisted(
|
|
108
|
+
field: string,
|
|
109
|
+
entityType: string | null | undefined,
|
|
110
|
+
config: SearchConfig,
|
|
111
|
+
): boolean {
|
|
112
|
+
const lower = field.toLowerCase()
|
|
113
|
+
if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true
|
|
114
|
+
if (!entityType) return false
|
|
115
|
+
const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]
|
|
116
|
+
if (!Array.isArray(scoped) || !scoped.length) return false
|
|
117
|
+
return scoped.some((blocked) => lower.includes(blocked))
|
|
118
|
+
}
|
|
119
|
+
|
|
50
120
|
/**
|
|
51
121
|
* Browser-safe accessor for the minimum search token length.
|
|
52
122
|
*
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type Kysely, sql } from 'kysely'
|
|
2
|
+
import { resolveSearchConfig, type SearchConfig } from './config'
|
|
3
|
+
import { tokenizeText } from './tokenize'
|
|
4
|
+
|
|
5
|
+
export type SearchTokenDatabase = {
|
|
6
|
+
search_tokens: {
|
|
7
|
+
entity_id: string
|
|
8
|
+
entity_type: string
|
|
9
|
+
field: string
|
|
10
|
+
token_hash: string
|
|
11
|
+
tenant_id: string | null
|
|
12
|
+
organization_id: string | null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tenant/organization scoping for a `search_tokens` lookup.
|
|
18
|
+
*
|
|
19
|
+
* `undefined` and `null` are NOT interchangeable:
|
|
20
|
+
* - `undefined` omits the predicate entirely (the caller owns visibility).
|
|
21
|
+
* - `null` emits a null-safe predicate that matches only globally scoped rows.
|
|
22
|
+
*/
|
|
23
|
+
export type SearchTokenScope = {
|
|
24
|
+
tenantId?: string | null
|
|
25
|
+
organizationId?: string | null
|
|
26
|
+
organizationIds?: readonly string[] | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why a lookup could not produce an id set. Callers MUST NOT read these as
|
|
31
|
+
* "nothing matched" — the token index was never consulted, so the caller's own
|
|
32
|
+
* predicate (usually an `ilike`) is still the authoritative one.
|
|
33
|
+
*/
|
|
34
|
+
export type SearchTokenLookupSkipReason = 'empty-query' | 'search-disabled' | 'no-tokens'
|
|
35
|
+
|
|
36
|
+
export type SearchTokenLookupResult =
|
|
37
|
+
| { matched: true; ids: string[] }
|
|
38
|
+
| { matched: false; reason: SearchTokenLookupSkipReason }
|
|
39
|
+
|
|
40
|
+
export type FindEntityIdsBySearchTokensInput = {
|
|
41
|
+
db: Kysely<SearchTokenDatabase>
|
|
42
|
+
entityType: string
|
|
43
|
+
query: string
|
|
44
|
+
fields?: readonly string[] | null
|
|
45
|
+
scope?: SearchTokenScope
|
|
46
|
+
config?: SearchConfig
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the record ids whose indexed `search_tokens` cover every token in
|
|
51
|
+
* `query`.
|
|
52
|
+
*
|
|
53
|
+
* This is the encryption-safe replacement for `ilike` filtering on columns an
|
|
54
|
+
* encryption map covers: the stored column holds ciphertext, so
|
|
55
|
+
* `ilike '%term%'` silently matches nothing, while the token index stores
|
|
56
|
+
* hashes of the plaintext and keeps matching. See issue #2990.
|
|
57
|
+
*
|
|
58
|
+
* Matching requires ALL query tokens to be present on the record. The token
|
|
59
|
+
* search strategy in `@open-mercato/search` uses a looser match ratio; list
|
|
60
|
+
* endpoints want the stricter behavior so a two-word query narrows rather than
|
|
61
|
+
* widens the result set.
|
|
62
|
+
*/
|
|
63
|
+
export async function findEntityIdsBySearchTokens({
|
|
64
|
+
db,
|
|
65
|
+
entityType,
|
|
66
|
+
query,
|
|
67
|
+
fields,
|
|
68
|
+
scope,
|
|
69
|
+
config,
|
|
70
|
+
}: FindEntityIdsBySearchTokensInput): Promise<SearchTokenLookupResult> {
|
|
71
|
+
const trimmed = query.trim()
|
|
72
|
+
if (!trimmed) return { matched: false, reason: 'empty-query' }
|
|
73
|
+
|
|
74
|
+
const searchConfig = config ?? resolveSearchConfig()
|
|
75
|
+
if (!searchConfig.enabled) return { matched: false, reason: 'search-disabled' }
|
|
76
|
+
|
|
77
|
+
const { hashes } = tokenizeText(trimmed, searchConfig)
|
|
78
|
+
if (!hashes.length) return { matched: false, reason: 'no-tokens' }
|
|
79
|
+
|
|
80
|
+
let builder = db
|
|
81
|
+
.selectFrom('search_tokens')
|
|
82
|
+
.select('entity_id')
|
|
83
|
+
.where('entity_type', '=', entityType)
|
|
84
|
+
.where('token_hash', 'in', hashes)
|
|
85
|
+
|
|
86
|
+
const scopedFields = (fields ?? []).filter((field) => typeof field === 'string' && field.length > 0)
|
|
87
|
+
if (scopedFields.length === 1) {
|
|
88
|
+
builder = builder.where('field', '=', scopedFields[0])
|
|
89
|
+
} else if (scopedFields.length > 1) {
|
|
90
|
+
builder = builder.where('field', 'in', Array.from(scopedFields))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (scope?.tenantId !== undefined) {
|
|
94
|
+
builder = builder.where(sql<boolean>`tenant_id is not distinct from ${scope.tenantId}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (scope?.organizationId !== undefined) {
|
|
98
|
+
builder = scope.organizationId === null
|
|
99
|
+
? builder.where(sql<boolean>`organization_id is not distinct from ${null}`)
|
|
100
|
+
: builder.where('organization_id', '=', scope.organizationId)
|
|
101
|
+
} else if (scope?.organizationIds?.length) {
|
|
102
|
+
builder = builder.where('organization_id', 'in', Array.from(scope.organizationIds))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rows = (await builder
|
|
106
|
+
.groupBy('entity_id')
|
|
107
|
+
.having(sql<boolean>`count(distinct token_hash) >= ${hashes.length}`)
|
|
108
|
+
.execute()) as Array<{ entity_id?: unknown }>
|
|
109
|
+
|
|
110
|
+
const ids = rows
|
|
111
|
+
.map((row) => (typeof row.entity_id === 'string' ? row.entity_id : null))
|
|
112
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
113
|
+
|
|
114
|
+
return { matched: true, ids }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Legacy-shaped adapter for call sites that predate
|
|
119
|
+
* {@link SearchTokenLookupResult}: `null` for a blank query, `[]` for every
|
|
120
|
+
* other non-answer, otherwise the matched ids.
|
|
121
|
+
*
|
|
122
|
+
* Prefer {@link findEntityIdsBySearchTokens} in new code — the discriminated
|
|
123
|
+
* result distinguishes "the index says nothing matched" from "the index was
|
|
124
|
+
* never consulted", and that distinction is exactly what a `null`/`[]` pair
|
|
125
|
+
* loses.
|
|
126
|
+
*/
|
|
127
|
+
export async function findEntityIdsBySearchTokensCompat(
|
|
128
|
+
input: FindEntityIdsBySearchTokensInput,
|
|
129
|
+
): Promise<string[] | null> {
|
|
130
|
+
const result = await findEntityIdsBySearchTokens(input)
|
|
131
|
+
if (result.matched) return result.ids
|
|
132
|
+
return result.reason === 'empty-query' ? null : []
|
|
133
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
// `defaultGroupOrder` in `auth/lib/backendChrome.tsx` ranks any group it lists ahead of any group it
|
|
4
|
+
// does not, regardless of an app's own page `priority`/`order` — so a downstream app could not place its
|
|
5
|
+
// own primary module above the shipped groups by any documented means. `overrides.nav.groupOrder` wires
|
|
6
|
+
// that as a real override domain, which `BACKWARD_COMPATIBILITY.md` §2 explicitly reserves for additive
|
|
7
|
+
// wiring.
|
|
8
|
+
|
|
9
|
+
const mockLoggerWarn = jest.fn()
|
|
10
|
+
|
|
11
|
+
jest.mock('../../lib/logger', () => ({
|
|
12
|
+
createLogger: () => {
|
|
13
|
+
const child = { warn: (...args: unknown[]) => mockLoggerWarn(...args), error: jest.fn(), info: jest.fn(), debug: jest.fn() }
|
|
14
|
+
return { child: () => child, warn: child.warn, error: child.error, info: child.info, debug: child.debug }
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
applyModuleOverridesFromEnabledModules,
|
|
20
|
+
applyNavGroupOrderOverrides,
|
|
21
|
+
getNavGroupOrderOverride,
|
|
22
|
+
resetModuleContractOverridesForTests,
|
|
23
|
+
resetModuleOverrideAppliersForTests,
|
|
24
|
+
} from '../overrides'
|
|
25
|
+
|
|
26
|
+
function reset() {
|
|
27
|
+
resetModuleOverrideAppliersForTests()
|
|
28
|
+
resetModuleContractOverridesForTests()
|
|
29
|
+
mockLoggerWarn.mockClear()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(reset)
|
|
33
|
+
afterEach(reset)
|
|
34
|
+
|
|
35
|
+
const collisionWarnings = () =>
|
|
36
|
+
mockLoggerWarn.mock.calls.filter((call) => String(call[0]).includes('nav.groupOrder declared by more than one'))
|
|
37
|
+
|
|
38
|
+
describe('nav.groupOrder override domain', () => {
|
|
39
|
+
it('is unset when no module declares it', () => {
|
|
40
|
+
applyModuleOverridesFromEnabledModules([{ id: 'catalog' }, { id: 'sales', overrides: {} }])
|
|
41
|
+
|
|
42
|
+
expect(getNavGroupOrderOverride()).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('captures the ids a module declares, in order — proving the domain is wired', () => {
|
|
46
|
+
applyModuleOverridesFromEnabledModules([
|
|
47
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['app.nav.group', 'catalog.nav.group'] } } },
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
expect(getNavGroupOrderOverride()).toEqual(['app.nav.group', 'catalog.nav.group'])
|
|
51
|
+
// An unwired domain would be dropped with a "not yet wired" warning instead of being captured.
|
|
52
|
+
expect(mockLoggerWarn.mock.calls.filter((call) => String(call[0]).includes('not yet wired'))).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('ignores an empty list, leaving the shipped ordering in charge', () => {
|
|
56
|
+
applyModuleOverridesFromEnabledModules([{ id: 'app', overrides: { nav: { groupOrder: [] } } }])
|
|
57
|
+
|
|
58
|
+
expect(getNavGroupOrderOverride()).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('drops blank entries and de-duplicates while preserving first position', () => {
|
|
62
|
+
applyModuleOverridesFromEnabledModules([
|
|
63
|
+
{
|
|
64
|
+
id: 'app',
|
|
65
|
+
overrides: { nav: { groupOrder: [' app.nav.group ', '', ' ', 'app.nav.group', 'catalog.nav.group'] } },
|
|
66
|
+
},
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
expect(getNavGroupOrderOverride()).toEqual(['app.nav.group', 'catalog.nav.group'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('ignores a non-array value rather than throwing', () => {
|
|
73
|
+
applyModuleOverridesFromEnabledModules([
|
|
74
|
+
{ id: 'app', overrides: { nav: { groupOrder: 'app.nav.group' as never } } },
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
expect(getNavGroupOrderOverride()).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('lets the later module win when two declare an order, and says so', () => {
|
|
81
|
+
applyModuleOverridesFromEnabledModules([
|
|
82
|
+
{ id: 'first', overrides: { nav: { groupOrder: ['first.nav.group'] } } },
|
|
83
|
+
{ id: 'second', overrides: { nav: { groupOrder: ['second.nav.group'] } } },
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
expect(getNavGroupOrderOverride()).toEqual(['second.nav.group'])
|
|
87
|
+
expect(collisionWarnings()).toHaveLength(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not warn when the same module declares it twice', () => {
|
|
91
|
+
applyModuleOverridesFromEnabledModules([
|
|
92
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['a.nav.group'] } } },
|
|
93
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['b.nav.group'] } } },
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
expect(getNavGroupOrderOverride()).toEqual(['b.nav.group'])
|
|
97
|
+
expect(collisionWarnings()).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('is cleared by the store reset hook so suites cannot leak into each other', () => {
|
|
101
|
+
applyModuleOverridesFromEnabledModules([
|
|
102
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['app.nav.group'] } } },
|
|
103
|
+
])
|
|
104
|
+
expect(getNavGroupOrderOverride()).not.toBeNull()
|
|
105
|
+
|
|
106
|
+
resetModuleContractOverridesForTests()
|
|
107
|
+
|
|
108
|
+
expect(getNavGroupOrderOverride()).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('nav.groupOrder programmatic tier', () => {
|
|
113
|
+
it('takes precedence over the modules.ts declaration', () => {
|
|
114
|
+
applyModuleOverridesFromEnabledModules([
|
|
115
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['from-modules.nav.group'] } } },
|
|
116
|
+
])
|
|
117
|
+
applyNavGroupOrderOverrides(['from-code.nav.group'])
|
|
118
|
+
|
|
119
|
+
expect(getNavGroupOrderOverride()).toEqual(['from-code.nav.group'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('falls back to the modules.ts declaration when cleared with null', () => {
|
|
123
|
+
applyModuleOverridesFromEnabledModules([
|
|
124
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['from-modules.nav.group'] } } },
|
|
125
|
+
])
|
|
126
|
+
applyNavGroupOrderOverrides(['from-code.nav.group'])
|
|
127
|
+
applyNavGroupOrderOverrides(null)
|
|
128
|
+
|
|
129
|
+
expect(getNavGroupOrderOverride()).toEqual(['from-modules.nav.group'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('applies the same normalisation as the modules.ts tier', () => {
|
|
133
|
+
applyNavGroupOrderOverrides([' a.nav.group ', '', 'a.nav.group', 'b.nav.group'])
|
|
134
|
+
|
|
135
|
+
expect(getNavGroupOrderOverride()).toEqual(['a.nav.group', 'b.nav.group'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('treats an all-blank list as no override', () => {
|
|
139
|
+
applyNavGroupOrderOverrides([' ', ''])
|
|
140
|
+
|
|
141
|
+
expect(getNavGroupOrderOverride()).toBeNull()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('works before any module override has been dispatched', () => {
|
|
145
|
+
applyNavGroupOrderOverrides(['standalone.nav.group'])
|
|
146
|
+
|
|
147
|
+
expect(getNavGroupOrderOverride()).toEqual(['standalone.nav.group'])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('nav.groupOrder survives module duplication', () => {
|
|
152
|
+
// Regression for `.ai/lessons.md`, "Global registries in publishable packages must use
|
|
153
|
+
// `globalThis`". This domain's writer (app bootstrap) and reader (`@open-mercato/core`'s backend
|
|
154
|
+
// chrome) sit in different packages, and standalone builds can evaluate `@open-mercato/shared`
|
|
155
|
+
// through more than one module instance. `jest.isolateModules` reproduces that: a second, freshly
|
|
156
|
+
// loaded copy of the module must still observe what the first copy stored.
|
|
157
|
+
it('a separately loaded module instance sees the value written by bootstrap', () => {
|
|
158
|
+
applyModuleOverridesFromEnabledModules([
|
|
159
|
+
{ id: 'app', overrides: { nav: { groupOrder: ['bootstrap.nav.group'] } } },
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
let observedFromSecondInstance: readonly string[] | null | undefined
|
|
163
|
+
jest.isolateModules(() => {
|
|
164
|
+
const freshModule = require('../overrides') as typeof import('../overrides')
|
|
165
|
+
observedFromSecondInstance = freshModule.getNavGroupOrderOverride()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// A module-local variable would leave this second instance blind to the bootstrap value.
|
|
169
|
+
expect(observedFromSecondInstance).toEqual(['bootstrap.nav.group'])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('a programmatic override is visible to a separately loaded instance too', () => {
|
|
173
|
+
applyNavGroupOrderOverrides(['programmatic.nav.group'])
|
|
174
|
+
|
|
175
|
+
let observed: readonly string[] | null | undefined
|
|
176
|
+
jest.isolateModules(() => {
|
|
177
|
+
const freshModule = require('../overrides') as typeof import('../overrides')
|
|
178
|
+
observed = freshModule.getNavGroupOrderOverride()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
expect(observed).toEqual(['programmatic.nav.group'])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -47,6 +47,19 @@ export type BackendChromeBrand = {
|
|
|
47
47
|
} | null
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The organization the current request is scoped to, resolved server-side.
|
|
52
|
+
*
|
|
53
|
+
* Distinct from `brand`, which is a *branding* channel and only populates when the organization has a
|
|
54
|
+
* logo configured. This is always present when a single organization is in scope, so UI can label
|
|
55
|
+
* "you are viewing: <name>" without a second round trip. `null` under an all-organizations selection,
|
|
56
|
+
* when no organization is in scope, or when the lookup fails.
|
|
57
|
+
*/
|
|
58
|
+
export type BackendChromeCurrentOrganization = {
|
|
59
|
+
id: string
|
|
60
|
+
name: string
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
export type BackendChromePayload = {
|
|
51
64
|
groups: BackendChromeNavGroup[]
|
|
52
65
|
settingsSections: BackendChromeSectionGroup[]
|
|
@@ -56,4 +69,5 @@ export type BackendChromePayload = {
|
|
|
56
69
|
grantedFeatures: string[]
|
|
57
70
|
roles: string[]
|
|
58
71
|
brand?: BackendChromeBrand | null
|
|
72
|
+
currentOrganization?: BackendChromeCurrentOrganization | null
|
|
59
73
|
}
|
package/src/modules/overrides.ts
CHANGED
|
@@ -116,6 +116,21 @@ export interface EncryptionOverridesShape {
|
|
|
116
116
|
maps?: EncryptionMapOverridesMap | LooseOverrideMap
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Backend navigation ordering.
|
|
121
|
+
*
|
|
122
|
+
* `groupOrder` **prepends** sidebar nav group ids: the ids listed here rank ahead of every other
|
|
123
|
+
* group, in the order given, and any group not named keeps the ordering it has today. Prepending
|
|
124
|
+
* rather than replacing means an app that only cares about its own group lists that one id, instead of
|
|
125
|
+
* having to enumerate every shipped group and accidentally demoting the ones it forgot.
|
|
126
|
+
*
|
|
127
|
+
* This is a *default*, applied beneath both role and user sidebar preferences — an operator's own
|
|
128
|
+
* arrangement still wins.
|
|
129
|
+
*/
|
|
130
|
+
export interface NavOverridesShape {
|
|
131
|
+
groupOrder?: string[]
|
|
132
|
+
}
|
|
133
|
+
|
|
119
134
|
/**
|
|
120
135
|
* Umbrella shape for `entry.overrides`. Every key is optional; a
|
|
121
136
|
* downstream app sets only the domains it cares about.
|
|
@@ -136,6 +151,7 @@ export interface ModuleOverrides {
|
|
|
136
151
|
acl?: AclOverridesShape
|
|
137
152
|
di?: DiOverridesMap | LooseOverrideMap
|
|
138
153
|
encryption?: EncryptionOverridesShape
|
|
154
|
+
nav?: NavOverridesShape
|
|
139
155
|
}
|
|
140
156
|
|
|
141
157
|
/**
|
|
@@ -175,6 +191,7 @@ export type ModuleOverrideDomain =
|
|
|
175
191
|
| 'acl'
|
|
176
192
|
| 'di'
|
|
177
193
|
| 'encryption'
|
|
194
|
+
| 'nav'
|
|
178
195
|
|
|
179
196
|
export interface ModuleOverrideEntry<TShape> {
|
|
180
197
|
moduleId: string
|
|
@@ -227,6 +244,7 @@ const DOMAIN_KEYS: ModuleOverrideDomain[] = [
|
|
|
227
244
|
'acl',
|
|
228
245
|
'di',
|
|
229
246
|
'encryption',
|
|
247
|
+
'nav',
|
|
230
248
|
]
|
|
231
249
|
|
|
232
250
|
const TRACKING_ISSUE_HINT =
|
|
@@ -441,6 +459,71 @@ const encryptionMapOverrideStore: OverrideStore<ModuleEncryptionMap> = { modules
|
|
|
441
459
|
const diOverrideStore: OverrideStore<Exclude<DiBindingOverride, null>> = { modules: {}, programmatic: {} }
|
|
442
460
|
const setupOverridesByModule: Record<string, SetupOverridesShape> = {}
|
|
443
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Sidebar nav ordering state.
|
|
464
|
+
*
|
|
465
|
+
* Persisted on `globalThis` rather than in a module-local variable. This is the one override domain
|
|
466
|
+
* whose consumer lives in a *different package* (`@open-mercato/core`'s backend chrome reads what the
|
|
467
|
+
* app's bootstrap wrote), and standalone builds can evaluate `@open-mercato/shared` through more than
|
|
468
|
+
* one server chunk — bootstrap would store the value in one instance while the reader saw `null` from
|
|
469
|
+
* another. See `.ai/lessons.md`, "Global registries in publishable packages must use `globalThis`".
|
|
470
|
+
*
|
|
471
|
+
* Two tiers, matching every other override domain and the documented resolution order: programmatic
|
|
472
|
+
* calls win over `modules.ts` inline declarations.
|
|
473
|
+
*/
|
|
474
|
+
const GLOBAL_NAV_OVERRIDE_STATE_KEY = '__openMercatoNavOverrideState__'
|
|
475
|
+
|
|
476
|
+
type NavOverrideState = {
|
|
477
|
+
/** From `modules.ts` inline `overrides.nav`, with the module entry that supplied it. */
|
|
478
|
+
modules: { moduleId: string; groupOrder: string[] } | null
|
|
479
|
+
/** From `applyNavGroupOrderOverrides`. Takes precedence over `modules`. */
|
|
480
|
+
programmatic: string[] | null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function getNavOverrideState(): NavOverrideState {
|
|
484
|
+
const existing = (globalThis as Record<string, unknown>)[GLOBAL_NAV_OVERRIDE_STATE_KEY]
|
|
485
|
+
if (existing && typeof existing === 'object') {
|
|
486
|
+
const typed = existing as NavOverrideState
|
|
487
|
+
if ('modules' in typed && 'programmatic' in typed) return typed
|
|
488
|
+
}
|
|
489
|
+
const initial: NavOverrideState = { modules: null, programmatic: null }
|
|
490
|
+
;(globalThis as Record<string, unknown>)[GLOBAL_NAV_OVERRIDE_STATE_KEY] = initial
|
|
491
|
+
return initial
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Drops blank/duplicate ids and returns `null` when nothing usable remains. */
|
|
495
|
+
function normalizeNavGroupOrder(value: unknown): string[] | null {
|
|
496
|
+
if (!Array.isArray(value)) return null
|
|
497
|
+
const ids = Array.from(
|
|
498
|
+
new Set(
|
|
499
|
+
value
|
|
500
|
+
.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
|
|
501
|
+
.map((id) => id.trim()),
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
return ids.length > 0 ? ids : null
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Programmatic nav ordering, for env-driven boot decisions and tests. Takes precedence over
|
|
509
|
+
* `modules.ts` inline `overrides.nav`, consistent with the other domains' programmatic tier. Pass
|
|
510
|
+
* `null` to clear it and fall back to the inline declaration.
|
|
511
|
+
*/
|
|
512
|
+
export function applyNavGroupOrderOverrides(groupOrder: string[] | null): void {
|
|
513
|
+
getNavOverrideState().programmatic = groupOrder === null ? null : normalizeNavGroupOrder(groupOrder)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Nav group ids an app wants ranked ahead of the built-in order, or `null` when none is configured.
|
|
518
|
+
*
|
|
519
|
+
* Consumers MUST treat `null` as "use the shipped ordering unchanged" — this is a default, applied
|
|
520
|
+
* beneath role and user sidebar preferences.
|
|
521
|
+
*/
|
|
522
|
+
export function getNavGroupOrderOverride(): readonly string[] | null {
|
|
523
|
+
const state = getNavOverrideState()
|
|
524
|
+
return state.programmatic ?? state.modules?.groupOrder ?? null
|
|
525
|
+
}
|
|
526
|
+
|
|
444
527
|
function normalizeIdOverrideKey(key: string, label: string): string | null {
|
|
445
528
|
if (typeof key !== 'string') return null
|
|
446
529
|
const trimmed = key.trim()
|
|
@@ -721,6 +804,9 @@ export function resetModuleContractOverridesForTests(): void {
|
|
|
721
804
|
clearStore(encryptionMapOverrideStore)
|
|
722
805
|
clearStore(diOverrideStore)
|
|
723
806
|
for (const key of Object.keys(setupOverridesByModule)) delete setupOverridesByModule[key]
|
|
807
|
+
const navState = getNavOverrideState()
|
|
808
|
+
navState.modules = null
|
|
809
|
+
navState.programmatic = null
|
|
724
810
|
}
|
|
725
811
|
|
|
726
812
|
/**
|
|
@@ -1500,7 +1586,24 @@ function encryptionOverridesApplier(entries: ReadonlyArray<ModuleOverrideEntry<E
|
|
|
1500
1586
|
}
|
|
1501
1587
|
}
|
|
1502
1588
|
|
|
1589
|
+
function navOverridesApplier(entries: ReadonlyArray<ModuleOverrideEntry<NavOverridesShape>>): void {
|
|
1590
|
+
const state = getNavOverrideState()
|
|
1591
|
+
for (const entry of entries) {
|
|
1592
|
+
const groupOrder = normalizeNavGroupOrder(entry.overrides?.groupOrder)
|
|
1593
|
+
if (!groupOrder) continue
|
|
1594
|
+
if (state.modules && state.modules.moduleId !== entry.moduleId) {
|
|
1595
|
+
logger.warn('nav.groupOrder declared by more than one module — the later one wins', {
|
|
1596
|
+
previousModuleId: state.modules.moduleId,
|
|
1597
|
+
moduleId: entry.moduleId,
|
|
1598
|
+
hint: 'Sidebar group ordering is a single app-wide decision; declare it on one module entry.',
|
|
1599
|
+
})
|
|
1600
|
+
}
|
|
1601
|
+
state.modules = { moduleId: entry.moduleId, groupOrder }
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1503
1605
|
function registerBuiltInModuleOverrideAppliers(): void {
|
|
1606
|
+
registerModuleOverrideApplier<NavOverridesShape>('nav', navOverridesApplier)
|
|
1504
1607
|
registerModuleOverrideApplier<RoutesOverridesShape>('routes', routesOverridesApplier)
|
|
1505
1608
|
registerModuleOverrideApplier<EventsOverridesShape>('events', eventsOverridesApplier)
|
|
1506
1609
|
registerModuleOverrideApplier<WorkerOverridesMap>('workers', workersOverridesApplier)
|