@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a

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 (115) 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/api/definitions.js +2 -0
  32. package/dist/modules/entities/api/definitions.js.map +2 -2
  33. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  34. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  35. package/dist/modules/entities/cli.js +193 -19
  36. package/dist/modules/entities/cli.js.map +2 -2
  37. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  38. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  39. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  40. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  41. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  42. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  43. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  44. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  45. package/dist/modules/messages/api/[id]/route.js +15 -0
  46. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  47. package/dist/modules/query_index/api/reindex.js +10 -0
  48. package/dist/modules/query_index/api/reindex.js.map +2 -2
  49. package/dist/modules/query_index/cli.js +125 -1
  50. package/dist/modules/query_index/cli.js.map +2 -2
  51. package/dist/modules/query_index/lib/coverage.js +19 -7
  52. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  53. package/dist/modules/query_index/lib/reindexer.js +43 -37
  54. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  55. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  56. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  57. package/dist/modules/sales/commands/documents.js +137 -22
  58. package/dist/modules/sales/commands/documents.js.map +2 -2
  59. package/dist/modules/sales/data/validators.js +2 -0
  60. package/dist/modules/sales/data/validators.js.map +2 -2
  61. package/dist/modules/sales/workflows.js +10 -7
  62. package/dist/modules/sales/workflows.js.map +2 -2
  63. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  64. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  65. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  66. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  67. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  68. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  69. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  70. package/generated/entities.ids.generated.ts +2 -1
  71. package/generated/entity-fields-registry.ts +17 -0
  72. package/package.json +7 -7
  73. package/src/bootstrap.ts +17 -2
  74. package/src/modules/attachments/api/route.ts +114 -7
  75. package/src/modules/attachments/data/entities.ts +59 -0
  76. package/src/modules/attachments/di.ts +9 -1
  77. package/src/modules/attachments/i18n/de.json +3 -0
  78. package/src/modules/attachments/i18n/en.json +3 -0
  79. package/src/modules/attachments/i18n/es.json +3 -0
  80. package/src/modules/attachments/i18n/pl.json +3 -0
  81. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  82. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  83. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  84. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  85. package/src/modules/attachments/lib/quota-service.ts +442 -0
  86. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  87. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  88. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  89. package/src/modules/auth/lib/setup-app.ts +20 -2
  90. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  91. package/src/modules/entities/api/definitions.ts +4 -0
  92. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  93. package/src/modules/entities/cli.ts +238 -21
  94. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  95. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  96. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  97. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  98. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  99. package/src/modules/messages/api/[id]/route.ts +21 -0
  100. package/src/modules/query_index/api/reindex.ts +15 -0
  101. package/src/modules/query_index/cli.ts +180 -1
  102. package/src/modules/query_index/lib/coverage.ts +38 -8
  103. package/src/modules/query_index/lib/reindexer.ts +62 -40
  104. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  105. package/src/modules/sales/commands/documents.ts +237 -29
  106. package/src/modules/sales/data/validators.ts +2 -0
  107. package/src/modules/sales/workflows.ts +10 -7
  108. package/src/modules/workflows/i18n/de.json +1 -0
  109. package/src/modules/workflows/i18n/en.json +1 -0
  110. package/src/modules/workflows/i18n/es.json +1 -0
  111. package/src/modules/workflows/i18n/ko.json +1 -0
  112. package/src/modules/workflows/i18n/pl.json +1 -0
  113. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  114. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  115. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
