@open-mercato/core 0.10.1-develop.7752.1.fd7c06569b → 0.10.1-develop.7756.1.8f5dec1b96

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 (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/dbFixtures.js +1 -1
  3. package/dist/helpers/integration/dbFixtures.js.map +1 -1
  4. package/dist/modules/auth/api/roles/acl/route.js +2 -2
  5. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  6. package/dist/modules/auth/api/roles/route.js +1 -1
  7. package/dist/modules/auth/api/roles/route.js.map +2 -2
  8. package/dist/modules/auth/api/users/acl/route.js +10 -2
  9. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  10. package/dist/modules/auth/commands/acl.js +2 -2
  11. package/dist/modules/auth/commands/acl.js.map +2 -2
  12. package/dist/modules/auth/commands/roles.js +1 -1
  13. package/dist/modules/auth/commands/roles.js.map +2 -2
  14. package/dist/modules/auth/commands/users.js +7 -3
  15. package/dist/modules/auth/commands/users.js.map +2 -2
  16. package/dist/modules/auth/data/entities.js.map +2 -2
  17. package/dist/modules/auth/lib/executionPrincipal.js +2 -2
  18. package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
  19. package/dist/modules/auth/lib/grantChecks.js +38 -11
  20. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  21. package/dist/modules/auth/lib/setup-app.js +2 -2
  22. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  23. package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
  24. package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
  25. package/dist/modules/auth/services/rbacService.js +138 -59
  26. package/dist/modules/auth/services/rbacService.js.map +3 -3
  27. package/dist/modules/entities/cli.js +12 -1
  28. package/dist/modules/entities/cli.js.map +2 -2
  29. package/dist/modules/entities/lib/helpers.js +13 -0
  30. package/dist/modules/entities/lib/helpers.js.map +2 -2
  31. package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
  32. package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
  33. package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
  34. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  35. package/dist/modules/query_index/lib/engine.js +179 -11
  36. package/dist/modules/query_index/lib/engine.js.map +3 -3
  37. package/package.json +7 -7
  38. package/src/helpers/integration/dbFixtures.ts +1 -1
  39. package/src/modules/auth/api/roles/acl/route.ts +2 -2
  40. package/src/modules/auth/api/roles/route.ts +1 -1
  41. package/src/modules/auth/api/users/acl/route.ts +10 -2
  42. package/src/modules/auth/commands/acl.ts +2 -2
  43. package/src/modules/auth/commands/roles.ts +1 -1
  44. package/src/modules/auth/commands/users.ts +7 -3
  45. package/src/modules/auth/data/entities.ts +12 -0
  46. package/src/modules/auth/lib/executionPrincipal.ts +2 -2
  47. package/src/modules/auth/lib/grantChecks.ts +115 -11
  48. package/src/modules/auth/lib/setup-app.ts +2 -2
  49. package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
  50. package/src/modules/auth/services/rbacService.ts +161 -76
  51. package/src/modules/entities/cli.ts +12 -1
  52. package/src/modules/entities/lib/helpers.ts +17 -0
  53. package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
  54. package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
  55. package/src/modules/query_index/lib/engine.ts +187 -9
@@ -0,0 +1,157 @@
1
+ import {
2
+ decryptWithAesGcm,
3
+ TenantDataEncryptionError,
4
+ TenantDataEncryptionErrorCode
5
+ } from "@open-mercato/shared/lib/encryption/aes";
6
+ import {
7
+ decryptCustomEntityDocument,
8
+ encryptCustomEntityDocument,
9
+ isCustomEntityDocumentCiphertext
10
+ } from "@open-mercato/shared/lib/encryption/customEntityDoc";
11
+ import { CustomFieldDef } from "../data/entities.js";
12
+ import { loadScopedCustomFieldDefs } from "./scoped-field-defs.js";
13
+ const BATCH_SIZE = 100;
14
+ function holdsCiphertext(value) {
15
+ return Array.isArray(value) ? value.some(isCustomEntityDocumentCiphertext) : isCustomEntityDocumentCiphertext(value);
16
+ }
17
+ function selectHistoricalDefinitions(definitions) {
18
+ const selected = /* @__PURE__ */ new Map();
19
+ const scopeScore = (definition) => (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0);
20
+ const updatedAt = (definition) => new Date(definition.updatedAt).getTime();
21
+ for (const definition of definitions) {
22
+ const previous = selected.get(definition.key);
23
+ if (!previous || scopeScore(definition) > scopeScore(previous) || scopeScore(definition) === scopeScore(previous) && updatedAt(definition) >= updatedAt(previous)) {
24
+ selected.set(definition.key, definition);
25
+ }
26
+ }
27
+ return selected;
28
+ }
29
+ async function rotateCustomEntityDocuments(em, options) {
30
+ const definitionScope = [];
31
+ if (options.tenantId) definitionScope.push({ $or: [{ tenantId: options.tenantId }, { tenantId: null }] });
32
+ if (options.organizationId) definitionScope.push({ $or: [{ organizationId: options.organizationId }, { organizationId: null }] });
33
+ const definitions = await em.find(CustomFieldDef, {
34
+ ...definitionScope.length ? { $and: definitionScope } : {}
35
+ });
36
+ const entityIds = Array.from(new Set(definitions.map((definition) => definition.entityId)));
37
+ if (!entityIds.length) return 0;
38
+ const connection = em.getConnection();
39
+ const fieldsByScope = /* @__PURE__ */ new Map();
40
+ const oldDeks = /* @__PURE__ */ new Map();
41
+ let cursor = null;
42
+ let updated = 0;
43
+ for (; ; ) {
44
+ const predicates = [`entity_type in (${entityIds.map(() => "?").join(", ")})`];
45
+ const parameters = [...entityIds];
46
+ if (options.tenantId) {
47
+ predicates.push("tenant_id = ?");
48
+ parameters.push(options.tenantId);
49
+ }
50
+ if (options.organizationId) {
51
+ predicates.push("organization_id = ?");
52
+ parameters.push(options.organizationId);
53
+ }
54
+ if (cursor) {
55
+ predicates.push("id > ?");
56
+ parameters.push(cursor);
57
+ }
58
+ const rows = await connection.execute(
59
+ `select id, entity_type, tenant_id, organization_id, doc from custom_entities_storage where ${predicates.join(" and ")} order by id asc limit ${BATCH_SIZE}`,
60
+ parameters
61
+ );
62
+ if (!rows.length) break;
63
+ for (const row of rows) {
64
+ const scopeKey = JSON.stringify([row.entity_type, row.tenant_id, row.organization_id]);
65
+ let policy = fieldsByScope.get(scopeKey);
66
+ if (!policy) {
67
+ const scopedDefinitions = await loadScopedCustomFieldDefs(em, {
68
+ entityId: row.entity_type,
69
+ tenantId: row.tenant_id,
70
+ organizationId: row.organization_id
71
+ });
72
+ const visibleDefinitions = definitions.filter((definition) => definition.entityId === row.entity_type && (definition.tenantId == null || definition.tenantId === row.tenant_id) && (definition.organizationId == null || definition.organizationId === row.organization_id));
73
+ const keys = new Set(visibleDefinitions.map((definition) => definition.key));
74
+ const historicalDefinitions = selectHistoricalDefinitions(visibleDefinitions);
75
+ policy = {
76
+ fields: Array.from(keys, (key) => ({ key, kind: "text", encrypted: true })),
77
+ protectedKeys: new Set(Array.from(keys).filter((key) => (scopedDefinitions.get(key) ?? historicalDefinitions.get(key))?.configJson?.encrypted === true)),
78
+ encryptedKeys: new Set(Array.from(scopedDefinitions.values()).filter((definition) => definition.configJson?.encrypted === true).map((definition) => definition.key))
79
+ };
80
+ fieldsByScope.set(scopeKey, policy);
81
+ }
82
+ const { fields, encryptedKeys, protectedKeys } = policy;
83
+ const knownKeys = new Set(fields.map((field) => field.key));
84
+ const entries = Object.entries(row.doc).filter(([key, value]) => {
85
+ const normalizedKey = key.replace(/^cf[:_]/, "");
86
+ return value != null && knownKeys.has(normalizedKey) && (encryptedKeys.has(normalizedKey) || holdsCiphertext(value));
87
+ });
88
+ if (!entries.length) continue;
89
+ const tenantId = row.tenant_id;
90
+ if (!tenantId || !options.encryptionService.isEnabled()) {
91
+ throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.KMS_UNAVAILABLE, "[internal] Custom entity encryption requires an available tenant key");
92
+ }
93
+ const currentDek = await options.encryptionService.getDek(tenantId);
94
+ if (options.oldKms && !oldDeks.has(tenantId)) oldDeks.set(tenantId, await options.oldKms.getTenantDek(tenantId));
95
+ const oldDek = oldDeks.get(tenantId) ?? null;
96
+ const candidates = {};
97
+ const arrayMasks = /* @__PURE__ */ new Map();
98
+ let changed = false;
99
+ const prepare = (value, encryptPlaintext, requireCiphertextKey) => {
100
+ if (value == null) return { value, selected: false };
101
+ if (typeof value === "string") {
102
+ if (currentDek && decryptWithAesGcm(value, currentDek.key) !== null) return { value, selected: true };
103
+ if (oldDek && decryptWithAesGcm(value, oldDek.key) !== null) {
104
+ changed = true;
105
+ return { value, selected: true };
106
+ }
107
+ }
108
+ if (!isCustomEntityDocumentCiphertext(value)) {
109
+ if (options.oldKms || !encryptPlaintext) return { value: null, selected: false };
110
+ changed = true;
111
+ return { value, selected: true };
112
+ }
113
+ if (!requireCiphertextKey) return { value: null, selected: false };
114
+ throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.WRONG_KEY, "[internal] Custom entity ciphertext does not decrypt under the supplied tenant keys");
115
+ };
116
+ for (const [key, value] of entries) {
117
+ const normalizedKey = key.replace(/^cf[:_]/, "");
118
+ const encryptPlaintext = encryptedKeys.has(normalizedKey);
119
+ const requireCiphertextKey = protectedKeys.has(normalizedKey);
120
+ if (Array.isArray(value)) {
121
+ const prepared = value.map((entry) => prepare(entry, encryptPlaintext, requireCiphertextKey));
122
+ candidates[key] = prepared.map((entry) => entry.value);
123
+ arrayMasks.set(key, prepared.map((entry) => entry.selected));
124
+ } else {
125
+ const prepared = prepare(value, encryptPlaintext, requireCiphertextKey);
126
+ if (prepared.selected) candidates[key] = prepared.value;
127
+ }
128
+ }
129
+ if (!changed) continue;
130
+ if (options.dryRun) {
131
+ updated += 1;
132
+ continue;
133
+ }
134
+ const plaintext = options.oldKms && oldDek ? await decryptCustomEntityDocument(candidates, fields, tenantId, options.encryptionService, /* @__PURE__ */ new Map([[tenantId, oldDek.key]])) : candidates;
135
+ const encrypted = await encryptCustomEntityDocument(plaintext, fields, tenantId, options.encryptionService);
136
+ const nextDoc = { ...row.doc };
137
+ for (const [key, value] of Object.entries(encrypted)) {
138
+ const mask = arrayMasks.get(key);
139
+ const previous = row.doc[key];
140
+ nextDoc[key] = mask && Array.isArray(value) && Array.isArray(previous) ? value.map((entry, index) => mask[index] ? entry : previous[index]) : value;
141
+ }
142
+ const written = await connection.execute(
143
+ "update custom_entities_storage set doc = ?::jsonb, updated_at = now() where id = ? and entity_type = ? and tenant_id is not distinct from ? and organization_id is not distinct from ? and doc = ?::jsonb returning id",
144
+ [JSON.stringify(nextDoc), row.id, row.entity_type, tenantId, row.organization_id, JSON.stringify(row.doc)]
145
+ );
146
+ if (!written.length) throw new Error("[internal] Custom entity document changed during encryption; retry the operation");
147
+ updated += 1;
148
+ }
149
+ cursor = rows[rows.length - 1].id;
150
+ if (rows.length < BATCH_SIZE) break;
151
+ }
152
+ return updated;
153
+ }
154
+ export {
155
+ rotateCustomEntityDocuments
156
+ };
157
+ //# sourceMappingURL=rotate-custom-entity-documents.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/entities/lib/rotate-custom-entity-documents.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport {\n decryptWithAesGcm,\n TenantDataEncryptionError,\n TenantDataEncryptionErrorCode,\n} from '@open-mercato/shared/lib/encryption/aes'\nimport {\n decryptCustomEntityDocument,\n encryptCustomEntityDocument,\n isCustomEntityDocumentCiphertext,\n type CustomEntityDocumentField,\n} from '@open-mercato/shared/lib/encryption/customEntityDoc'\nimport type { KmsService, TenantDek } from '@open-mercato/shared/lib/encryption/kms'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { CustomFieldDef } from '../data/entities'\nimport { loadScopedCustomFieldDefs } from './scoped-field-defs'\n\ntype RotationOptions = {\n encryptionService: TenantDataEncryptionService\n oldKms: KmsService | null\n tenantId: string | null\n organizationId: string | null\n dryRun: boolean\n}\n\ntype DocumentRow = {\n id: string\n entity_type: string\n tenant_id: string | null\n organization_id: string | null\n doc: Record<string, unknown>\n}\n\ntype DocumentFieldPolicy = {\n fields: CustomEntityDocumentField[]\n encryptedKeys: Set<string>\n protectedKeys: Set<string>\n}\n\nconst BATCH_SIZE = 100\n\nfunction holdsCiphertext(value: unknown): boolean {\n return Array.isArray(value) ? value.some(isCustomEntityDocumentCiphertext) : isCustomEntityDocumentCiphertext(value)\n}\n\nfunction selectHistoricalDefinitions(definitions: CustomFieldDef[]): Map<string, CustomFieldDef> {\n const selected = new Map<string, CustomFieldDef>()\n const scopeScore = (definition: CustomFieldDef) => (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0)\n const updatedAt = (definition: CustomFieldDef) => new Date(definition.updatedAt).getTime()\n for (const definition of definitions) {\n const previous = selected.get(definition.key)\n if (!previous || scopeScore(definition) > scopeScore(previous)\n || (scopeScore(definition) === scopeScore(previous) && updatedAt(definition) >= updatedAt(previous))) {\n selected.set(definition.key, definition)\n }\n }\n return selected\n}\n\n/** @internal */\nexport async function rotateCustomEntityDocuments(\n em: EntityManager,\n options: RotationOptions,\n): Promise<number> {\n const definitionScope: FilterQuery<CustomFieldDef>[] = []\n if (options.tenantId) definitionScope.push({ $or: [{ tenantId: options.tenantId }, { tenantId: null }] })\n if (options.organizationId) definitionScope.push({ $or: [{ organizationId: options.organizationId }, { organizationId: null }] })\n const definitions = await em.find(CustomFieldDef, {\n ...(definitionScope.length ? { $and: definitionScope } : {}),\n })\n const entityIds = Array.from(new Set(definitions.map((definition) => definition.entityId)))\n if (!entityIds.length) return 0\n\n const connection = em.getConnection()\n const fieldsByScope = new Map<string, DocumentFieldPolicy>()\n const oldDeks = new Map<string, TenantDek | null>()\n let cursor: string | null = null\n let updated = 0\n for (;;) {\n const predicates = [`entity_type in (${entityIds.map(() => '?').join(', ')})`]\n const parameters: unknown[] = [...entityIds]\n if (options.tenantId) {\n predicates.push('tenant_id = ?')\n parameters.push(options.tenantId)\n }\n if (options.organizationId) {\n predicates.push('organization_id = ?')\n parameters.push(options.organizationId)\n }\n if (cursor) {\n predicates.push('id > ?')\n parameters.push(cursor)\n }\n const rows = await connection.execute<DocumentRow[]>(\n `select id, entity_type, tenant_id, organization_id, doc from custom_entities_storage where ${predicates.join(' and ')} order by id asc limit ${BATCH_SIZE}`,\n parameters,\n )\n if (!rows.length) break\n for (const row of rows) {\n const scopeKey = JSON.stringify([row.entity_type, row.tenant_id, row.organization_id])\n let policy = fieldsByScope.get(scopeKey)\n if (!policy) {\n const scopedDefinitions = await loadScopedCustomFieldDefs(em, {\n entityId: row.entity_type,\n tenantId: row.tenant_id,\n organizationId: row.organization_id,\n })\n const visibleDefinitions = definitions\n .filter((definition) => definition.entityId === row.entity_type\n && (definition.tenantId == null || definition.tenantId === row.tenant_id)\n && (definition.organizationId == null || definition.organizationId === row.organization_id))\n const keys = new Set(visibleDefinitions.map((definition) => definition.key))\n const historicalDefinitions = selectHistoricalDefinitions(visibleDefinitions)\n policy = {\n fields: Array.from(keys, (key) => ({ key, kind: 'text', encrypted: true })),\n protectedKeys: new Set(Array.from(keys)\n .filter((key) => (scopedDefinitions.get(key) ?? historicalDefinitions.get(key))?.configJson?.encrypted === true)),\n encryptedKeys: new Set(Array.from(scopedDefinitions.values())\n .filter((definition) => definition.configJson?.encrypted === true)\n .map((definition) => definition.key)),\n }\n fieldsByScope.set(scopeKey, policy)\n }\n const { fields, encryptedKeys, protectedKeys } = policy\n const knownKeys = new Set(fields.map((field) => field.key))\n const entries = Object.entries(row.doc).filter(([key, value]) => {\n const normalizedKey = key.replace(/^cf[:_]/, '')\n return value != null && knownKeys.has(normalizedKey) && (encryptedKeys.has(normalizedKey) || holdsCiphertext(value))\n })\n if (!entries.length) continue\n const tenantId = row.tenant_id\n if (!tenantId || !options.encryptionService.isEnabled()) {\n throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.KMS_UNAVAILABLE, '[internal] Custom entity encryption requires an available tenant key')\n }\n const currentDek = await options.encryptionService.getDek(tenantId)\n if (options.oldKms && !oldDeks.has(tenantId)) oldDeks.set(tenantId, await options.oldKms.getTenantDek(tenantId))\n const oldDek = oldDeks.get(tenantId) ?? null\n const candidates: Record<string, unknown> = {}\n const arrayMasks = new Map<string, boolean[]>()\n let changed = false\n const prepare = (value: unknown, encryptPlaintext: boolean, requireCiphertextKey: boolean): { value: unknown; selected: boolean } => {\n if (value == null) return { value, selected: false }\n if (typeof value === 'string') {\n if (currentDek && decryptWithAesGcm(value, currentDek.key) !== null) return { value, selected: true }\n if (oldDek && decryptWithAesGcm(value, oldDek.key) !== null) {\n changed = true\n return { value, selected: true }\n }\n }\n if (!isCustomEntityDocumentCiphertext(value)) {\n if (options.oldKms || !encryptPlaintext) return { value: null, selected: false }\n changed = true\n return { value, selected: true }\n }\n if (!requireCiphertextKey) return { value: null, selected: false }\n throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.WRONG_KEY, '[internal] Custom entity ciphertext does not decrypt under the supplied tenant keys')\n }\n for (const [key, value] of entries) {\n const normalizedKey = key.replace(/^cf[:_]/, '')\n const encryptPlaintext = encryptedKeys.has(normalizedKey)\n const requireCiphertextKey = protectedKeys.has(normalizedKey)\n if (Array.isArray(value)) {\n const prepared = value.map((entry) => prepare(entry, encryptPlaintext, requireCiphertextKey))\n candidates[key] = prepared.map((entry) => entry.value)\n arrayMasks.set(key, prepared.map((entry) => entry.selected))\n } else {\n const prepared = prepare(value, encryptPlaintext, requireCiphertextKey)\n if (prepared.selected) candidates[key] = prepared.value\n }\n }\n if (!changed) continue\n if (options.dryRun) {\n updated += 1\n continue\n }\n const plaintext = options.oldKms && oldDek\n ? await decryptCustomEntityDocument(candidates, fields, tenantId, options.encryptionService, new Map([[tenantId, oldDek.key]]))\n : candidates\n const encrypted = await encryptCustomEntityDocument(plaintext, fields, tenantId, options.encryptionService)\n const nextDoc = { ...row.doc }\n for (const [key, value] of Object.entries(encrypted)) {\n const mask = arrayMasks.get(key)\n const previous = row.doc[key]\n nextDoc[key] = mask && Array.isArray(value) && Array.isArray(previous)\n ? value.map((entry, index) => mask[index] ? entry : previous[index])\n : value\n }\n const written = await connection.execute<Array<{ id: string }>>(\n 'update custom_entities_storage set doc = ?::jsonb, updated_at = now() where id = ? and entity_type = ? and tenant_id is not distinct from ? and organization_id is not distinct from ? and doc = ?::jsonb returning id',\n [JSON.stringify(nextDoc), row.id, row.entity_type, tenantId, row.organization_id, JSON.stringify(row.doc)],\n )\n if (!written.length) throw new Error('[internal] Custom entity document changed during encryption; retry the operation')\n updated += 1\n }\n cursor = rows[rows.length - 1]!.id\n if (rows.length < BATCH_SIZE) break\n }\n return updated\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -26,7 +26,7 @@ async function getScopedNotificationRecipientUserIds(db, tenantId, organizationI
26
26
  }
