@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,7 +1,9 @@
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
  const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3;
4
5
  const DEFAULT_BLOCKLIST = ["password", "token", "secret", "hash"];
6
+ const ENTITY_BLOCKLIST_SEPARATOR = "@";
5
7
  function parseBoolean(raw, fallback) {
6
8
  return parseBooleanWithDefault(raw, fallback);
7
9
  }
@@ -14,21 +16,56 @@ function parseHashAlgorithm(raw) {
14
16
  if (value === "md5") return "md5";
15
17
  return "sha256";
16
18
  }
19
+ function parseFieldBlocklist(raw) {
20
+ const global = [];
21
+ const byEntity = /* @__PURE__ */ new Map();
22
+ for (const rawEntry of parseCommaSeparatedList(raw)) {
23
+ const entry = rawEntry.toLowerCase();
24
+ const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR);
25
+ const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : "";
26
+ const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry;
27
+ if (!field.length) continue;
28
+ if (!entityType.length) {
29
+ if (!global.includes(field)) global.push(field);
30
+ continue;
31
+ }
32
+ const scoped = byEntity.get(entityType) ?? [];
33
+ if (!scoped.includes(field)) scoped.push(field);
34
+ byEntity.set(entityType, scoped);
35
+ }
36
+ for (const fallback of DEFAULT_BLOCKLIST) {
37
+ if (!global.includes(fallback)) global.push(fallback);
38
+ }
39
+ const scopedBlocklist = /* @__PURE__ */ Object.create(null);
40
+ for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields;
41
+ return { global, byEntity: scopedBlocklist };
42
+ }
17
43
  function resolveSearchConfig() {
44
+ const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST);
18
45
  return {
19
46
  enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),
20
47
  minTokenLength: resolveSearchMinTokenLength(),
21
48
  enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),
22
49
  hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),
23
50
  storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
24
- blocklistedFields: (process.env.OM_SEARCH_FIELD_BLOCKLIST ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).filter((value, index, arr) => arr.indexOf(value) === index).map((entry) => entry.toLowerCase()).concat(DEFAULT_BLOCKLIST).filter((value, index, arr) => arr.indexOf(value) === index)
51
+ blocklistedFields: blocklist.global,
52
+ entityBlocklistedFields: blocklist.byEntity
25
53
  };
26
54
  }
55
+ function isSearchFieldBlocklisted(field, entityType, config) {
56
+ const lower = field.toLowerCase();
57
+ if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true;
58
+ if (!entityType) return false;
59
+ const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()];
60
+ if (!Array.isArray(scoped) || !scoped.length) return false;
61
+ return scoped.some((blocked) => lower.includes(blocked));
62
+ }
27
63
  function resolveSearchMinTokenLength() {
28
64
  return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1);
29
65
  }
