@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.
- package/.turbo/turbo-build.log +2 -1
- package/build.mjs +4 -1
- package/dist/lib/crud/errors.js +6 -1
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -13
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/custom-fields/kinds.js +113 -0
- package/dist/lib/custom-fields/kinds.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +12 -9
- package/dist/lib/encryption/indexDoc.js.map +2 -2
- package/dist/lib/location/countries.generated.js +265 -0
- package/dist/lib/location/countries.generated.js.map +7 -0
- package/dist/lib/location/countries.js +2 -13
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/navigation/pageReload.js +11 -0
- package/dist/lib/navigation/pageReload.js.map +7 -0
- package/dist/lib/schedule/interval.js +33 -0
- package/dist/lib/schedule/interval.js.map +7 -0
- package/dist/lib/schedule/invalidScheduleValue.js +19 -0
- package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +3 -2
- package/scripts/generate-countries.mjs +75 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
- package/src/lib/crud/__tests__/errors.test.ts +31 -1
- package/src/lib/crud/errors.ts +16 -0
- package/src/lib/crud/factory.ts +37 -15
- package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
- package/src/lib/custom-fields/kinds.ts +205 -0
- package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
- package/src/lib/encryption/indexDoc.ts +16 -7
- package/src/lib/location/__tests__/countries.test.ts +25 -0
- package/src/lib/location/countries.generated.ts +264 -0
- package/src/lib/location/countries.ts +2 -23
- package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
- package/src/lib/navigation/pageReload.ts +43 -0
- package/src/lib/schedule/__tests__/interval.test.ts +37 -0
- package/src/lib/schedule/interval.ts +45 -0
- 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
|
|
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(
|
|
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
|
-
|
|
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(
|