@open-mercato/core 0.10.1-develop.7787.1.96c7cfc66f → 0.10.1-develop.7792.1.b0ce6289cf

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.
@@ -0,0 +1,50 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ function record(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
4
+ }
5
+ async function isInitialReplacementOrderApproval(ctx, scope, orderId, savedVersion, currentVersion) {
6
+ let logs;
7
+ try {
8
+ logs = ctx.container.resolve("actionLogService");
9
+ } catch {
10
+ return false;
11
+ }
12
+ if (!logs) return false;
13
+ const history = await logs.list({
14
+ ...scope,
15
+ resourceKind: "sales.order",
16
+ resourceId: orderId,
17
+ includeRelated: true,
18
+ after: new Date(savedVersion),
19
+ pageSize: 3
20
+ });
21
+ const entries = history.items;
22
+ if (history.total > 3 || entries.length !== history.total) return false;
23
+ const edits = entries.filter((entry2) => !(entry2.commandId === "sales.orders.create" && entry2.resourceKind === "sales.order" && entry2.resourceId === orderId && entry2.tenantId === scope.tenantId && entry2.organizationId === scope.organizationId));
24
+ if (edits.length !== 1) return false;
25
+ const entry = edits[0];
26
+ if (entry.commandId !== "sales.orders.update" || entry.executionState !== "done") return false;
27
+ if (entry.tenantId !== scope.tenantId || entry.organizationId !== scope.organizationId || entry.resourceKind !== "sales.order" || entry.resourceId !== orderId) return false;
28
+ const before = record(entry.snapshotBefore);
29
+ const after = record(entry.snapshotAfter);
30
+ const beforeOrder = record(before?.order);
31
+ const afterOrder = record(after?.order);
32
+ if (!beforeOrder || !afterOrder || beforeOrder.id !== orderId || afterOrder.id !== orderId) return false;
33
+ if (beforeOrder.updatedAt !== savedVersion || afterOrder.updatedAt !== currentVersion) return false;
34
+ if (beforeOrder.status !== null || beforeOrder.statusEntryId !== null || afterOrder.status !== "pending_approval") return false;
35
+ const comparableOrder = (order) => Object.fromEntries(
36
+ Object.entries(order).filter(([key]) => !["status", "statusEntryId", "updatedAt"].includes(key))
37
+ );
38
+ if (!isDeepStrictEqual(comparableOrder(beforeOrder), comparableOrder(afterOrder))) return false;
39
+ const input = record(record(entry.commandPayload)?.__redoInput);
40
+ if (!input || input.id !== orderId || typeof input.statusEntryId !== "string" || input.statusEntryId !== afterOrder.statusEntryId || Object.keys(input).some((key) => !["id", "statusEntryId"].includes(key))) return false;
41
+ const beforeNotes = before?.notes;
42
+ const afterNotes = after?.notes;
43
+ if (!Array.isArray(beforeNotes) || !Array.isArray(afterNotes) || afterNotes.length !== beforeNotes.length + 1) return false;
44
+ if (!beforeNotes.every((note) => afterNotes.some((candidate) => isDeepStrictEqual(note, candidate)))) return false;
45
+ return ["lines", "adjustments", "addresses", "tags", "shipments", "payments"].every((key) => Array.isArray(before?.[key]) && isDeepStrictEqual(before?.[key], after?.[key]));
46
+ }
47
+ export {
48
+ isInitialReplacementOrderApproval
49
+ };
50
+ //# sourceMappingURL=replacementOrderUndo.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/warranty_claims/lib/replacementOrderUndo.ts"],
4
+ "sourcesContent": ["import { isDeepStrictEqual } from 'node:util'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'\nimport type { WarrantyClaimScope } from '../commands/shared'\n\ntype OrderAuditEntry = NonNullable<Awaited<ReturnType<ActionLogService['findById']>>>\n\nfunction record(value: unknown): Record<string, unknown> | null {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n ? value as Record<string, unknown>\n : null\n}\n\nexport async function isInitialReplacementOrderApproval(\n ctx: CommandRuntimeContext,\n scope: WarrantyClaimScope,\n orderId: string,\n savedVersion: string,\n currentVersion: string,\n): Promise<boolean> {\n let logs: ActionLogService\n try {\n logs = ctx.container.resolve<ActionLogService>('actionLogService')\n } catch {\n return false\n }\n if (!logs) return false\n const history = await logs.list({\n ...scope,\n resourceKind: 'sales.order',\n resourceId: orderId,\n includeRelated: true,\n after: new Date(savedVersion),\n pageSize: 3,\n })\n const entries: OrderAuditEntry[] = history.items\n if (history.total > 3 || entries.length !== history.total) return false\n const edits = entries.filter((entry) => !(entry.commandId === 'sales.orders.create'\n && entry.resourceKind === 'sales.order' && entry.resourceId === orderId\n && entry.tenantId === scope.tenantId && entry.organizationId === scope.organizationId))\n if (edits.length !== 1) return false\n const entry = edits[0]\n if (entry.commandId !== 'sales.orders.update' || entry.executionState !== 'done') return false\n if (entry.tenantId !== scope.tenantId || entry.organizationId !== scope.organizationId\n || entry.resourceKind !== 'sales.order' || entry.resourceId !== orderId) return false\n const before = record(entry.snapshotBefore)\n const after = record(entry.snapshotAfter)\n const beforeOrder = record(before?.order)\n const afterOrder = record(after?.order)\n if (!beforeOrder || !afterOrder || beforeOrder.id !== orderId || afterOrder.id !== orderId) return false\n if (beforeOrder.updatedAt !== savedVersion || afterOrder.updatedAt !== currentVersion) return false\n if (beforeOrder.status !== null || beforeOrder.statusEntryId !== null || afterOrder.status !== 'pending_approval') return false\n const comparableOrder = (order: Record<string, unknown>) => Object.fromEntries(\n Object.entries(order).filter(([key]) => !['status', 'statusEntryId', 'updatedAt'].includes(key)),\n )\n if (!isDeepStrictEqual(comparableOrder(beforeOrder), comparableOrder(afterOrder))) return false\n const input = record(record(entry.commandPayload)?.__redoInput)\n if (!input || input.id !== orderId || typeof input.statusEntryId !== 'string'\n || input.statusEntryId !== afterOrder.statusEntryId\n || Object.keys(input).some((key) => !['id', 'statusEntryId'].includes(key))) return false\n const beforeNotes = before?.notes\n const afterNotes = after?.notes\n if (!Array.isArray(beforeNotes) || !Array.isArray(afterNotes) || afterNotes.length !== beforeNotes.length + 1) return false\n if (!beforeNotes.every((note) => afterNotes.some((candidate) => isDeepStrictEqual(note, candidate)))) return false\n return ['lines', 'adjustments', 'addresses', 'tags', 'shipments', 'payments']\n .every((key) => Array.isArray(before?.[key]) && isDeepStrictEqual(before?.[key], after?.[key]))\n}\n"],
5
+ "mappings": "AAAA,SAAS,yBAAyB;AAOlC,SAAS,OAAO,OAAgD;AAC9D,SAAO,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,IACtE,QACA;AACN;AAEA,eAAsB,kCACpB,KACA,OACA,SACA,cACA,gBACkB;AAClB,MAAI;AACJ,MAAI;AACF,WAAO,IAAI,UAAU,QAA0B,kBAAkB;AAAA,EACnE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,MAAM,KAAK,KAAK;AAAA,IAC9B,GAAG;AAAA,IACH,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,OAAO,IAAI,KAAK,YAAY;AAAA,IAC5B,UAAU;AAAA,EACZ,CAAC;AACD,QAAM,UAA6B,QAAQ;AAC3C,MAAI,QAAQ,QAAQ,KAAK,QAAQ,WAAW,QAAQ,MAAO,QAAO;AAClE,QAAM,QAAQ,QAAQ,OAAO,CAACA,WAAU,EAAEA,OAAM,cAAc,yBACzDA,OAAM,iBAAiB,iBAAiBA,OAAM,eAAe,WAC7DA,OAAM,aAAa,MAAM,YAAYA,OAAM,mBAAmB,MAAM,eAAe;AACxF,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,QAAQ,MAAM,CAAC;AACrB,MAAI,MAAM,cAAc,yBAAyB,MAAM,mBAAmB,OAAQ,QAAO;AACzF,MAAI,MAAM,aAAa,MAAM,YAAY,MAAM,mBAAmB,MAAM,kBACnE,MAAM,iBAAiB,iBAAiB,MAAM,eAAe,QAAS,QAAO;AAClF,QAAM,SAAS,OAAO,MAAM,cAAc;AAC1C,QAAM,QAAQ,OAAO,MAAM,aAAa;AACxC,QAAM,cAAc,OAAO,QAAQ,KAAK;AACxC,QAAM,aAAa,OAAO,OAAO,KAAK;AACtC,MAAI,CAAC,eAAe,CAAC,cAAc,YAAY,OAAO,WAAW,WAAW,OAAO,QAAS,QAAO;AACnG,MAAI,YAAY,cAAc,gBAAgB,WAAW,cAAc,eAAgB,QAAO;AAC9F,MAAI,YAAY,WAAW,QAAQ,YAAY,kBAAkB,QAAQ,WAAW,WAAW,mBAAoB,QAAO;AAC1H,QAAM,kBAAkB,CAAC,UAAmC,OAAO;AAAA,IACjE,OAAO,QAAQ,KAAK,EAAE,OAAO,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,UAAU,iBAAiB,WAAW,EAAE,SAAS,GAAG,CAAC;AAAA,EACjG;AACA,MAAI,CAAC,kBAAkB,gBAAgB,WAAW,GAAG,gBAAgB,UAAU,CAAC,EAAG,QAAO;AAC1F,QAAM,QAAQ,OAAO,OAAO,MAAM,cAAc,GAAG,WAAW;AAC9D,MAAI,CAAC,SAAS,MAAM,OAAO,WAAW,OAAO,MAAM,kBAAkB,YAChE,MAAM,kBAAkB,WAAW,iBACnC,OAAO,KAAK,KAAK,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC,MAAM,eAAe,EAAE,SAAS,GAAG,CAAC,EAAG,QAAO;AACtF,QAAM,cAAc,QAAQ;AAC5B,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,MAAM,QAAQ,WAAW,KAAK,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,YAAY,SAAS,EAAG,QAAO;AACtH,MAAI,CAAC,YAAY,MAAM,CAAC,SAAS,WAAW,KAAK,CAAC,cAAc,kBAAkB,MAAM,SAAS,CAAC,CAAC,EAAG,QAAO;AAC7G,SAAO,CAAC,SAAS,eAAe,aAAa,QAAQ,aAAa,UAAU,EACzE,MAAM,CAAC,QAAQ,MAAM,QAAQ,SAAS,GAAG,CAAC,KAAK,kBAAkB,SAAS,GAAG,GAAG,QAAQ,GAAG,CAAC,CAAC;AAClG;",
6
+ "names": ["entry"]
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7787.1.96c7cfc66f",
3
+ "version": "0.10.1-develop.7792.1.b0ce6289cf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.4.3"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7787.1.96c7cfc66f",
270
- "@open-mercato/shared": "0.10.1-develop.7787.1.96c7cfc66f",
271
- "@open-mercato/ui": "0.10.1-develop.7787.1.96c7cfc66f",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7792.1.b0ce6289cf",
270
+ "@open-mercato/shared": "0.10.1-develop.7792.1.b0ce6289cf",
271
+ "@open-mercato/ui": "0.10.1-develop.7792.1.b0ce6289cf",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7787.1.96c7cfc66f",
277
- "@open-mercato/shared": "0.10.1-develop.7787.1.96c7cfc66f",
278
- "@open-mercato/ui": "0.10.1-develop.7787.1.96c7cfc66f",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7792.1.b0ce6289cf",
277
+ "@open-mercato/shared": "0.10.1-develop.7792.1.b0ce6289cf",
278
+ "@open-mercato/ui": "0.10.1-develop.7792.1.b0ce6289cf",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -5,7 +5,8 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
8
- import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
+ import { isCrudHttpError, translateCrudErrorBody } from '@open-mercato/shared/lib/crud/errors'
9
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
10
  import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
