@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.
- 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/cli.js +193 -19
- package/dist/modules/entities/cli.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/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 +110 -22
- package/dist/modules/sales/commands/documents.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/ko.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/cli.ts +238 -21
- package/src/modules/messages/api/[id]/route.ts +21 -0
- package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
- package/src/modules/sales/commands/documents.ts +188 -29
- 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
|
@@ -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
|
|
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
|
|
869
|
-
if (!
|
|
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
|
|
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]
|
|
@@ -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
|
|
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}`)
|