@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +10 -0
  3. package/dist/lib/auth/apiKeyAuthCache.js +17 -6
  4. package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
  5. package/dist/lib/commands/command-bus.js +56 -47
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/flush.js +23 -1
  8. package/dist/lib/commands/flush.js.map +2 -2
  9. package/dist/lib/commands/index.js +6 -1
  10. package/dist/lib/commands/index.js.map +2 -2
  11. package/dist/lib/commands/redo.js +106 -0
  12. package/dist/lib/commands/redo.js.map +7 -0
  13. package/dist/lib/commands/runCrudCommandWrite.js +38 -0
  14. package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
  15. package/dist/lib/commands/scope.js +51 -37
  16. package/dist/lib/commands/scope.js.map +2 -2
  17. package/dist/lib/commands/types.js.map +2 -2
  18. package/dist/lib/crud/errors.js +22 -0
  19. package/dist/lib/crud/errors.js.map +2 -2
  20. package/dist/lib/crud/factory.js +16 -0
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  23. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  24. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  25. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  26. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  27. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  28. package/dist/lib/crud/optimistic-lock.js +172 -0
  29. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  30. package/dist/lib/data/engine.js +2 -2
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/di/container.js +18 -2
  33. package/dist/lib/di/container.js.map +2 -2
  34. package/dist/lib/encryption/aes.js +37 -3
  35. package/dist/lib/encryption/aes.js.map +2 -2
  36. package/dist/lib/encryption/kms.js +57 -23
  37. package/dist/lib/encryption/kms.js.map +2 -2
  38. package/dist/lib/encryption/subscriber.js +41 -8
  39. package/dist/lib/encryption/subscriber.js.map +2 -2
  40. package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
  41. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  42. package/dist/lib/i18n/context.js +5 -0
  43. package/dist/lib/i18n/context.js.map +2 -2
  44. package/dist/lib/query/engine.js +41 -31
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/version.js +1 -1
  47. package/dist/lib/version.js.map +1 -1
  48. package/dist/modules/integrations/types.js.map +2 -2
  49. package/dist/modules/search.js.map +2 -2
  50. package/package.json +8 -9
  51. package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
  52. package/src/lib/auth/apiKeyAuthCache.ts +20 -6
  53. package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
  54. package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
  55. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
  56. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  57. package/src/lib/commands/__tests__/redo.test.ts +265 -0
  58. package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
  59. package/src/lib/commands/__tests__/scope.test.ts +48 -0
  60. package/src/lib/commands/command-bus.ts +62 -44
  61. package/src/lib/commands/flush.ts +79 -2
  62. package/src/lib/commands/index.ts +9 -0
  63. package/src/lib/commands/redo.ts +235 -0
  64. package/src/lib/commands/runCrudCommandWrite.ts +82 -0
  65. package/src/lib/commands/scope.ts +70 -55
  66. package/src/lib/commands/types.ts +54 -1
  67. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  68. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  69. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  70. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  71. package/src/lib/crud/errors.ts +29 -0
  72. package/src/lib/crud/factory.ts +23 -0
  73. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  74. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  75. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  76. package/src/lib/crud/optimistic-lock.ts +379 -0
  77. package/src/lib/data/engine.ts +11 -8
  78. package/src/lib/di/container.ts +17 -1
  79. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
  80. package/src/lib/encryption/__tests__/kms.test.ts +44 -6
  81. package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
  82. package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
  83. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
  84. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
  85. package/src/lib/encryption/aes.ts +78 -2
  86. package/src/lib/encryption/kms.ts +76 -24
  87. package/src/lib/encryption/subscriber.ts +54 -9
  88. package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
  89. package/src/lib/i18n/context.tsx +11 -0
  90. package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
  91. package/src/lib/query/engine.ts +59 -30
  92. package/src/modules/integrations/types.ts +14 -0
  93. package/src/modules/notifications/handler.ts +7 -0
  94. package/src/modules/search.ts +9 -0
  95. package/src/modules/vector.ts +7 -0
