@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/bootstrap.js +16 -1
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
- package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +17 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/route.js +104 -8
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +67 -1
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/di.js +7 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
- package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
- package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
- package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/attachments/lib/quota-service.js +224 -0
- package/dist/modules/attachments/lib/quota-service.js.map +7 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
- package/dist/modules/attachments/workers/quota-recovery.js +45 -0
- package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
- package/dist/modules/auth/lib/setup-app.js +16 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/dashboards/services/widgetDataService.js +3 -4
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +2 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/cli.js +193 -19
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
- package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +15 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +10 -0
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/cli.js +125 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +19 -7
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +43 -37
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +137 -22
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +2 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/workflows.js +10 -7
- package/dist/modules/sales/workflows.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +27 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/attachment_quota_reservation/index.ts +15 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +17 -0
- package/package.json +7 -7
- package/src/bootstrap.ts +17 -2
- package/src/modules/attachments/api/route.ts +114 -7
- package/src/modules/attachments/data/entities.ts +59 -0
- package/src/modules/attachments/di.ts +9 -1
- package/src/modules/attachments/i18n/de.json +3 -0
- package/src/modules/attachments/i18n/en.json +3 -0
- package/src/modules/attachments/i18n/es.json +3 -0
- package/src/modules/attachments/i18n/pl.json +3 -0
- package/src/modules/attachments/lib/drivers/index.ts +1 -1
- package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
- package/src/modules/attachments/lib/drivers/types.ts +5 -0
- package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
- package/src/modules/attachments/lib/quota-service.ts +442 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
- package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
- package/src/modules/attachments/workers/quota-recovery.ts +68 -0
- package/src/modules/auth/lib/setup-app.ts +20 -2
- package/src/modules/dashboards/services/widgetDataService.ts +3 -6
- package/src/modules/entities/api/definitions.ts +4 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
- package/src/modules/entities/cli.ts +238 -21
- package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
- package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/messages/api/[id]/route.ts +21 -0
- package/src/modules/query_index/api/reindex.ts +15 -0
- package/src/modules/query_index/cli.ts +180 -1
- package/src/modules/query_index/lib/coverage.ts +38 -8
- package/src/modules/query_index/lib/reindexer.ts +62 -40
- package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
- package/src/modules/sales/commands/documents.ts +237 -29
- package/src/modules/sales/data/validators.ts +2 -0
- package/src/modules/sales/workflows.ts +10 -7
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/ko.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/lib/activity-executor.ts +40 -1
- package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
- 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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
480
|
-
|
|
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(() => {
|
|
@@ -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) => {
|