30
66
  export {
31
67
  DEFAULT_SEARCH_MIN_TOKEN_LENGTH,
68
+ isSearchFieldBlocklisted,
32
69
  resolveSearchConfig,
33
70
  resolveSearchMinTokenLength
34
71
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/search/config.ts"],
4
- "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\n\nexport type SearchConfig = {\n enabled: boolean\n minTokenLength: number\n enablePartials: boolean\n hashAlgorithm: 'sha256' | 'sha1' | 'md5'\n storeRawTokens: boolean\n blocklistedFields: string[]\n}\n\nexport const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3\n\nconst DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']\n\nfunction parseBoolean(raw: string | undefined, fallback: boolean): boolean {\n return parseBooleanWithDefault(raw, fallback)\n}\n\nfunction parseNumber(raw: string | undefined, fallback: number, min = 1): number {\n return parseNumberWithDefault(raw, fallback, { integer: true, min })\n}\n\nfunction parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {\n const value = (raw ?? '').trim().toLowerCase()\n if (value === 'sha1') return 'sha1'\n if (value === 'md5') return 'md5'\n return 'sha256'\n}\n\nexport function resolveSearchConfig(): SearchConfig {\n return {\n enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),\n minTokenLength: resolveSearchMinTokenLength(),\n enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),\n hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),\n storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),\n blocklistedFields: (process.env.OM_SEARCH_FIELD_BLOCKLIST ?? '')\n .split(',')\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0)\n .filter((value, index, arr) => arr.indexOf(value) === index)\n .map((entry) => entry.toLowerCase())\n .concat(DEFAULT_BLOCKLIST)\n .filter((value, index, arr) => arr.indexOf(value) === index),\n }\n}\n\n/**\n * Browser-safe accessor for the minimum search token length.\n *\n * Why: client components (e.g. global search dialog) must mirror the server-side\n * tokenizer's `minTokenLength` so the UI gates the request before hitting an\n * empty result set. Pulling the value through this single helper keeps the env\n * contract (`OM_SEARCH_MIN_LEN`) authoritative on both sides.\n *\n * How to apply: call from anywhere \u2014 server, client (when the host app exposes\n * `OM_SEARCH_MIN_LEN` through `next.config.ts`'s `env` block), or tests.\n */\nexport function resolveSearchMinTokenLength(): number {\n return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1)\n}\n"],
5
- "mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AAWhC,MAAM,kCAAkC;AAE/C,MAAM,oBAAoB,CAAC,YAAY,SAAS,UAAU,MAAM;AAEhE,SAAS,aAAa,KAAyB,UAA4B;AACzE,SAAO,wBAAwB,KAAK,QAAQ;AAC9C;AAEA,SAAS,YAAY,KAAyB,UAAkB,MAAM,GAAW;AAC/E,SAAO,uBAAuB,KAAK,UAAU,EAAE,SAAS,MAAM,IAAI,CAAC;AACrE;AAEA,SAAS,mBAAmB,KAAoD;AAC9E,QAAM,SAAS,OAAO,IAAI,KAAK,EAAE,YAAY;AAC7C,MAAI,UAAU,OAAQ,QAAO;AAC7B,MAAI,UAAU,MAAO,QAAO;AAC5B,SAAO;AACT;AAEO,SAAS,sBAAoC;AAClD,SAAO;AAAA,IACL,SAAS,aAAa,QAAQ,IAAI,mBAAmB,IAAI;AAAA,IACzD,gBAAgB,4BAA4B;AAAA,IAC5C,gBAAgB,aAAa,QAAQ,IAAI,0BAA0B,IAAI;AAAA,IACvE,eAAe,mBAAmB,QAAQ,IAAI,mBAAmB;AAAA,IACjE,gBAAgB,aAAa,QAAQ,IAAI,4BAA4B,KAAK;AAAA,IAC1E,oBAAoB,QAAQ,IAAI,6BAA6B,IAC1D,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC,EAClC,OAAO,CAAC,OAAO,OAAO,QAAQ,IAAI,QAAQ,KAAK,MAAM,KAAK,EAC1D,IAAI,CAAC,UAAU,MAAM,YAAY,CAAC,EAClC,OAAO,iBAAiB,EACxB,OAAO,CAAC,OAAO,OAAO,QAAQ,IAAI,QAAQ,KAAK,MAAM,KAAK;AAAA,EAC/D;AACF;AAaO,SAAS,8BAAsC;AACpD,SAAO,YAAY,QAAQ,IAAI,mBAAmB,iCAAiC,CAAC;AACtF;",
4
+ "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'\n\nexport type SearchConfig = {\n enabled: boolean\n minTokenLength: number\n enablePartials: boolean\n hashAlgorithm: 'sha256' | 'sha1' | 'md5'\n storeRawTokens: boolean\n blocklistedFields: string[]\n entityBlocklistedFields?: Record<string, string[]>\n}\n\nexport const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3\n\nconst DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']\n\nconst ENTITY_BLOCKLIST_SEPARATOR = '@'\n\nfunction parseBoolean(raw: string | undefined, fallback: boolean): boolean {\n return parseBooleanWithDefault(raw, fallback)\n}\n\nfunction parseNumber(raw: string | undefined, fallback: number, min = 1): number {\n return parseNumberWithDefault(raw, fallback, { integer: true, min })\n}\n\nfunction parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {\n const value = (raw ?? '').trim().toLowerCase()\n if (value === 'sha1') return 'sha1'\n if (value === 'md5') return 'md5'\n return 'sha256'\n}\n\n/**\n * Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.\n *\n * Why: a deployment often needs to keep one large free-text column out of the token\n * index (e-mail bodies on `customers:customer_interaction`) while still indexing the\n * same-named column elsewhere. A flat global list cannot express that.\n *\n * How to apply: entries are comma-separated; an entry may carry an optional\n * `entityType@` prefix \u2014 `body` blocks the field everywhere, while\n * `customers:customer_interaction@body` blocks it only for that entity type. Entries\n * whose field part is empty are ignored so malformed env input cannot break indexing.\n */\nfunction parseFieldBlocklist(raw: string | undefined): {\n global: string[]\n byEntity: Record<string, string[]>\n} {\n const global: string[] = []\n const byEntity = new Map<string, string[]>()\n\n for (const rawEntry of parseCommaSeparatedList(raw)) {\n const entry = rawEntry.toLowerCase()\n const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)\n const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''\n const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry\n if (!field.length) continue\n\n if (!entityType.length) {\n if (!global.includes(field)) global.push(field)\n continue\n }\n\n const scoped = byEntity.get(entityType) ?? []\n if (!scoped.includes(field)) scoped.push(field)\n byEntity.set(entityType, scoped)\n }\n\n for (const fallback of DEFAULT_BLOCKLIST) {\n if (!global.includes(fallback)) global.push(fallback)\n }\n\n const scopedBlocklist = Object.create(null) as Record<string, string[]>\n for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields\n\n return { global, byEntity: scopedBlocklist }\n}\n\nexport function resolveSearchConfig(): SearchConfig {\n const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)\n return {\n enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),\n minTokenLength: resolveSearchMinTokenLength(),\n enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),\n hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),\n storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),\n blocklistedFields: blocklist.global,\n entityBlocklistedFields: blocklist.byEntity,\n }\n}\n\n/**\n * Single matcher for \"should this field be kept out of the search index?\".\n *\n * Why: the per-field token path and the `search_text` aggregate previously each\n * decided this on their own, and the aggregate simply never consulted the config \u2014\n * so a blocklisted column's text came back into the index under the aggregate's\n * field name (#4624). Both paths now share this function so they cannot drift.\n *\n * How to apply: pass the document's field name and the entity type being indexed;\n * `entityType` may be omitted when unknown, in which case only global entries apply.\n * Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).\n */\nexport function isSearchFieldBlocklisted(\n field: string,\n entityType: string | null | undefined,\n config: SearchConfig,\n): boolean {\n const lower = field.toLowerCase()\n if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true\n if (!entityType) return false\n const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]\n if (!Array.isArray(scoped) || !scoped.length) return false\n return scoped.some((blocked) => lower.includes(blocked))\n}\n\n/**\n * Browser-safe accessor for the minimum search token length.\n *\n * Why: client components (e.g. global search dialog) must mirror the server-side\n * tokenizer's `minTokenLength` so the UI gates the request before hitting an\n * empty result set. Pulling the value through this single helper keeps the env\n * contract (`OM_SEARCH_MIN_LEN`) authoritative on both sides.\n *\n * How to apply: call from anywhere \u2014 server, client (when the host app exposes\n * `OM_SEARCH_MIN_LEN` through `next.config.ts`'s `env` block), or tests.\n */\nexport function resolveSearchMinTokenLength(): number {\n return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1)\n}\n"],
5
+ "mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,+BAA+B;AAYjC,MAAM,kCAAkC;AAE/C,MAAM,oBAAoB,CAAC,YAAY,SAAS,UAAU,MAAM;AAEhE,MAAM,6BAA6B;AAEnC,SAAS,aAAa,KAAyB,UAA4B;AACzE,SAAO,wBAAwB,KAAK,QAAQ;AAC9C;AAEA,SAAS,YAAY,KAAyB,UAAkB,MAAM,GAAW;AAC/E,SAAO,uBAAuB,KAAK,UAAU,EAAE,SAAS,MAAM,IAAI,CAAC;AACrE;AAEA,SAAS,mBAAmB,KAAoD;AAC9E,QAAM,SAAS,OAAO,IAAI,KAAK,EAAE,YAAY;AAC7C,MAAI,UAAU,OAAQ,QAAO;AAC7B,MAAI,UAAU,MAAO,QAAO;AAC5B,SAAO;AACT;AAcA,SAAS,oBAAoB,KAG3B;AACA,QAAM,SAAmB,CAAC;AAC1B,QAAM,WAAW,oBAAI,IAAsB;AAE3C,aAAW,YAAY,wBAAwB,GAAG,GAAG;AACnD,UAAM,QAAQ,SAAS,YAAY;AACnC,UAAM,iBAAiB,MAAM,QAAQ,0BAA0B;AAC/D,UAAM,aAAa,kBAAkB,IAAI,MAAM,MAAM,GAAG,cAAc,EAAE,KAAK,IAAI;AACjF,UAAM,QAAQ,kBAAkB,IAAI,MAAM,MAAM,iBAAiB,CAAC,EAAE,KAAK,IAAI;AAC7E,QAAI,CAAC,MAAM,OAAQ;AAEnB,QAAI,CAAC,WAAW,QAAQ;AACtB,UAAI,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO,KAAK,KAAK;AAC9C;AAAA,IACF;AAEA,UAAM,SAAS,SAAS,IAAI,UAAU,KAAK,CAAC;AAC5C,QAAI,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO,KAAK,KAAK;AAC9C,aAAS,IAAI,YAAY,MAAM;AAAA,EACjC;AAEA,aAAW,YAAY,mBAAmB;AACxC,QAAI,CAAC,OAAO,SAAS,QAAQ,EAAG,QAAO,KAAK,QAAQ;AAAA,EACtD;AAEA,QAAM,kBAAkB,uBAAO,OAAO,IAAI;AAC1C,aAAW,CAAC,YAAY,MAAM,KAAK,SAAU,iBAAgB,UAAU,IAAI;AAE3E,SAAO,EAAE,QAAQ,UAAU,gBAAgB;AAC7C;AAEO,SAAS,sBAAoC;AAClD,QAAM,YAAY,oBAAoB,QAAQ,IAAI,yBAAyB;AAC3E,SAAO;AAAA,IACL,SAAS,aAAa,QAAQ,IAAI,mBAAmB,IAAI;AAAA,IACzD,gBAAgB,4BAA4B;AAAA,IAC5C,gBAAgB,aAAa,QAAQ,IAAI,0BAA0B,IAAI;AAAA,IACvE,eAAe,mBAAmB,QAAQ,IAAI,mBAAmB;AAAA,IACjE,gBAAgB,aAAa,QAAQ,IAAI,4BAA4B,KAAK;AAAA,IAC1E,mBAAmB,UAAU;AAAA,IAC7B,yBAAyB,UAAU;AAAA,EACrC;AACF;AAcO,SAAS,yBACd,OACA,YACA,QACS;AACT,QAAM,QAAQ,MAAM,YAAY;AAChC,MAAI,OAAO,kBAAkB,KAAK,CAAC,YAAY,MAAM,SAAS,OAAO,CAAC,EAAG,QAAO;AAChF,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,SAAS,OAAO,0BAA0B,WAAW,KAAK,EAAE,YAAY,CAAC;AAC/E,MAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,CAAC,OAAO,OAAQ,QAAO;AACrD,SAAO,OAAO,KAAK,CAAC,YAAY,MAAM,SAAS,OAAO,CAAC;AACzD;AAaO,SAAS,8BAAsC;AACpD,SAAO,YAAY,QAAQ,IAAI,mBAAmB,iCAAiC,CAAC;AACtF;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,46 @@
1
+ import { sql } from "kysely";
2
+ import { resolveSearchConfig } from "./config.js";
3
+ import { tokenizeText } from "./tokenize.js";
4
+ async function findEntityIdsBySearchTokens({
5
+ db,
6
+ entityType,
7
+ query,
8
+ fields,
9
+ scope,
10
+ config
11
+ }) {
12
+ const trimmed = query.trim();
13
+ if (!trimmed) return { matched: false, reason: "empty-query" };
14
+ const searchConfig = config ?? resolveSearchConfig();
15
+ if (!searchConfig.enabled) return { matched: false, reason: "search-disabled" };
16
+ const { hashes } = tokenizeText(trimmed, searchConfig);
17
+ if (!hashes.length) return { matched: false, reason: "no-tokens" };
18
+ let builder = db.selectFrom("search_tokens").select("entity_id").where("entity_type", "=", entityType).where("token_hash", "in", hashes);
19
+ const scopedFields = (fields ?? []).filter((field) => typeof field === "string" && field.length > 0);
20
+ if (scopedFields.length === 1) {
21
+ builder = builder.where("field", "=", scopedFields[0]);
22
+ } else if (scopedFields.length > 1) {
23
+ builder = builder.where("field", "in", Array.from(scopedFields));
24
+ }
25
+ if (scope?.tenantId !== void 0) {
26
+ builder = builder.where(sql`tenant_id is not distinct from ${scope.tenantId}`);
27
+ }
28
+ if (scope?.organizationId !== void 0) {
29
+ builder = scope.organizationId === null ? builder.where(sql`organization_id is not distinct from ${null}`) : builder.where("organization_id", "=", scope.organizationId);
30
+ } else if (scope?.organizationIds?.length) {
31
+ builder = builder.where("organization_id", "in", Array.from(scope.organizationIds));
32
+ }
33
+ const rows = await builder.groupBy("entity_id").having(sql`count(distinct token_hash) >= ${hashes.length}`).execute();
34
+ const ids = rows.map((row) => typeof row.entity_id === "string" ? row.entity_id : null).filter((id) => typeof id === "string" && id.length > 0);
35
+ return { matched: true, ids };
36
+ }
37
+ async function findEntityIdsBySearchTokensCompat(input) {
38
+ const result = await findEntityIdsBySearchTokens(input);
39
+ if (result.matched) return result.ids;
40
+ return result.reason === "empty-query" ? null : [];
41
+ }
42
+ export {
43
+ findEntityIdsBySearchTokens,
44
+ findEntityIdsBySearchTokensCompat
45
+ };
46
+ //# sourceMappingURL=tokenLookup.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/search/tokenLookup.ts"],
4
+ "sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { resolveSearchConfig, type SearchConfig } from './config'\nimport { tokenizeText } from './tokenize'\n\nexport type SearchTokenDatabase = {\n search_tokens: {\n entity_id: string\n entity_type: string\n field: string\n token_hash: string\n tenant_id: string | null\n organization_id: string | null\n }\n}\n\n/**\n * Tenant/organization scoping for a `search_tokens` lookup.\n *\n * `undefined` and `null` are NOT interchangeable:\n * - `undefined` omits the predicate entirely (the caller owns visibility).\n * - `null` emits a null-safe predicate that matches only globally scoped rows.\n */\nexport type SearchTokenScope = {\n tenantId?: string | null\n organizationId?: string | null\n organizationIds?: readonly string[] | null\n}\n\n/**\n * Why a lookup could not produce an id set. Callers MUST NOT read these as\n * \"nothing matched\" \u2014 the token index was never consulted, so the caller's own\n * predicate (usually an `ilike`) is still the authoritative one.\n */\nexport type SearchTokenLookupSkipReason = 'empty-query' | 'search-disabled' | 'no-tokens'\n\nexport type SearchTokenLookupResult =\n | { matched: true; ids: string[] }\n | { matched: false; reason: SearchTokenLookupSkipReason }\n\nexport type FindEntityIdsBySearchTokensInput = {\n db: Kysely<SearchTokenDatabase>\n entityType: string\n query: string\n fields?: readonly string[] | null\n scope?: SearchTokenScope\n config?: SearchConfig\n}\n\n/**\n * Resolve the record ids whose indexed `search_tokens` cover every token in\n * `query`.\n *\n * This is the encryption-safe replacement for `ilike` filtering on columns an\n * encryption map covers: the stored column holds ciphertext, so\n * `ilike '%term%'` silently matches nothing, while the token index stores\n * hashes of the plaintext and keeps matching. See issue #2990.\n *\n * Matching requires ALL query tokens to be present on the record. The token\n * search strategy in `@open-mercato/search` uses a looser match ratio; list\n * endpoints want the stricter behavior so a two-word query narrows rather than\n * widens the result set.\n */\nexport async function findEntityIdsBySearchTokens({\n db,\n entityType,\n query,\n fields,\n scope,\n config,\n}: FindEntityIdsBySearchTokensInput): Promise<SearchTokenLookupResult> {\n const trimmed = query.trim()\n if (!trimmed) return { matched: false, reason: 'empty-query' }\n\n const searchConfig = config ?? resolveSearchConfig()\n if (!searchConfig.enabled) return { matched: false, reason: 'search-disabled' }\n\n const { hashes } = tokenizeText(trimmed, searchConfig)\n if (!hashes.length) return { matched: false, reason: 'no-tokens' }\n\n let builder = db\n .selectFrom('search_tokens')\n .select('entity_id')\n .where('entity_type', '=', entityType)\n .where('token_hash', 'in', hashes)\n\n const scopedFields = (fields ?? []).filter((field) => typeof field === 'string' && field.length > 0)\n if (scopedFields.length === 1) {\n builder = builder.where('field', '=', scopedFields[0])\n } else if (scopedFields.length > 1) {\n builder = builder.where('field', 'in', Array.from(scopedFields))\n }\n\n if (scope?.tenantId !== undefined) {\n builder = builder.where(sql<boolean>`tenant_id is not distinct from ${scope.tenantId}`)\n }\n\n if (scope?.organizationId !== undefined) {\n builder = scope.organizationId === null\n ? builder.where(sql<boolean>`organization_id is not distinct from ${null}`)\n : builder.where('organization_id', '=', scope.organizationId)\n } else if (scope?.organizationIds?.length) {\n builder = builder.where('organization_id', 'in', Array.from(scope.organizationIds))\n }\n\n const rows = (await builder\n .groupBy('entity_id')\n .having(sql<boolean>`count(distinct token_hash) >= ${hashes.length}`)\n .execute()) as Array<{ entity_id?: unknown }>\n\n const ids = rows\n .map((row) => (typeof row.entity_id === 'string' ? row.entity_id : null))\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n\n return { matched: true, ids }\n}\n\n/**\n * Legacy-shaped adapter for call sites that predate\n * {@link SearchTokenLookupResult}: `null` for a blank query, `[]` for every\n * other non-answer, otherwise the matched ids.\n *\n * Prefer {@link findEntityIdsBySearchTokens} in new code \u2014 the discriminated\n * result distinguishes \"the index says nothing matched\" from \"the index was\n * never consulted\", and that distinction is exactly what a `null`/`[]` pair\n * loses.\n */\nexport async function findEntityIdsBySearchTokensCompat(\n input: FindEntityIdsBySearchTokensInput,\n): Promise<string[] | null> {\n const result = await findEntityIdsBySearchTokens(input)\n if (result.matched) return result.ids\n return result.reason === 'empty-query' ? null : []\n}\n"],
5
+ "mappings": "AAAA,SAAsB,WAAW;AACjC,SAAS,2BAA8C;AACvD,SAAS,oBAAoB;AA4D7B,eAAsB,4BAA4B;AAAA,EAChD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAuE;AACrE,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAS,QAAO,EAAE,SAAS,OAAO,QAAQ,cAAc;AAE7D,QAAM,eAAe,UAAU,oBAAoB;AACnD,MAAI,CAAC,aAAa,QAAS,QAAO,EAAE,SAAS,OAAO,QAAQ,kBAAkB;AAE9E,QAAM,EAAE,OAAO,IAAI,aAAa,SAAS,YAAY;AACrD,MAAI,CAAC,OAAO,OAAQ,QAAO,EAAE,SAAS,OAAO,QAAQ,YAAY;AAEjE,MAAI,UAAU,GACX,WAAW,eAAe,EAC1B,OAAO,WAAW,EAClB,MAAM,eAAe,KAAK,UAAU,EACpC,MAAM,cAAc,MAAM,MAAM;AAEnC,QAAM,gBAAgB,UAAU,CAAC,GAAG,OAAO,CAAC,UAAU,OAAO,UAAU,YAAY,MAAM,SAAS,CAAC;AACnG,MAAI,aAAa,WAAW,GAAG;AAC7B,cAAU,QAAQ,MAAM,SAAS,KAAK,aAAa,CAAC,CAAC;AAAA,EACvD,WAAW,aAAa,SAAS,GAAG;AAClC,cAAU,QAAQ,MAAM,SAAS,MAAM,MAAM,KAAK,YAAY,CAAC;AAAA,EACjE;AAEA,MAAI,OAAO,aAAa,QAAW;AACjC,cAAU,QAAQ,MAAM,qCAA8C,MAAM,QAAQ,EAAE;AAAA,EACxF;AAEA,MAAI,OAAO,mBAAmB,QAAW;AACvC,cAAU,MAAM,mBAAmB,OAC/B,QAAQ,MAAM,2CAAoD,IAAI,EAAE,IACxE,QAAQ,MAAM,mBAAmB,KAAK,MAAM,cAAc;AAAA,EAChE,WAAW,OAAO,iBAAiB,QAAQ;AACzC,cAAU,QAAQ,MAAM,mBAAmB,MAAM,MAAM,KAAK,MAAM,eAAe,CAAC;AAAA,EACpF;AAEA,QAAM,OAAQ,MAAM,QACjB,QAAQ,WAAW,EACnB,OAAO,oCAA6C,OAAO,MAAM,EAAE,EACnE,QAAQ;AAEX,QAAM,MAAM,KACT,IAAI,CAAC,QAAS,OAAO,IAAI,cAAc,WAAW,IAAI,YAAY,IAAK,EACvE,OAAO,CAAC,OAAqB,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC;AAEvE,SAAO,EAAE,SAAS,MAAM,IAAI;AAC9B;AAYA,eAAsB,kCACpB,OAC0B;AAC1B,QAAM,SAAS,MAAM,4BAA4B,KAAK;AACtD,MAAI,OAAO,QAAS,QAAO,OAAO;AAClC,SAAO,OAAO,WAAW,gBAAgB,OAAO,CAAC;AACnD;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6706.1.b3a4c759bb";
1
+ const APP_VERSION = "0.6.7-develop.6726.1.983ae8a07e";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6706.1.b3a4c759bb'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6726.1.983ae8a07e'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
@@ -23,7 +23,8 @@ const DOMAIN_KEYS = [
23
23
  "setup",
24
24
  "acl",
25
25
  "di",
26
- "encryption"
26
+ "encryption",
27
+ "nav"
27
28
  ];
