@open-mercato/enterprise 0.6.6-develop.6317.1.b3be10ab84 → 0.6.6-develop.6331.1.a33b8e99b7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/enterprise",
3
- "version": "0.6.6-develop.6317.1.b3be10ab84",
3
+ "version": "0.6.6-develop.6331.1.a33b8e99b7",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@open-mercato/core": "0.6.6-develop.6317.1.b3be10ab84",
69
- "@open-mercato/ui": "0.6.6-develop.6317.1.b3be10ab84",
68
+ "@open-mercato/core": "0.6.6-develop.6331.1.a33b8e99b7",
69
+ "@open-mercato/ui": "0.6.6-develop.6331.1.a33b8e99b7",
70
70
  "@simplewebauthn/browser": "^13.3.0",
71
71
  "@simplewebauthn/server": "^13.3.1",
72
72
  "@simplewebauthn/types": "^12.0.0",
@@ -76,12 +76,12 @@
76
76
  "qrcode": "^1.5.4"
77
77
  },
78
78
  "peerDependencies": {
79
- "@open-mercato/shared": "0.6.6-develop.6317.1.b3be10ab84",
79
+ "@open-mercato/shared": "0.6.6-develop.6331.1.a33b8e99b7",
80
80
  "react": "^19.0.0",
81
81
  "react-dom": "^19.0.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@open-mercato/shared": "0.6.6-develop.6317.1.b3be10ab84",
84
+ "@open-mercato/shared": "0.6.6-develop.6331.1.a33b8e99b7",
85
85
  "@types/jest": "^30.0.0",
86
86
  "@types/react": "^19.2.17",
87
87
  "@types/react-dom": "^19.2.3",
@@ -4,6 +4,9 @@ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
5
5
  import type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
8
+ import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
9
+ import { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
10
  import { createRecordLockService } from './lib/recordLockService'
8
11
  import type { RecordLockService } from './lib/recordLockService'
9
12
  import { createRecordLockCrudMutationGuardService } from './lib/crudMutationGuardService'
@@ -23,8 +26,31 @@ export function register(container: AppContainer) {
23
26
  rbacService: rbacService ?? null,
24
27
  }),
25
28
  ).scoped(),
26
- crudMutationGuardService: asFunction((recordLockService: RecordLockService) =>
27
- createRecordLockCrudMutationGuardService(recordLockService),
29
+ // CRUD guard decorator: chains the OSS `updated_at` floor first (built here
30
+ // because this DI key overrides the platform default), then adds the
31
+ // record_locks enrichment. record_locks can only ADD a 409, never skip the
32
+ // floor (S1/H2). Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)
33
+ crudMutationGuardService: asFunction((
34
+ recordLockService: RecordLockService,
35
+ em: EntityManager,
36
+ ) =>
37
+ createRecordLockCrudMutationGuardService(
38
+ recordLockService,
39
+ createOptimisticLockGuardService({
40
+ getEm: () => em,
41
+ readers: getAllOptimisticLockReaders(),
42
+ }),
43
+ ),
44
+ ).scoped(),
45
+ // Command guard override: lock-backed `resolveExpected` derived from
46
+ // authoritative server state (never requiring a client lock token, H2),
47
+ // awaited by `enforceCommandOptimisticLockWithGuards`. The OSS floor still
48
+ // runs first inside that runner.
49
+ commandOptimisticLockGuardService: asFunction((recordLockService: RecordLockService) =>
50
+ createCommandOptimisticLockGuardService({
51
+ resolveExpected: ({ expectedFromHeader, resourceKind }) =>
52
+ recordLockService.resolveExpectedVersion({ expectedFromHeader, resourceKind }),
53
+ }),
28
54
  ).scoped(),
29
55
  })
30
56
  }
@@ -3,50 +3,121 @@ import type {
3
3
  CrudMutationGuardValidateInput,
4
4
  CrudMutationGuardValidationResult,
5
5
  } from '@open-mercato/shared/lib/crud/mutation-guard'
6
+ import {
7
+ parseOptimisticLockEnv,
8
+ type OptimisticLockConfig,
9
+ } from '@open-mercato/shared/lib/crud/optimistic-lock'
10
+ import { OPTIMISTIC_LOCK_ENV_VAR } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
6
11
  import { readRecordLockHeaders, type RecordLockService } from './recordLockService'
12
+ import { isRecordLockingEnabledForResource } from './config'
7
13
 
8
14
  export type RecordLockCrudMutationGuardService = {
9
15
  validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>
10
16
  afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>
11
17
  }
