@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
@@ -1,44 +1,69 @@
1
1
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
2
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
3
  import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
4
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
5
  import { env } from 'process'
5
6
 
7
+ function buildScopeLogContext(ctx: CommandRuntimeContext) {
8
+ const requestInfo =
9
+ ctx.request && typeof ctx.request === 'object'
10
+ ? {
11
+ method: (ctx.request as Request).method ?? undefined,
12
+ url: (ctx.request as Request).url ?? undefined,
13
+ }
14
+ : null
15
+ const scope = ctx.organizationScope
16
+ ? {
17
+ selectedId: ctx.organizationScope.selectedId ?? null,
18
+ tenantId: ctx.organizationScope.tenantId ?? null,
19
+ allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
20
+ ? ctx.organizationScope.allowedIds.length
21
+ : null,
22
+ filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
23
+ ? ctx.organizationScope.filterIds.length
24
+ : null,
25
+ }
26
+ : null
27
+ return {
28
+ userId: ctx.auth?.sub ?? null,
29
+ actorTenantId: ctx.auth?.tenantId ?? null,
30
+ actorOrganizationId: ctx.auth?.orgId ?? null,
31
+ selectedOrganizationId: ctx.selectedOrganizationId ?? null,
32
+ organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
33
+ scope,
34
+ request: requestInfo,
35
+ }
36
+ }
37
+
38
+ function isStrictOrganizationScopeEnforced(): boolean {
39
+ return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)
40
+ }
41
+
6
42
  function logScopeViolation(
7
43
  ctx: CommandRuntimeContext,
8
44
  expected: string,
9
45
  actual: string | null
10
46
  ): void {
11
47
  try {
12
- const requestInfo =
13
- ctx.request && typeof ctx.request === 'object'
14
- ? {
15
- method: (ctx.request as Request).method ?? undefined,
16
- url: (ctx.request as Request).url ?? undefined,
17
- }
18
- : null
19
- const scope = ctx.organizationScope
20
- ? {
21
- selectedId: ctx.organizationScope.selectedId ?? null,
22
- tenantId: ctx.organizationScope.tenantId ?? null,
23
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
24
- ? ctx.organizationScope.allowedIds.length
25
- : null,
26
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
27
- ? ctx.organizationScope.filterIds.length
28
- : null,
29
- }
30
- : null
31
48
  if (env.NODE_ENV !== 'test') {
32
49
  console.warn('[scope] Forbidden organization scope mismatch detected', {
33
50
  expectedId: expected,
34
51
  actualId: actual,
35
- userId: ctx.auth?.sub ?? null,
36
- actorTenantId: ctx.auth?.tenantId ?? null,
37
- actorOrganizationId: ctx.auth?.orgId ?? null,
38
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
39
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
40
- scope,
41
- request: requestInfo,
52
+ ...buildScopeLogContext(ctx),
53
+ })
54
+ }
55
+ } catch {
56
+ // best-effort logging
57
+ }
58
+ }
59
+
60
+ function logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {
61
+ try {
62
+ if (env.NODE_ENV !== 'test') {
63
+ console.warn('[scope] Unscoped organization command executed without organization context', {
64
+ targetOrganizationId: organizationId,
65
+ strictEnforcement: isStrictOrganizationScopeEnforced(),
66
+ ...buildScopeLogContext(ctx),
42
67
  })
43
68
  }
44
69
  } catch {
@@ -52,36 +77,11 @@ function logTenantScopeViolation(
52
77
  actualTenantId: string | null
53
78
  ): void {
54
79
  try {
55
- const requestInfo =
56
- ctx.request && typeof ctx.request === 'object'
57
- ? {
58
- method: (ctx.request as Request).method ?? undefined,
59
- url: (ctx.request as Request).url ?? undefined,
60
- }
61
- : null
62
- const scope = ctx.organizationScope
63
- ? {
64
- selectedId: ctx.organizationScope.selectedId ?? null,
65
- tenantId: ctx.organizationScope.tenantId ?? null,
66
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
67
- ? ctx.organizationScope.allowedIds.length
68
- : null,
69
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
70
- ? ctx.organizationScope.filterIds.length
71
- : null,
72
- }
73
- : null
74
80
  if (env.NODE_ENV !== 'test') {
75
81
  console.warn('[scope] Forbidden tenant scope mismatch detected', {
76
82
  expectedTenantId,
77
83
  actualTenantId,
78
- userId: ctx.auth?.sub ?? null,
79
- actorTenantId: ctx.auth?.tenantId ?? null,
80
- actorOrganizationId: ctx.auth?.orgId ?? null,
81
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
82
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
83
- scope,
84
- request: requestInfo,
84
+ ...buildScopeLogContext(ctx),
85
85
  })
86
86
  }
87
87
  } catch {
@@ -100,9 +100,24 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
100
100
  if (!scope) {
101
101
  if (isSuperAdmin) return
102
102
  const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
103
- if (currentOrg && currentOrg !== organizationId) {
104
- logScopeViolation(ctx, organizationId, currentOrg)
105
- throw new CrudHttpError(403, { error: 'Forbidden' })
103
+ if (currentOrg) {
104
+ if (currentOrg !== organizationId) {
105
+ logScopeViolation(ctx, organizationId, currentOrg)
106
+ throw new CrudHttpError(403, { error: 'Forbidden' })
107
+ }
108
+ return
109
+ }
110
+ // No current org could be resolved either. This branch previously returned
111
+ // with no validation and no signal — a fail-open-by-omission shape (#2441):
112
+ // a new command path reaching here with `organizationScope: null` would act
113
+ // on an arbitrary target org silently. Preserve the legacy allow behavior by
114
+ // default (the path is load-bearing) but make the unscoped access observable,
115
+ // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.
116
+ if (organizationId) {
117
+ logUnscopedOrganizationAccess(ctx, organizationId)
118
+ if (isStrictOrganizationScopeEnforced()) {
119
+ throw new CrudHttpError(403, { error: 'Forbidden' })
120
+ }
106
121
  }
107
122
  return
108
123
  }
@@ -54,6 +54,38 @@ export type CommandExecuteResult<TResult> = {
54
54
  logEntry: any | null
55
55
  }
56
56
 
57
+ /**
58
+ * Shape of the persisted action log handed to a command's `undo()` handler.
59
+ *
60
+ * IMPORTANT: there is intentionally **no `payload` field**. `buildLog()` returns
61
+ * a `payload` in its metadata, but the command bus persists that under
62
+ * `commandPayload` (column `command_payload`, wrapped in a redo envelope) — the
63
+ * stored row never has a top-level `payload`. Reading `logEntry.payload` in an
64
+ * undo handler is therefore always `undefined` and silently no-ops the undo
65
+ * (issue #2504). Always read the undo snapshot through
66
+ * `extractUndoPayload(logEntry)` from `@open-mercato/shared/lib/commands/undo`,
67
+ * which unwraps `commandPayload`/snapshots correctly. Omitting `payload` here
68
+ * makes the footgun a compile-time error instead of a runtime silent failure.
69
+ */
70
+ export type CommandUndoLogEntry = {
71
+ id?: string
72
+ commandId?: string
73
+ commandPayload?: unknown | null
74
+ snapshotBefore?: unknown | null
75
+ snapshotAfter?: unknown | null
76
+ resourceKind?: string | null
77
+ resourceId?: string | null
78
+ undoToken?: string | null
79
+ actionLabel?: string | null
80
+ tenantId?: string | null
81
+ organizationId?: string | null
82
+ actorUserId?: string | null
83
+ changesJson?: Record<string, unknown> | null
84
+ contextJson?: Record<string, unknown> | null
85
+ createdAt?: Date | string
86
+ updatedAt?: Date | string
87
+ }
88
+
57
89
  export type CommandLogBuilderArgs<TInput, TResult> = {
58
90
  input: TInput
59
91
  result: TResult
@@ -71,7 +103,18 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
71
103
  execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
72
104
  buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
73
105
  captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
74
- undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: any }): Promise<void> | void
106
+ undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void
107
+ /**
108
+ * Optional redo handler. When defined, the command bus calls this instead of
109
+ * `execute()` while replaying a previously undone action (the redo route passes
110
+ * `redoLogEntry` in the execution options). It receives the source action log so
111
+ * it can re-materialize the original record **reusing its id** — for a create
112
+ * command this restores the soft-deleted row (or re-creates it from the
113
+ * `snapshotAfter`) instead of minting a new id, keeping undo/redo snapshots and
114
+ * references stable (issue #2506, invariant I6). Handlers without `redo` keep the
115
+ * legacy behavior of replaying `execute(__redoInput)`.
116
+ */
117
+ redo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> | TResult
75
118
  }
76
119
 
77
120
  export type CommandExecutionOptions<TInput> = {
@@ -79,6 +122,16 @@ export type CommandExecutionOptions<TInput> = {
79
122
  ctx: CommandRuntimeContext
80
123
  metadata?: CommandLogMetadata | null
81
124
  skipCacheInvalidation?: boolean
125
+ /**
126
+ * When set, marks this execution as a redo of a previously undone action. If the
127
+ * resolved handler defines a `redo` method, the command bus calls
128
+ * `handler.redo({ input, ctx, logEntry })` instead of `handler.execute(...)`. The
129
+ * rest of the pipeline (snapshots, buildLog, undo-token minting, persistence,
130
+ * cache invalidation, side effects) is identical, so the fresh log entry — and
131
+ * the `x-om-operation` header derived from it — automatically carry the restored
132
+ * resourceId. Ignored when the handler has no `redo` method (legacy replay path).
133
+ */
134
+ redoLogEntry?: CommandUndoLogEntry | null
82
135
  }
83
136
 
84
137
  export function defaultUndoToken(): string {
@@ -4,6 +4,11 @@ jest.mock('@open-mercato/cache', () => ({
4
4
 
5
5
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
6
  import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
7
+ import {
8
+ clearOptimisticLockReadersForTests,
9
+ getAllOptimisticLockReaders,
10
+ registerOptimisticLockReaders,
11
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
7
12
  import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
8
13
  import { z } from 'zod'
9
14
 
@@ -754,3 +759,104 @@ describe('CRUD Factory', () => {
754
759
  expect(body._interceptor).toEqual({ ok: true, count: 1 })
755
760
  })
756
761
  })
762
+
763
+ describe('CRUD Factory — optimistic-lock auto-registration', () => {
764
+ beforeEach(() => {
765
+ clearOptimisticLockReadersForTests()
766
+ })
767
+
768
+ afterAll(() => {
769
+ clearOptimisticLockReadersForTests()
770
+ })
771
+
772
+ function makeMinimalRoute(opts: { eventsResource: string; entity: any }) {
773
+ return makeCrudRoute({
774
+ metadata: { GET: { requireAuth: true } },
775
+ orm: { entity: opts.entity, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId' },
776
+ events: { module: opts.eventsResource.split('.')[0], entity: opts.eventsResource.split('.')[1], persistent: false } as any,
777
+ list: { schema: z.object({}).passthrough() as any },
778
+ create: {
779
+ commandId: `${opts.eventsResource}.create`,
780
+ schema: z.object({}).passthrough() as any,
781
+ },
782
+ update: {
783
+ commandId: `${opts.eventsResource}.update`,
784
+ schema: z.object({ id: z.string() }).passthrough() as any,
785
+ },
786
+ del: {
787
+ commandId: `${opts.eventsResource}.delete`,
788
+ schema: z.object({ id: z.string() }).passthrough() as any,
789
+ },
790
+ })
791
+ }
792
+
793
+ it('auto-registers a reader for the route resourceKind at factory call time', () => {
794
+ expect(getAllOptimisticLockReaders()).toEqual({})
795
+ makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
796
+ const all = getAllOptimisticLockReaders()
797
+ expect(Object.keys(all)).toContain('example.todo')
798
+ expect(typeof all['example.todo']).toBe('function')
799
+ })
800
+
801
+ it('does NOT override an existing hand-wired reader (IfAbsent semantics)', () => {
802
+ const handWired = async () => 'hand-wired'
803
+ registerOptimisticLockReaders({ 'example.todo': handWired })
804
+ makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
805
+ expect(getAllOptimisticLockReaders()['example.todo']).toBe(handWired)
806
+ })
807
+
808
+ it('skips registration when the entity has no resolvable resourceKind', () => {
809
+ expect(getAllOptimisticLockReaders()).toEqual({})
810
+ // Route with no events.module + no command IDs → resourceKind falls back to 'resource'
811
+ makeCrudRoute({
812
+ metadata: { GET: { requireAuth: true } },
813
+ orm: { entity: Todo },
814
+ list: { schema: z.object({}).passthrough() as any },
815
+ } as any)
816
+ // 'resource' is filtered out by the auto-registration guard
817
+ expect(getAllOptimisticLockReaders()['resource']).toBeUndefined()
818
+ })
819
+
820
+ it('the registered reader projects only updatedAt and fails open on schema mismatch', async () => {
821
+ makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
822
+ const reader = getAllOptimisticLockReaders()['example.todo']
823
+ expect(reader).toBeDefined()
824
+ let captured: { entity: unknown; filter: Record<string, unknown>; options?: Record<string, unknown> } | null = null
825
+ const fakeEm = {
826
+ async findOne(entity: unknown, filter: Record<string, unknown>, options?: Record<string, unknown>) {
827
+ captured = { entity, filter, options }
828
+ return { updatedAt: new Date('2026-05-26T07:30:00.000Z') }
829
+ },
830
+ } as never
831
+ const out = await reader!(fakeEm, {
832
+ resourceKind: 'example.todo',
833
+ resourceId: 'todo-1',
834
+ tenantId: 'tenant-1',
835
+ organizationId: 'org-1',
836
+ })
837
+ expect(out).toBe('2026-05-26T07:30:00.000Z')
838
+ expect(captured).not.toBeNull()
839
+ expect(captured!.entity).toBe(Todo)
840
+ expect(captured!.filter).toEqual({
841
+ id: 'todo-1',
842
+ tenantId: 'tenant-1',
843
+ organizationId: 'org-1',
844
+ deletedAt: null,
845
+ })
846
+ expect(captured!.options).toEqual({ fields: ['updatedAt'] })
847
+
848
+ // Fail-open contract: throwing findOne yields null, not a re-thrown error.
849
+ const throwingEm = {
850
+ async findOne() {
851
+ throw new Error('schema mismatch')
852
+ },
853
+ } as never
854
+ const safe = await reader!(throwingEm, {
855
+ resourceKind: 'example.todo',
856
+ resourceId: 'todo-1',
857
+ tenantId: 'tenant-1',
858
+ organizationId: 'org-1',
859
+ })
860
+ expect(safe).toBeNull()
861
+ })
862
+ })