@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
@@ -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: (process.env.OM_SEARCH_FIELD_BLOCKLIST ?? '')
40
- .split(',')
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
  }
@@ -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)