@open-mercato/shared 0.6.5-develop.5048.1.fd82f4ae17 → 0.6.5-develop.5099.1.85c0aff78c
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/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/encryption/kms.js +43 -15
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +31 -4
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +11 -5
- package/src/lib/encryption/kms.ts +60 -15
- package/src/lib/encryption/tenantDataEncryptionService.ts +37 -4
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/vector.ts +7 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveRegisteredEntityTableName,
|
|
3
|
+
resolveEntityTableName,
|
|
4
|
+
isValidEntityIdShape,
|
|
5
|
+
ENTITY_ID_PATTERN,
|
|
6
|
+
} from '../engine'
|
|
7
|
+
|
|
8
|
+
function makeEm(metaByClass: Record<string, string>) {
|
|
9
|
+
const all = Object.entries(metaByClass).map(([className, tableName]) => ({ className, tableName }))
|
|
10
|
+
return {
|
|
11
|
+
getMetadata: () => ({
|
|
12
|
+
find: (className: string) => {
|
|
13
|
+
const tableName = metaByClass[className]
|
|
14
|
+
return tableName ? { tableName } : undefined
|
|
15
|
+
},
|
|
16
|
+
getAll: () => all,
|
|
17
|
+
}),
|
|
18
|
+
} as any
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('resolveRegisteredEntityTableName', () => {
|
|
22
|
+
it('resolves a registered entity via class-name metadata', () => {
|
|
23
|
+
const em = makeEm({ Todo: 'todos' })
|
|
24
|
+
expect(resolveRegisteredEntityTableName(em, 'example:todo')).toBe('todos')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('resolves a registered entity via the secondary table-name lookup', () => {
|
|
28
|
+
const em = makeEm({ SomeOtherClass: 'directory_organizations' })
|
|
29
|
+
expect(resolveRegisteredEntityTableName(em, 'directory:organization')).toBe('directory_organizations')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns null for an entity type that does not map to any registered metadata', () => {
|
|
33
|
+
const em = makeEm({ Todo: 'todos' })
|
|
34
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:auth_user')).toBeNull()
|
|
35
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:user')).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns null when no metadata is available', () => {
|
|
39
|
+
expect(resolveRegisteredEntityTableName(undefined, 'example:todo')).toBeNull()
|
|
40
|
+
expect(resolveRegisteredEntityTableName({} as any, 'example:todo')).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('never pluralizes attacker-chosen ids into a real table name', () => {
|
|
44
|
+
const em = makeEm({})
|
|
45
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:auth_user')).toBeNull()
|
|
46
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:user')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('resolveEntityTableName fallback (broad query path)', () => {
|
|
51
|
+
it('still falls back to a pluralized guess for unregistered ids', () => {
|
|
52
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
53
|
+
try {
|
|
54
|
+
const em = makeEm({})
|
|
55
|
+
expect(resolveEntityTableName(em, 'foo:never_registered_widget')).toBe('never_registered_widgets')
|
|
56
|
+
} finally {
|
|
57
|
+
warnSpy.mockRestore()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('isValidEntityIdShape', () => {
|
|
63
|
+
it('accepts canonical module:entity ids', () => {
|
|
64
|
+
expect(isValidEntityIdShape('example:todo')).toBe(true)
|
|
65
|
+
expect(isValidEntityIdShape('directory:organization')).toBe(true)
|
|
66
|
+
expect(isValidEntityIdShape('query_index:search_token')).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects ids without exactly two snake_case segments', () => {
|
|
70
|
+
expect(isValidEntityIdShape('todos')).toBe(false)
|
|
71
|
+
expect(isValidEntityIdShape('auth_users')).toBe(false)
|
|
72
|
+
expect(isValidEntityIdShape('foo:bar:baz')).toBe(false)
|
|
73
|
+
expect(isValidEntityIdShape('Foo:Bar')).toBe(false)
|
|
74
|
+
expect(isValidEntityIdShape('1bad:entity')).toBe(false)
|
|
75
|
+
expect(isValidEntityIdShape('foo:')).toBe(false)
|
|
76
|
+
expect(isValidEntityIdShape(':bar')).toBe(false)
|
|
77
|
+
expect(isValidEntityIdShape('foo bar:baz')).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('exposes the pattern for schema reuse', () => {
|
|
81
|
+
expect(ENTITY_ID_PATTERN.test('example:todo')).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -42,6 +42,15 @@ type ResolvedCustomFieldSource = {
|
|
|
42
42
|
|
|
43
43
|
type ResultRow = Record<string, unknown>
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Canonical `module:entity` entity-id shape (both segments snake_case,
|
|
47
|
+
* starting with a lowercase letter). Used to validate caller-supplied entity
|
|
48
|
+
* ids at security-sensitive boundaries before they reach table resolution.
|
|
49
|
+
*/
|
|
50
|
+
export const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/
|
|
51
|
+
|
|
52
|
+
export const isValidEntityIdShape = (value: string): boolean => ENTITY_ID_PATTERN.test(value)
|
|
53
|
+
|
|
45
54
|
const pluralizeBaseName = (name: string): string => {
|
|
46
55
|
if (!name) return name
|
|
47
56
|
if (name.endsWith('s')) return name
|
|
@@ -65,46 +74,66 @@ const candidateClassNames = (rawName: string): string[] => {
|
|
|
65
74
|
return Array.from(candidates)
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Resolve an entity id to a table name strictly via registered MikroORM metadata.
|
|
79
|
+
*
|
|
80
|
+
* Unlike {@link resolveEntityTableName}, this never falls back to a pluralized
|
|
81
|
+
* guess: it returns `null` when no registered entity matches. Use it for
|
|
82
|
+
* security-sensitive call sites (e.g. the reindexer) that must refuse to read
|
|
83
|
+
* arbitrary, attacker-chosen tables that happen to exist in the schema.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveRegisteredEntityTableName(
|
|
86
|
+
em: EntityManager | undefined,
|
|
87
|
+
entity: EntityId,
|
|
88
|
+
): string | null {
|
|
72
89
|
const parts = String(entity || '').split(':')
|
|
73
90
|
const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
|
|
74
91
|
const metadata = (em as any)?.getMetadata?.()
|
|
75
92
|
|
|
76
|
-
if (metadata
|
|
77
|
-
const candidates = candidateClassNames(rawName)
|
|
78
|
-
for (const candidate of candidates) {
|
|
79
|
-
try {
|
|
80
|
-
const meta = metadata.find?.(candidate)
|
|
81
|
-
if (meta?.tableName) {
|
|
82
|
-
const tableName = String(meta.tableName)
|
|
83
|
-
entityTableCache.set(entity, tableName)
|
|
84
|
-
return tableName
|
|
85
|
-
}
|
|
86
|
-
} catch {}
|
|
87
|
-
}
|
|
93
|
+
if (!metadata || !rawName) return null
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const candidateTables = [
|
|
92
|
-
`${modulePrefix}_${rawName}`,
|
|
93
|
-
pluralizeBaseName(rawName),
|
|
94
|
-
`${modulePrefix}_${pluralizeBaseName(rawName)}`,
|
|
95
|
-
]
|
|
95
|
+
const candidates = candidateClassNames(rawName)
|
|
96
|
+
for (const candidate of candidates) {
|
|
96
97
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const tableName = String(meta.tableName)
|
|
101
|
-
entityTableCache.set(entity, tableName)
|
|
102
|
-
return tableName
|
|
103
|
-
}
|
|
98
|
+
const meta = metadata.find?.(candidate)
|
|
99
|
+
if (meta?.tableName) {
|
|
100
|
+
return String(meta.tableName)
|
|
104
101
|
}
|
|
105
102
|
} catch {}
|
|
106
103
|
}
|
|
107
104
|
|
|
105
|
+
// Secondary lookup: search ORM metadata by candidate table names
|
|
106
|
+
const modulePrefix = parts[0] ?? ''
|
|
107
|
+
const candidateTables = [
|
|
108
|
+
`${modulePrefix}_${rawName}`,
|
|
109
|
+
pluralizeBaseName(rawName),
|
|
110
|
+
`${modulePrefix}_${pluralizeBaseName(rawName)}`,
|
|
111
|
+
]
|
|
112
|
+
try {
|
|
113
|
+
const allMeta: any[] = metadata.getAll?.() ?? []
|
|
114
|
+
for (const meta of allMeta) {
|
|
115
|
+
if (meta?.tableName && candidateTables.includes(String(meta.tableName))) {
|
|
116
|
+
return String(meta.tableName)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveEntityTableName(em: EntityManager | undefined, entity: EntityId): string {
|
|
125
|
+
if (entityTableCache.has(entity)) {
|
|
126
|
+
return entityTableCache.get(entity)!
|
|
127
|
+
}
|
|
128
|
+
const parts = String(entity || '').split(':')
|
|
129
|
+
const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
|
|
130
|
+
|
|
131
|
+
const registered = resolveRegisteredEntityTableName(em, entity)
|
|
132
|
+
if (registered) {
|
|
133
|
+
entityTableCache.set(entity, registered)
|
|
134
|
+
return registered
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
const fallback = pluralizeBaseName(rawName || '')
|
|
109
138
|
console.warn(
|
|
110
139
|
`[QueryEngine] Could not resolve entity "${entity}" via ORM metadata. ` +
|
package/src/modules/vector.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityId } from './entities'
|
|
2
2
|
import type { QueryEngine } from '../lib/query/types'
|
|
3
|
+
import type { SearchFieldPolicy } from './search'
|
|
3
4
|
|
|
4
5
|
export type VectorDriverId = 'pgvector' | 'qdrant' | 'chromadb'
|
|
5
6
|
|
|
@@ -71,6 +72,12 @@ export type VectorEntityConfig = {
|
|
|
71
72
|
* Provide extra deep links rendered next to the search result.
|
|
72
73
|
*/
|
|
73
74
|
resolveLinks?: (ctx: VectorBuildContext) => Promise<VectorLinkDescriptor[] | null> | VectorLinkDescriptor[] | null
|
|
75
|
+
/**
|
|
76
|
+
* Controls which record/custom fields are eligible for the default embedding source builder.
|
|
77
|
+
* Applied only when `buildSource` is not provided. `excluded`/`hashOnly` fields are never embedded;
|
|
78
|
+
* when `searchable` is defined it acts as an allowlist for both record and custom fields.
|
|
79
|
+
*/
|
|
80
|
+
fieldPolicy?: SearchFieldPolicy
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export type VectorModuleConfig = {
|