10
11
  import type { CommandRuntimeContext, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
11
12
  import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
@@ -197,11 +198,10 @@ export async function POST(req: Request) {
197
198
  }
198
199
  return response
199
200
  } catch (err) {
200
- // Same contract as undo: a domain refusal carries an already-localized body the
201
- // operator can act on, so surface it instead of a generic message.
202
201
  if (isCrudHttpError(err)) {
203
202
  logger.warn('Redo rejected', { err })
204
- return NextResponse.json(err.body, { status: err.status })
203
+ const { translate } = await resolveTranslations()
204
+ return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status })
205
205
  }
206
206
  const interceptorRejection = getCommandInterceptorHttpRejection(err)
207
207
  if (interceptorRejection) {
@@ -5,7 +5,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
8
- import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
+ import { isCrudHttpError, translateCrudErrorBody } from '@open-mercato/shared/lib/crud/errors'
9
9
  import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
10
10
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
11
11
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
@@ -158,13 +158,10 @@ export async function POST(req: Request) {
158
158
  await commandBus.undo(undoToken, ctx)
159
159
  return NextResponse.json({ ok: true, logId: target.id })
160
160
  } catch (err) {
161
- // A command can refuse an undo for a domain reason the operator can act on — e.g.
162
- // reverting a role grant would drop a tenant below a protected role's active-holder
163
- // floor. Those arrive as CrudHttpError with an already-localized body, so pass them
164
- // through verbatim instead of flattening every failure to a generic message.
165
161
  if (isCrudHttpError(err)) {
166
162
  logger.warn('Undo rejected', { err, logId: target.id })
167
- return NextResponse.json(err.body, { status: err.status })
163
+ const { translate } = await resolveTranslations()
164
+ return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status })
168
165
  }