@@ -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]
@@ -0,0 +1,90 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
5
+ import {
6
+ getRecordListRelationDisplays,
7
+ resolveRecordListRelationDisplays,
8
+ type RelationDisplaysByField,
9
+ } from './recordListRelationDisplays'
10
+
11
+ const RelationDisplaysContext = React.createContext<RelationDisplaysByField>({})
12
+
13
+ export function RelationDisplaysProvider({
14
+ displaysByField,
15
+ children,
16
+ }: {
17
+ displaysByField: RelationDisplaysByField
18
+ children: React.ReactNode
19
+ }) {
20
+ return (
21
+ <RelationDisplaysContext.Provider value={displaysByField}>
22
+ {children}
23
+ </RelationDisplaysContext.Provider>
24
+ )
25
+ }
26
+
27
+ export function useRecordListRelationDisplays(
28
+ definitions: CustomFieldDefDto[],
29
+ records: Array<Record<string, unknown>>,
30
+ ): RelationDisplaysByField {
31
+ const [displaysByField, setDisplaysByField] = React.useState<RelationDisplaysByField>({})
32
+
33
+ React.useEffect(() => {
34
+ const abortController = new AbortController()
35
+ setDisplaysByField((current) => (Object.keys(current).length ? {} : current))
36
+ if (!definitions.length || !records.length) {
37
+ return () => abortController.abort()
38
+ }
39
+
40
+ void resolveRecordListRelationDisplays(definitions, records, abortController.signal)
41
+ .then((resolvedDisplays) => {
42
+ if (!abortController.signal.aborted) setDisplaysByField(resolvedDisplays)
43
+ })
44
+ .catch(() => {
45
+ if (!abortController.signal.aborted) setDisplaysByField({})
46
+ })
47
+
48
+ return () => abortController.abort()
49
+ }, [definitions, records])
50
+
51
+ return displaysByField
52
+ }
53
+
54
+ export function RelationCell({
55
+ fieldKey,
56
+ value,
57
+ fallbackText,
58
+ }: {
59
+ fieldKey: string
60
+ value: unknown
61
+ fallbackText: string
62
+ }) {
63
+ const displaysByField = React.useContext(RelationDisplaysContext)
64
+ const resolvedDisplays = getRecordListRelationDisplays(value, displaysByField[fieldKey])
65
+ const rawValue = resolvedDisplays.map((display) => display.id).join(', ') || fallbackText
66
+
67
+ if (!resolvedDisplays.length) {
68
+ return <span className="inline-block max-w-60 truncate align-top" title={rawValue}>{rawValue}</span>
69
+ }
70
+
71
+ return (
72
+ <span className="inline-block max-w-60 truncate align-top" title={rawValue}>
73
+ {resolvedDisplays.map((display, index) => (
74
+ <React.Fragment key={`${display.id}-${index}`}>
75
+ {index > 0 ? ', ' : null}
76
+ {display.href ? (
77
+ <Link
78
+ href={display.href}
79
+ className="font-medium text-primary underline-offset-2 hover:underline focus-visible:underline"
80
+ onClick={(event) => event.stopPropagation()}
81
+ onKeyDown={(event) => event.stopPropagation()}
82
+ >
83
+ {display.label}
84
+ </Link>
85
+ ) : display.label}
86
+ </React.Fragment>
87
+ ))}
88
+ </span>
89
+ )
90
+ }
@@ -0,0 +1,109 @@
1
+ import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
2
+ import {
3
+ buildRelationHref,
4
+ collectRelationValueIds,
5
+ fetchRelationRecordDisplays,
6
+ parseRelationOptionsMetadata,
7
+ type RelationOptionsMetadata,
8
+ type ResolvedValueDisplay,
9
+ } from '@open-mercato/ui/backend/utils/customFieldRelationDisplay'
10
+
11
+ export type RelationDisplaysByField = Record<string, Record<string, ResolvedValueDisplay>>
12
+
13
+ type RelationLookup = {
14
+ optionsUrl: string
15
+ relation: RelationOptionsMetadata
16
+ recordIds: Set<string>
17
+ recordIdsByField: Map<string, Set<string>>
18
+ }
19
+
20
+ export function resolveRelationOptionsUrl(definition: CustomFieldDefDto): string | null {
21
+ if (definition.kind !== 'relation') return null
22
+
23
+ const configuredUrl = definition.optionsUrl?.trim()
24
+ if (configuredUrl && parseRelationOptionsMetadata(configuredUrl)) return configuredUrl
25
+
26
+ const relatedEntityId = definition.relatedEntityId?.trim()
27
+ if (!relatedEntityId) return null
28
+ return `/api/entities/relations/options?entityId=${encodeURIComponent(relatedEntityId)}`
29
+ }
30
+
31
+ function collectRelationLookups(
32
+ definitions: CustomFieldDefDto[],
33
+ records: Array<Record<string, unknown>>,
34
+ ): RelationLookup[] {
35
+ const lookups = new Map<string, RelationLookup>()
36
+
37
+ definitions.forEach((definition) => {
38
+ const optionsUrl = resolveRelationOptionsUrl(definition)
39
+ if (!optionsUrl) return
40
+ const relation = parseRelationOptionsMetadata(optionsUrl)
41
+ if (!relation) return
42
+
43
+ const recordIds = new Set(
44
+ records.flatMap((record) => collectRelationValueIds(record[definition.key])),
45
+ )
46
+ if (!recordIds.size) return
47
+
48
+ const lookupKey = JSON.stringify([relation.entityId, optionsUrl])
49
+ const lookup = lookups.get(lookupKey) ?? {
50
+ optionsUrl,
51
+ relation,
52
+ recordIds: new Set<string>(),
53
+ recordIdsByField: new Map<string, Set<string>>(),
54
+ }
55
+ recordIds.forEach((recordId) => lookup.recordIds.add(recordId))
56
+ lookup.recordIdsByField.set(definition.key, recordIds)
57
+ lookups.set(lookupKey, lookup)
58
+ })
59
+
60
+ return Array.from(lookups.values())
61
+ }
62
+
63
+ export async function resolveRecordListRelationDisplays(
64
+ definitions: CustomFieldDefDto[],
65
+ records: Array<Record<string, unknown>>,
66
+ signal?: AbortSignal,
67
+ ): Promise<RelationDisplaysByField> {
68
+ const displaysByField: RelationDisplaysByField = {}
69
+ const lookups = collectRelationLookups(definitions, records)
70
+
71
+ await Promise.all(lookups.map(async (lookup) => {
72
+ let fetchedDisplays: Record<string, ResolvedValueDisplay> = {}
73
+ try {
74
+ fetchedDisplays = await fetchRelationRecordDisplays(
75
+ lookup.optionsUrl,
76
+ lookup.relation,
77
+ Array.from(lookup.recordIds),
78
+ signal,
79
+ )
80
+ } catch {
81
+ fetchedDisplays = {}
82
+ }
83
+
84
+ if (signal?.aborted) return
85
+ lookup.recordIdsByField.forEach((recordIds, fieldKey) => {
86
+ const fieldDisplays: Record<string, ResolvedValueDisplay> = {}
87
+ recordIds.forEach((recordId) => {
88
+ fieldDisplays[recordId] = fetchedDisplays[recordId] ?? {
89
+ label: recordId,
90
+ href: buildRelationHref(lookup.relation.entityId, recordId),
91
+ }
92
+ })
93
+ displaysByField[fieldKey] = fieldDisplays
94
+ })
95
+ }))
96
+
97
+ return displaysByField
98
+ }
99
+
100
+ export function getRecordListRelationDisplays(
101
+ value: unknown,
102
+ displays: Record<string, ResolvedValueDisplay> | undefined,
103
+ ): Array<{ id: string; label: string; href?: string }> {
104
+ return collectRelationValueIds(value).map((recordId) => ({
105
+ id: recordId,
106
+ label: displays?.[recordId]?.label ?? recordId,
107
+ href: displays?.[recordId]?.href,
108
+ }))
109
+ }
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
6
  import {
5
7
  runCrudMutationGuardAfterSuccess,
6
8
  validateCrudMutationGuard,
@@ -40,6 +42,20 @@ export async function PATCH(req: Request) {
40
42
  return NextResponse.json({ error: 'Action already processed' }, { status: 409 })
41
43
  }
42
44
 
45
+ // Optimistic lock: refuse a stale overwrite when two tabs edit the same action
46
+ // payload. Strictly additive — a no-op without the expected-version header.
47
+ try {
48
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
49
+ resourceKind: 'inbox_ops.action',
50
+ resourceId: action.id,
51
+ current: action.updatedAt ?? null,
52
+ request: req,
53
+ })
54
+ } catch (err) {
55
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
56
+ throw err
57
+ }
58
+
43
59
  const mergedPayload = { ...action.payload as Record<string, unknown>, ...parsed.data.payload }
