@open-mercato/core 0.6.7-develop.6851.1.c1680b00d8 → 0.6.7-develop.6860.1.f796b22e80

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 (82) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/bootstrap.js +16 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
  5. package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +2 -1
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +17 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +104 -8
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/data/entities.js +67 -1
  13. package/dist/modules/attachments/data/entities.js.map +2 -2
  14. package/dist/modules/attachments/di.js +7 -1
  15. package/dist/modules/attachments/di.js.map +2 -2
  16. package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
  17. package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
  18. package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
  19. package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
  20. package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
  21. package/dist/modules/attachments/lib/quota-service.js +224 -0
  22. package/dist/modules/attachments/lib/quota-service.js.map +7 -0
  23. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
  24. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
  25. package/dist/modules/attachments/workers/quota-recovery.js +45 -0
  26. package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
  27. package/dist/modules/auth/lib/setup-app.js +16 -2
  28. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  29. package/dist/modules/dashboards/services/widgetDataService.js +3 -4
  30. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  31. package/dist/modules/entities/cli.js +193 -19
  32. package/dist/modules/entities/cli.js.map +2 -2
  33. package/dist/modules/messages/api/[id]/route.js +15 -0
  34. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  35. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  36. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  37. package/dist/modules/sales/commands/documents.js +110 -22
  38. package/dist/modules/sales/commands/documents.js.map +2 -2
  39. package/dist/modules/sales/workflows.js +10 -7
  40. package/dist/modules/sales/workflows.js.map +2 -2
  41. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  42. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  43. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  44. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  45. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  46. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  47. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  48. package/generated/entities.ids.generated.ts +2 -1
  49. package/generated/entity-fields-registry.ts +17 -0
  50. package/package.json +7 -7
  51. package/src/bootstrap.ts +17 -2
  52. package/src/modules/attachments/api/route.ts +114 -7
  53. package/src/modules/attachments/data/entities.ts +59 -0
  54. package/src/modules/attachments/di.ts +9 -1
  55. package/src/modules/attachments/i18n/de.json +3 -0
  56. package/src/modules/attachments/i18n/en.json +3 -0
  57. package/src/modules/attachments/i18n/es.json +3 -0
  58. package/src/modules/attachments/i18n/ko.json +3 -0
  59. package/src/modules/attachments/i18n/pl.json +3 -0
  60. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  61. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  62. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  63. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  64. package/src/modules/attachments/lib/quota-service.ts +442 -0
  65. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  66. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  67. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  68. package/src/modules/auth/lib/setup-app.ts +20 -2
  69. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  70. package/src/modules/entities/cli.ts +238 -21
  71. package/src/modules/messages/api/[id]/route.ts +21 -0
  72. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  73. package/src/modules/sales/commands/documents.ts +188 -29
  74. package/src/modules/sales/workflows.ts +10 -7
  75. package/src/modules/workflows/i18n/de.json +1 -0
  76. package/src/modules/workflows/i18n/en.json +1 -0
  77. package/src/modules/workflows/i18n/es.json +1 -0
  78. package/src/modules/workflows/i18n/ko.json +1 -0
  79. package/src/modules/workflows/i18n/pl.json +1 -0
  80. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  81. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  82. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
