@open-mercato/shared 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518

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 (41) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/build.mjs +4 -1
  3. package/dist/lib/crud/errors.js +6 -1
  4. package/dist/lib/crud/errors.js.map +2 -2
  5. package/dist/lib/crud/factory.js +22 -13
  6. package/dist/lib/crud/factory.js.map +2 -2
  7. package/dist/lib/custom-fields/kinds.js +113 -0
  8. package/dist/lib/custom-fields/kinds.js.map +7 -0
  9. package/dist/lib/encryption/indexDoc.js +12 -9
  10. package/dist/lib/encryption/indexDoc.js.map +2 -2
  11. package/dist/lib/location/countries.generated.js +265 -0
  12. package/dist/lib/location/countries.generated.js.map +7 -0
  13. package/dist/lib/location/countries.js +2 -13
  14. package/dist/lib/location/countries.js.map +2 -2
  15. package/dist/lib/navigation/pageReload.js +11 -0
  16. package/dist/lib/navigation/pageReload.js.map +7 -0
  17. package/dist/lib/schedule/interval.js +33 -0
  18. package/dist/lib/schedule/interval.js.map +7 -0
  19. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  20. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  21. package/dist/lib/version.js +1 -1
  22. package/dist/lib/version.js.map +1 -1
  23. package/package.json +3 -2
  24. package/scripts/generate-countries.mjs +75 -0
  25. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  26. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  27. package/src/lib/crud/errors.ts +16 -0
  28. package/src/lib/crud/factory.ts +37 -15
  29. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  30. package/src/lib/custom-fields/kinds.ts +205 -0
  31. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  32. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  33. package/src/lib/encryption/indexDoc.ts +16 -7
  34. package/src/lib/location/__tests__/countries.test.ts +25 -0
  35. package/src/lib/location/countries.generated.ts +264 -0
  36. package/src/lib/location/countries.ts +2 -23
  37. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  38. package/src/lib/navigation/pageReload.ts +43 -0
  39. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  40. package/src/lib/schedule/interval.ts +45 -0
  41. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