@@ -0,0 +1,106 @@
1
+ import { CrudHttpError, conflict, isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
2
+ import { extractUndoPayload } from "./undo.js";
3
+ import { emitCrudSideEffects } from "./helpers.js";
4
+ import { withAtomicFlush } from "./flush.js";
5
+ const DEFAULT_SNAPSHOT_DATE_FIELDS = ["createdAt", "updatedAt", "deletedAt"];
6
+ function reviveSnapshotSeed(snapshot, dateFields = DEFAULT_SNAPSHOT_DATE_FIELDS) {
7
+ const seed = { ...snapshot };
8
+ for (const field of dateFields) {
9
+ const value = seed[field];
10
+ if (typeof value === "string") seed[field] = new Date(value);
11
+ }
12
+ return seed;
13
+ }
14
+ function serializeRowSnapshot(entity, fields, dateFields = DEFAULT_SNAPSHOT_DATE_FIELDS) {
15
+ const snapshot = {};
16
+ const dateFieldSet = new Set(dateFields);
17
+ for (const field of fields) {
18
+ const value = entity[field];
19
+ if (dateFieldSet.has(field)) {
20
+ snapshot[field] = value instanceof Date ? value.toISOString() : value ?? null;
21
+ } else {
22
+ snapshot[field] = value ?? null;
23
+ }
24
+ }
25
+ return snapshot;
26
+ }
27
+ function resolveRedoSnapshot(logEntry) {
28
+ if (!logEntry) return null;
29
+ const undo = extractUndoPayload(logEntry);
30
+ if (undo && undo.after != null) return undo.after;
31
+ if (logEntry.snapshotAfter != null) return logEntry.snapshotAfter;
32
+ return null;
33
+ }
34
+ async function restoreCreatedRow(em, entityClass, id, seedFromSnapshot, findRow) {
35
+ const existing = findRow ? await findRow(em, id) : await em.findOne(entityClass, { id });
36
+ if (existing) {
37
+ existing.deletedAt = null;
38
+ const seed = seedFromSnapshot();
39
+ if (typeof seed.isActive === "boolean") existing.isActive = seed.isActive;
40
+ return existing;
41
+ }
42
+ const record = em.create(entityClass, { ...seedFromSnapshot(), deletedAt: null });
43
+ em.persist(record);
44
+ return record;
45
+ }
46
+ function makeCreateRedo(config) {
47
+ const getSnapshotId = config.getSnapshotId ?? ((snapshot) => snapshot.id ?? null);
48
+ const seedFromSnapshot = config.seedFromSnapshot ?? ((snapshot) => reviveSnapshotSeed(snapshot, config.dateFields));
49
+ return async ({ ctx, logEntry }) => {
50
+ const snapshot = resolveRedoSnapshot(logEntry);
51
+ const id = snapshot ? getSnapshotId(snapshot) : logEntry.resourceId ?? null;
52
+ if (!snapshot || !id) {
53
+ throw new CrudHttpError(400, { error: "[internal] redo snapshot unavailable for create command" });
54
+ }
55
+ const em = ctx.container.resolve("em").fork();
56
+ const overrides = config.beforeRestore ? await config.beforeRestore({ em, ctx, snapshot }) : void 0;
57
+ const buildSeed = () => overrides ? { ...seedFromSnapshot(snapshot), ...overrides } : seedFromSnapshot(snapshot);
58
+ const findRow = config.findRow ? (forkedEm, rowId) => config.findRow({ em: forkedEm, ctx, id: rowId, snapshot }) : void 0;
59
+ let entity;
60
+ const restorePhase = async () => {
61
+ entity = await restoreCreatedRow(em, config.entityClass, id, buildSeed, findRow);
62
+ };
63
+ const afterRestorePhase = async () => {
64
+ if (config.afterRestore) {
65
+ await config.afterRestore({ em, ctx, entity, snapshot, logEntry });
66
+ }
67
+ };
68
+ try {
69
+ if (config.transaction) {
70
+ const phases = config.afterRestore ? [restorePhase, afterRestorePhase] : [restorePhase];
71
+ await withAtomicFlush(em, phases, { transaction: true });
72
+ } else {
73
+ await restorePhase();
74
+ await em.flush();
75
+ await afterRestorePhase();
76
+ }
77
+ } catch (err) {
78
+ if (isUniqueViolation(err)) {
79
+ throw conflict("[internal] Cannot redo: a record with the same unique key already exists.");
80
+ }
81
+ throw err;
82
+ }
83
+ const dataEngine = ctx.container.resolve("dataEngine");
84
+ await emitCrudSideEffects({
85
+ dataEngine,
86
+ action: "created",
87
+ entity,
88
+ identifiers: {
89
+ id,
90
+ organizationId: entity.organizationId ?? null,
91
+ tenantId: entity.tenantId ?? null
92
+ },
93
+ events: config.events,
94
+ indexer: config.indexer
95
+ });
96
+ return config.buildResult(entity, snapshot);
97
+ };
98
+ }
99
+ export {
100
+ makeCreateRedo,
101
+ resolveRedoSnapshot,
102
+ restoreCreatedRow,
103
+ reviveSnapshotSeed,
104
+ serializeRowSnapshot
105
+ };
106
+ //# sourceMappingURL=redo.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/commands/redo.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext, CommandUndoLogEntry } from './types'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'\nimport { CrudHttpError, conflict, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport { extractUndoPayload, type UndoPayload } from './undo'\nimport { emitCrudSideEffects } from './helpers'\nimport { withAtomicFlush } from './flush'\n\ntype EntityClass<T> = abstract new (...args: never[]) => T\n\ntype ScopedSoftDeletable = {\n id: string\n organizationId?: string | null\n tenantId?: string | null\n deletedAt?: Date | null\n isActive?: boolean\n}\n\n/** Snapshot keys revived from ISO strings to `Date` when no explicit `dateFields` is given. */\nconst DEFAULT_SNAPSHOT_DATE_FIELDS = ['createdAt', 'updatedAt', 'deletedAt'] as const\n\n/**\n * Turn an after-snapshot into a create seed by shallow-cloning it and reviving the\n * declared date fields from ISO strings back to `Date`. Single-row snapshots are a\n * faithful serialized row whose keys already equal entity property names, so the\n * snapshot doubles as the seed once dates are revived \u2014 no per-command mapping needed.\n */\nexport function reviveSnapshotSeed(\n snapshot: Record<string, unknown>,\n dateFields: readonly string[] = DEFAULT_SNAPSHOT_DATE_FIELDS,\n): Record<string, unknown> {\n const seed: Record<string, unknown> = { ...snapshot }\n for (const field of dateFields) {\n const value = seed[field]\n if (typeof value === 'string') seed[field] = new Date(value)\n }\n return seed\n}\n\n/**\n * Serialize a persisted row into a plain after-snapshot object: pick `fields` from\n * the entity and convert the declared `dateFields` from `Date` to ISO strings. Use\n * for single-row snapshot loaders that are a clean 1:1 column copy; loaders that\n * shape nested/related data keep their bespoke mapping.\n */\nexport function serializeRowSnapshot<TEntity extends Record<string, unknown>>(\n entity: TEntity,\n fields: readonly string[],\n dateFields: readonly string[] = DEFAULT_SNAPSHOT_DATE_FIELDS,\n): Record<string, unknown> {\n const snapshot: Record<string, unknown> = {}\n const dateFieldSet = new Set(dateFields)\n for (const field of fields) {\n const value = entity[field]\n if (dateFieldSet.has(field)) {\n snapshot[field] = value instanceof Date ? value.toISOString() : (value ?? null)\n } else {\n snapshot[field] = value ?? null\n }\n }\n return snapshot\n}\n\n/**\n * Resolve the after-snapshot a create command persisted for its action log, so a\n * `redo` handler can re-materialize the original record. Reads the `undo.after`\n * snapshot via {@link extractUndoPayload} and falls back to `snapshotAfter`.\n */\nexport function resolveRedoSnapshot<T>(logEntry: CommandUndoLogEntry | null | undefined): T | null {\n if (!logEntry) return null\n const undo = extractUndoPayload<UndoPayload<T>>(logEntry)\n if (undo && undo.after != null) return undo.after as T\n if (logEntry.snapshotAfter != null) return logEntry.snapshotAfter as T\n return null\n}\n\n/**\n * Re-materialize a single row that a create command produced, reusing its original\n * id. If the row still exists (it was soft-deleted by undo), clears `deletedAt` and\n * restores `isActive` from the seed. If it was hard-deleted, re-creates it from the\n * seed (which MUST include the original `id`). This keeps redo idempotent on id so\n * undo/redo snapshots and cross-references stay stable (issue #2506, invariant I6).\n */\nexport async function restoreCreatedRow<TEntity extends ScopedSoftDeletable>(\n em: EntityManager,\n entityClass: EntityClass<TEntity>,\n id: string,\n seedFromSnapshot: () => Record<string, unknown>,\n findRow?: (em: EntityManager, id: string) => Promise<TEntity | null>,\n): Promise<TEntity> {\n const existing = findRow\n ? await findRow(em, id)\n : ((await em.findOne(entityClass as never, { id } as never)) as TEntity | null)\n if (existing) {\n existing.deletedAt = null\n const seed = seedFromSnapshot()\n if (typeof seed.isActive === 'boolean') existing.isActive = seed.isActive\n return existing\n }\n const record = em.create(entityClass as never, { ...seedFromSnapshot(), deletedAt: null } as never) as TEntity\n em.persist(record as never)\n return record\n}\n\nexport type CreateRedoConfig<TEntity extends ScopedSoftDeletable, TSnapshot, TResult> = {\n entityClass: EntityClass<TEntity>\n /**\n * Pulls the original primary id out of the after-snapshot. Defaults to\n * `(snapshot) => snapshot.id`, which fits single-row snapshots whose top-level\n * `id` is the row's primary key. Override only when the id lives elsewhere.\n */\n getSnapshotId?: (snapshot: TSnapshot) => string | null | undefined\n /**\n * Maps the after-snapshot back to a create seed; MUST include the original id.\n * Defaults to {@link reviveSnapshotSeed} \u2014 the snapshot itself with `dateFields`\n * revived to `Date`. Override only when the snapshot keys diverge from entity\n * columns (e.g. nested shapes or derived columns).\n */\n seedFromSnapshot?: (snapshot: TSnapshot) => Record<string, unknown>\n /**\n * Snapshot keys to revive from ISO string to `Date` for the default seed.\n * Defaults to `['createdAt', 'updatedAt', 'deletedAt']`; list additional date\n * columns (e.g. `effectiveAt`, `returnedAt`) when the entity has them.\n */\n dateFields?: readonly string[]\n /** Builds the command result (mirrors `execute`'s return), e.g. `(e) => ({ currencyId: e.id })`. */\n buildResult: (entity: TEntity, snapshot: TSnapshot) => TResult\n events?: CrudEventsConfig<any>\n indexer?: CrudIndexerConfig<any>\n /**\n * Override how the existing row is looked up before restore. Defaults to\n * `em.findOne(entityClass, { id })`. Pass a decryption-aware finder\n * (`findOneWithDecryption`) for encrypted entities so the revive-in-place path\n * sees the same row the rest of the module does.\n */\n findRow?: (args: { em: EntityManager; ctx: CommandRuntimeContext; id: string; snapshot: TSnapshot }) => Promise<TEntity | null>\n /**\n * Runs after the em fork, before the row is restored. Use to validate\n * referenced relations (throw `CrudHttpError` to fail the redo) or resolve\n * relation entities. Anything it returns is shallow-merged into the create\n * seed (e.g. `{ entity: resolvedEntity }`), letting the seed reference a live\n * relation instead of a raw id.\n */\n beforeRestore?: (args: {\n em: EntityManager\n ctx: CommandRuntimeContext\n snapshot: TSnapshot\n }) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void\n /** Optional extra side effects after the row is restored (e.g. query-index upserts, custom-field restore). */\n afterRestore?: (args: {\n em: EntityManager\n ctx: CommandRuntimeContext\n entity: TEntity\n snapshot: TSnapshot\n logEntry: CommandUndoLogEntry\n }) => Promise<void> | void\n /**\n * Wrap the restore (create/revive + flush) in a single transaction via\n * {@link withAtomicFlush}. Use when the create participated in a multi-phase\n * atomic flush in `execute` and partial commits must be impossible.\n */\n transaction?: boolean\n}\n\n/**\n * Build a create command's `redo` handler that restores the original row in place\n * (reusing its id) instead of replaying `execute` and minting a new id. Wires the\n * `created` side effects (events + query index) exactly like `execute`. Use this for\n * single-row create commands; multi-entity creates implement `redo` by hand.\n */\nexport function makeCreateRedo<\n TEntity extends ScopedSoftDeletable,\n TSnapshot,\n TInput = unknown,\n TResult = unknown,\n>(config: CreateRedoConfig<TEntity, TSnapshot, TResult>) {\n const getSnapshotId = config.getSnapshotId ?? ((snapshot: TSnapshot) => (snapshot as { id?: string | null }).id ?? null)\n const seedFromSnapshot =\n config.seedFromSnapshot ?? ((snapshot: TSnapshot) => reviveSnapshotSeed(snapshot as Record<string, unknown>, config.dateFields))\n return async ({ ctx, logEntry }: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> => {\n const snapshot = resolveRedoSnapshot<TSnapshot>(logEntry)\n const id = snapshot ? getSnapshotId(snapshot) : (logEntry.resourceId ?? null)\n if (!snapshot || !id) {\n throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for create command' })\n }\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const overrides = config.beforeRestore ? await config.beforeRestore({ em, ctx, snapshot }) : undefined\n const buildSeed = () => (overrides ? { ...seedFromSnapshot(snapshot), ...overrides } : seedFromSnapshot(snapshot))\n const findRow = config.findRow\n ? (forkedEm: EntityManager, rowId: string) => config.findRow!({ em: forkedEm, ctx, id: rowId, snapshot })\n : undefined\n let entity!: TEntity\n const restorePhase = async () => {\n entity = await restoreCreatedRow(em, config.entityClass, id, buildSeed, findRow)\n }\n const afterRestorePhase = async () => {\n if (config.afterRestore) {\n await config.afterRestore({ em, ctx, entity, snapshot, logEntry })\n }\n }\n try {\n if (config.transaction) {\n const phases = config.afterRestore ? [restorePhase, afterRestorePhase] : [restorePhase]\n await withAtomicFlush(em, phases, { transaction: true })\n } else {\n await restorePhase()\n await em.flush()\n await afterRestorePhase()\n }\n } catch (err) {\n if (isUniqueViolation(err)) {\n // [internal] prefix: this shared-lib helper has no `t(...)` context; the\n // redo unique-collision is a rare developer-facing edge (the after-snapshot's\n // unique key was re-taken since undo), surfaced via normal error handling.\n throw conflict('[internal] Cannot redo: a record with the same unique key already exists.')\n }\n throw err\n }\n const dataEngine = ctx.container.resolve('dataEngine') as DataEngine\n await emitCrudSideEffects({\n dataEngine,\n action: 'created',\n entity,\n identifiers: {\n id,\n organizationId: entity.organizationId ?? null,\n tenantId: entity.tenantId ?? null,\n },\n events: config.events,\n indexer: config.indexer,\n })\n return config.buildResult(entity, snapshot)\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,38 @@
1
+ import { withAtomicFlush } from "./flush.js";
2
+ import { setCustomFieldsIfAny, emitCrudSideEffects } from "./helpers.js";
3
+ async function runCrudCommandWrite(opts) {
4
+ const em = opts.em ?? opts.ctx.container.resolve("em").fork();
5
+ const transaction = opts.transaction ?? true;
6
+ await withAtomicFlush(
7
+ em,
8
+ opts.phases.map((phase) => () => phase({ em })),
9
+ { transaction }
10
+ );
11
+ const dataEngine = opts.dataEngine ?? opts.ctx.container.resolve("dataEngine");
12
+ const target = opts.sideEffect();
13
+ if (opts.customFields && Object.keys(opts.customFields).length > 0) {
14
+ await setCustomFieldsIfAny({
15
+ dataEngine,
16
+ entityId: opts.entityId,
17
+ recordId: target.identifiers.id,
18
+ tenantId: opts.scope.tenantId,
19
+ organizationId: opts.scope.organizationId,
20
+ values: opts.customFields,
21
+ notify: opts.notifyCustomFields ?? false
22
+ });
23
+ }
24
+ await emitCrudSideEffects({
25
+ dataEngine,
26
+ action: opts.action,
27
+ entity: target.entity,
28
+ identifiers: target.identifiers,
29
+ syncOrigin: opts.syncOrigin ?? null,
30
+ events: opts.events,
31
+ indexer: opts.indexer
32
+ });
33
+ return { em };
34
+ }
35
+ export {
36
+ runCrudCommandWrite
37
+ };
38
+ //# sourceMappingURL=runCrudCommandWrite.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/commands/runCrudCommandWrite.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from './types'\nimport type { DataEngine } from '../data/engine'\nimport type {\n CrudEventAction,\n CrudEventsConfig,\n CrudIndexerConfig,\n CrudEntityIdentifiers,\n} from '../crud/types'\nimport { withAtomicFlush } from './flush'\nimport { setCustomFieldsIfAny, emitCrudSideEffects } from './helpers'\n\nexport type CrudCommandWritePhase = (args: { em: EntityManager }) => void | Promise<void>\n\nexport type CrudCommandWriteSideEffectTarget<TEntity> = {\n entity: TEntity\n identifiers: CrudEntityIdentifiers\n}\n\nexport type CrudCommandWriteScope = {\n tenantId: string | null\n organizationId: string | null\n}\n\nexport type RunCrudCommandWriteOptions<TEntity> = {\n ctx: CommandRuntimeContext\n entityId: string\n action: CrudEventAction\n scope: CrudCommandWriteScope\n phases: CrudCommandWritePhase[]\n customFields?: Record<string, unknown>\n notifyCustomFields?: boolean\n events?: CrudEventsConfig<TEntity>\n indexer?: CrudIndexerConfig<TEntity>\n syncOrigin?: string | null\n sideEffect: () => CrudCommandWriteSideEffectTarget<TEntity>\n em?: EntityManager\n transaction?: boolean\n dataEngine?: DataEngine\n}\n\nexport type RunCrudCommandWriteResult = { em: EntityManager }\n\nexport async function runCrudCommandWrite<TEntity>(\n opts: RunCrudCommandWriteOptions<TEntity>,\n): Promise<RunCrudCommandWriteResult> {\n const em = opts.em ?? (opts.ctx.container.resolve('em') as EntityManager).fork()\n const transaction = opts.transaction ?? true\n\n await withAtomicFlush(\n em,\n opts.phases.map((phase) => () => phase({ em })),\n { transaction },\n )\n\n const dataEngine = opts.dataEngine ?? (opts.ctx.container.resolve('dataEngine') as DataEngine)\n const target = opts.sideEffect()\n\n if (opts.customFields && Object.keys(opts.customFields).length > 0) {\n await setCustomFieldsIfAny({\n dataEngine,\n entityId: opts.entityId,\n recordId: target.identifiers.id,\n tenantId: opts.scope.tenantId,\n organizationId: opts.scope.organizationId,\n values: opts.customFields,\n notify: opts.notifyCustomFields ?? false,\n })\n }\n\n await emitCrudSideEffects({\n dataEngine,\n action: opts.action,\n entity: target.entity,\n identifiers: target.identifiers,\n syncOrigin: opts.syncOrigin ?? null,\n events: opts.events,\n indexer: opts.indexer,\n })\n\n return { em }\n}\n"],
5
+ "mappings": "AASA,SAAS,uBAAuB;AAChC,SAAS,sBAAsB,2BAA2B;AAiC1D,eAAsB,oBACpB,MACoC;AACpC,QAAM,KAAK,KAAK,MAAO,KAAK,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC/E,QAAM,cAAc,KAAK,eAAe;AAExC,QAAM;AAAA,IACJ;AAAA,IACA,KAAK,OAAO,IAAI,CAAC,UAAU,MAAM,MAAM,EAAE,GAAG,CAAC,CAAC;AAAA,IAC9C,EAAE,YAAY;AAAA,EAChB;AAEA,QAAM,aAAa,KAAK,cAAe,KAAK,IAAI,UAAU,QAAQ,YAAY;AAC9E,QAAM,SAAS,KAAK,WAAW;AAE/B,MAAI,KAAK,gBAAgB,OAAO,KAAK,KAAK,YAAY,EAAE,SAAS,GAAG;AAClE,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA,UAAU,KAAK;AAAA,MACf,UAAU,OAAO,YAAY;AAAA,MAC7B,UAAU,KAAK,MAAM;AAAA,MACrB,gBAAgB,KAAK,MAAM;AAAA,MAC3B,QAAQ,KAAK;AAAA,MACb,QAAQ,KAAK,sBAAsB;AAAA,IACrC,CAAC;AAAA,EACH;AAEA,QAAM,oBAAoB;AAAA,IACxB;AAAA,IACA,QAAQ,KAAK;AAAA,IACb,QAAQ,OAAO;AAAA,IACf,aAAa,OAAO;AAAA,IACpB,YAAY,KAAK,cAAc;AAAA,IAC/B,QAAQ,KAAK;AAAA,IACb,SAAS,KAAK;AAAA,EAChB,CAAC;AAED,SAAO,EAAE,GAAG;AACd;",
6
+ "names": []
7
+ }
@@ -1,29 +1,50 @@
1
1
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
2
2
  import { isOrganizationAccessAllowed } from "@open-mercato/shared/lib/auth/organizationAccess";