12
18
 
19
+ /** The OSS floor service this decorator chains. Same shape as the platform default. */
20
+ export type OssCrudMutationGuardServiceLike = {
21
+ validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>
22
+ afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>
23
+ }
24
+
13
25
  function resolveRecordLockMutationMethod(operation: CrudMutationGuardValidateInput['operation']): 'PUT' | 'DELETE' {
14
26
  if (operation === 'delete') return 'DELETE'
15
27
  return 'PUT'
16
28
  }
17
29
 
30
+ function resolveConfig(envValue: string | null | undefined): OptimisticLockConfig {
31
+ return parseOptimisticLockEnv(envValue !== undefined ? envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR])
32
+ }
33
+
34
+ export type CreateRecordLockCrudMutationGuardServiceOptions = {
35
+ /** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */
36
+ envValue?: string | null
37
+ }
38
+
39
+ /**
40
+ * Enterprise CRUD mutation guard. A **decorator** over the OSS optimistic-lock
41
+ * floor (S1/H2): it never replaces the floor, only adds blocks.
42
+ *
43
+ * Evaluation order (matches the spec's guard-layering chain):
44
+ * 1. `OM_OPTIMISTIC_LOCK=off` → pure pass-through (single global kill switch;
45
+ * neither floor nor enrichment runs).
46
+ * 2. OSS `updated_at` floor runs first (delegated to the default OSS guard the
47
+ * enterprise registration overrides). A stale write 409s here regardless of
48
+ * any record-lock token / widget state — so a tokenless API/CLI client is
49
+ * still caught (H1/H2).
50
+ * 3. Only if the floor passes, AND record_locks is enabled for the resource in
51
+ * settings, the enterprise `validateMutation` enrichment runs (pessimistic
52
+ * lock + action-log diff). It can only ADD a 409, never relax the floor.
53
+ *
54
+ * Fail-closed (H3): if the enterprise enrichment throws, the decorator degrades
55
+ * to the floor result (floor-pass ⇒ allow), never to "skip the guard".
56
+ */
18
57
  export function createRecordLockCrudMutationGuardService(
19
58
  recordLockService: RecordLockService,
59
+ ossFloorGuardService: OssCrudMutationGuardServiceLike,
60
+ options: CreateRecordLockCrudMutationGuardServiceOptions = {},
20
61
  ): RecordLockCrudMutationGuardService {
62
+ async function isRecordLockEnrichmentEnabled(resourceKind: string): Promise<boolean> {
63
+ try {
64
+ const settings = await recordLockService.getSettings()
65
+ return isRecordLockingEnabledForResource(settings, resourceKind)
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
21
71
  return {
22
72
  async validateMutation(input) {
23
- const result = await recordLockService.validateMutation({
24
- tenantId: input.tenantId,
25
- organizationId: input.organizationId ?? null,
26
- userId: input.userId,
27
- resourceKind: input.resourceKind,
28
- resourceId: input.resourceId,
29
- method: resolveRecordLockMutationMethod(input.operation),
30
- headers: readRecordLockHeaders(input.requestHeaders),
31
- mutationPayload: input.mutationPayload ?? null,
32
- })
73
+ const config = resolveConfig(options.envValue)
74
+ if (config.mode === 'off') {
75
+ // Single global kill switch: neither floor nor enrichment runs.
76
+ return { ok: true, shouldRunAfterSuccess: false, metadata: null }
77
+ }
78
+
79
+ // 1. OSS floor — always runs first, independent of any client lock token.
80
+ const floorResult = await ossFloorGuardService.validateMutation(input)
81
+ if (!floorResult.ok) return floorResult
82
+
83
+ // 2. Enterprise enrichment — only when the resource is enabled in settings.
84
+ if (!(await isRecordLockEnrichmentEnabled(input.resourceKind))) {
85
+ return { ok: true, shouldRunAfterSuccess: false, metadata: null }
86
+ }
87
+
88
+ // 3. Fail-closed: a throwing enrichment degrades to the (passed) floor.
89
+ let enrichmentResult: Awaited<ReturnType<RecordLockService['validateMutation']>>
90
+ try {
91
+ enrichmentResult = await recordLockService.validateMutation({
92
+ tenantId: input.tenantId,
93
+ organizationId: input.organizationId ?? null,
94
+ userId: input.userId,
95
+ resourceKind: input.resourceKind,
96
+ resourceId: input.resourceId,
97
+ method: resolveRecordLockMutationMethod(input.operation),
98
+ headers: readRecordLockHeaders(input.requestHeaders),
99
+ mutationPayload: input.mutationPayload ?? null,
100
+ })
101
+ } catch {
102
+ return { ok: true, shouldRunAfterSuccess: false, metadata: null }
103
+ }
33
104
 
34
- if (result.ok) {
105
+ if (enrichmentResult.ok) {
35
106
  return {
36
107
  ok: true,
37
- shouldRunAfterSuccess: result.resourceEnabled,
108
+ shouldRunAfterSuccess: enrichmentResult.resourceEnabled,
38
109
  metadata: null,
39
110
  }
40
111
  }
41
112
 
42
113
  return {
43
114
  ok: false,
44
- status: result.status,
115
+ status: enrichmentResult.status,
45
116
  body: {
46
- error: result.error,
47
- code: result.code,
48
- lock: result.lock ?? null,
49
- conflict: result.conflict ?? null,
117
+ error: enrichmentResult.error,
118
+ code: enrichmentResult.code,
119
+ lock: enrichmentResult.lock ?? null,
120
+ conflict: enrichmentResult.conflict ?? null,
50
121
  },
51
122
  }
52
123
  },
@@ -491,6 +491,31 @@ export class RecordLockService {
491
491
  return settings // NOSONAR — both paths return settings by design; the branch controls persistence
492
492
  }
493
493
 
494
+ /**
495
+ * Command-guard `resolveExpected` seam (Phase 0 / S1). Derives the expected
496
+ * version token the OSS command floor compares against the record's current
497
+ * server-side `updated_at`.
498
+ *
499
+ * The compare is server-authoritative: the command handler already loaded the
500
+ * record from the DB and passes its `updated_at` as `current`, so a stale
501
+ * write is detected from the version header alone — NEVER requiring a client
502
+ * record-lock token (H2). When record_locks is not enabled for the resource
503
+ * (settings off), this returns the header token unchanged so the floor still
504
+ * runs (pure floor behavior). The richer pessimistic/action-log conflict
505
+ * detection runs through `validateMutation` at the CRUD layer.
506
+ */
507
+ async resolveExpectedVersion(input: {
508
+ resourceKind: string
509
+ expectedFromHeader: string | null
510
+ }): Promise<string | null> {
511
+ // Enabled or not, the server-authoritative floor compares the header token
512
+ // against the DB `updated_at`, so resolution is unconditionally the header
513
+ // value. Gating (record_locks settings) only changes whether the richer
514
+ // CRUD-layer conflict enrichment runs in `validateMutation` — it never
515
+ // affects the version floor here.
516
+ return input.expectedFromHeader
517
+ }
518
+
494
519
  async acquire(input: RecordLockAcquireInput): Promise<RecordLockAcquireResult | RecordLockAcquireFailure> {
495
520
  this.scheduleCleanup(input.tenantId)
496
521
  const settings = await this.getSettings()
@@ -10,6 +10,7 @@ import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
10
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
11
  import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
12
12
  import { BACKEND_MUTATION_ERROR_EVENT } from '@open-mercato/ui/backend/injection/mutationEvents'
13
+ import { registerRecordLockConflictHandler } from '@open-mercato/ui/backend/conflicts'
13
14
  import { useSearchParams } from 'next/navigation'
14
15
  import { Mail } from 'lucide-react'
15
16
  import {
@@ -32,7 +33,7 @@ import {
32
33
  type RecordLockUiView,
33
34
  } from '@open-mercato/enterprise/modules/record_locks/lib/clientLockStore'
34
35
  import { isUuid, resolveConflictId, runAcceptIncoming } from './conflictResolution'
35
- import { classifyUnmatchedSaveError } from '@open-mercato/enterprise/modules/record_locks/lib/optimisticLockFloor'
36
+ import { isOptimisticLockFloorConflict } from '@open-mercato/enterprise/modules/record_locks/lib/optimisticLockFloor'
36
37
 
37
38
  type CrudInjectionContext = {
38
39
  formId?: string
@@ -295,6 +296,74 @@ function isRecordDeletedError(error: unknown): boolean {
295
296
  return false
296
297
  }
297
298
 
299
+ /**
300
+ * Action the record-lock widget takes when a CrudForm / guarded-mutation save
301
+ * error event (`om:crud-save-error` / backend-mutation-error) fires. Extracted
302
+ * as a pure function so the single-conflict-surface (S3) decision is unit-testable
303
+ * without rendering the widget:
304
+ *
305
+ * - `ignore` — event is for a different form/record, there is no
306
+ * actionable mounted record, OR the error is a plain OSS
307
+ * `optimistic_lock_conflict` 409 already owned by the OSS
308
+ * conflict bar via `surfaceRecordConflict` (opening the
309
+ * dialog too would render TWO surfaces — #3504/#3505).
310
+ * - `record-deleted` — the record was deleted by another user.
311
+ * - `apply-conflict` — a genuine enterprise `record_lock_conflict` payload;
312
+ * open the field-level merge dialog with it.
313
+ * - `fallback-dialog` — an unrecognized 409 (neither record-lock nor OSS
314
+ * optimistic-lock) for the mounted record; open the
315
+ * degraded fallback dialog (legacy behavior).
316
+ */
317
+ export type RecordLockSaveErrorDecision =
318
+ | { action: 'ignore' }
319
+ | { action: 'record-deleted' }
320
+ | {
321
+ action: 'apply-conflict'
322
+ payload: {
323
+ conflict: RecordLockUiConflict
324
+ lock?: RecordLockUiView | null
325
+ latestActionLogId?: string | null
326
+ }
327
+ }
328
+ | { action: 'fallback-dialog' }
329
+
330
+ export function resolveRecordLockSaveErrorDecision(args: {
331
+ error: unknown
332
+ eventContextId: string | null | undefined
333
+ formId: string
334
+ currentState: { resourceKind?: string | null; resourceId?: string | null } | null | undefined
335
+ }): RecordLockSaveErrorDecision {
336
+ const { error, eventContextId, formId, currentState } = args
337
+ const payload = extractRecordLockConflictPayload(error)
338
+ const eventTargetsCurrentForm = !eventContextId || eventContextId === formId
339
+ if (!eventTargetsCurrentForm) {
340
+ if (!payload || !currentState?.resourceKind || !currentState?.resourceId) return { action: 'ignore' }
341
+ const payloadResourceKind = payload.conflict.resourceKind?.trim() ?? ''
342
+ const payloadResourceId = payload.conflict.resourceId?.trim() ?? ''
343
+ if (!payloadResourceKind || !payloadResourceId) return { action: 'ignore' }
344
+ if (payloadResourceKind !== currentState.resourceKind || payloadResourceId !== currentState.resourceId) {
345
+ return { action: 'ignore' }
346
+ }
347
+ }
348
+ if (!payload) {
349
+ if (!currentState?.resourceKind || !currentState?.resourceId) return { action: 'ignore' }
350
+ if (isRecordDeletedError(error)) return { action: 'record-deleted' }
351
+ // A plain OSS `optimistic_lock_conflict` 409 (the shape `makeCrudRoute` /
352
+ // `CrudForm` returns on a stale write) is already surfaced by the OSS conflict
353
+ // bar via `surfaceRecordConflict`. Defer to it instead of opening the degraded
354
+ // fallback merge dialog — otherwise both surfaces render at once (#3504) and the
355
+ // dialog has no field diff and a no-op "Accept incoming" (#3505). The rich merge
356
+ // dialog stays reserved for genuine `record_lock_conflict` payloads (handled
357
+ // above) whose conflict carries field changes + resolution options. Delegates to
358
+ // the shared `optimisticLockFloor` detector so this arbitration stays identical to
359
+ // the conflict bar's ownership decision (single source of truth).
360
+ if (isOptimisticLockFloorConflict(error)) return { action: 'ignore' }
361
+ if (extractErrorStatus(error) === 409) return { action: 'fallback-dialog' }
362
+ return { action: 'ignore' }
363
+ }
364
+ return { action: 'apply-conflict', payload }
365
+ }
366
+
298
367
  function clearIncomingChangesQueryFlag() {
299
368
  if (typeof window === 'undefined') return
300
369
  try {
@@ -1019,41 +1088,33 @@ export default function RecordLockingWidget({
1019
1088
  const detail = (event as CustomEvent<CrudSaveErrorEventDetail>).detail
1020
1089
  if (!detail) return
1021
1090
  const eventContextId = detail.contextId ?? detail.formId
1022
- let payload = extractRecordLockConflictPayload(detail.error)
1023
1091
  const currentState = getRecordLockFormState(formId)
1024
- const eventTargetsCurrentForm = !eventContextId || eventContextId === formId
1025
- if (!eventTargetsCurrentForm) {
1026
- if (!payload || !currentState?.resourceKind || !currentState?.resourceId) return
1027
- const payloadResourceKind = payload.conflict.resourceKind?.trim() ?? ''
1028
- const payloadResourceId = payload.conflict.resourceId?.trim() ?? ''
1029
- if (!payloadResourceKind || !payloadResourceId) return
1030
- if (payloadResourceKind !== currentState.resourceKind || payloadResourceId !== currentState.resourceId) return
1092
+ const decision = resolveRecordLockSaveErrorDecision({
1093
+ error: detail.error,
1094
+ eventContextId,
1095
+ formId,
1096
+ currentState,
1097
+ })
1098
+ if (decision.action === 'ignore') return
1099
+ if (decision.action === 'record-deleted') {
1100
+ setIsConflictDialogOpen(true)
1101
+ setRecordLockFormState(formId, {
1102
+ recordDeleted: true,
1103
+ acquired: false,
1104
+ lock: null,
1105
+ conflict: null,
1106
+ pendingConflictId: null,
1107
+ pendingResolution: 'normal',
1108
+ pendingResolutionArmed: false,
1109
+ })
1110
+ return
1031
1111
  }
1032
- if (!payload) {
1033
- if (!currentState?.resourceKind || !currentState?.resourceId) return
1034
- if (isRecordDeletedError(detail.error)) {
1035
- setIsConflictDialogOpen(true)
1036
- setRecordLockFormState(formId, {
1037
- recordDeleted: true,
1038
- acquired: false,
1039
- lock: null,
1040
- conflict: null,
1041
- pendingConflictId: null,
1042
- pendingResolution: 'normal',
1043
- pendingResolutionArmed: false,
1044
- })
1045
- return
1046
- }
1047
- // Single-surface arbitration (issue #3504 / S3): an OSS optimistic-lock-floor
1048
- // 409 is already owned by the shared conflict bar, so defer to it instead of
1049
- // opening a second, degraded merge dialog. Other 409s keep the fallback dialog.
1050
- if (classifyUnmatchedSaveError(detail.error, extractErrorStatus(detail.error)) === 'fallback-merge-dialog') {
1051
- applyConflictPayload(buildFallbackConflict(currentState))
1052
- }
1112
+ if (decision.action === 'fallback-dialog') {
1113
+ if (currentState) applyConflictPayload(buildFallbackConflict(currentState))
1053
1114
  return
1054
1115
  }
1055
1116
 
1056
- applyConflictPayload(payload)
1117
+ applyConflictPayload(decision.payload)
1057
1118
  }
1058
1119
 
1059
1120
  window.addEventListener(BACKEND_MUTATION_ERROR_EVENT, onCrudSaveError)
@@ -1064,6 +1125,33 @@ export default function RecordLockingWidget({
1064
1125
  }
1065
1126
  }, [formId, isPrimaryInstance])
1066
1127
 
1128
+ // Single conflict surface (S3): own the surface for record_lock_conflict 409s
1129
+ // routed through `surfaceRecordConflict` (command/raw-write paths that don't
1130
+ // emit the crud-save-error event). Opening the dialog here is idempotent with
1131
+ // the listener above; returning true tells the core helper to suppress the OSS
1132
+ // bar (so we never render both). Decline (false) for a different record so the
1133
+ // bar still renders there — a conflict is never swallowed.
1134
+ React.useEffect(() => {
1135
+ if (!isPrimaryInstance) return
1136
+ if (!resourceKind || !resourceId) return
1137
+ const unregister = registerRecordLockConflictHandler((_conflict, error) => {
1138
+ const payload = extractRecordLockConflictPayload(error)
1139
+ if (!payload) return false
1140
+ const payloadKind = payload.conflict.resourceKind?.trim() ?? ''
1141
+ const payloadId = payload.conflict.resourceId?.trim() ?? ''
1142
+ if (payloadKind && payloadId && (payloadKind !== resourceKind || payloadId !== resourceId)) return false
1143
+ setIsConflictDialogOpen(true)
1144
+ setRecordLockFormState(formId, {
1145
+ conflict: payload.conflict,
1146
+ pendingConflictId: payload.conflict.id,
1147
+ pendingResolution: 'normal',
1148
+ pendingResolutionArmed: false,
1149
+ })
1150
+ return true
1151
+ })
1152
+ return unregister
1153
+ }, [formId, isPrimaryInstance, resourceKind, resourceId])
1154
+
1067
1155
  const handleTakeOver = React.useCallback(async () => {
1068
1156
  keepMineRetryVersionRef.current += 1
1069
1157
  if (!state?.resourceKind || !state?.resourceId) return