@@ -0,0 +1,208 @@
1
+ import {
2
+ buildCustomFieldKindIndex,
3
+ createCustomFieldKindMapResolver,
4
+ emptyCustomFieldKindIndex,
5
+ mergeCustomFieldKindIndexes,
6
+ resolveCustomFieldKind,
7
+ selectCustomFieldKindMap,
8
+ } from '../kinds'
9
+
10
+ const ORG_A = '11111111-1111-4111-8111-111111111111'
11
+ const ORG_B = '22222222-2222-4222-8222-222222222222'
12
+ const TENANT = '33333333-3333-4333-8333-333333333333'
13
+
14
+ const kindMap = (rows: Parameters<typeof buildCustomFieldKindIndex>[0], orgId: string | null = null, tenantId: string | null = null) =>
15
+ selectCustomFieldKindMap(buildCustomFieldKindIndex(rows), orgId, tenantId)
16
+
17
+ describe('custom-fields/kinds', () => {
18
+ it('indexes a definition under both its authored key and its sanitized alias', () => {
19
+ expect(kindMap([{ key: 'order-ref', kind: 'text' }])).toEqual({ 'order-ref': 'text', order_ref: 'text' })
20
+ })
21
+
22
+ it('lets an authored key win over an alias another definition sanitizes to', () => {
23
+ const aliasFirst = kindMap([
24
+ { key: 'order-ref', kind: 'text' },
25
+ { key: 'order_ref', kind: 'integer' },
26
+ ])
27
+ const authoredFirst = kindMap([
28
+ { key: 'order_ref', kind: 'integer' },
29
+ { key: 'order-ref', kind: 'text' },
30
+ ])
31
+
32
+ expect(aliasFirst.order_ref).toBe('integer')
33
+ expect(authoredFirst.order_ref).toBe('integer')
34
+ })
35
+
36
+ it('normalizes rows with blank, non-string, or missing values', () => {
37
+ const map = kindMap([
38
+ { key: ' padded ', kind: 'text' },
39
+ { key: 'no-kind' },
40
+ { key: 'null-kind', kind: null },
41
+ { key: 'numeric-kind', kind: 7 },
42
+ { key: ' ' },
43
+ { key: null },
44
+ { key: 123, kind: 'text' },
45
+ ])
46
+
47
+ expect(map.padded).toBe('text')
48
+ expect(map['no-kind']).toBeNull()
49
+ expect(map['null-kind']).toBeNull()
50
+ expect(map['numeric-kind']).toBeNull()
51
+ expect(map['123']).toBe('text')
52
+ expect(Object.keys(map)).not.toContain('')
53
+ })
54
+
55
+ describe('scope resolution', () => {
56
+ // custom_field_defs has no unique constraint on (entity_id, key): an organization may
57
+ // override an inherited definition, and POST /definitions does not force the override's
58
+ // kind to match. Typing a value by the wrong scope's definition would turn an integer
59
+ // custom field into a string on read — the exact regression #5968 set out to avoid.
60
+ const collidingDefs = [
61
+ { key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
62
+ { key: 'code', kind: 'text', organizationId: ORG_B, tenantId: TENANT },
63
+ { key: 'code', kind: 'boolean', organizationId: null, tenantId: null },
64
+ ]
65
+
66
+ it('types a key by the definition of the row\'s own organization', () => {
67
+ expect(kindMap(collidingDefs, ORG_A, TENANT).code).toBe('integer')
68
+ expect(kindMap(collidingDefs, ORG_B, TENANT).code).toBe('text')
69
+ })
70
+
71
+ it('is not decided by the order rows came back in', () => {
72
+ const reversed = collidingDefs.slice().reverse()
73
+
74
+ expect(kindMap(reversed, ORG_A, TENANT).code).toBe('integer')
75
+ expect(kindMap(reversed, ORG_B, TENANT).code).toBe('text')
76
+ })
77
+
78
+ it('prefers a tenant-scoped definition over a global one when no organization matches', () => {
79
+ const defs = [
80
+ { key: 'code', kind: 'boolean', organizationId: null, tenantId: null },
81
+ { key: 'code', kind: 'float', organizationId: null, tenantId: TENANT },
82
+ ]
83
+
84
+ expect(kindMap(defs, null, TENANT).code).toBe('float')
85
+ })
86
+
87
+ it('falls back to the global definition for an organization that defines none', () => {
88
+ const unknownOrg = '44444444-4444-4444-8444-444444444444'
89
+
90
+ expect(kindMap(collidingDefs, unknownOrg, TENANT).code).toBe('boolean')
91
+ })
92
+
93
+ it('breaks a same-scope tie deterministically rather than by row order', () => {
94
+ const defs = [
95
+ { key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT, configJson: { priority: 5 } },
96
+ { key: 'code', kind: 'text', organizationId: ORG_A, tenantId: TENANT, configJson: { priority: 1 } },
97
+ ]
98
+
99
+ expect(kindMap(defs, ORG_A, TENANT).code).toBe('text')
100
+ expect(kindMap(defs.slice().reverse(), ORG_A, TENANT).code).toBe('text')
101
+ })
102
+
103
+ it('applies the same scope rule to a sanitized alias as to an authored key', () => {
104
+ const defs = [
105
+ { key: 'order-ref', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
106
+ { key: 'order-ref', kind: 'text', organizationId: ORG_B, tenantId: TENANT },
107
+ ]
108
+
109
+ expect(kindMap(defs, ORG_A, TENANT).order_ref).toBe('integer')
110
+ expect(kindMap(defs, ORG_B, TENANT).order_ref).toBe('text')
111
+ })
112
+ })
113
+
114
+ describe('createCustomFieldKindMapResolver', () => {
115
+ const index = buildCustomFieldKindIndex([
116
+ { key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
117
+ { key: 'code', kind: 'text', organizationId: ORG_B, tenantId: TENANT },
118
+ ])
119
+
120
+ it('gives each organization its own map', () => {
121
+ const resolve = createCustomFieldKindMapResolver(index)
122
+
123
+ expect(resolve(ORG_A, TENANT).code).toBe('integer')
124
+ expect(resolve(ORG_B, TENANT).code).toBe('text')
125
+ })
126
+
127
+ it('reuses the map for a repeated scope', () => {
128
+ const resolve = createCustomFieldKindMapResolver(index)
129
+
130
+ expect(resolve(ORG_A, TENANT)).toBe(resolve(ORG_A, TENANT))
131
+ expect(resolve(ORG_A, TENANT)).not.toBe(resolve(ORG_B, TENANT))
132
+ })
133
+
134
+ it('yields an empty map for an absent index', () => {
135
+ expect(createCustomFieldKindMapResolver(null)(ORG_A, TENANT)).toEqual({})
136
+ })
137
+ })
138
+
139
+ describe('mergeCustomFieldKindIndexes', () => {
140
+ it('lets the earliest source win when several define the same key', () => {
141
+ // The query engine reads a cf value with `coalesce(source0, source1, ...)`, so the kind
142
+ // has to come from the same source the value did.
143
+ const base = buildCustomFieldKindIndex([{ key: 'code', kind: 'text' }])
144
+ const joined = buildCustomFieldKindIndex([
145
+ { key: 'code', kind: 'integer' },
146
+ { key: 'extra', kind: 'boolean' },
147
+ ])
148
+
149
+ expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([base, joined]), null, null))
150
+ .toEqual({ code: 'text', extra: 'boolean' })
151
+ expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([joined, base]), null, null))
152
+ .toEqual({ code: 'integer', extra: 'boolean' })
153
+ })
154
+
155
+ it('keeps the earlier source winning per scope, not just for the default one', () => {
156
+ const base = buildCustomFieldKindIndex([
157
+ { key: 'code', kind: 'text', organizationId: ORG_A, tenantId: TENANT },
158
+ ])
159
+ const joined = buildCustomFieldKindIndex([
160
+ { key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
161
+ ])
162
+
163
+ expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([base, joined]), ORG_A, TENANT).code).toBe('text')
164
+ expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([joined, base]), ORG_A, TENANT).code).toBe('integer')
165
+ })
166
+
167
+ it('ignores absent sources when merging', () => {
168
+ const index = buildCustomFieldKindIndex([{ key: 'code', kind: 'text' }])
169
+
170
+ expect(selectCustomFieldKindMap(
171
+ mergeCustomFieldKindIndexes([null, index, undefined, emptyCustomFieldKindIndex()]), null, null,
172
+ )).toEqual({ code: 'text' })
173
+ expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([]), null, null)).toEqual({})
174
+ })
175
+
176
+ it('keeps a null kind from an earlier source instead of falling through', () => {
177
+ const base = buildCustomFieldKindIndex([{ key: 'code' }])
178
+ const joined = buildCustomFieldKindIndex([{ key: 'code', kind: 'integer' }])
179
+
180
+ expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([base, joined]), null, null).code).toBeNull()
181
+ })
182
+ })
183
+
184
+ describe('resolveCustomFieldKind', () => {
185
+ it('resolves both index-document key shapes', () => {
186
+ const map = kindMap([{ key: 'note', kind: 'multiline' }])
187
+
188
+ expect(resolveCustomFieldKind(map, 'cf:note')).toBe('multiline')
189
+ expect(resolveCustomFieldKind(map, 'cf_note')).toBe('multiline')
190
+ })
191
+
192
+ it('resolves a sanitized document key back to its authored definition', () => {
193
+ const map = kindMap([{ key: 'order-ref', kind: 'text' }])
194
+
195
+ expect(resolveCustomFieldKind(map, 'cf_order_ref')).toBe('text')
196
+ expect(resolveCustomFieldKind(map, 'cf:order-ref')).toBe('text')
197
+ })
198
+
199
+ it('returns null for an unknown key, an absent map, or a bare prefix', () => {
200
+ const map = kindMap([{ key: 'note', kind: 'text' }])
201
+
202
+ expect(resolveCustomFieldKind(map, 'cf:missing')).toBeNull()
203
+ expect(resolveCustomFieldKind(null, 'cf:note')).toBeNull()
204
+ expect(resolveCustomFieldKind(undefined, 'cf:note')).toBeNull()
205
+ expect(resolveCustomFieldKind(map, 'cf:')).toBeNull()
206
+ })
207
+ })
208
+ })
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Custom-field `kind` lookup for the `cf:`/`cf_` keys carried by a query-index document.
3
+ *
4
+ * `decryptCustomFieldValue` cannot recover a value's original JS type from the ciphertext
5
+ * alone: the encrypt path stores a raw string unwrapped and JSON-stringifies everything
6
+ * else, so the string `"123"` and the number `123` produce the same plaintext. The field's
7
+ * `kind` is the only disambiguator, which is why every index-document read path has to
8
+ * resolve it before decrypting (issue #5968).
9
+ *
10
+ * `custom_field_defs` has no unique constraint on `(entity_id, key)` — per-scope overrides
11
+ * are a first-class feature, and an organization's override may declare a different `kind`
12
+ * than the tenant-wide or global definition it shadows. A flat "last row wins" map would
13
+ * therefore type a value from whichever definition the database happened to return last.
14
+ * So the index keeps every candidate definition per key and resolves the winner against the
15
+ * scope of the row being decrypted, through the same `selectDefinitionForRecord` precedence
16
+ * the non-indexed loader in `crud/custom-fields.ts` already applies per record.
17
+ */
18
+ import {
19
+ selectDefinitionForRecord,
20
+ sortDefinitionSummaries,
21
+ summarizeDefinitionRow,
22
+ type CustomFieldDefinitionSummary,
23
+ } from '../crud/custom-field-definition-index'
24
+
25
+ export type CustomFieldKindMap = Record<string, string | null>
26
+
27
+ export type CustomFieldKindRow = {
28
+ key: unknown
29
+ kind?: unknown
30
+ organizationId?: unknown
31
+ tenantId?: unknown
32
+ configJson?: unknown
33
+ updatedAt?: unknown
34
+ }
35
+
36
+ /**
37
+ * Candidate definitions per document key. `keys` holds the authored keys; `aliases` holds
38
+ * the SQL-safe aliases only, and is consulted solely when the document key matches no
39
+ * authored key — so a literal `my_field` definition is never shadowed by the alias a
40
+ * `my-field` definition also sanitizes to.
41
+ */
42
+ export type CustomFieldKindIndex = {
43
+ keys: Map<string, CustomFieldDefinitionSummary[]>
44
+ aliases: Map<string, CustomFieldDefinitionSummary[]>
45
+ }
46
+
47
+ /** Resolves the kind map that applies to a row in the given scope. */
48
+ export type CustomFieldKindMapResolver = (
49
+ organizationId: string | null | undefined,
50
+ tenantId: string | null | undefined,
51
+ ) => CustomFieldKindMap
52
+
53
+ const CF_KEY_PREFIX = /^cf[:_]/
54
+
55
+ /**
56
+ * Mirrors `HybridQueryEngine.sanitize()`, which aliases the doc key `cf:my-field` as the
57
+ * SQL-safe column alias `cf_my_field`. The transform is lossy, so an index keyed only by the
58
+ * authored key would never match a sanitized alias.
59
+ */
60
+ function sanitizeKey(key: string): string {
61
+ return key.replace(/[^a-zA-Z0-9_]/g, '_')
62
+ }
63
+
64
+ function normalizeKey(value: unknown): string {
65
+ if (typeof value === 'string') return value.trim()
66
+ if (value == null) return ''
67
+ return String(value).trim()
68
+ }
69
+
70
+ function normalizeScopeId(value: unknown): string | null {
71
+ if (typeof value !== 'string') return null
72
+ const trimmed = value.trim()
73
+ return trimmed.length ? trimmed : null
74
+ }
75
+
76
+ function normalizeTimestamp(value: unknown): Date | string | number | null {
77
+ if (value instanceof Date) return value
78
+ if (typeof value === 'string' || typeof value === 'number') return value
79
+ return null
80
+ }
81
+
82
+ function pushCandidate(
83
+ bucket: Map<string, CustomFieldDefinitionSummary[]>,
84
+ key: string,
85
+ summary: CustomFieldDefinitionSummary,
86
+ ): void {
87
+ const existing = bucket.get(key)
88
+ if (existing) existing.push(summary)
89
+ else bucket.set(key, [summary])
90
+ }
91
+
92
+ export function buildCustomFieldKindIndex(rows: Iterable<CustomFieldKindRow>): CustomFieldKindIndex {
93
+ const keys = new Map<string, CustomFieldDefinitionSummary[]>()
94
+ const aliases = new Map<string, CustomFieldDefinitionSummary[]>()
95
+ for (const row of rows) {
96
+ const key = normalizeKey(row.key)
97
+ if (!key) continue
98
+ const summary = summarizeDefinitionRow({
99
+ key,
100
+ entityId: '',
101
+ kind: typeof row.kind === 'string' && row.kind.length ? row.kind : null,
102
+ configJson: row.configJson ?? null,
103
+ organizationId: normalizeScopeId(row.organizationId),
104
+ tenantId: normalizeScopeId(row.tenantId),
105
+ deletedAt: null,
106
+ updatedAt: normalizeTimestamp(row.updatedAt),
107
+ })
108
+ if (!summary) continue
109
+ pushCandidate(keys, key, summary)
110
+ const alias = sanitizeKey(key)
111
+ if (alias !== key) pushCandidate(aliases, alias, summary)
112
+ }
113
+ // One precedence rule everywhere: `selectDefinitionForRecord` picks the scope winner and
114
+ // `sortDefinitionSummaries` breaks ties deterministically, for authored keys and aliases
115
+ // alike, so no bucket depends on the order rows arrived in.
116
+ for (const [key, candidates] of keys) keys.set(key, sortDefinitionSummaries(candidates))
117
+ for (const [alias, candidates] of aliases) aliases.set(alias, sortDefinitionSummaries(candidates))
118
+ return { keys, aliases }
119
+ }
120
+
121
+ export function emptyCustomFieldKindIndex(): CustomFieldKindIndex {
122
+ return { keys: new Map(), aliases: new Map() }
123
+ }
124
+
125
+ /**
126
+ * Merges per-source indexes into one, with earlier sources winning a key outright. Callers
127
+ * pass their sources in the same priority order the value itself resolves in, so a key
128
+ * defined by more than one source is typed by the source the value actually came from.
129
+ */
130
+ export function mergeCustomFieldKindIndexes(
131
+ indexes: Array<CustomFieldKindIndex | null | undefined>,
132
+ ): CustomFieldKindIndex {
133
+ const merged = emptyCustomFieldKindIndex()
134
+ for (const index of indexes) {
135
+ if (!index) continue
136
+ for (const [key, candidates] of index.keys) {
137
+ if (!merged.keys.has(key)) merged.keys.set(key, candidates)
138
+ }
139
+ for (const [alias, candidates] of index.aliases) {
140
+ if (!merged.aliases.has(alias)) merged.aliases.set(alias, candidates)
141
+ }
142
+ }
143
+ return merged
144
+ }
145
+
146
+ /**
147
+ * Flattens the index to the kind map that applies to one scope. The winner per key is the
148
+ * organization's own definition, then a tenant-scoped one, then a global one — the order
149
+ * `selectDefinitionForRecord` encodes and the whole codebase resolves definitions by.
150
+ */
151
+ export function selectCustomFieldKindMap(
152
+ index: CustomFieldKindIndex | null | undefined,
153
+ organizationId: string | null | undefined,
154
+ tenantId: string | null | undefined,
155
+ ): CustomFieldKindMap {
156
+ const map: CustomFieldKindMap = {}
157
+ if (!index) return map
158
+ const orgId = organizationId ?? null
159
+ const tenant = tenantId ?? null
160
+ for (const [key, candidates] of index.keys) {
161
+ map[key] = selectDefinitionForRecord(candidates, orgId, tenant)?.kind ?? null
162
+ }
163
+ for (const [alias, candidates] of index.aliases) {
164
+ if (alias in map) continue
165
+ map[alias] = selectDefinitionForRecord(candidates, orgId, tenant)?.kind ?? null
166
+ }
167
+ return map
168
+ }
169
+
170
+ /**
171
+ * Memoizing wrapper for the common read shape: many rows, few distinct scopes. A list query
172
+ * whose rows all share one organization resolves the map once; a global search spanning
173
+ * organizations resolves one map per organization rather than one per row.
174
+ */
175
+ export function createCustomFieldKindMapResolver(
176
+ index: CustomFieldKindIndex | null | undefined,
177
+ ): CustomFieldKindMapResolver {
178
+ const cache = new Map<string, CustomFieldKindMap>()
179
+ return (organizationId, tenantId) => {
180
+ const scopeKey = `${organizationId ?? ''}::${tenantId ?? ''}`
181
+ const cached = cache.get(scopeKey)
182
+ if (cached) return cached
183
+ const map = selectCustomFieldKindMap(index, organizationId, tenantId)
184
+ cache.set(scopeKey, map)
185
+ return map
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Resolves the `kind` for a `cf:`/`cf_`-prefixed index-document key. Returns `null` when the
191
+ * key has no active definition — callers then keep the historical no-`kind` behavior rather
192
+ * than guessing, so an unresolved lookup degrades instead of corrupting the value.
193
+ */
194
+ export function resolveCustomFieldKind(
195
+ kinds: CustomFieldKindMap | null | undefined,
196
+ docKey: string,
197
+ ): string | null {
198
+ if (!kinds) return null
199
+ const bare = docKey.replace(CF_KEY_PREFIX, '')
200
+ if (!bare) return null
201
+ if (bare in kinds) return kinds[bare] ?? null
202
+ const alias = sanitizeKey(bare)
203
+ if (alias !== bare && alias in kinds) return kinds[alias] ?? null
204
+ return null
205
+ }
@@ -0,0 +1,127 @@
1
+ import { decryptIndexDocCustomFields, decryptIndexDocForSearch } from '../indexDoc'
2
+ import { encryptCustomFieldValue } from '../customFieldValues'
3
+ import {
4
+ buildCustomFieldKindIndex,
5
+ selectCustomFieldKindMap,
6
+ type CustomFieldKindRow,
7
+ } from '../../custom-fields/kinds'
8
+
9
+ function buildCustomFieldKindMap(rows: CustomFieldKindRow[]) {
10
+ return selectCustomFieldKindMap(buildCustomFieldKindIndex(rows), null, null)
11
+ }
12
+
13
+ /**
14
+ * Regression coverage for issue #5968: the query-index and search read paths decrypted
15
+ * `cf:`/`cf_` values without the field's `kind`, so `decryptCustomFieldValue` fell through
16
+ * to `JSON.parse` and retyped every string-typed custom field whose plaintext looked like
17
+ * JSON. Unlike the sibling `indexDoc.test.ts`, this suite does NOT mock
18
+ * `decryptCustomFieldValue` — the defect lives in the argument the helpers pass to it, so a
19
+ * mocked value would hide it.
20
+ */
21
+
22
+ const fixedKey = Buffer.alloc(32, 7).toString('base64')
23
+
24
+ const service = {
25
+ isEnabled: () => true,
26
+ getDek: async () => ({ key: fixedKey }),
27
+ } as any
28
+
29
+ const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
30
+
31
+ async function encrypt(value: unknown): Promise<unknown> {
32
+ return encryptCustomFieldValue(value, scope.tenantId, service, new Map())
33
+ }
34
+
35
+ describe('encryption/indexDoc custom field kinds (#5968)', () => {
36
+ it.each([
37
+ ['123', 'text'],
38
+ ['0042', 'multiline'],
39
+ ['true', 'select'],
40
+ ['false', 'text'],
41
+ ['null', 'text'],
42
+ ['{"nested":1}', 'multiline'],
43
+ ['[1,2,3]', 'text'],
44
+ ['1.5e3', 'currency'],
45
+ ])('keeps the string %p verbatim for a %s field', async (plaintext, kind) => {
46
+ const doc = { id: '1', 'cf:note': await encrypt(plaintext) }
47
+ const kinds = buildCustomFieldKindMap([{ key: 'note', kind }])
48
+
49
+ const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
50
+
51
+ expect(out['cf:note']).toBe(plaintext)
52
+ })
53
+
54
+ it('still coerces numeric and boolean kinds through the JSON round trip', async () => {
55
+ const doc = {
56
+ id: '1',
57
+ 'cf:count': await encrypt(42),
58
+ 'cf:ratio': await encrypt(3.14),
59
+ 'cf:flag': await encrypt(true),
60
+ }
61
+ const kinds = buildCustomFieldKindMap([
62
+ { key: 'count', kind: 'integer' },
63
+ { key: 'ratio', kind: 'float' },
64
+ { key: 'flag', kind: 'boolean' },
65
+ ])
66
+
67
+ const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
68
+
69
+ expect(out['cf:count']).toBe(42)
70
+ expect(out['cf:ratio']).toBe(3.14)
71
+ expect(out['cf:flag']).toBe(true)
72
+ })
73
+
74
+ it('applies the kind to every entry of a multi-value field', async () => {
75
+ const doc = { id: '1', 'cf:codes': [await encrypt('1'), await encrypt('2')] }
76
+ const kinds = buildCustomFieldKindMap([{ key: 'codes', kind: 'text' }])
77
+
78
+ const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
79
+
80
+ expect(out['cf:codes']).toEqual(['1', '2'])
81
+ })
82
+
83
+ it('resolves the sanitized `cf_` alias the hybrid query engine emits', async () => {
84
+ // `HybridQueryEngine.sanitize()` turns the doc key `cf:order-ref` into `cf_order_ref`,
85
+ // so a map keyed only by the authored key would miss it.
86
+ const doc = { id: '1', cf_order_ref: await encrypt('900') }
87
+ const kinds = buildCustomFieldKindMap([{ key: 'order-ref', kind: 'text' }])
88
+
89
+ const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
90
+
91
+ expect(out.cf_order_ref).toBe('900')
92
+ })
93
+
94
+ it('falls back to the legacy JSON round trip when the kind cannot be resolved', async () => {
95
+ const doc = { id: '1', 'cf:unknown': await encrypt('123') }
96
+
97
+ const withoutMap = await decryptIndexDocCustomFields(doc, scope, service, new Map())
98
+ const withEmptyMap = await decryptIndexDocCustomFields(doc, scope, service, new Map(), {})
99
+
100
+ expect(withoutMap['cf:unknown']).toBe(123)
101
+ expect(withEmptyMap['cf:unknown']).toBe(123)
102
+ })
103
+
104
+ it('threads the kind map through decryptIndexDocForSearch', async () => {
105
+ const searchService = {
106
+ ...service,
107
+ isEnabled: () => true,
108
+ decryptEntityPayload: async () => ({ title: 'Plain' }),
109
+ } as any
110
+ const doc = { id: '1', title: 'Encrypted', 'cf:note': await encrypt('123') }
111
+ const kinds = buildCustomFieldKindMap([{ key: 'note', kind: 'text' }])
112
+
113
+ const out = await decryptIndexDocForSearch('example:todo', doc, scope, searchService, new Map(), kinds)
114
+
115
+ expect(out.title).toBe('Plain')
116
+ expect(out['cf:note']).toBe('123')
117
+ })
118
+
119
+ it('threads the kind map through decryptIndexDocForSearch without an entity payload decryptor', async () => {
120
+ const doc = { id: '1', 'cf:note': await encrypt('true') }
121
+ const kinds = buildCustomFieldKindMap([{ key: 'note', kind: 'text' }])
122
+
123
+ const out = await decryptIndexDocForSearch('example:todo', doc, scope, service, new Map(), kinds)
124
+
125
+ expect(out['cf:note']).toBe('true')
126
+ })
127
+ })
@@ -39,6 +39,45 @@ describe('encryption/indexDoc', () => {
39
39
  expect(decryptCustomFieldValueMock).toHaveBeenCalled()
40
40
  })
41
41
 
42
+ test('decryptIndexDocCustomFields passes each key its resolved kind (#5968)', async () => {
43
+ const doc = { id: '1', 'cf:note': 'enc', 'cf:count': 'enc', cf_order_ref: 'enc' }
44
+ const kinds = { note: 'text', count: 'integer', 'order-ref': 'text', order_ref: 'text' }
45
+
46
+ await decryptIndexDocCustomFields(doc, { tenantId: 't1', organizationId: 'org1' }, {} as any, undefined, kinds)
47
+
48
+ expect(decryptCustomFieldValueMock).toHaveBeenCalledTimes(3)
49
+ const kindsPassed = decryptCustomFieldValueMock.mock.calls.map((call) => call[4]?.kind).sort()
50
+ expect(kindsPassed).toEqual(['integer', 'text', 'text'])
51
+ })
52
+
53
+ test('decryptIndexDocCustomFields passes a null kind when no map is supplied (#5968)', async () => {
54
+ const doc = { id: '1', 'cf:note': 'enc' }
55
+
56
+ await decryptIndexDocCustomFields(doc, { tenantId: 't1', organizationId: 'org1' }, {} as any)
57
+
58
+ expect(decryptCustomFieldValueMock).toHaveBeenCalledWith('enc', 't1', {}, undefined, { kind: null })
59
+ })
60
+
61
+ test('decryptIndexDocForSearch forwards the kind map to the cf decryption (#5968)', async () => {
62
+ const service = {
63
+ isEnabled: () => true,
64
+ decryptEntityPayload: jest.fn(async () => ({})),
65
+ }
66
+
67
+ await decryptIndexDocForSearch(
68
+ 'example:todo',
69
+ { id: '1', 'cf:note': 'enc' },
70
+ { tenantId: 't1', organizationId: 'org1' },
71
+ service as any,
72
+ undefined,
73
+ { note: 'multiline' },
74
+ )
75
+
76
+ expect(decryptCustomFieldValueMock).toHaveBeenCalledWith(
77
+ 'enc', 't1', service, undefined, { kind: 'multiline' },
78
+ )
79
+ })
80
+
42
81
  test('decryptIndexDocForSearch merges decrypted entity payload and decrypts cf keys', async () => {
43
82
  decryptCustomFieldValueMock.mockImplementation(async (value: unknown) => (value === 'enc' ? 'dec' : value))
44
83
 
@@ -1,4 +1,5 @@
1
1
  import { decryptCustomFieldValue } from './customFieldValues'
2
+ import { resolveCustomFieldKind, type CustomFieldKindMap } from '../custom-fields/kinds'
2
3
  import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
3
4
 
4
5
  export type IndexDocScope = {
@@ -10,12 +11,15 @@ async function decryptValue(
10
11
  value: unknown,
11
12
  scope: IndexDocScope,
12
13
  service: TenantDataEncryptionService | null,
13
- cache?: Map<string | null, string | null>,
14
+ cache: Map<string | null, string | null> | undefined,
15
+ kind: string | null,
14
16
  ): Promise<unknown> {
15
17
  if (Array.isArray(value)) {
16
- return Promise.all(value.map((entry) => decryptCustomFieldValue(entry, scope.tenantId, service, cache)))
18
+ return Promise.all(
19
+ value.map((entry) => decryptCustomFieldValue(entry, scope.tenantId, service, cache, { kind })),
20
+ )
17
21
  }
18
- return decryptCustomFieldValue(value, scope.tenantId, service, cache)
22
+ return decryptCustomFieldValue(value, scope.tenantId, service, cache, { kind })
19
23
  }
20
24
 
21
25
  export async function decryptIndexDocCustomFields(
@@ -23,6 +27,7 @@ export async function decryptIndexDocCustomFields(
23
27
  scope: IndexDocScope,
24
28
  service: TenantDataEncryptionService | null,
25
29
  cache?: Map<string | null, string | null>,
30
+ kinds?: CustomFieldKindMap | null,
26
31
  ): Promise<Record<string, unknown>> {
27
32
  // HybridQueryEngine aliases cf keys as `cf_<key>` (sanitized), while index docs use `cf:<key>`.
28
33
  // Support both shapes to keep decryption consistent across query paths.
@@ -33,7 +38,10 @@ export async function decryptIndexDocCustomFields(
33
38
  await Promise.all(
34
39
  keys.map(async (key) => {
35
40
  try {
36
- working[key] = await decryptValue(working[key], scope, service, cache)
41
+ // Without the field's kind, `decryptCustomFieldValue` falls back to `JSON.parse` and
42
+ // retypes string-typed values whose plaintext looks like JSON — `"123"` becomes 123
43
+ // and `"true"` becomes true (issue #5968). An unresolved kind keeps the legacy path.
44
+ working[key] = await decryptValue(working[key], scope, service, cache, resolveCustomFieldKind(kinds, key))
37
45
  } catch {
38
46
  // ignore; keep original value
39
47
  }
@@ -48,12 +56,13 @@ export async function decryptIndexDocForSearch(
48
56
  scope: IndexDocScope,
49
57
  service: TenantDataEncryptionService | null,
50
58
  cache?: Map<string | null, string | null>,
59
+ kinds?: CustomFieldKindMap | null,
51
60
  ): Promise<Record<string, unknown>> {
52
61
  if (!service || typeof service.decryptEntityPayload !== 'function') {
53
- return decryptIndexDocCustomFields(doc, scope, service, cache)
62
+ return decryptIndexDocCustomFields(doc, scope, service, cache, kinds)
54
63
  }
55
64
  if (service.isEnabled?.() === false) {
56
- return decryptIndexDocCustomFields(doc, scope, service, cache)
65
+ return decryptIndexDocCustomFields(doc, scope, service, cache, kinds)
57
66
  }
58
67
 
59
68
  let working: Record<string, unknown> = doc
@@ -72,7 +81,7 @@ export async function decryptIndexDocForSearch(
72
81
  await decryptEntity('customers:customer_entity')
73
82
  }
74
83
 
75
- return decryptIndexDocCustomFields(working, scope, service, cache)
84
+ return decryptIndexDocCustomFields(working, scope, service, cache, kinds)
76
85
  }
77
86
 
78
87
  export async function encryptIndexDocForStorage(