@@ -0,0 +1,12 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260709234741_attachments extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`create table "attachment_quota_reservations" ("id" uuid not null, "tenant_id" uuid not null, "organization_id" uuid not null, "reserved_bytes" bigint not null, "actual_bytes" bigint null, "status" text not null default 'reserved', "source" text not null, "storage_driver" text not null, "partition_code" text null, "storage_path" text not null, "lease_token" uuid not null, "upload_token_hash" text null, "expires_at" timestamptz null, "created_at" timestamptz not null, "updated_at" timestamptz not null, primary key ("id"));`);
7
+ this.addSql(`create index "attachment_quota_reservations_expires_idx" on "attachment_quota_reservations" ("expires_at");`);
8
+ this.addSql(`create index "attachment_quota_reservations_tenant_status_idx" on "attachment_quota_reservations" ("tenant_id", "status");`);
9
+ this.addSql(`alter table "attachment_quota_reservations" add constraint "attachment_quota_reservations_scope_path_unique" unique ("tenant_id", "storage_driver", "storage_path");`);
10
+ }
11
+
12
+ }
@@ -0,0 +1,68 @@
1
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AttachmentQuotaService } from '../lib/quota-service'
4
+ import type { StorageDriverFactory } from '../lib/drivers'
5
+ import { scheduleAttachmentQuotaRecovery, type AttachmentQuotaRecoveryJob } from '../lib/quota-recovery-queue'
6
+
7
+ export const metadata: WorkerMeta = {
8
+ queue: 'attachments-quota-recovery',
9
+ id: 'attachments:quota-recovery',
10
+ concurrency: 2,
11
+ }
12
+
13
+ type HandlerContext = JobContext & { resolve: <T = unknown>(name: string) => T }
14
+
15
+ type AttachmentLookupDatabase = {
16
+ attachments: {
17
+ id: string
18
+ tenant_id: string
19
+ storage_driver: string
20
+ storage_path: string
21
+ }
22
+ }
23
+
24
+ export default async function handle(
25
+ job: QueuedJob<AttachmentQuotaRecoveryJob>,
26
+ ctx: HandlerContext,
27
+ ): Promise<void> {
28
+ const quotaService = ctx.resolve<AttachmentQuotaService>('attachmentQuotaService')
29
+ const scope = {
30
+ tenantId: job.payload.tenantId,
31
+ organizationId: job.payload.organizationId,
32
+ }
33
+ const record = await quotaService.getReservation(job.payload.reservationId, scope)
34
+ if (!record || record.status === 'committed') return
35
+ if (record.expiresAt && record.expiresAt.getTime() > Date.now()) {
36
+ await scheduleAttachmentQuotaRecovery(job.payload, record.expiresAt.getTime() - Date.now())
37
+ return
38
+ }
39
+
40
+ const claimed = await quotaService.claimExpired(record.id, scope)
41
+ if (!claimed) return
42
+ try {
43
+ const em = ctx.resolve<EntityManager>('em')
44
+ const existingAttachment = await em.getKysely<AttachmentLookupDatabase>()
45
+ .selectFrom('attachments')
46
+ .select('id')
47
+ .where('tenant_id', '=', claimed.tenantId)
48
+ .where('storage_driver', '=', claimed.storageDriver)
49
+ .where('storage_path', '=', claimed.storagePath)
50
+ .executeTakeFirst()
51
+ if (!existingAttachment) {
52
+ if (!claimed.partitionCode) throw new Error('Attachment quota recovery is missing its partition code.')
53
+ const factory = ctx.resolve<StorageDriverFactory>('storageDriverFactory')
54
+ const driver = await factory.resolveForPartition(claimed.partitionCode, {
55
+ tenantId: claimed.tenantId,
56
+ organizationId: claimed.organizationId,
57
+ })
58
+ await (driver.deleteStrict?.(claimed.partitionCode, claimed.storagePath)
59
+ ?? driver.delete(claimed.partitionCode, claimed.storagePath))
60
+ }
61
+ await quotaService.release(claimed.id, claimed.leaseToken)
62
+ } catch (error) {
63
+ if (claimed.expiresAt) {
64
+ await scheduleAttachmentQuotaRecovery(job.payload, claimed.expiresAt.getTime() - Date.now())
65
+ }
66
+ throw error
67
+ }
68
+ }
@@ -5,7 +5,7 @@ import { Role, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/d
5
5
  import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
6
6
  import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
7
7
  import { normalizeTenantId } from './tenantAccess'
8
- import { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'
8
+ import { computeEmailHash, emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'
9
9
  import { getDefaultEncryptionMaps, type Module } from '@open-mercato/shared/modules/registry'
10
10
  import { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
11
11
  import { EncryptionMap } from '@open-mercato/core/modules/entities/data/entities'
@@ -190,7 +190,20 @@ export async function setupInitialTenant(
190
190
  const defaultEncryptionMaps = getDefaultEncryptionMaps(resolvedModules)
191
191
 
192
192
  const mainEmail = primaryUser.email
193
- const existingUser = await findOneWithDecryption(em, User, { email: mainEmail }, {}, { tenantId: null, organizationId: null })
193
+ const existingUserByHash = await findOneWithDecryption(
194
+ em,
195
+ User,
196
+ { emailHash: { $in: emailHashLookupValues(mainEmail) }, deletedAt: null },
197
+ {},
198
+ { tenantId: null, organizationId: null },
199
+ )
200
+ const existingUser = existingUserByHash ?? await findOneWithDecryption(
201
+ em,
202
+ User,
203
+ { email: mainEmail, deletedAt: null },
204
+ {},
205
+ { tenantId: null, organizationId: null },
206
+ )
194
207
  if (existingUser && failIfUserExists) {
195
208
  throw new Error('USER_EXISTS')
196
209
  }
@@ -358,7 +371,12 @@ export async function setupInitialTenant(
358
371
  await tem.flush()
359
372
 
360
373
  if (isTenantDataEncryptionEnabled()) {
374
+ // System-scoped maps are resolved from module code, never from a tenant row.
375
+ // Persisting one here would make the tenant-scoped encryption CLIs believe they
376
+ // own that entity and re-wrap its `system:<entityId>` ciphertext under the tenant
377
+ // DEK, which runtime decryption can no longer read.
361
378
  for (const spec of defaultEncryptionMaps) {
379
+ if (spec.keyScope === 'system') continue
362
380
  const existing = await findOneWithDecryption(tem, EncryptionMap, { entityId: spec.entityId, tenantId: tenant.id, organizationId: organization.id, deletedAt: null }, {}, { tenantId: String(tenant.id), organizationId: String(organization.id) })
363
381
  if (!existing) {
364
382
  tem.persist(tem.create(EncryptionMap, {
@@ -5,6 +5,7 @@ import { decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
5
5
  import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
6
6
  import { resolveEntityIdFromMetadata } from '@open-mercato/shared/lib/encryption/entityIds'
7
7
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
+ import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
8
9
  import {
9
10
  type DateRangePreset,
10
11
  resolveDateRange,
@@ -865,12 +866,8 @@ export class WidgetDataService {
865
866
  }
866
867
 
867
868
  private resolveEntityMetadata(tableName: string): Record<string, any> | null {
868
- const registry = (this.em as any)?.getMetadata?.()
869
- if (!registry) return null
870
- const entries =
871
- (typeof registry.getAll === 'function' && registry.getAll()) ||
872
- (Array.isArray(registry.metadata) ? registry.metadata : Object.values(registry.metadata ?? {}))
873
- const metas = Array.isArray(entries) ? entries : Object.values(entries ?? {})
869
+ const metas = listEntityMetadata(this.em)
870
+ if (!metas.length) return null
874
871
  const match = metas.find((meta: any) => {
875
872
  const table = meta?.tableName ?? meta?.collection
876
873
  if (typeof table !== 'string') return false
@@ -23,6 +23,7 @@ import {
23
23
  parseDecryptedFieldValue,
24
24
  } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
25
25
  import { resolveEntityIdFromMetadata } from '@open-mercato/shared/lib/encryption/entityIds'
26
+ import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
26
27
  import { Organization } from '../directory/data/entities'
27
28
  import crypto from 'node:crypto'
28
29
 
@@ -263,8 +264,28 @@ function resolveEncryptionMapModules(): Module[] {
263
264
  }
264
265
  }
265
266
 
267
+ /**
268
+ * Entity ids whose encryption map is declared in module code with `keyScope: 'system'`.
269
+ *
270
+ * Their ciphertext is sealed under a `system:<entityId>` DEK that has no tenant, so every
271
+ * tenant-scoped command here MUST leave them alone: re-wrapping such a value under a tenant
272
+ * DEK produces a payload runtime decryption can never read again. `backfill-system-encryption`
273
+ * is the one command that handles them.
274
+ */
275
+ function getSystemScopedEntityIds(): Set<string> {
276
+ return new Set(
277
+ getDefaultEncryptionMaps(resolveEncryptionMapModules())
278
+ .filter((map) => map.keyScope === 'system')
279
+ .map((map) => map.entityId),
280
+ )
281
+ }
282
+
266
283
  async function upsertEncryptionMaps(em: any, tenantId: string, organizationId: string | null, logger: (msg: string) => void) {
267
284
  for (const spec of getDefaultEncryptionMaps(resolveEncryptionMapModules())) {
285
+ if (spec.keyScope === 'system') {
286
+ logger(`Skipping ${spec.entityId}: system-scoped map, resolved from module code rather than a tenant row.`)
287
+ continue
288
+ }
268
289
  const existing = await em.findOne(EncryptionMap, {
269
290
  entityId: spec.entityId,
270
291
  tenantId,
@@ -378,9 +399,7 @@ function resolveProperty(meta: any, field: string): { columnName: string | null;
378
399
  }
379
400
 
380
401
  function buildEntityMetaRegistry(em: any): Map<string, any> {
381
- const registry = em?.getMetadata?.()
382
- const allMetaRaw = typeof registry?.getAll === 'function' ? registry.getAll() : []
383
- const allMeta = Array.isArray(allMetaRaw) ? allMetaRaw : Object.values(allMetaRaw ?? {})
402
+ const allMeta = listEntityMetadata(em)
384
403
  const metaByEntityId = new Map<string, any>()
385
404
  for (const meta of allMeta) {
386
405
  const resolved = resolveEntityIdFromMetadata(meta)
@@ -414,6 +433,21 @@ function resolveMapMeta(
414
433
  return { entityId, meta, fields, tenantId }
415
434
  }
416
435
 
436
+ function isEncryptedPayload(value: unknown): boolean {
437
+ if (typeof value !== 'string') return false
438
+ const parts = value.split(':')
439
+ return parts.length === 4 && parts[3] === 'v1'
440
+ }
441
+
442
+ function formatValueForColumn(prop: any, value: unknown): unknown {
443
+ if (value === null || value === undefined) return value
444
+ const types = Array.isArray(prop?.columnTypes) ? prop.columnTypes : []
445
+ const type = String(prop?.type ?? '').toLowerCase()
446
+ const isJson = types.some((entry: string) => entry.toLowerCase().includes('json')) || type === 'json' || type === 'jsonb'
447
+ if (!isJson) return value
448
+ return JSON.stringify(value)
449
+ }
450
+
417
451
  const rotateEncryptionKey: ModuleCli = {
418
452
  command: 'rotate-encryption-key',
419
453
  async run(rest) {
@@ -470,32 +504,25 @@ const rotateEncryptionKey: ModuleCli = {
470
504
  }
471
505
  }
472
506
 
473
- const isEncryptedPayload = (value: unknown): boolean => {
474
- if (typeof value !== 'string') return false
475
- const parts = value.split(':')
476
- return parts.length === 4 && parts[3] === 'v1'
477
- }
478
-
479
507
  const metaByEntityId = buildEntityMetaRegistry(em)
480
508
 
481
509
  const where: any = { deletedAt: null }
482
510
  if (tenantIdArg) where.tenantId = tenantIdArg
483
511
  if (organizationIdArg) where.organizationId = organizationIdArg
484
- const maps = await em.find(EncryptionMap, where)
512
+ const allMaps = await em.find(EncryptionMap, where)
513
+ const systemScopedEntityIds = getSystemScopedEntityIds()
514
+ const maps = allMaps.filter((map: EncryptionMap) => {
515
+ if (!systemScopedEntityIds.has(String(map.entityId))) return true
516
+ console.warn(
517
+ `Skipping ${map.entityId}: system-scoped entity. Its ciphertext is sealed under a system key and cannot be rotated with a tenant key — use "mercato entities backfill-system-encryption" instead.`,
518
+ )
519
+ return false
520
+ })
485
521
  if (!maps.length) {
486
522
  console.log('No encryption maps found for the selected scope.')
487
523
  return
488
524
  }
489
525
 
490
- const formatValueForColumn = (prop: any, value: unknown): unknown => {
491
- if (value === null || value === undefined) return value
492
- const types = Array.isArray(prop?.columnTypes) ? prop.columnTypes : []
493
- const type = String(prop?.type ?? '').toLowerCase()
494
- const isJson = types.some((entry: string) => entry.toLowerCase().includes('json')) || type === 'json' || type === 'jsonb'
495
- if (!isJson) return value
496
- return JSON.stringify(value)
497
- }
498
-
499
526
  const resolveScopes = async (tenantId: string, organizationId: string | null) => {
500
527
  if (organizationId) return [{ tenantId, organizationId }]
501
528
  const orgs = await em.find(Organization, { tenant: tenantId })
@@ -699,7 +726,15 @@ const decryptDatabase: ModuleCli = {
699
726
  const mapWhere: any = { tenantId: tenantIdArg, deletedAt: null, isActive: true }
700
727
  if (organizationIdArg) mapWhere.organizationId = organizationIdArg
701
728
  if (entityIdArg) mapWhere.entityId = entityIdArg
702
- const maps = await em.find(EncryptionMap, mapWhere)
729
+ const allMaps = await em.find(EncryptionMap, mapWhere)
730
+ const systemScopedEntityIds = getSystemScopedEntityIds()
731
+ const maps = allMaps.filter((map: EncryptionMap) => {
732
+ if (!systemScopedEntityIds.has(String(map.entityId))) return true
733
+ console.warn(
734
+ `Skipping ${map.entityId}: system-scoped entity. Its ciphertext is sealed under a system key that the tenant DEK cannot open, so this command leaves it untouched.`,
735
+ )
736
+ return false
737
+ })
703
738
 
704
739
  if (!maps.length) {
705
740
  console.log('No active encryption maps found for the selected scope.')
@@ -986,5 +1021,187 @@ const decryptDatabase: ModuleCli = {
986
1021
  },
987
1022
  }
988
1023
 
1024
+ /**
1025
+ * Encrypt rows that a **system-scoped** encryption map covers but that were written
1026
+ * before the map existed.
1027
+ *
1028
+ * `rotate-encryption-key` cannot reach them: it walks the `encryption_maps` table and
1029
+ * skips every row without a `tenant_id`, while system-scoped maps are declared in module
1030
+ * code (`defaultEncryptionMaps` with `keyScope: 'system'`) and cover pre-tenant records
1031
+ * that have no tenant at all — onboarding requests being the first such entity. Without a
1032
+ * backfill those historical rows stay plaintext forever, since only the write path
1033
+ * encrypts.
1034
+ *
1035
+ * The command is forward-only (plaintext → ciphertext), never accepts an old key and never
1036
+ * decrypts, so it cannot re-encrypt data under the wrong key. It is idempotent: values that
1037
+ * already decrypt under the current system DEK are left untouched by
1038
+ * `encryptEntityPayload`, so a partial run can simply be repeated.
1039
+ */
1040
+ const backfillSystemEncryption: ModuleCli = {
1041
+ command: 'backfill-system-encryption',
1042
+ async run(rest) {
1043
+ const args = parseArgs(rest)
1044
+ const entityIdArg = (args.entity as string) || null
1045
+ const dryRun = Boolean(args['dry-run'] || args.dry)
1046
+ const batchSize = Math.max(1, parseInt(String(args['batch-size'] || args.batchSize || '500'), 10) || 500)
1047
+ const debug = Boolean(args.debug)
1048
+
1049
+ if (!isTenantDataEncryptionEnabled()) {
1050
+ console.error('TENANT_DATA_ENCRYPTION is disabled; aborting. Enable encryption before backfilling.')
1051
+ return
1052
+ }
1053
+
1054
+ const systemMaps = getDefaultEncryptionMaps(resolveEncryptionMapModules()).filter((map) => map.keyScope === 'system')
1055
+ if (!systemMaps.length) {
1056
+ console.log('No system-scoped encryption maps are declared by the installed modules. Nothing to backfill.')
1057
+ return
1058
+ }
1059
+ const selectedMaps = entityIdArg ? systemMaps.filter((map) => map.entityId === entityIdArg) : systemMaps
1060
+ if (!selectedMaps.length) {
1061
+ console.error(`No system-scoped encryption map declared for entity "${entityIdArg}".`)
1062
+ console.error(`Known system-scoped entities: ${systemMaps.map((map) => map.entityId).join(', ')}`)
1063
+ return
1064
+ }
1065
+
1066
+ const { resolve } = await createRequestContainer()
1067
+ const em = resolve('em') as any
1068
+ const conn: any = em?.getConnection?.()
1069
+ if (!conn || typeof conn.execute !== 'function') {
1070
+ console.error('Unable to access raw database connection; aborting.')
1071
+ return
1072
+ }
1073
+
1074
+ const encryptionService = new TenantDataEncryptionService(em as any, {
1075
+ kms: createKmsService(),
1076
+ defaultEncryptionMaps: systemMaps,
1077
+ })
1078
+ if (!encryptionService.isEnabled()) {
1079
+ console.error('Encryption service is not enabled (KMS unhealthy). Aborting without touching any row.')
1080
+ return
1081
+ }
1082
+
1083
+ const metaByEntityId = buildEntityMetaRegistry(em)
1084
+ const prefix = dryRun ? '[dry-run] ' : ''
1085
+ let totalRowsScanned = 0
1086
+ let totalRowsUpdated = 0
1087
+ let totalRowsUnchanged = 0
1088
+
1089
+ for (const map of selectedMaps) {
1090
+ const entityId = map.entityId
1091
+ const meta = metaByEntityId.get(entityId)
1092
+ if (!meta) {
1093
+ console.warn(`Skipping ${entityId}: entity metadata not found (is the owning module enabled?).`)
1094
+ continue
1095
+ }
1096
+ const tableName = meta?.tableName
1097
+ if (!tableName) {
1098
+ console.warn(`Skipping ${entityId}: entity has no table name.`)
1099
+ continue
1100
+ }
1101
+ const primaryKey = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : 'id'
1102
+ const { columnName: primaryKeyColumn } = resolveProperty(meta, primaryKey)
1103
+ const pkColumn = primaryKeyColumn ?? primaryKey
1104
+ const qualifiedTable = meta?.schema ? `"${meta.schema}"."${tableName}"` : `"${tableName}"`
1105
+
1106
+ const columns = new Set<string>([pkColumn])
1107
+ for (const rule of map.fields) {
1108
+ const resolved = resolveProperty(meta, rule.field)
1109
+ if (resolved.columnName) columns.add(resolved.columnName)
1110
+ if (rule.hashField) {
1111
+ const resolvedHash = resolveProperty(meta, rule.hashField)
1112
+ if (resolvedHash.columnName) columns.add(resolvedHash.columnName)
1113
+ }
1114
+ }
1115
+ const columnList = Array.from(columns)
1116
+ const selectList = columnList.map((column) => `"${column}"`).join(', ')
1117
+
1118
+ let cursor: unknown = null
1119
+ let entityRowsScanned = 0
1120
+ let entityRowsUpdated = 0
1121
+ let entityRowsUnchanged = 0
1122
+
1123
+ for (;;) {
1124
+ const selectSql = cursor === null
1125
+ ? `select ${selectList} from ${qualifiedTable} order by "${pkColumn}" asc limit ?`
1126
+ : `select ${selectList} from ${qualifiedTable} where "${pkColumn}" > ? order by "${pkColumn}" asc limit ?`
1127
+ const params = cursor === null ? [batchSize] : [cursor, batchSize]
1128
+ const rows = await conn.execute(selectSql, params)
1129
+ const list = Array.isArray(rows) ? rows : []
1130
+ if (!list.length) break
1131
+
1132
+ for (const row of list) {
1133
+ entityRowsScanned += 1
1134
+ cursor = row[pkColumn]
1135
+ const payload: Record<string, unknown> = {}
1136
+ let hasPlaintext = false
1137
+ for (const rule of map.fields) {
1138
+ const resolved = resolveProperty(meta, rule.field)
1139
+ if (!resolved.columnName) continue
1140
+ const rawValue = row[resolved.columnName]
1141
+ payload[rule.field] = rawValue
1142
+ if (rawValue !== null && rawValue !== undefined && !isEncryptedPayload(rawValue)) hasPlaintext = true
1143
+ if (rule.hashField) {
1144
+ const resolvedHash = resolveProperty(meta, rule.hashField)
1145
+ if (resolvedHash.columnName) payload[rule.hashField] = row[resolvedHash.columnName]
1146
+ }
1147
+ }
1148
+ if (!hasPlaintext) continue
1149
+
1150
+ const encrypted = await encryptionService.encryptEntityPayload(entityId, payload, null, null)
1151
+ const updates: Record<string, unknown> = {}
1152
+ for (const rule of map.fields) {
1153
+ const resolved = resolveProperty(meta, rule.field)
1154
+ if (resolved.columnName) {
1155
+ const nextValue = encrypted[rule.field]
1156
+ if (nextValue !== undefined && nextValue !== row[resolved.columnName]) {
1157
+ updates[resolved.columnName] = formatValueForColumn(resolved.prop, nextValue)
1158
+ }
1159
+ }
1160
+ if (rule.hashField) {
1161
+ const resolvedHash = resolveProperty(meta, rule.hashField)
1162
+ const nextHash = encrypted[rule.hashField]
1163
+ if (resolvedHash.columnName && nextHash !== undefined && nextHash !== row[resolvedHash.columnName]) {
1164
+ updates[resolvedHash.columnName] = formatValueForColumn(resolvedHash.prop, nextHash)
1165
+ }
1166
+ }
1167
+ }
1168
+ if (!Object.keys(updates).length) {
1169
+ entityRowsUnchanged += 1
1170
+ if (debug) console.warn(`[backfill-system-encryption] ${entityId} ${String(row[pkColumn])}: plaintext left unchanged`)
1171
+ continue
1172
+ }
1173
+ if (!dryRun) {
1174
+ const setSql = Object.keys(updates).map((column) => `"${column}" = ?`).join(', ')
1175
+ await conn.execute(
1176
+ `update ${qualifiedTable} set ${setSql} where "${pkColumn}" = ?`,
1177
+ [...Object.values(updates), row[pkColumn]],
1178
+ )
1179
+ }
1180
+ entityRowsUpdated += 1
1181
+ }
1182
+
1183
+ if (list.length < batchSize) break
1184
+ }
1185
+
1186
+ totalRowsScanned += entityRowsScanned
1187
+ totalRowsUpdated += entityRowsUpdated
1188
+ totalRowsUnchanged += entityRowsUnchanged
1189
+ console.log(`${prefix}${entityId}: scanned ${entityRowsScanned}, encrypted ${entityRowsUpdated}`)
1190
+ }
1191
+
1192
+ console.log(`\n${prefix}Backfill summary:`)
1193
+ console.log(` Rows scanned: ${totalRowsScanned}`)
1194
+ console.log(` Rows encrypted: ${totalRowsUpdated}`)
1195
+ if (totalRowsUnchanged > 0) {
1196
+ console.warn(
1197
+ ` ⚠ ${totalRowsUnchanged} row(s) held plaintext that could not be encrypted — the system DEK was unavailable. Verify the KMS/fallback key, then re-run (use --debug to list the rows).`,
1198
+ )
1199
+ }
1200
+ if (!dryRun && totalRowsUpdated > 0) {
1201
+ console.log('\n✅ Backfill complete. Re-run with --dry-run to confirm no plaintext rows remain.')
1202
+ }
1203
+ },
1204
+ }
1205
+
989
1206
  // Keep default export stable (install first for help listing)
990
- export default [seedDefs, reinstallDefs, addField, seedEncryptionMaps, rotateEncryptionKey, decryptDatabase]
1207
+ export default [seedDefs, reinstallDefs, addField, seedEncryptionMaps, rotateEncryptionKey, decryptDatabase, backfillSystemEncryption]
@@ -402,6 +402,27 @@ export async function DELETE(req: Request, { params }: { params: { id: string }
402
402
  return Response.json({ error: 'Access denied' }, { status: 403 })
403
403
  }
404
404
 
405
+ const isSender = message.senderUserId === scope.userId
406
+ // Only look the recipient row up when the actor is not the sender: deleting one's own
407
+ // message is the common path, and the lookup is a decryption-aware query.
408
+ const isRecipient = isSender
409
+ ? false
410
+ : Boolean(await findOneWithDecryption(
411
+ em,
412
+ MessageRecipient,
413
+ {
414
+ messageId: params.id,
415
+ recipientUserId: scope.userId,
416
+ deletedAt: null,
417
+ },
418
+ undefined,
419
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
420
+ ))
421
+
422
+ if (!isSender && !isRecipient) {
423
+ return Response.json({ error: 'Access denied' }, { status: 403 })
424
+ }
425
+
405
426
  const guardResult = await runMessageMutationGuards(
406
427
  ctx.container,
407
428
  {
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
3
3
  import { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
4
+ import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
4
5
 
5
6
  export type QueryIndexScope = {
6
7
  tenantId: string | null
@@ -58,13 +59,7 @@ export function resolveQueryIndexSourceMetadata(
58
59
  throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)
59
60
  }
60
61
 
61
- const registry = em.getMetadata?.()
62
- const allMetadataRaw = typeof registry?.getAll === 'function' ? registry.getAll() : null
63
- const allMetadata: QueryIndexEntityMetadata[] = Array.isArray(allMetadataRaw)
64
- ? allMetadataRaw as QueryIndexEntityMetadata[]
65
- : allMetadataRaw instanceof Map
66
- ? Array.from(allMetadataRaw.values()) as QueryIndexEntityMetadata[]
67
- : Object.values(allMetadataRaw ?? {}) as QueryIndexEntityMetadata[]
62
+ const allMetadata = listEntityMetadata(em) as unknown as QueryIndexEntityMetadata[]
68
63
  const metadata = allMetadata.find((candidate) => String(candidate.tableName ?? '') === table)
69
64
  if (!metadata) {
70
65
  throw new QueryIndexScopeError(`Query index entity metadata was not found for table: ${table}`)