28
29
  const TRACKING_ISSUE_HINT = "See `.ai/specs/implemented/2026-05-04-modules-ts-unified-overrides.md` and tracking issue https://github.com/open-mercato/open-mercato/issues/1787.";
29
30
  function applyModuleOverridesFromEnabledModules(modules) {
@@ -74,6 +75,33 @@ const aclFeatureOverrideStore = { modules: {}, programmatic: {} };
74
75
  const encryptionMapOverrideStore = { modules: {}, programmatic: {} };
75
76
  const diOverrideStore = { modules: {}, programmatic: {} };
76
77
  const setupOverridesByModule = {};
78
+ const GLOBAL_NAV_OVERRIDE_STATE_KEY = "__openMercatoNavOverrideState__";
79
+ function getNavOverrideState() {
80
+ const existing = globalThis[GLOBAL_NAV_OVERRIDE_STATE_KEY];
81
+ if (existing && typeof existing === "object") {
82
+ const typed = existing;
83
+ if ("modules" in typed && "programmatic" in typed) return typed;
84
+ }
85
+ const initial = { modules: null, programmatic: null };
86
+ globalThis[GLOBAL_NAV_OVERRIDE_STATE_KEY] = initial;
87
+ return initial;
88
+ }
89
+ function normalizeNavGroupOrder(value) {
90
+ if (!Array.isArray(value)) return null;
91
+ const ids = Array.from(
92
+ new Set(
93
+ value.filter((id) => typeof id === "string" && id.trim().length > 0).map((id) => id.trim())
94
+ )
95
+ );
96
+ return ids.length > 0 ? ids : null;
97
+ }
98
+ function applyNavGroupOrderOverrides(groupOrder) {
99
+ getNavOverrideState().programmatic = groupOrder === null ? null : normalizeNavGroupOrder(groupOrder);
100
+ }
101
+ function getNavGroupOrderOverride() {
102
+ const state = getNavOverrideState();
103
+ return state.programmatic ?? state.modules?.groupOrder ?? null;
104
+ }
77
105
  function normalizeIdOverrideKey(key, label) {
78
106
  if (typeof key !== "string") return null;
79
107
  const trimmed = key.trim();
@@ -261,6 +289,9 @@ function resetModuleContractOverridesForTests() {
261
289
  clearStore(encryptionMapOverrideStore);
262
290
  clearStore(diOverrideStore);
263
291
  for (const key of Object.keys(setupOverridesByModule)) delete setupOverridesByModule[key];
292
+ const navState = getNavOverrideState();
293
+ navState.modules = null;
294
+ navState.programmatic = null;
264
295
  }
265
296
  function composeApiRouteOverrides() {
266
297
  const modulesKeys = Object.keys(modulesConfigApiRouteOverrides);
@@ -827,7 +858,23 @@ function encryptionOverridesApplier(entries) {
827
858
  applyStoreOverrides(encryptionMapOverrideStore, "modules", entry.overrides?.maps, { label: "encryption.maps" });
828
859
  }
829
860
  }
861
+ function navOverridesApplier(entries) {
862
+ const state = getNavOverrideState();
863
+ for (const entry of entries) {
864
+ const groupOrder = normalizeNavGroupOrder(entry.overrides?.groupOrder);
865
+ if (!groupOrder) continue;
866
+ if (state.modules && state.modules.moduleId !== entry.moduleId) {
867
+ logger.warn("nav.groupOrder declared by more than one module \u2014 the later one wins", {
868
+ previousModuleId: state.modules.moduleId,
869
+ moduleId: entry.moduleId,
870
+ hint: "Sidebar group ordering is a single app-wide decision; declare it on one module entry."
871
+ });
872
+ }
873
+ state.modules = { moduleId: entry.moduleId, groupOrder };
874
+ }
875
+ }
830
876
  function registerBuiltInModuleOverrideAppliers() {
877
+ registerModuleOverrideApplier("nav", navOverridesApplier);
831
878
  registerModuleOverrideApplier("routes", routesOverridesApplier);
832
879
  registerModuleOverrideApplier("events", eventsOverridesApplier);
833
880
  registerModuleOverrideApplier("workers", workersOverridesApplier);
@@ -865,6 +912,7 @@ export {
865
912
  applyInjectionWidgetOverridesToTables,
866
913
  applyModuleOverridesFromEnabledModules,
867
914
  applyModuleOverridesToModules,
915
+ applyNavGroupOrderOverrides,
868
916
  applyNotificationHandlerOverrides,
869
917
  applyNotificationHandlerOverridesToEntries,
870
918
  applyNotificationTypeOverrides,
@@ -895,6 +943,7 @@ export {
895
943
  composeResponseEnricherOverrides,
896
944
  composeSubscriberOverrides,
897
945
  composeWorkerOverrides,
946
+ getNavGroupOrderOverride,
898
947
  registerModuleOverrideApplier,
899
948
  resetApiRouteOverridesForTests,
900
949
  resetModuleContractOverridesForTests,