44
60
  const payloadValidation = validateActionPayloadForType(action.actionType, mergedPayload)
45
61
  if (!payloadValidation.success) {
@@ -108,7 +124,7 @@ export const openApi: OpenApiRouteDoc = {
108
124
  responses: [
109
125
  { status: 200, description: 'Action updated' },
110
126
  { status: 404, description: 'Action not found' },
111
- { status: 409, description: 'Action already processed' },
127
+ { status: 409, description: 'Action already processed or stale (optimistic-lock conflict)' },
112
128
  ],
113
129
  },
114
130
  },
@@ -9,8 +9,8 @@ import {
9
9
  SelectTrigger,
10
10
  SelectValue,
11
11
  } from '@open-mercato/ui/primitives/select'
12
- import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
13
- import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
12
+ import { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
13
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
14
14
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
15
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
16
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -461,25 +461,30 @@ export function EditActionDialog({
461
461
  }
462
462
 
463
463
  setIsSaving(true)
464
- const result = await runMutation({
465
- // TODO(#2373-D): thread updatedAt — ActionDetail type lives outside this file's edit scope
466
- operation: () => withScopedApiRequestHeaders(
467
- buildOptimisticLockHeader(undefined),
468
- () => apiCall<{ ok: boolean; error?: string }>(
469
- `/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
470
- { method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
464
+ try {
465
+ await runMutation({
466
+ operation: () => withScopedApiRequestHeaders(
467
+ buildOptimisticLockHeader(action.updatedAt),
468
+ () => apiCallOrThrow<{ ok: boolean; error?: string }>(
469
+ `/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
470
+ { method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
471
+ ),
471
472
  ),
472
- ),
473
- context: {},
474
- })
475
- if (result?.ok && result.result?.ok) {
473
+ context: {},
474
+ })
476
475
  flash(t('inbox_ops.edit_dialog.saved', 'Action updated successfully'), 'success')
477
476
  onSaved()
478
477
  onClose()
479
- } else {
480
- flash(result?.result?.error || t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
478
+ } catch (error) {
479
+ if (extractOptimisticLockConflict(error)) {
480
+ onClose()
481
+ return
482
+ }
483
+ const message = error instanceof Error ? error.message.trim() : ''
484
+ flash(message || t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
485
+ } finally {
486
+ setIsSaving(false)
481
487
  }
482
- setIsSaving(false)
483
488
  }, [action, payload, jsonMode, jsonText, t, onSaved, onClose, runMutation])
484
489
 
485
490
  React.useEffect(() => {
@@ -27,6 +27,7 @@ export type ActionDetail = {
27
27
  createdEntityType?: string
28
28
  executionError?: string
29
29
  executedAt?: string
30
+ updatedAt?: string
30
31
  }
31
32
 
32
33
  export type DiscrepancyDetail = {
@@ -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
  {
@@ -85,6 +85,21 @@ export async function POST(req: Request) {
85
85
  },
86
86
  },
87
87
  ).catch(() => undefined)
88
+ // Queued once here rather than from a partition: the purge deletes every vector for the
89
+ // scope, so emitting it from inside the fan-out would race the per-record vectorize jobs
90
+ // its siblings are already queueing and drop vectors this run had just rebuilt.
91
+ if (force && partitions.length > 1) {
92
+ try {
93
+ await bus.emitEvent(
94
+ 'query_index.vectorize_purge',
95
+ { entityType, tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
96
+ { persistent: true, deliverInline: false },
97
+ )
98
+ } catch {
99
+ // Best effort: a failed purge leaves stale vectors, which the orphan sweep still prunes.
100
+ }
101
+ }
102
+
88
103
  try {
89
104
  await Promise.all(
90
105
  partitions.map((part) => {