3
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
4
  import { env } from "process";
5
+ function buildScopeLogContext(ctx) {
6
+ const requestInfo = ctx.request && typeof ctx.request === "object" ? {
7
+ method: ctx.request.method ?? void 0,
8
+ url: ctx.request.url ?? void 0
9
+ } : null;
10
+ const scope = ctx.organizationScope ? {
11
+ selectedId: ctx.organizationScope.selectedId ?? null,
12
+ tenantId: ctx.organizationScope.tenantId ?? null,
13
+ allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
14
+ filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
15
+ } : null;
16
+ return {
17
+ userId: ctx.auth?.sub ?? null,
18
+ actorTenantId: ctx.auth?.tenantId ?? null,
19
+ actorOrganizationId: ctx.auth?.orgId ?? null,
20
+ selectedOrganizationId: ctx.selectedOrganizationId ?? null,
21
+ organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
22
+ scope,
23
+ request: requestInfo
24
+ };
25
+ }
26
+ function isStrictOrganizationScopeEnforced() {
27
+ return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false);
28
+ }
4
29
  function logScopeViolation(ctx, expected, actual) {
5
30
  try {
6
- const requestInfo = ctx.request && typeof ctx.request === "object" ? {
7
- method: ctx.request.method ?? void 0,
8
- url: ctx.request.url ?? void 0
9
- } : null;
10
- const scope = ctx.organizationScope ? {
11
- selectedId: ctx.organizationScope.selectedId ?? null,
12
- tenantId: ctx.organizationScope.tenantId ?? null,
13
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
14
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
15
- } : null;
16
31
  if (env.NODE_ENV !== "test") {
17
32
  console.warn("[scope] Forbidden organization scope mismatch detected", {
18
33
  expectedId: expected,
19
34
  actualId: actual,
20
- userId: ctx.auth?.sub ?? null,
21
- actorTenantId: ctx.auth?.tenantId ?? null,
22
- actorOrganizationId: ctx.auth?.orgId ?? null,
23
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
24
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
25
- scope,
26
- request: requestInfo
35
+ ...buildScopeLogContext(ctx)
36
+ });
37
+ }
38
+ } catch {
39
+ }
40
+ }
41
+ function logUnscopedOrganizationAccess(ctx, organizationId) {
42
+ try {
43
+ if (env.NODE_ENV !== "test") {
44
+ console.warn("[scope] Unscoped organization command executed without organization context", {
45
+ targetOrganizationId: organizationId,
46
+ strictEnforcement: isStrictOrganizationScopeEnforced(),
47
+ ...buildScopeLogContext(ctx)
27
48
  });
28
49
  }
29
50
  } catch {
@@ -31,27 +52,11 @@ function logScopeViolation(ctx, expected, actual) {
31
52
  }
32
53
  function logTenantScopeViolation(ctx, expectedTenantId, actualTenantId) {
33
54
  try {
34
- const requestInfo = ctx.request && typeof ctx.request === "object" ? {
35
- method: ctx.request.method ?? void 0,
36
- url: ctx.request.url ?? void 0
37
- } : null;
38
- const scope = ctx.organizationScope ? {
39
- selectedId: ctx.organizationScope.selectedId ?? null,
40
- tenantId: ctx.organizationScope.tenantId ?? null,
41
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
42
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
43
- } : null;
44
55
  if (env.NODE_ENV !== "test") {
45
56
  console.warn("[scope] Forbidden tenant scope mismatch detected", {
46
57
  expectedTenantId,
47
58
  actualTenantId,
48
- userId: ctx.auth?.sub ?? null,
49
- actorTenantId: ctx.auth?.tenantId ?? null,
50
- actorOrganizationId: ctx.auth?.orgId ?? null,
51
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
52
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
53
- scope,
54
- request: requestInfo
59
+ ...buildScopeLogContext(ctx)
55
60
  });
56
61
  }
57
62
  } catch {
@@ -63,9 +68,18 @@ function ensureOrganizationScope(ctx, organizationId) {
63
68
  if (!scope) {
64
69
  if (isSuperAdmin) return;
65
70
  const currentOrg2 = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
66
- if (currentOrg2 && currentOrg2 !== organizationId) {
67
- logScopeViolation(ctx, organizationId, currentOrg2);
68
- throw new CrudHttpError(403, { error: "Forbidden" });
71
+ if (currentOrg2) {
72
+ if (currentOrg2 !== organizationId) {
73
+ logScopeViolation(ctx, organizationId, currentOrg2);
74
+ throw new CrudHttpError(403, { error: "Forbidden" });
75
+ }
76
+ return;
77
+ }
78
+ if (organizationId) {
79
+ logUnscopedOrganizationAccess(ctx, organizationId);
80
+ if (isStrictOrganizationScopeEnforced()) {
81
+ throw new CrudHttpError(403, { error: "Forbidden" });
82
+ }
69
83
  }
70
84
  return;
71
85
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/commands/scope.ts"],
4
- "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { env } from 'process'\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg && currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,qBAAqB;AAE9B,SAAS,mCAAmC;AAC5C,SAAS,WAAW;AAEpB,SAAS,kBACP,KACA,UACA,QACM;AACN,MAAI;AACF,UAAM,cACJ,IAAI,WAAW,OAAO,IAAI,YAAY,WAClC;AAAA,MACE,QAAS,IAAI,QAAoB,UAAU;AAAA,MAC3C,KAAM,IAAI,QAAoB,OAAO;AAAA,IACvC,IACA;AACN,UAAM,QAAQ,IAAI,oBACd;AAAA,MACE,YAAY,IAAI,kBAAkB,cAAc;AAAA,MAChD,UAAU,IAAI,kBAAkB,YAAY;AAAA,MAC5C,iBAAiB,MAAM,QAAQ,IAAI,kBAAkB,UAAU,IAC3D,IAAI,kBAAkB,WAAW,SACjC;AAAA,MACJ,gBAAgB,MAAM,QAAQ,IAAI,kBAAkB,SAAS,IACzD,IAAI,kBAAkB,UAAU,SAChC;AAAA,IACN,IACA;AACJ,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,0DAA0D;AAAA,QACrE,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,QAAQ,IAAI,MAAM,OAAO;AAAA,QACzB,eAAe,IAAI,MAAM,YAAY;AAAA,QACrC,qBAAqB,IAAI,MAAM,SAAS;AAAA,QACxC,wBAAwB,IAAI,0BAA0B;AAAA,QACtD,sBAAsB,MAAM,QAAQ,IAAI,eAAe,IAAI,IAAI,gBAAgB,SAAS;AAAA,QACxF;AAAA,QACA,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,wBACP,KACA,kBACA,gBACM;AACN,MAAI;AACF,UAAM,cACJ,IAAI,WAAW,OAAO,IAAI,YAAY,WAClC;AAAA,MACE,QAAS,IAAI,QAAoB,UAAU;AAAA,MAC3C,KAAM,IAAI,QAAoB,OAAO;AAAA,IACvC,IACA;AACN,UAAM,QAAQ,IAAI,oBACd;AAAA,MACE,YAAY,IAAI,kBAAkB,cAAc;AAAA,MAChD,UAAU,IAAI,kBAAkB,YAAY;AAAA,MAC5C,iBAAiB,MAAM,QAAQ,IAAI,kBAAkB,UAAU,IAC3D,IAAI,kBAAkB,WAAW,SACjC;AAAA,MACJ,gBAAgB,MAAM,QAAQ,IAAI,kBAAkB,SAAS,IACzD,IAAI,kBAAkB,UAAU,SAChC;AAAA,IACN,IACA;AACJ,QAAI,IAAI,aAAa,QAAQ;AAC3B,cAAQ,KAAK,oDAAoD;AAAA,QAC/D;AAAA,QACA;AAAA,QACA,QAAQ,IAAI,MAAM,OAAO;AAAA,QACzB,eAAe,IAAI,MAAM,YAAY;AAAA,QACrC,qBAAqB,IAAI,MAAM,SAAS;AAAA,QACxC,wBAAwB,IAAI,0BAA0B;AAAA,QACtD,sBAAsB,MAAM,QAAQ,IAAI,eAAe,IAAI,IAAI,gBAAgB,SAAS;AAAA,QACxF;AAAA,QACA,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,wBAAwB,KAA4B,gBAA8B;AAChG,QAAM,eAAe,IAAI,MAAM,iBAAiB;AAChD,QAAM,QAAQ,IAAI;AAMlB,MAAI,CAAC,OAAO;AACV,QAAI,aAAc;AAClB,UAAMA,cAAa,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACpE,QAAIA,eAAcA,gBAAe,gBAAgB;AAC/C,wBAAkB,KAAK,gBAAgBA,WAAU;AACjD,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,IACrD;AACA;AAAA,EACF;AAEA,MACE,4BAA4B;AAAA,IAC1B;AAAA,IACA,wBAAwB,MAAM;AAAA,IAC9B,sBAAsB;AAAA,EACxB,CAAC,GACD;AACA;AAAA,EACF;AAEA,QAAM,aAAa,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACpE,oBAAkB,KAAK,gBAAgB,UAAU;AACjD,QAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AACrD;AAEO,SAAS,kBAAkB,KAA4B,UAAwB;AACpF,QAAM,gBAAgB,IAAI,MAAM,YAAY;AAC5C,MAAI,iBAAiB,kBAAkB,UAAU;AAC/C,4BAAwB,KAAK,UAAU,aAAa;AACpD,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,EACrD;AACF;AAEO,SAAS,gBACd,QACA,gBACA,UACM;AACN,MAAI,OAAO,mBAAmB,kBAAkB,OAAO,aAAa,UAAU;AAC5E,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,kCAAkC,CAAC;AAAA,EAC3E;AACF;",
4
+ "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["currentOrg"]
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/commands/types.ts"],
4
- "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { randomUUID } from 'crypto'\nimport type { AuthContext } from '../auth/server'\nimport type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\nexport type CommandRuntimeContext = {\n container: AwilixContainer\n auth: AuthContext | null\n organizationScope: OrganizationScope | null\n selectedOrganizationId: string | null\n organizationIds: string[] | null\n request?: Request\n syncOrigin?: string | null\n /**\n * Marks a trusted server-side invocation (CLI seeding, tenant setup) that runs\n * without an authenticated end-user actor. Commands that gate writes behind a\n * privileged actor (e.g. super-admin-only platform tables) may treat this as\n * an explicit system grant. HTTP request paths MUST NOT set this \u2014 they always\n * carry a real `auth` actor, so a present-but-unprivileged actor stays denied.\n */\n systemActor?: boolean\n /**\n * When set, command handlers that support it MUST run their writes within this\n * existing transactional EntityManager (reusing its row locks) instead of\n * opening their own transaction. Lets a caller compose a command with its own\n * surrounding work as a single atomic, single-locked operation.\n */\n transactionalEm?: EntityManager\n}\n\nexport type CommandLogMetadata = {\n skipLog?: boolean\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n actionLabel?: string | null\n resourceKind?: string | null\n resourceId?: string | null\n parentResourceKind?: string | null\n parentResourceId?: string | null\n undoToken?: string | null\n payload?: unknown\n snapshotBefore?: unknown\n snapshotAfter?: unknown\n relatedResourceKind?: string | null\n relatedResourceId?: string | null\n changes?: Record<string, unknown> | null\n context?: Record<string, unknown> | null\n}\n\nexport type CommandExecuteResult<TResult> = {\n result: TResult\n logEntry: any | null\n}\n\nexport type CommandLogBuilderArgs<TInput, TResult> = {\n input: TInput\n result: TResult\n ctx: CommandRuntimeContext\n snapshots: {\n before?: unknown\n after?: unknown\n }\n}\n\nexport interface CommandHandler<TInput = unknown, TResult = unknown> {\n readonly id: string\n readonly isUndoable?: boolean\n prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null\n execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult\n buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined\n captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown\n undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: any }): Promise<void> | void\n}\n\nexport type CommandExecutionOptions<TInput> = {\n input: TInput\n ctx: CommandRuntimeContext\n metadata?: CommandLogMetadata | null\n skipCacheInvalidation?: boolean\n}\n\nexport function defaultUndoToken(): string {\n return randomUUID()\n}\n"],
5
- "mappings": "AAEA,SAAS,kBAAkB;AAiFpB,SAAS,mBAA2B;AACzC,SAAO,WAAW;AACpB;",
4
+ "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { randomUUID } from 'crypto'\nimport type { AuthContext } from '../auth/server'\nimport type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\nexport type CommandRuntimeContext = {\n container: AwilixContainer\n auth: AuthContext | null\n organizationScope: OrganizationScope | null\n selectedOrganizationId: string | null\n organizationIds: string[] | null\n request?: Request\n syncOrigin?: string | null\n /**\n * Marks a trusted server-side invocation (CLI seeding, tenant setup) that runs\n * without an authenticated end-user actor. Commands that gate writes behind a\n * privileged actor (e.g. super-admin-only platform tables) may treat this as\n * an explicit system grant. HTTP request paths MUST NOT set this \u2014 they always\n * carry a real `auth` actor, so a present-but-unprivileged actor stays denied.\n */\n systemActor?: boolean\n /**\n * When set, command handlers that support it MUST run their writes within this\n * existing transactional EntityManager (reusing its row locks) instead of\n * opening their own transaction. Lets a caller compose a command with its own\n * surrounding work as a single atomic, single-locked operation.\n */\n transactionalEm?: EntityManager\n}\n\nexport type CommandLogMetadata = {\n skipLog?: boolean\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n actionLabel?: string | null\n resourceKind?: string | null\n resourceId?: string | null\n parentResourceKind?: string | null\n parentResourceId?: string | null\n undoToken?: string | null\n payload?: unknown\n snapshotBefore?: unknown\n snapshotAfter?: unknown\n relatedResourceKind?: string | null\n relatedResourceId?: string | null\n changes?: Record<string, unknown> | null\n context?: Record<string, unknown> | null\n}\n\nexport type CommandExecuteResult<TResult> = {\n result: TResult\n logEntry: any | null\n}\n\n/**\n * Shape of the persisted action log handed to a command's `undo()` handler.\n *\n * IMPORTANT: there is intentionally **no `payload` field**. `buildLog()` returns\n * a `payload` in its metadata, but the command bus persists that under\n * `commandPayload` (column `command_payload`, wrapped in a redo envelope) \u2014 the\n * stored row never has a top-level `payload`. Reading `logEntry.payload` in an\n * undo handler is therefore always `undefined` and silently no-ops the undo\n * (issue #2504). Always read the undo snapshot through\n * `extractUndoPayload(logEntry)` from `@open-mercato/shared/lib/commands/undo`,\n * which unwraps `commandPayload`/snapshots correctly. Omitting `payload` here\n * makes the footgun a compile-time error instead of a runtime silent failure.\n */\nexport type CommandUndoLogEntry = {\n id?: string\n commandId?: string\n commandPayload?: unknown | null\n snapshotBefore?: unknown | null\n snapshotAfter?: unknown | null\n resourceKind?: string | null\n resourceId?: string | null\n undoToken?: string | null\n actionLabel?: string | null\n tenantId?: string | null\n organizationId?: string | null\n actorUserId?: string | null\n changesJson?: Record<string, unknown> | null\n contextJson?: Record<string, unknown> | null\n createdAt?: Date | string\n updatedAt?: Date | string\n}\n\nexport type CommandLogBuilderArgs<TInput, TResult> = {\n input: TInput\n result: TResult\n ctx: CommandRuntimeContext\n snapshots: {\n before?: unknown\n after?: unknown\n }\n}\n\nexport interface CommandHandler<TInput = unknown, TResult = unknown> {\n readonly id: string\n readonly isUndoable?: boolean\n prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null\n execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult\n buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined\n captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown\n undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void\n /**\n * Optional redo handler. When defined, the command bus calls this instead of\n * `execute()` while replaying a previously undone action (the redo route passes\n * `redoLogEntry` in the execution options). It receives the source action log so\n * it can re-materialize the original record **reusing its id** \u2014 for a create\n * command this restores the soft-deleted row (or re-creates it from the\n * `snapshotAfter`) instead of minting a new id, keeping undo/redo snapshots and\n * references stable (issue #2506, invariant I6). Handlers without `redo` keep the\n * legacy behavior of replaying `execute(__redoInput)`.\n */\n redo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> | TResult\n}\n\nexport type CommandExecutionOptions<TInput> = {\n input: TInput\n ctx: CommandRuntimeContext\n metadata?: CommandLogMetadata | null\n skipCacheInvalidation?: boolean\n /**\n * When set, marks this execution as a redo of a previously undone action. If the\n * resolved handler defines a `redo` method, the command bus calls\n * `handler.redo({ input, ctx, logEntry })` instead of `handler.execute(...)`. The\n * rest of the pipeline (snapshots, buildLog, undo-token minting, persistence,\n * cache invalidation, side effects) is identical, so the fresh log entry \u2014 and\n * the `x-om-operation` header derived from it \u2014 automatically carry the restored\n * resourceId. Ignored when the handler has no `redo` method (legacy replay path).\n */\n redoLogEntry?: CommandUndoLogEntry | null\n}\n\nexport function defaultUndoToken(): string {\n return randomUUID()\n}\n"],
5
+ "mappings": "AAEA,SAAS,kBAAkB;AAsIpB,SAAS,mBAA2B;AACzC,SAAO,WAAW;AACpB;",
6
6
  "names": []
7
7
  }
@@ -21,6 +21,26 @@ function forbidden(message = "Forbidden") {
21
21
  function notFound(message = "Not found") {
22
22
  return new CrudHttpError(404, { error: message });
23
23
  }
24
+ function conflict(message) {
25
+ return new CrudHttpError(409, { error: message });
26
+ }
27
+ const POSTGRES_UNIQUE_VIOLATION = "23505";
28
+ function isUniqueViolation(err, constraintName) {
29
+ if (!err || typeof err !== "object") return false;
30
+ const candidates = [err, err.cause, err.previous];
31
+ for (const candidate of candidates) {
32
+ if (!candidate || typeof candidate !== "object") continue;
33
+ const record = candidate;
34
+ if (record.code === POSTGRES_UNIQUE_VIOLATION) {
35
+ if (!constraintName) return true;
36
+ const constraint = typeof record.constraint === "string" ? record.constraint : "";
37
+ const detail = typeof record.detail === "string" ? record.detail : "";
38
+ const message = typeof record.message === "string" ? record.message : "";
39
+ return constraint === constraintName || detail.includes(constraintName) || message.includes(constraintName);
40
+ }
41
+ }
42
+ return false;
43
+ }
24
44
  function assertFound(value, message) {
25
45
  if (!value) throw notFound(message);
26
46
  return value;
@@ -29,8 +49,10 @@ export {
29
49
  CrudHttpError,
30
50
  assertFound,
31
51
  badRequest,
52
+ conflict,
32
53
  forbidden,
33
54
  isCrudHttpError,
55
+ isUniqueViolation,
34
56
  notFound
35
57
  };
36
58
  //# sourceMappingURL=errors.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/crud/errors.ts"],
4
- "sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle boundaries\n// (same behaviour as globalThis-based registries used for DI registrars)\nconst CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')\n\nexport class CrudHttpError extends Error {\n readonly [CRUD_HTTP_ERROR_MARKER] = true\n status: number\n body: Record<string, any>\n\n constructor(\n status: number,\n body?: Record<string, any> | string,\n options?: { cause?: unknown },\n ) {\n const normalizedBody = typeof body === 'string' ? { error: body } : body ?? {}\n super(typeof body === 'string' ? body : normalizedBody.error ?? 'Request failed', options)\n this.status = status\n this.body = normalizedBody\n }\n}\n\n/**\n * Type-safe check for CrudHttpError that works across module/bundle boundaries.\n * Prefer this over `instanceof CrudHttpError` whenever the error may originate\n * from a different module bundle (e.g. enterprise packages, dynamic imports).\n */\nexport function isCrudHttpError(err: unknown): err is CrudHttpError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true\n}\n\nexport function badRequest(message: string): CrudHttpError {\n return new CrudHttpError(400, { error: message })\n}\n\nexport function forbidden(message = 'Forbidden'): CrudHttpError {\n return new CrudHttpError(403, { error: message })\n}\n\nexport function notFound(message = 'Not found'): CrudHttpError {\n return new CrudHttpError(404, { error: message })\n}\n\nexport function assertFound<T>(value: T | null | undefined, message: string): T {\n if (!value) throw notFound(message)\n return value\n}\n"],
5
- "mappings": "AAAA;AAEA,MAAM,yBAAyB,uBAAO,IAAI,6BAA6B;AAEhE,MAAM,uBAAsB,YACvB,6BADuB,IAAM;AAAA,EAKvC,YACE,QACA,MACA,SACA;AACA,UAAM,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC;AAC7E,UAAM,OAAO,SAAS,WAAW,OAAO,eAAe,SAAS,kBAAkB,OAAO;AAV3F,SAAU,MAA0B;AAWlC,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,gBAAgB,KAAoC;AAClE,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,sBAAsB,MAAM;AAC1G;AAEO,SAAS,WAAW,SAAgC;AACzD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,UAAU,UAAU,aAA4B;AAC9D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,SAAS,UAAU,aAA4B;AAC7D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,YAAe,OAA6B,SAAoB;AAC9E,MAAI,CAAC,MAAO,OAAM,SAAS,OAAO;AAClC,SAAO;AACT;",
4
+ "sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle boundaries\n// (same behaviour as globalThis-based registries used for DI registrars)\nconst CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')\n\nexport class CrudHttpError extends Error {\n readonly [CRUD_HTTP_ERROR_MARKER] = true\n status: number\n body: Record<string, any>\n\n constructor(\n status: number,\n body?: Record<string, any> | string,\n options?: { cause?: unknown },\n ) {\n const normalizedBody = typeof body === 'string' ? { error: body } : body ?? {}\n super(typeof body === 'string' ? body : normalizedBody.error ?? 'Request failed', options)\n this.status = status\n this.body = normalizedBody\n }\n}\n\n/**\n * Type-safe check for CrudHttpError that works across module/bundle boundaries.\n * Prefer this over `instanceof CrudHttpError` whenever the error may originate\n * from a different module bundle (e.g. enterprise packages, dynamic imports).\n */\nexport function isCrudHttpError(err: unknown): err is CrudHttpError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true\n}\n\nexport function badRequest(message: string): CrudHttpError {\n return new CrudHttpError(400, { error: message })\n}\n\nexport function forbidden(message = 'Forbidden'): CrudHttpError {\n return new CrudHttpError(403, { error: message })\n}\n\nexport function notFound(message = 'Not found'): CrudHttpError {\n return new CrudHttpError(404, { error: message })\n}\n\nexport function conflict(message: string): CrudHttpError {\n return new CrudHttpError(409, { error: message })\n}\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n/**\n * Detects a Postgres unique-constraint violation (SQLSTATE 23505) on a thrown\n * error, looking through MikroORM's driver-error wrapping. Use this to map a DB\n * uniqueness race (e.g. a soft-deleted row the in-app check missed, or a\n * timestamp-precision mismatch) onto a clean 409 instead of a generic 500.\n */\nexport function isUniqueViolation(err: unknown, constraintName?: string): boolean {\n if (!err || typeof err !== 'object') return false\n const candidates: unknown[] = [err, (err as { cause?: unknown }).cause, (err as { previous?: unknown }).previous]\n for (const candidate of candidates) {\n if (!candidate || typeof candidate !== 'object') continue\n const record = candidate as Record<string, unknown>\n if (record.code === POSTGRES_UNIQUE_VIOLATION) {\n if (!constraintName) return true\n const constraint = typeof record.constraint === 'string' ? record.constraint : ''\n const detail = typeof record.detail === 'string' ? record.detail : ''\n const message = typeof record.message === 'string' ? record.message : ''\n return constraint === constraintName || detail.includes(constraintName) || message.includes(constraintName)\n }\n }\n return false\n}\n\nexport function assertFound<T>(value: T | null | undefined, message: string): T {\n if (!value) throw notFound(message)\n return value\n}\n"],
5
+ "mappings": "AAAA;AAEA,MAAM,yBAAyB,uBAAO,IAAI,6BAA6B;AAEhE,MAAM,uBAAsB,YACvB,6BADuB,IAAM;AAAA,EAKvC,YACE,QACA,MACA,SACA;AACA,UAAM,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC;AAC7E,UAAM,OAAO,SAAS,WAAW,OAAO,eAAe,SAAS,kBAAkB,OAAO;AAV3F,SAAU,MAA0B;AAWlC,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,gBAAgB,KAAoC;AAClE,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,sBAAsB,MAAM;AAC1G;AAEO,SAAS,WAAW,SAAgC;AACzD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,UAAU,UAAU,aAA4B;AAC9D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,SAAS,UAAU,aAA4B;AAC7D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,SAAS,SAAgC;AACvD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEA,MAAM,4BAA4B;AAQ3B,SAAS,kBAAkB,KAAc,gBAAkC;AAChF,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,aAAwB,CAAC,KAAM,IAA4B,OAAQ,IAA+B,QAAQ;AAChH,aAAW,aAAa,YAAY;AAClC,QAAI,CAAC,aAAa,OAAO,cAAc,SAAU;AACjD,UAAM,SAAS;AACf,QAAI,OAAO,SAAS,2BAA2B;AAC7C,UAAI,CAAC,eAAgB,QAAO;AAC5B,YAAM,aAAa,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;AAC/E,YAAM,SAAS,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AACnE,YAAM,UAAU,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;AACtE,aAAO,eAAe,kBAAkB,OAAO,SAAS,cAAc,KAAK,QAAQ,SAAS,cAAc;AAAA,IAC5G;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,YAAe,OAA6B,SAAoB;AAC9E,MAAI,CAAC,MAAO,OAAM,SAAS,OAAO;AAClC,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -48,6 +48,8 @@ import { runApiInterceptorsAfter, runApiInterceptorsBefore } from "./interceptor
48
48
  import { mergeIdFilter, parseIdsParam } from "./ids.js";
49
49
  import { mergeAdvancedFilters } from "./advanced-filter-integration.js";
50
50
  import { parseExtensionHeaders } from "../umes/extension-headers.js";
51
+ import { createGenericOptimisticLockReader } from "./optimistic-lock.js";
52
+ import { registerOptimisticLockReaderIfAbsent } from "./optimistic-lock-store.js";
51
53
  function resolveSortParams(queryParams) {
52
54
  const rawSortField = queryParams.sortField ?? queryParams.sort ?? "id";
53
55
  const rawSortDir = queryParams.sortDir ?? queryParams.order ?? "asc";
@@ -586,6 +588,20 @@ function makeCrudRoute(opts) {
586
588
  const resourceKind = resourceInfo.primary;
587
589
  const resourceAliases = resourceInfo.aliases;
588
590
  const resourceTargets = expandResourceAliases(resourceKind, resourceAliases);
591
+ if (ormCfg.entity && resourceKind && resourceKind !== "resource") {
592
+ const genericReader = createGenericOptimisticLockReader({
593
+ entity: ormCfg.entity,
594
+ idField: ormCfg.idField ?? "id",
595
+ tenantField: ormCfg.tenantField,
596
+ orgField: ormCfg.orgField,
597
+ softDeleteField: ormCfg.softDeleteField
598
+ });
599
+ const keysToRegister = { [resourceKind]: genericReader };
600
+ for (const alias of resourceAliases) {
601
+ if (alias && alias !== resourceKind) keysToRegister[alias] = genericReader;
602
+ }
603
+ registerOptimisticLockReaderIfAbsent(keysToRegister);
604
+ }
589
605
  const defaultIdentifierResolver = (entity, _action) => {
590
606
  const id = normalizeIdentifierValue(entity[ormCfg.idField]);
591
607
  const orgId = ormCfg.orgField ? normalizeIdentifierValue(entity[ormCfg.orgField]) : null;