27
27
  async function getRecipientUserIdsForRole(db, tenantId, roleId) {
28
28
  const builder = db;
29
- const userRoles = await builder.selectFrom("user_roles").innerJoin("users", "user_roles.user_id", "users.id").where("user_roles.role_id", "=", roleId).where("user_roles.deleted_at", "is", null).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select("users.id as user_id").execute();
29
+ const userRoles = await builder.selectFrom("user_roles").innerJoin("roles", "user_roles.role_id", "roles.id").innerJoin("users", "user_roles.user_id", "users.id").where("user_roles.role_id", "=", roleId).where("user_roles.deleted_at", "is", null).where("roles.deleted_at", "is", null).where("roles.tenant_id", "=", tenantId).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select("users.id as user_id").execute();
30
30
  return userRoles.map((row) => row.user_id);
31
31
  }
32
32
  async function getRecipientUserIdsForFeature(db, tenantId, requiredFeature) {
@@ -38,7 +38,7 @@ async function getRecipientUserIdsForFeature(db, tenantId, requiredFeature) {
38
38
  "user_acls.is_super_admin"
39
39
  ]).execute();
40
40
  collectUsersWithFeature(userIdsSet, userAcls, requiredFeature);
41
- const roleAcls = await builder.selectFrom("role_acls").innerJoin("user_roles", "role_acls.role_id", "user_roles.role_id").innerJoin("users", "user_roles.user_id", "users.id").where("role_acls.tenant_id", "=", tenantId).where("role_acls.deleted_at", "is", null).where("user_roles.deleted_at", "is", null).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select([
41
+ const roleAcls = await builder.selectFrom("role_acls").innerJoin("roles", "role_acls.role_id", "roles.id").innerJoin("user_roles", "role_acls.role_id", "user_roles.role_id").innerJoin("users", "user_roles.user_id", "users.id").where("role_acls.tenant_id", "=", tenantId).where("role_acls.deleted_at", "is", null).where("roles.deleted_at", "is", null).where("roles.tenant_id", "=", tenantId).where("user_roles.deleted_at", "is", null).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select([
42
42
  "users.id as user_id",
43
43
  "role_acls.features_json",
44
44
  "role_acls.is_super_admin"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/notifications/lib/notificationRecipients.ts"],
4
- "sourcesContent": ["import type { Kysely } from 'kysely'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\ninterface AclRow {\n user_id: string\n features_json: unknown\n is_super_admin: boolean\n}\n\nfunction normalizeFeatures(features: unknown): string[] | undefined {\n if (!Array.isArray(features)) return undefined\n const normalized = features.filter((feature): feature is string => typeof feature === 'string')\n return normalized.length ? normalized : undefined\n}\n\n/**\n * Extract user IDs from ACL rows that have the required feature or are super admins.\n */\nfunction collectUsersWithFeature(\n userIdsSet: Set<string>,\n rows: AclRow[],\n requiredFeature: string\n): void {\n for (const row of rows) {\n const features = normalizeFeatures(row.features_json) ?? []\n if (authorizeFeatures([requiredFeature], {\n grantedFeatures: features,\n unrestricted: row.is_super_admin,\n })) {\n userIdsSet.add(row.user_id)\n }\n }\n}\n\nexport async function getScopedNotificationRecipientUserIds(\n db: Kysely<any>,\n tenantId: string,\n organizationId: string | null,\n recipientUserIds: string[],\n): Promise<string[]> {\n const builder: any = db\n let query = builder\n .selectFrom('users')\n .where('users.id', 'in', recipientUserIds)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n\n if (organizationId) {\n query = query.where('users.organization_id', '=', organizationId)\n }\n\n const users = await query\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return users.map((user) => user.user_id)\n}\n\nexport async function getRecipientUserIdsForRole(\n db: Kysely<any>,\n tenantId: string,\n roleId: string\n): Promise<string[]> {\n const builder: any = db\n const userRoles = await builder\n .selectFrom('user_roles')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('user_roles.role_id', '=', roleId)\n .where('user_roles.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return userRoles.map((row) => row.user_id)\n}\n\nexport async function getRecipientUserIdsForFeature(\n db: Kysely<any>,\n tenantId: string,\n requiredFeature: string\n): Promise<string[]> {\n const userIdsSet = new Set<string>()\n const builder: any = db\n\n const userAcls = await builder\n .selectFrom('user_acls')\n .innerJoin('users', 'user_acls.user_id', 'users.id')\n .where('user_acls.tenant_id', '=', tenantId)\n .where('user_acls.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'user_acls.features_json',\n 'user_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, userAcls, requiredFeature)\n\n const roleAcls = await builder\n .selectFrom('role_acls')\n .innerJoin('user_roles', 'role_acls.role_id', 'user_roles.role_id')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('role_acls.tenant_id', '=', tenantId)\n .where('role_acls.deleted_at', 'is', null)\n .where('user_roles.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'role_acls.features_json',\n 'role_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, roleAcls, requiredFeature)\n\n return Array.from(userIdsSet)\n}\n"],
5
- "mappings": "AACA,SAAS,yBAAyB;AAQlC,SAAS,kBAAkB,UAAyC;AAClE,MAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACrC,QAAM,aAAa,SAAS,OAAO,CAAC,YAA+B,OAAO,YAAY,QAAQ;AAC9F,SAAO,WAAW,SAAS,aAAa;AAC1C;AAKA,SAAS,wBACP,YACA,MACA,iBACM;AACN,aAAW,OAAO,MAAM;AACtB,UAAM,WAAW,kBAAkB,IAAI,aAAa,KAAK,CAAC;AAC1D,QAAI,kBAAkB,CAAC,eAAe,GAAG;AAAA,MACvC,iBAAiB;AAAA,MACjB,cAAc,IAAI;AAAA,IACpB,CAAC,GAAG;AACF,iBAAW,IAAI,IAAI,OAAO;AAAA,IAC5B;AAAA,EACF;AACF;AAEA,eAAsB,sCACpB,IACA,UACA,gBACA,kBACmB;AACnB,QAAM,UAAe;AACrB,MAAI,QAAQ,QACT,WAAW,OAAO,EAClB,MAAM,YAAY,MAAM,gBAAgB,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ;AAEzC,MAAI,gBAAgB;AAClB,YAAQ,MAAM,MAAM,yBAAyB,KAAK,cAAc;AAAA,EAClE;AAEA,QAAM,QAAQ,MAAM,MACjB,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,MAAM,IAAI,CAAC,SAAS,KAAK,OAAO;AACzC;AAEA,eAAsB,2BACpB,IACA,UACA,QACmB;AACnB,QAAM,UAAe;AACrB,QAAM,YAAY,MAAM,QACrB,WAAW,YAAY,EACvB,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,sBAAsB,KAAK,MAAM,EACvC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,UAAU,IAAI,CAAC,QAAQ,IAAI,OAAO;AAC3C;AAEA,eAAsB,8BACpB,IACA,UACA,iBACmB;AACnB,QAAM,aAAa,oBAAI,IAAY;AACnC,QAAM,UAAe;AAErB,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,SAAS,qBAAqB,UAAU,EAClD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,cAAc,qBAAqB,oBAAoB,EACjE,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,SAAO,MAAM,KAAK,UAAU;AAC9B;",
4
+ "sourcesContent": ["import type { Kysely } from 'kysely'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\ninterface AclRow {\n user_id: string\n features_json: unknown\n is_super_admin: boolean\n}\n\nfunction normalizeFeatures(features: unknown): string[] | undefined {\n if (!Array.isArray(features)) return undefined\n const normalized = features.filter((feature): feature is string => typeof feature === 'string')\n return normalized.length ? normalized : undefined\n}\n\n/**\n * Extract user IDs from ACL rows that have the required feature or are super admins.\n */\nfunction collectUsersWithFeature(\n userIdsSet: Set<string>,\n rows: AclRow[],\n requiredFeature: string\n): void {\n for (const row of rows) {\n const features = normalizeFeatures(row.features_json) ?? []\n if (authorizeFeatures([requiredFeature], {\n grantedFeatures: features,\n unrestricted: row.is_super_admin,\n })) {\n userIdsSet.add(row.user_id)\n }\n }\n}\n\nexport async function getScopedNotificationRecipientUserIds(\n db: Kysely<any>,\n tenantId: string,\n organizationId: string | null,\n recipientUserIds: string[],\n): Promise<string[]> {\n const builder: any = db\n let query = builder\n .selectFrom('users')\n .where('users.id', 'in', recipientUserIds)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n\n if (organizationId) {\n query = query.where('users.organization_id', '=', organizationId)\n }\n\n const users = await query\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return users.map((user) => user.user_id)\n}\n\nexport async function getRecipientUserIdsForRole(\n db: Kysely<any>,\n tenantId: string,\n roleId: string\n): Promise<string[]> {\n const builder: any = db\n const userRoles = await builder\n .selectFrom('user_roles')\n .innerJoin('roles', 'user_roles.role_id', 'roles.id')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('user_roles.role_id', '=', roleId)\n .where('user_roles.deleted_at', 'is', null)\n .where('roles.deleted_at', 'is', null)\n .where('roles.tenant_id', '=', tenantId)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return userRoles.map((row) => row.user_id)\n}\n\nexport async function getRecipientUserIdsForFeature(\n db: Kysely<any>,\n tenantId: string,\n requiredFeature: string\n): Promise<string[]> {\n const userIdsSet = new Set<string>()\n const builder: any = db\n\n const userAcls = await builder\n .selectFrom('user_acls')\n .innerJoin('users', 'user_acls.user_id', 'users.id')\n .where('user_acls.tenant_id', '=', tenantId)\n .where('user_acls.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'user_acls.features_json',\n 'user_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, userAcls, requiredFeature)\n\n const roleAcls = await builder\n .selectFrom('role_acls')\n .innerJoin('roles', 'role_acls.role_id', 'roles.id')\n .innerJoin('user_roles', 'role_acls.role_id', 'user_roles.role_id')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('role_acls.tenant_id', '=', tenantId)\n .where('role_acls.deleted_at', 'is', null)\n .where('roles.deleted_at', 'is', null)\n .where('roles.tenant_id', '=', tenantId)\n .where('user_roles.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'role_acls.features_json',\n 'role_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, roleAcls, requiredFeature)\n\n return Array.from(userIdsSet)\n}\n"],
5
+ "mappings": "AACA,SAAS,yBAAyB;AAQlC,SAAS,kBAAkB,UAAyC;AAClE,MAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACrC,QAAM,aAAa,SAAS,OAAO,CAAC,YAA+B,OAAO,YAAY,QAAQ;AAC9F,SAAO,WAAW,SAAS,aAAa;AAC1C;AAKA,SAAS,wBACP,YACA,MACA,iBACM;AACN,aAAW,OAAO,MAAM;AACtB,UAAM,WAAW,kBAAkB,IAAI,aAAa,KAAK,CAAC;AAC1D,QAAI,kBAAkB,CAAC,eAAe,GAAG;AAAA,MACvC,iBAAiB;AAAA,MACjB,cAAc,IAAI;AAAA,IACpB,CAAC,GAAG;AACF,iBAAW,IAAI,IAAI,OAAO;AAAA,IAC5B;AAAA,EACF;AACF;AAEA,eAAsB,sCACpB,IACA,UACA,gBACA,kBACmB;AACnB,QAAM,UAAe;AACrB,MAAI,QAAQ,QACT,WAAW,OAAO,EAClB,MAAM,YAAY,MAAM,gBAAgB,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ;AAEzC,MAAI,gBAAgB;AAClB,YAAQ,MAAM,MAAM,yBAAyB,KAAK,cAAc;AAAA,EAClE;AAEA,QAAM,QAAQ,MAAM,MACjB,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,MAAM,IAAI,CAAC,SAAS,KAAK,OAAO;AACzC;AAEA,eAAsB,2BACpB,IACA,UACA,QACmB;AACnB,QAAM,UAAe;AACrB,QAAM,YAAY,MAAM,QACrB,WAAW,YAAY,EACvB,UAAU,SAAS,sBAAsB,UAAU,EACnD,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,sBAAsB,KAAK,MAAM,EACvC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,UAAU,IAAI,CAAC,QAAQ,IAAI,OAAO;AAC3C;AAEA,eAAsB,8BACpB,IACA,UACA,iBACmB;AACnB,QAAM,aAAa,oBAAI,IAAY;AACnC,QAAM,UAAe;AAErB,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,SAAS,qBAAqB,UAAU,EAClD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,SAAS,qBAAqB,UAAU,EAClD,UAAU,cAAc,qBAAqB,oBAAoB,EACjE,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,SAAO,MAAM,KAAK,UAAU;AAC9B;",
6
6
  "names": []
7
7
  }
@@ -12,6 +12,7 @@ import { sql } from "kysely";
12
12
  import { readCoverageSnapshot, refreshCoverageSnapshot } from "./coverage.js";
13
13
  import { createProfiler, shouldEnableProfiler } from "@open-mercato/shared/lib/profiler";
14
14
  import { decryptIndexDocCustomFields } from "@open-mercato/shared/lib/encryption/indexDoc";
15
+ import { decryptCustomEntityDocument, isCustomEntityDocumentCiphertext } from "@open-mercato/shared/lib/encryption/customEntityDoc";
15
16
  import {
16
17
  DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
17
18
  DECRYPT_REFUSAL_LOG_MESSAGE,
@@ -58,6 +59,91 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
58
59
  import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
59
60
  import { createBoundedTtlMemo } from "@open-mercato/shared/lib/query/bounded-ttl-memo";
60
61
  const logger = createLogger("query_index").child({ component: "engine" });
62
+ function customDocumentValue(row, field) {
63
+ const bare = field.replace(/^cf[:_]/, "");
64
+ return row[field] ?? row[`cf_${bare.replace(/[^a-zA-Z0-9_]/g, "_")}`] ?? row[`cf:${bare}`] ?? row[bare];
65
+ }
66
+ function assertCustomDocumentPlaintext(actual) {
67
+ if (isCustomEntityDocumentCiphertext(actual) || Array.isArray(actual) && actual.some(isCustomEntityDocumentCiphertext)) {
68
+ throw new Error("[internal] Cannot compare custom entity ciphertext without its tenant key");
69
+ }
70
+ }
71
+ function matchesCustomDocumentPattern(value, pattern, ignoreCase) {
72
+ const source = Array.from(value);
73
+ const authored = Array.from(pattern);
74
+ const normalize = (character) => ignoreCase ? character.toLowerCase() : character;
75
+ const tokens = [];
76
+ for (let index = 0; index < authored.length; index += 1) {
77
+ const character = authored[index];
78
+ if (character === "\\") {
79
+ const literal = authored[++index];
80
+ if (literal === void 0) throw new Error("[internal] Invalid custom entity LIKE escape");
81
+ tokens.push({ kind: "literal", value: normalize(literal) });
82
+ } else {
83
+ tokens.push({ kind: character === "%" ? "many" : character === "_" ? "one" : "literal", value: normalize(character) });
84
+ }
85
+ }
86
+ let sourceIndex = 0;
87
+ let tokenIndex = 0;
88
+ let wildcardIndex = -1;
89
+ let retryIndex = 0;
90
+ while (sourceIndex < source.length) {
91
+ const token = tokens[tokenIndex];
92
+ if (token?.kind === "one" || token?.kind === "literal" && token.value === normalize(source[sourceIndex])) {
93
+ sourceIndex += 1;
94
+ tokenIndex += 1;
95
+ } else if (token?.kind === "many") {
96
+ wildcardIndex = tokenIndex++;
97
+ retryIndex = sourceIndex;
98
+ } else if (wildcardIndex >= 0) {
99
+ tokenIndex = wildcardIndex + 1;
100
+ sourceIndex = ++retryIndex;
101
+ } else {
102
+ return false;
103
+ }
104
+ }
105
+ while (tokens[tokenIndex]?.kind === "many") tokenIndex += 1;
106
+ return tokenIndex === tokens.length;
107
+ }
108
+ function matchesCustomDocumentFilter(row, filter, actual = customDocumentValue(row, filter.field)) {
109
+ const expected = filter.value;
110
+ assertCustomDocumentPlaintext(actual);
111
+ const equal = (left, right) => left == null || right == null ? left == null && right == null : String(left) === String(right);
112
+ const values = filter.field.startsWith("cf:") && Array.isArray(actual) ? actual : [actual];
113
+ const included = (candidates) => values.some((value) => candidates.some((candidate) => equal(value, candidate)));
114
+ switch (filter.op) {
115
+ case "exists":
116
+ return Boolean(expected) === (actual != null);
117
+ case "eq":
118
+ return values.some((value) => equal(value, expected));
119
+ case "ne":
120
+ return actual != null && !values.some((value) => equal(value, expected));
121
+ case "in":
122
+ return included(Array.isArray(expected) ? expected : []);
123
+ case "nin":
124
+ return actual != null && !included(Array.isArray(expected) ? expected : []);
125
+ case "overlap":
126
+ return Array.isArray(actual) && normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value));
127
+ case "noverlap":
128
+ return Array.isArray(actual) && !normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value));
129
+ case "gt":
130
+ case "gte":
131
+ case "lt":
132
+ case "lte": {
133
+ if (actual == null || expected == null) return false;
134
+ const left = typeof actual === "number" ? actual : String(actual);
135
+ const right = typeof actual === "number" ? Number(expected) : String(expected);
136
+ const order = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right));
137
+ return filter.op === "gt" ? order > 0 : filter.op === "gte" ? order >= 0 : filter.op === "lt" ? order < 0 : order <= 0;
138
+ }
139
+ case "like":
140
+ case "ilike": {
141
+ return values.some((value) => value != null && matchesCustomDocumentPattern(String(value), String(expected ?? ""), filter.op === "ilike"));
142
+ }
143
+ default:
144
+ throw new Error("[internal] Unsupported custom entity filter operator");
145
+ }
146
+ }
61
147
  const CF_FILTER_SUPPORTED_OPS = /* @__PURE__ */ new Set([
62
148
  "eq",
63
149
  "ne",
@@ -1752,6 +1838,20 @@ class HybridQueryEngine {
1752
1838
  const orgScope = this.resolveOrganizationScope(opts);
1753
1839
  if (!opts.tenantId) throw new Error("QueryEngine: tenantId is required");
1754
1840
  const normalizedFilters = normalizeFilters(opts.filters);
1841
+ const encryptionService = resolveDecryptEnabled(opts) ? this.getEncryptionService() : null;
1842
+ const documentEncryptionService = encryptionService && "getDek" in encryptionService && typeof encryptionService.getDek === "function" && typeof encryptionService.isEnabled === "function" ? encryptionService : null;
1843
+ const definitions = encryptionService ? await this.resolveCustomFieldDefs([entity], opts.tenantId, true) : [];
1844
+ const resolveKinds = createCustomFieldKindMapResolver(buildCustomFieldKindIndex(definitions));
1845
+ const encryptedKeys = new Set(definitions.filter((definition) => {
1846
+ const config = definition.configJson;
1847
+ return config != null && typeof config === "object" && "encrypted" in config && Boolean(config.encrypted);
1848
+ }).map((definition) => definition.key));
1849
+ const isProtected = (field) => encryptedKeys.has(field.replace(/^cf[:_]/, ""));
1850
+ const plaintextFilters = normalizedFilters.some((filter) => isProtected(filter.field));
1851
+ const plaintextSort = (opts.sort ?? []).some((sort) => isProtected(sort.field));
1852
+ const scanPlaintext = plaintextFilters || plaintextSort;
1853
+ const deferredFilters = plaintextFilters ? normalizedFilters.filter((filter) => filter.orGroup || isProtected(filter.field)) : [];
1854
+ const jsonComparisonAliases = new Map(Array.from(new Set(deferredFilters.filter((filter) => !isProtected(filter.field) && !filter.field.startsWith("cf:") && !this.resolveCustomEntityColumn(alias, filter.field) && (filter.op === "overlap" || filter.op === "noverlap")).map((filter) => filter.field)), (field, index) => [field, `__om_ce_filter_${index}`]));
1755
1855
  const searchConfig = resolveSearchConfig();
1756
1856
  const searchEnabled = await this.searchAvailability().staticEnabled();
1757
1857
  const hasSearchTokens = searchEnabled && hasSearchFilter(normalizedFilters) ? await this.searchAvailability().hasTokens(entity, opts.tenantId ?? null, orgScope) : false;
@@ -1769,6 +1869,7 @@ class HybridQueryEngine {
1769
1869
  }
1770
1870
  if (!opts.withDeleted) next = next.where(`${alias}.deleted_at`, "is", null);
1771
1871
  for (const filter of normalizedFilters) {
1872
+ if (plaintextFilters && (filter.orGroup || isProtected(filter.field))) continue;
1772
1873
  if (filter.field.startsWith("cf:")) {
1773
1874
  next = this.applyCfFilterFromAlias(next, alias, entity, filter.field, filter.op, filter.value, searchRuntime);
1774
1875
  continue;
@@ -1812,8 +1913,8 @@ class HybridQueryEngine {
1812
1913
  }
1813
1914
  if (opts.includeCustomFields === true) {
1814
1915
  try {
1815
- const rows = await db.selectFrom("custom_field_defs").select("key").where("entity_id", "=", entity).where("is_active", "=", true).where("tenant_id", "=", opts.tenantId).execute();
1816
- for (const row of rows) {
1916
+ const rows2 = await db.selectFrom("custom_field_defs").select("key").where("entity_id", "=", entity).where("is_active", "=", true).where("tenant_id", "=", opts.tenantId).execute();
1917
+ for (const row of rows2) {
1817
1918
  const key = row.key;
1818
1919
  if (typeof key === "string") cfKeys.add(key);
1819
1920
  else if (key != null) cfKeys.add(String(key));
@@ -1823,20 +1924,26 @@ class HybridQueryEngine {
1823
1924
  } else if (Array.isArray(opts.includeCustomFields)) {
1824
1925
  for (const k of opts.includeCustomFields) cfKeys.add(k);
1825
1926
  }
1927
+ const projectedFields = new Set((opts.fields?.length ? opts.fields : ["id"]).map((field) => field.startsWith("cf:") ? this.sanitize(field) : field));
1928
+ for (const key of cfKeys) projectedFields.add(this.sanitize(`cf:${key}`));
1929
+ const hiddenFields = /* @__PURE__ */ new Set();
1930
+ const comparisonFields = scanPlaintext ? Array.from(/* @__PURE__ */ new Set([...deferredFilters.map((filter) => filter.field), ...(opts.sort ?? []).map((sort) => sort.field)])) : [];
1931
+ for (const field of comparisonFields) {
1932
+ const outputKey = field.startsWith("cf:") ? this.sanitize(field) : field;
1933
+ if (!projectedFields.has(outputKey)) hiddenFields.add(outputKey);
1934
+ }
1826
1935
  const applySelection = (q) => {
1827
1936
  let next = q;
1828
- const requested = opts.fields && opts.fields.length ? opts.fields : ["id"];
1937
+ const requested = Array.from(/* @__PURE__ */ new Set([...opts.fields?.length ? opts.fields : ["id"], ...comparisonFields]));
1829
1938
  for (const field of requested) {
1830
1939
  const f = String(field);
1831
1940
  if (f.startsWith("cf:")) {
1832
1941
  const aliasName = this.sanitize(f);
1833
1942
  next = next.select(this.jsonbSqlAlias(alias, f).as(aliasName));
1834
- } else if (f === "id") {
1835
- next = next.select(`${alias}.entity_id as id`);
1836
- } else if (f === "created_at" || f === "updated_at" || f === "deleted_at") {
1837
- next = next.select(`${alias}.${f} as ${f}`);
1943
+ } else if (this.resolveCustomEntityColumn(alias, f)) {
1944
+ next = next.select(`${this.resolveCustomEntityColumn(alias, f)} as ${f}`);
1838
1945
  } else {
1839
- const expr = sql`(${sql.ref(alias + ".doc")} ->> ${f})`;
1946
+ const expr = isProtected(f) ? sql`(${sql.ref(alias + ".doc")} -> ${f})` : sql`(${sql.ref(alias + ".doc")} ->> ${f})`;
1840
1947
  next = next.select(expr.as(f));
1841
1948
  }
1842
1949
  }
@@ -1844,6 +1951,12 @@ class HybridQueryEngine {
1844
1951
  const aliasName = this.sanitize(`cf:${key}`);
1845
1952
  next = next.select(this.jsonbSqlAlias(alias, `cf:${key}`).as(aliasName));
1846
1953
  }
1954
+ for (const [field, comparisonAlias] of jsonComparisonAliases) {
1955
+ next = next.select(sql`(${sql.ref(alias + ".doc")} -> ${field})`.as(comparisonAlias));
1956
+ }
1957
+ if (encryptionService) {
1958
+ next = next.select([`${alias}.tenant_id as __om_ce_tenant_id`, `${alias}.organization_id as __om_ce_organization_id`]);
1959
+ }
1847
1960
  return next;
1848
1961
  };
1849
1962
  const applySort = (q) => {
@@ -1866,6 +1979,60 @@ class HybridQueryEngine {
1866
1979
  };
1867
1980
  const page = opts.page?.page ?? 1;
1868
1981
  const pageSize = opts.page?.pageSize ?? 20;
1982
+ const decryptRow = async (item) => {
1983
+ let next = item;
1984
+ if (encryptionService) {
1985
+ const hasOrganizationScope = Object.prototype.hasOwnProperty.call(item, "__om_ce_organization_id");
1986
+ const decision = resolveDecryptScope({
1987
+ rowTenantId: Object.prototype.hasOwnProperty.call(item, "__om_ce_tenant_id") ? item.__om_ce_tenant_id : item.tenant_id ?? null,
1988
+ rowOrganizationId: hasOrganizationScope ? item.__om_ce_organization_id : item.organization_id ?? null,
1989
+ callerTenantId: opts.tenantId,
1990
+ callerOrganizationId: hasOrganizationScope ? null : opts.organizationId ?? null
1991
+ });
1992
+ if (decision.decrypt) {
1993
+ const fields = Object.entries(resolveKinds(decision.organizationId, decision.tenantId)).map(([key, kind]) => ({ key, kind, encrypted: encryptedKeys.has(key) }));
1994
+ next = await decryptCustomEntityDocument(item, fields, decision.tenantId, documentEncryptionService);
1995
+ }
1996
+ }
1997
+ const projected = { ...next };
1998
+ delete projected.__om_ce_tenant_id;
1999
+ delete projected.__om_ce_organization_id;
2000
+ return projected;
2001
+ };
2002
+ if (scanPlaintext) {
2003
+ const maxRows = resolveEncryptedSortMaxRows();
2004
+ let candidateQuery = applySelection(applyScope(db.selectFrom(`custom_entities_storage as ${alias}`)));
2005
+ if (maxRows !== null) candidateQuery = candidateQuery.limit(maxRows + 1);
2006
+ const rows2 = await candidateQuery.execute();
2007
+ if (maxRows !== null && rows2.length > maxRows) throw new Error("[internal] Custom entity encrypted query exceeds the candidate row limit");
2008
+ let candidates = await mapWithConcurrency(rows2, DECRYPT_CONCURRENCY, decryptRow);
2009
+ if (plaintextFilters) {
2010
+ const common = deferredFilters.filter((filter) => !filter.orGroup);
2011
+ const groups = /* @__PURE__ */ new Map();
2012
+ for (const filter of normalizedFilters) {
2013
+ if (filter.orGroup) groups.set(filter.orGroup, [...groups.get(filter.orGroup) ?? [], filter]);
2014
+ }
2015
+ const matches = (row, filter) => {
2016
+ const comparisonAlias = jsonComparisonAliases.get(filter.field);
2017
+ return matchesCustomDocumentFilter(row, filter, comparisonAlias ? row[comparisonAlias] : customDocumentValue(row, filter.field));
2018
+ };
2019
+ candidates = candidates.filter((row) => common.every((filter) => matches(row, filter)) && (!groups.size || Array.from(groups.values()).some((group) => group.every((filter) => matches(row, filter)))));
2020
+ }
2021
+ for (const row of candidates) {
2022
+ for (const sort of opts.sort ?? []) {
2023
+ if (isProtected(sort.field)) assertCustomDocumentPlaintext(customDocumentValue(row, sort.field));
2024
+ }
2025
+ }
2026
+ candidates = sortRowsInMemory(candidates, (opts.sort ?? []).map((sort) => ({ ...sort, field: sort.field.startsWith("cf:") ? this.sanitize(sort.field) : sort.field })));
2027
+ const total2 = candidates.length;
2028
+ const items2 = candidates.slice((page - 1) * pageSize, page * pageSize).map((row) => {
2029
+ const projected = { ...row };
2030
+ for (const field of hiddenFields) delete projected[field];
2031
+ for (const comparisonAlias of jsonComparisonAliases.values()) delete projected[comparisonAlias];
2032
+ return projected;
2033
+ });
2034
+ return { items: items2, page, pageSize, total: total2 };
2035
+ }
1869
2036
  const countCap = resolveListCountCap();
1870
2037
  const root = db.selectFrom(`custom_entities_storage as ${alias}`);
1871
2038
  const countShape = applyScope(root);
@@ -1882,7 +2049,8 @@ class HybridQueryEngine {
1882
2049
  dataQuery = applySelection(dataQuery);
1883
2050
  dataQuery = applySort(dataQuery);
1884
2051
  dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize);
1885
- const items = await dataQuery.execute();
2052
+ const rows = await dataQuery.execute();
2053
+ const items = encryptionService ? await mapWithConcurrency(rows, DECRYPT_CONCURRENCY, decryptRow) : rows;
1886
2054
  const result = { items, page, pageSize, total };
1887
2055
  if (listCountCapWarning) result.meta = { listCountCapWarning };
1888
2056
  return result;
@@ -1895,12 +2063,12 @@ class HybridQueryEngine {
1895
2063
  if (present) baseTableExistsCache.set(table, true);
1896
2064
  return present;
1897
2065
  }
1898
- async resolveCustomFieldDefs(entityIds, tenantId) {
2066
+ async resolveCustomFieldDefs(entityIds, tenantId, fresh = false) {
1899
2067
  if (!entityIds.length) return [];
1900
2068
  const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId);
1901
2069
  const now = Date.now();
1902
2070
  const cached = this.customFieldDefsCache.get(cacheKey);
1903
- if (cached && cached.expiresAt > now) return cached.value.slice();
2071
+ if (!fresh && cached && cached.expiresAt > now) return cached.value.slice();
1904
2072
  const db = this.getDb();
1905
2073
  const rows = await db.selectFrom("custom_field_defs").select(["entity_id", "key", "kind", "organization_id", "tenant_id", "config_json", "updated_at"]).where("entity_id", "in", entityIds).where("is_active", "=", true).where((eb) => eb.or([
1906
2074
  eb("tenant_id", "=", tenantId),