169
166
  // A beforeUndo interceptor that blocked with an explicit status is a deliberate business
170
167
  // rejection, not an undo failure — surface its status and message (issue #5045).
@@ -330,6 +330,12 @@ function normalizeKeyInput(value: string): string {
330
330
  return value.trim().replace(/(?:^['"]|['"]$)/g, '')
331
331
  }
332
332
 
333
+ const OLD_ENCRYPTION_KEY_ENV = 'TENANT_DATA_ENCRYPTION_OLD_KEY'
334
+
335
+ function failCli(message: string): never {
336
+ throw new Error(message)
337
+ }
338
+
333
339
  class DerivedKeyKmsService implements KmsService {
334
340
  private root: Buffer
335
341
  constructor(secret: string) {
@@ -357,11 +363,6 @@ class DerivedKeyKmsService implements KmsService {
357
363
  }
358
364
  }
359
365
 
360
- function fingerprintDek(dek: TenantDek | null): string | null {
361
- if (!dek?.key) return null
362
- return crypto.createHash('sha256').update(dek.key).digest('hex').slice(0, 12)
363
- }
364
-
365
366
  function decryptWithOldKey(
366
367
  payload: string,
367
368
  dek: TenantDek | null,
@@ -405,22 +406,90 @@ interface EncryptionMapMeta {
405
406
  tenantId: string
406
407
  }
407
408
 
408
- function resolveMapMeta(
409
+ // A map is only safe to act on when every mapped field resolves to a real column. The
410
+ // distinction that matters for the exit code is whether skipping it could leave ciphertext
411
+ // behind: a map that declares no fields, or that this command does not own, cannot — while a map
412
+ // whose fields exist on paper but cannot be located is a reason to refuse the run.
413
+ type EncryptionMapResolution =
414
+ | { status: 'ready'; mapMeta: EncryptionMapMeta }
415
+ | { status: 'nothing-to-process'; reason: string }
416
+ | { status: 'unresolvable'; reason: string }
417
+
418
+ function classifyEncryptionMap(
409
419
  map: EncryptionMap,
410
420
  metaByEntityId: Map<string, any>,
411
- warn: (msg: string) => void = () => {},
412
- ): EncryptionMapMeta | null {
421
+ ): EncryptionMapResolution {
413
422
  const entityId = String(map.entityId)
423
+ const fields = Array.isArray(map.fieldsJson) ? map.fieldsJson : []
424
+ if (!fields.length) {
425
+ return { status: 'nothing-to-process', reason: 'the map declares no encrypted fields' }
426
+ }
427
+ // The "not this command's business" checks come before the consistency checks: a tenantless map
428
+ // is out of scope whether or not its entity still resolves, so testing scope first keeps an
429
+ // out-of-scope row from being reported as a blocking inconsistency.
430
+ const tenantId = map.tenantId ? String(map.tenantId) : null
431
+ if (!tenantId) {
432
+ // Documented behaviour (apps/docs/docs/user-guide/encryption.mdx, "Backfilling system-scoped
433
+ // records"): these commands walk encryption_maps and skip every row without a tenant, because
434
+ // a tenant key cannot open ciphertext sealed under a system key. That is
435
+ // `backfill-system-encryption`'s job, not this command's.
436
+ return {
437
+ status: 'nothing-to-process',
438
+ reason: 'the map has no tenant_id — use "mercato entities backfill-system-encryption" for system-scoped records',
439
+ }
440
+ }
414
441
  const meta = metaByEntityId.get(entityId)
415
442
  if (!meta) {
416
- warn(`Skipping ${entityId}: metadata not found.`)
417
- return null
443
+ return {
444
+ status: 'unresolvable',
445
+ reason: 'entity metadata is not registered in this app — is the module that owns it still enabled?',
446
+ }
418
447
  }
419
- const fields = Array.isArray(map.fieldsJson) ? map.fieldsJson : []
420
- if (!fields.length) return null
421
- const tenantId = map.tenantId ? String(map.tenantId) : null
422
- if (!tenantId) return null
423
- return { entityId, meta, fields, tenantId }
448
+ if (!meta.tableName) {
449
+ return { status: 'unresolvable', reason: 'table metadata is missing' }
450
+ }
451
+ for (const rule of fields) {
452
+ if (!resolveProperty(meta, rule.field).columnName) {
453
+ return { status: 'unresolvable', reason: `mapped field "${rule.field}" was not found` }
454
+ }
455
+ if (rule.hashField && !resolveProperty(meta, rule.hashField).columnName) {
456
+ return { status: 'unresolvable', reason: `hash field "${rule.hashField}" was not found` }
457
+ }
458
+ }
459
+ return { status: 'ready', mapMeta: { entityId, meta, fields, tenantId } }
460
+ }
461
+
462
+ function resolveMapsOrFail(
463
+ maps: EncryptionMap[],
464
+ metaByEntityId: ReturnType<typeof buildEntityMetaRegistry>,
465
+ ): Array<{ map: EncryptionMap; mapMeta: EncryptionMapMeta }> {
466
+ const resolved: Array<{ map: EncryptionMap; mapMeta: EncryptionMapMeta }> = []
467
+ const unresolvable: string[] = []
468
+ for (const map of maps) {
469
+ const resolution = classifyEncryptionMap(map, metaByEntityId)
470
+ if (resolution.status === 'ready') {
471
+ resolved.push({ map, mapMeta: resolution.mapMeta })
472
+ continue
473
+ }
474
+ if (resolution.status === 'nothing-to-process') {
475
+ console.warn(`Skipping ${String(map.entityId)}: ${resolution.reason}.`)
476
+ continue
477
+ }
478
+ unresolvable.push(` ${String(map.entityId)} (encryption_maps.id=${String(map.id)}): ${resolution.reason}`)
479
+ }
480
+ // Report every offending map at once: the operator has to clean up all of them before the
481
+ // command can run, and failing on the first one would make that an N-run guessing game.
482
+ if (unresolvable.length) {
483
+ failCli(
484
+ `${unresolvable.length} encryption map(s) in the selected scope cannot be processed, so this command would have `
485
+ + 'left their ciphertext untouched while reporting success. No rows were changed.\n'
486
+ + unresolvable.join('\n')
487
+ + '\nResolve each one before re-running: re-enable the module that owns the entity, or — once the entity\'s data '
488
+ + 'is confirmed gone — soft-delete the stale map row '
489
+ + '(UPDATE encryption_maps SET deleted_at = now() WHERE id = \'…\').',
490
+ )
491
+ }
492
+ return resolved
424
493
  }
425
494
 
426
495
  function formatValueForColumn(prop: any, value: unknown): unknown {
@@ -438,33 +507,46 @@ const rotateEncryptionKey: ModuleCli = {
438
507
  const args = parseArgs(rest)
439
508
  const tenantIdArg = (args.tenant as string) || (args.tenantId as string) || null
440
509
  const organizationIdArg = (args.org as string) || (args.organization as string) || (args.organizationId as string) || null
441
- const oldKey = (args['old-key'] as string) || (args.oldKey as string) || null
510
+ const oldKeyArgument = (args['old-key'] as string) || (args.oldKey as string) || null
511
+ const oldKeyEnvironment = process.env[OLD_ENCRYPTION_KEY_ENV]
512
+ ? normalizeKeyInput(process.env[OLD_ENCRYPTION_KEY_ENV]!)
513
+ : null
514
+ if (
515
+ oldKeyArgument
516
+ && oldKeyEnvironment
517
+ && normalizeKeyInput(oldKeyArgument) !== oldKeyEnvironment
518
+ ) {
519
+ failCli(`--old-key and ${OLD_ENCRYPTION_KEY_ENV} contain different values. Remove --old-key and retry.`)
520
+ }
521
+ if (oldKeyArgument) {
522
+ console.warn(
523
+ `Passing key material through --old-key can expose it in shell history and process listings. Set ${OLD_ENCRYPTION_KEY_ENV} through your secret manager instead.`,
524
+ )
525
+ }
526
+ const oldKey = oldKeyEnvironment || oldKeyArgument
442
527
  const dryRun = Boolean(args['dry-run'] || args.dry)
443
528
  const debug = Boolean(args.debug)
444
529
  const rotate = Boolean(oldKey)
445
530
  if (rotate && !tenantIdArg) {
446
531
  console.warn(
447
- '⚠️ Rotating with --old-key across all tenants. A single old key should normally target one tenant; consider --tenant.',
532
+ '⚠️ Rotating with one previous key across all tenants. A single previous key should normally target one tenant; consider --tenant.',
448
533
  )
449
534
  }
450
535
  if (!isTenantDataEncryptionEnabled()) {
451
- console.error('TENANT_DATA_ENCRYPTION is disabled; aborting.')
452
- return
536
+ failCli('TENANT_DATA_ENCRYPTION is disabled; aborting.')
453
537
  }
454
538
 
455
539
  const { resolve } = await createRequestContainer()
456
540
  const em = resolve('em') as any
457
541
  const conn: any = em?.getConnection?.()
458
542
  if (!conn || typeof conn.execute !== 'function') {
459
- console.error('Unable to access raw connection; aborting.')
460
- return
543
+ failCli('Unable to access raw connection; aborting.')
461
544
  }
462
545
 
463
546
  const encryptionService = new TenantDataEncryptionService(em as any, { kms: createKmsService() })
464
547
  const oldKms = rotate && oldKey ? new DerivedKeyKmsService(oldKey) : null
465
548
  if (!encryptionService.isEnabled()) {
466
- console.error('Encryption service is not enabled (KMS unhealthy or no DEK). Aborting.')
467
- return
549
+ failCli('Encryption service is not enabled (KMS unhealthy or no DEK). Aborting.')
468
550
  }
469
551
 
470
552
  if (debug) {
@@ -474,18 +556,6 @@ const rotateEncryptionKey: ModuleCli = {
474
556
  tenantId: tenantIdArg ?? null,
475
557
  organizationId: organizationIdArg ?? null,
476
558
  })
477
- if (tenantIdArg) {
478
- const [oldDek, newDek] = await Promise.all([
479
- oldKms?.getTenantDek(tenantIdArg) ?? Promise.resolve(null),
480
- encryptionService.getDek(tenantIdArg),
481
- ])
482
- console.log('[rotate-encryption-key] dek fingerprints', {
483
- oldKey: fingerprintDek(oldDek),
484
- currentKey: fingerprintDek(newDek),
485
- })
486
- } else {
487
- console.log('[rotate-encryption-key] dek fingerprints skipped (no tenantId)')
488
- }
489
559
  }
490
560
 
491
561
  const metaByEntityId = buildEntityMetaRegistry(em)
@@ -502,6 +572,7 @@ const rotateEncryptionKey: ModuleCli = {
502
572
  )
503
573
  return false
504
574
  })
575
+ const resolvedMaps = resolveMapsOrFail(maps, metaByEntityId)
505
576
  const customDocuments = await rotateCustomEntityDocuments(em, {
506
577
  encryptionService,
507
578
  oldKms,
@@ -516,7 +587,6 @@ const rotateEncryptionKey: ModuleCli = {
516
587
  if (!customDocuments) console.log('No encryption maps found for the selected scope.')
517
588
  return
518
589
  }
519
-
520
590
  const resolveScopes = async (tenantId: string, organizationId: string | null) => {
521
591
  if (organizationId) return [{ tenantId, organizationId }]
522
592
  const orgs = await em.find(Organization, { tenant: tenantId })
@@ -530,7 +600,7 @@ const rotateEncryptionKey: ModuleCli = {
530
600
 
531
601
  const oldDekCache = new Map<string, TenantDek | null>()
532
602
  // Rows this run could not rotate because their ciphertext opens under neither
533
- // --old-key nor the current tenant key — surfaced in the closing summary so an
603
+ // the previous nor the current tenant key — surfaced in the closing summary so an
534
604
  // operator does not have to grep console.warn output for a batch of thousands.
535
605
  const unrecoverableRows: Array<{ entityId: string; field: string; rowId: unknown }> = []
536
606
  // A dry run must not provision key material. `encryptEntityPayload` creates and
@@ -635,7 +705,7 @@ const rotateEncryptionKey: ModuleCli = {
635
705
  if (rule.hashField) delete payload[rule.hashField]
636
706
  unrecoverableRows.push({ entityId, field: rule.field, rowId: row[pk] })
637
707
  console.warn(
638
- `Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither --old-key nor the current tenant key. Re-run with the key that sealed it.`,
708
+ `Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither the previous nor the current tenant key. Re-run with the key that sealed it.`,
639
709
  )
640
710
  }
641
711
  }
@@ -693,9 +763,7 @@ const rotateEncryptionKey: ModuleCli = {
693
763
  }
694
764
 
695
765
  let total = 0
696
- for (const map of maps) {
697
- const mapMeta = resolveMapMeta(map, metaByEntityId, console.warn)
698
- if (!mapMeta) continue
766
+ for (const { map, mapMeta } of resolvedMaps) {
699
767
  const { entityId, meta, fields, tenantId } = mapMeta
700
768
  const scopes = await resolveScopes(tenantId, map.organizationId ? String(map.organizationId) : null)
701
769
  for (const scope of scopes) {
@@ -718,7 +786,7 @@ const rotateEncryptionKey: ModuleCli = {
718
786
  const maxListed = 20
719
787
  console.log(
720
788
  `\n⚠️ ${unrecoverableRows.length} field(s) could not be rotated — their ciphertext opens under neither `
721
- + '--old-key nor the current tenant key, so they were left untouched. See '
789
+ + 'the previous nor the current tenant key, so they were left untouched. See '
722
790
  + '"Key management" in apps/docs/docs/architecture/data-encryption.mdx for how to resolve these manually.',
723
791
  )
724
792
  for (const row of unrecoverableRows.slice(0, maxListed)) {
@@ -727,6 +795,9 @@ const rotateEncryptionKey: ModuleCli = {
727
795
  if (unrecoverableRows.length > maxListed) {
728
796
  console.log(` ... and ${unrecoverableRows.length - maxListed} more`)
729
797
  }
798
+ failCli(
799
+ `${unrecoverableRows.length} encrypted field(s) could not be rotated. The command left them untouched and did not complete successfully.`,
800
+ )
730
801
  }
731
802
  },
732
803
  }
@@ -747,18 +818,15 @@ const decryptDatabase: ModuleCli = {
747
818
  const debug = Boolean(args.debug)
748
819
 
749
820
  if (!tenantIdArg) {
750
- console.error('--tenant <uuid> is required.')
751
- return
821
+ failCli('--tenant <uuid> is required.')
752
822
  }
753
823
 
754
824
  if (!checkMode) {
755
825
  if (!confirm) {
756
- console.error('--confirm <tenantUuid> is required (safety gate). Pass the exact tenant UUID to confirm the operation.')
757
- return
826
+ failCli('--confirm <tenantUuid> is required (safety gate). Pass the exact tenant UUID to confirm the operation.')
758
827
  }
759
828
  if (confirm !== tenantIdArg) {
760
- console.error(`--confirm value "${confirm}" does not match --tenant "${tenantIdArg}". Aborting.`)
761
- return
829
+ failCli(`--confirm value "${confirm}" does not match --tenant "${tenantIdArg}". Aborting.`)
762
830
  }
763
831
  }
764
832
 
@@ -766,13 +834,11 @@ const decryptDatabase: ModuleCli = {
766
834
  const em = resolve('em') as any
767
835
  const conn: any = em?.getConnection?.()
768
836
  if (!conn || typeof conn.execute !== 'function') {
769
- console.error('Unable to access raw database connection; aborting.')
770
- return
837
+ failCli('Unable to access raw database connection; aborting.')
771
838
  }
772
839
 
773
840
  if (!checkMode && !isTenantDataEncryptionEnabled()) {
774
- console.error('TENANT_DATA_ENCRYPTION is disabled; aborting. Data may already be decrypted.')
775
- return
841
+ failCli('TENANT_DATA_ENCRYPTION is disabled; aborting. Data may already be decrypted.')
776
842
  }
777
843
 
778
844
  const metaByEntityId = buildEntityMetaRegistry(em)
@@ -811,6 +877,7 @@ const decryptDatabase: ModuleCli = {
811
877
  console.log('No active encryption maps found for the selected scope.')
812
878
  return
813
879
  }
880
+ const resolvedMaps = resolveMapsOrFail(maps, metaByEntityId)
814
881
 
815
882
  const kms = createKmsService()
816
883
  const dekCache = new Map<string, TenantDek | null>()
@@ -827,16 +894,22 @@ const decryptDatabase: ModuleCli = {
827
894
  console.log(`Active EncryptionMap records for scope: ${maps.length}`)
828
895
  let encryptedCandidatesSampled = 0
829
896
  let malformedPayloadCountSampled = 0
830
- for (const map of maps) {
831
- const mapMeta = resolveMapMeta(map, metaByEntityId)
832
- if (!mapMeta) continue
897
+ // --check writes nothing, so an unreachable DEK is the diagnosis the operator came for,
898
+ // not a reason to abort mid-inspection. Report every uninspectable entity, inspect the
899
+ // rest, and let the closing gate below make the incomplete run exit non-zero.
900
+ const uninspectableEntityIds: string[] = []
901
+ const failedSampleScopes: string[] = []
902
+ for (const { map, mapMeta } of resolvedMaps) {
833
903
  const { entityId, meta, fields, tenantId } = mapMeta
834
904
  const dek = await getDek(tenantId).catch(() => null)
835
- if (!dek) continue
905
+ if (!dek) {
906
+ console.warn(`⚠ No DEK available for tenant ${tenantId}; cannot inspect ${entityId}.`)
907
+ uninspectableEntityIds.push(entityId)
908
+ continue
909
+ }
836
910
  const scopes = await resolveDecryptScopes(tenantId, map.organizationId ? String(map.organizationId) : null)
837
911
  const pk = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : 'id'
838
- const tableName = meta?.tableName
839
- if (!tableName) continue
912
+ const tableName = meta.tableName
840
913
  const schema = meta?.schema
841
914
  const qualifiedTable = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`
842
915
  const fieldCols = fields.flatMap((f: any) => {
@@ -845,10 +918,19 @@ const decryptDatabase: ModuleCli = {
845
918
  })
846
919
  const colList = Array.from(new Set([pk, ...fieldCols]))
847
920
  for (const scope of scopes) {
848
- const sampleRows = await conn.execute(
849
- `SELECT ${colList.map((c: string) => `"${c}"`).join(', ')} FROM ${qualifiedTable} WHERE tenant_id = ? AND organization_id IS NOT DISTINCT FROM ? LIMIT 100`,
850
- [scope.tenantId, scope.organizationId],
851
- ).catch(() => [])
921
+ let sampleRows: unknown
922
+ try {
923
+ sampleRows = await conn.execute(
924
+ `SELECT ${colList.map((c: string) => `"${c}"`).join(', ')} FROM ${qualifiedTable} WHERE tenant_id = ? AND organization_id IS NOT DISTINCT FROM ? LIMIT 100`,
925
+ [scope.tenantId, scope.organizationId],
926
+ )
927
+ } catch (error: unknown) {
928
+ const scopeLabel = scope.organizationId ?? 'global'
929
+ const errorMessage = error instanceof Error ? error.message : String(error)
930
+ console.warn(`⚠ Could not sample ${entityId} for organization ${scopeLabel}: ${errorMessage}`)
931
+ failedSampleScopes.push(`${entityId} (organization_id=${scopeLabel})`)
932
+ continue
933
+ }
852
934
  for (const row of Array.isArray(sampleRows) ? sampleRows : []) {
853
935
  let rowHasEncrypted = false
854
936
  for (const fieldRule of fields) {
@@ -877,6 +959,18 @@ const decryptDatabase: ModuleCli = {
877
959
  console.log(`malformed payloads (sampled): ${malformedPayloadCountSampled}`)
878
960
  }
879
961
  console.log('not a proof of absence — run full command + rerun --check to confirm')
962
+ if (uninspectableEntityIds.length || failedSampleScopes.length) {
963
+ const unavailableDekSummary = uninspectableEntityIds.length
964
+ ? ` No DEK was available for ${uninspectableEntityIds.join(', ')}.`
965
+ : ''
966
+ const failedSampleSummary = failedSampleScopes.length
967
+ ? ` Sample queries failed for ${failedSampleScopes.join(', ')}.`
968
+ : ''
969
+ failCli(
970
+ `Encryption check incomplete.${unavailableDekSummary}${failedSampleSummary} `
971
+ + 'The counts above cover only successfully inspected scopes.',
972
+ )
973
+ }
880
974
  return
881
975
  }
882
976
 
@@ -888,21 +982,21 @@ const decryptDatabase: ModuleCli = {
888
982
  const malformedByLocation = new Map<string, number>()
889
983
  let totalEntitiesProcessed = 0
890
984
 
891
- for (const map of maps) {
892
- const mapMeta = resolveMapMeta(map, metaByEntityId, console.warn)
893
- if (!mapMeta) continue
894
- const { entityId, meta, fields, tenantId } = mapMeta
895
- const dek = await getDek(tenantId)
985
+ const deksByTenantId = new Map<string, TenantDek>()
986
+ for (const { mapMeta } of resolvedMaps) {
987
+ if (deksByTenantId.has(mapMeta.tenantId)) continue
988
+ const dek = await getDek(mapMeta.tenantId)
896
989
  if (!dek) {
897
- console.warn(`No DEK available for tenant ${tenantId}; skipping ${entityId}.`)
898
- continue
990
+ failCli(`No DEK available for tenant ${mapMeta.tenantId}. No rows were changed.`)
899
991
  }
992
+ deksByTenantId.set(mapMeta.tenantId, dek)
993
+ }
994
+
995
+ for (const { map, mapMeta } of resolvedMaps) {
996
+ const { entityId, meta, fields, tenantId } = mapMeta
997
+ const dek = deksByTenantId.get(tenantId)!
900
998
  const pk = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : 'id'
901
- const tableName = meta?.tableName
902
- if (!tableName) {
903
- console.warn(`Skipping ${entityId}: table name not found.`)
904
- continue
905
- }
999
+ const tableName = meta.tableName
906
1000
  const schema = meta?.schema
907
1001
  const qualifiedTable = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`
908
1002
  const fieldCols = fields.flatMap((f: any) => {
@@ -1035,28 +1129,6 @@ const decryptDatabase: ModuleCli = {
1035
1129
  }
1036
1130
  }
1037
1131
 
1038
- if (deactivateMaps && !dryRun) {
1039
- let deactivateSql = `UPDATE encryption_maps SET is_active = false, deleted_at = now() WHERE tenant_id = ? AND deleted_at IS NULL`
1040
- const deactivateParams: unknown[] = [tenantIdArg]
1041
- if (organizationIdArg) {
1042
- deactivateSql += ` AND (organization_id = ? OR organization_id IS NULL)`
1043
- deactivateParams.push(organizationIdArg)
1044
- }
1045
- if (entityIdArg) {
1046
- deactivateSql += ` AND entity_id = ?`
1047
- deactivateParams.push(entityIdArg)
1048
- }
1049
- await conn.execute(deactivateSql, deactivateParams)
1050
- console.warn('⚠ Restart all application replicas — in-process map caches may still be active.')
1051
- if (isTenantDataEncryptionEnabled()) {
1052
- console.warn('⚠ Env TENANT_DATA_ENCRYPTION is still true — new writes will be re-encrypted until env is updated and replicas restarted.')
1053
- }
1054
- }
1055
-
1056
- if (deactivateMaps && dryRun) {
1057
- console.log(`[dry-run] Would deactivate ${maps.length} EncryptionMap record(s).`)
1058
- }
1059
-
1060
1132
  const prefix = dryRun ? '[dry-run] ' : ''
1061
1133
  console.log(`\n${prefix}Decryption summary:`)
1062
1134
  console.log(` Rows fetched: ${totalRowsFetched}`)
@@ -1079,6 +1151,31 @@ const decryptDatabase: ModuleCli = {
1079
1151
  console.log(` ${loc}: ${count}`)
1080
1152
  }
1081
1153
  }
1154
+ failCli(
1155
+ `${totalMalformedPayloadCount} field value(s) could not be decrypted because their ciphertext was malformed. Encryption maps were left active.`,
1156
+ )
1157
+ }
1158
+
1159
+ if (deactivateMaps && !dryRun) {
1160
+ let deactivateSql = `UPDATE encryption_maps SET is_active = false, deleted_at = now() WHERE tenant_id = ? AND deleted_at IS NULL`
1161
+ const deactivateParams: unknown[] = [tenantIdArg]
1162
+ if (organizationIdArg) {
1163
+ deactivateSql += ` AND (organization_id = ? OR organization_id IS NULL)`
1164
+ deactivateParams.push(organizationIdArg)
1165
+ }
1166
+ if (entityIdArg) {
1167
+ deactivateSql += ` AND entity_id = ?`
1168
+ deactivateParams.push(entityIdArg)
1169
+ }
1170
+ await conn.execute(deactivateSql, deactivateParams)
1171
+ console.warn('⚠ Restart all application replicas — in-process map caches may still be active.')
1172
+ if (isTenantDataEncryptionEnabled()) {
1173
+ console.warn('⚠ Env TENANT_DATA_ENCRYPTION is still true — new writes will be re-encrypted until env is updated and replicas restarted.')
1174
+ }
1175
+ }
1176
+
1177
+ if (deactivateMaps && dryRun) {
1178
+ console.log(`[dry-run] Would deactivate ${maps.length} EncryptionMap record(s).`)
1082
1179
  }
1083
1180
 
1084
1181
  if (!dryRun) {