@open-mercato/core 0.6.6-develop.6305.1.4503070c9d → 0.6.6-develop.6309.1.983aeec27a

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.
@@ -12,6 +12,13 @@ function normalizeIsoToken(raw) {
12
12
  if (!Number.isFinite(ms)) return null;
13
13
  return new Date(ms).toISOString();
14
14
  }
15
+ const RECORD_LOCK_RESOLUTION_HEADER_NAME = "x-om-record-lock-resolution";
16
+ const RECORD_LOCK_OVERRIDE_RESOLUTIONS = /* @__PURE__ */ new Set(["accept_mine", "merged"]);
17
+ function isAuthorizedRecordLockOverride(headers) {
18
+ const raw = headers.get(RECORD_LOCK_RESOLUTION_HEADER_NAME);
19
+ if (typeof raw !== "string") return false;
20
+ return RECORD_LOCK_OVERRIDE_RESOLUTIONS.has(raw.trim().toLowerCase());
21
+ }
15
22
  const optimisticLockGuard = {
16
23
  id: "customers.optimistic-lock",
17
24
  targetEntity: "*",
@@ -20,6 +27,7 @@ const optimisticLockGuard = {
20
27
  async validate(input) {
21
28
  const config = parseOptimisticLockEnv(process.env[OPTIMISTIC_LOCK_ENV_VAR]);
22
29
  if (config.mode === "off") return { ok: true };
30
+ if (isAuthorizedRecordLockOverride(input.requestHeaders)) return { ok: true };
23
31
  const enabled = config.mode === "all" || config.entities.has(input.resourceKind.toLowerCase());
24
32
  if (!enabled) return { ok: true };
25
33
  const readers = getAllOptimisticLockReaders();
@@ -61,6 +69,7 @@ const optimisticLockGuard = {
61
69
  };
62
70
  const guards = [optimisticLockGuard];
63
71
  export {
64
- guards
72
+ guards,
73
+ isAuthorizedRecordLockOverride
65
74
  };
66
75
  //# sourceMappingURL=guards.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customers/data/guards.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { MutationGuard } from '@open-mercato/shared/lib/crud/mutation-guard-registry'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { parseOptimisticLockEnv } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\nfunction normalizeIsoToken(raw: string): string | null {\n const ms = Date.parse(raw)\n if (!Number.isFinite(ms)) return null\n return new Date(ms).toISOString()\n}\n\nconst optimisticLockGuard: MutationGuard = {\n id: 'customers.optimistic-lock',\n targetEntity: '*',\n operations: ['update', 'delete'],\n priority: 100,\n async validate(input) {\n const config = parseOptimisticLockEnv(process.env[OPTIMISTIC_LOCK_ENV_VAR])\n if (config.mode === 'off') return { ok: true }\n const enabled = config.mode === 'all' || config.entities.has(input.resourceKind.toLowerCase())\n if (!enabled) return { ok: true }\n const readers = getAllOptimisticLockReaders()\n const reader = readers[input.resourceKind]\n if (!reader) return { ok: true }\n const expectedRaw = input.requestHeaders.get(OPTIMISTIC_LOCK_HEADER_NAME)\n if (!expectedRaw || expectedRaw.trim().length === 0) return { ok: true }\n const expectedIso = normalizeIsoToken(expectedRaw.trim())\n if (!expectedIso) return { ok: true }\n if (!input.resourceId) return { ok: true }\n const container = await createRequestContainer()\n let em: EntityManager\n try {\n em = container.resolve('em') as EntityManager\n } catch {\n return { ok: true }\n }\n const currentRaw = await reader(em, {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n })\n if (currentRaw == null) return { ok: true }\n const currentIso = normalizeIsoToken(currentRaw)\n if (currentIso == null) return { ok: true }\n if (currentIso === expectedIso) return { ok: true }\n return {\n ok: false,\n status: 409,\n body: {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n },\n }\n },\n}\n\nexport const guards: MutationGuard[] = [optimisticLockGuard]\n"],
5
- "mappings": "AAEA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AACvC,SAAS,mCAAmC;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,kBAAkB,KAA4B;AACrD,QAAM,KAAK,KAAK,MAAM,GAAG;AACzB,MAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AACjC,SAAO,IAAI,KAAK,EAAE,EAAE,YAAY;AAClC;AAEA,MAAM,sBAAqC;AAAA,EACzC,IAAI;AAAA,EACJ,cAAc;AAAA,EACd,YAAY,CAAC,UAAU,QAAQ;AAAA,EAC/B,UAAU;AAAA,EACV,MAAM,SAAS,OAAO;AACpB,UAAM,SAAS,uBAAuB,QAAQ,IAAI,uBAAuB,CAAC;AAC1E,QAAI,OAAO,SAAS,MAAO,QAAO,EAAE,IAAI,KAAK;AAC7C,UAAM,UAAU,OAAO,SAAS,SAAS,OAAO,SAAS,IAAI,MAAM,aAAa,YAAY,CAAC;AAC7F,QAAI,CAAC,QAAS,QAAO,EAAE,IAAI,KAAK;AAChC,UAAM,UAAU,4BAA4B;AAC5C,UAAM,SAAS,QAAQ,MAAM,YAAY;AACzC,QAAI,CAAC,OAAQ,QAAO,EAAE,IAAI,KAAK;AAC/B,UAAM,cAAc,MAAM,eAAe,IAAI,2BAA2B;AACxE,QAAI,CAAC,eAAe,YAAY,KAAK,EAAE,WAAW,EAAG,QAAO,EAAE,IAAI,KAAK;AACvE,UAAM,cAAc,kBAAkB,YAAY,KAAK,CAAC;AACxD,QAAI,CAAC,YAAa,QAAO,EAAE,IAAI,KAAK;AACpC,QAAI,CAAC,MAAM,WAAY,QAAO,EAAE,IAAI,KAAK;AACzC,UAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAI;AACJ,QAAI;AACF,WAAK,UAAU,QAAQ,IAAI;AAAA,IAC7B,QAAQ;AACN,aAAO,EAAE,IAAI,KAAK;AAAA,IACpB;AACA,UAAM,aAAa,MAAM,OAAO,IAAI;AAAA,MAClC,cAAc,MAAM;AAAA,MACpB,YAAY,MAAM;AAAA,MAClB,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM,kBAAkB;AAAA,IAC1C,CAAC;AACD,QAAI,cAAc,KAAM,QAAO,EAAE,IAAI,KAAK;AAC1C,UAAM,aAAa,kBAAkB,UAAU;AAC/C,QAAI,cAAc,KAAM,QAAO,EAAE,IAAI,KAAK;AAC1C,QAAI,eAAe,YAAa,QAAO,EAAE,IAAI,KAAK;AAClD,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,MAAM;AAAA,QACJ,OAAO;AAAA,QACP,MAAM;AAAA,QACN,kBAAkB;AAAA,QAClB,mBAAmB;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,SAA0B,CAAC,mBAAmB;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { MutationGuard } from '@open-mercato/shared/lib/crud/mutation-guard-registry'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { parseOptimisticLockEnv } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\nfunction normalizeIsoToken(raw: string): string | null {\n const ms = Date.parse(raw)\n if (!Number.isFinite(ms)) return null\n return new Date(ms).toISOString()\n}\n\n/**\n * HTTP header carrying the enterprise `record_locks` conflict resolution the\n * client chose in the merge dialog. This guard only reads the header value off\n * the request (no enterprise import) \u2014 it is the integration contract between\n * the record-lock layer and this universal optimistic-lock floor.\n */\nconst RECORD_LOCK_RESOLUTION_HEADER_NAME = 'x-om-record-lock-resolution'\n\n/**\n * Resolutions that express an explicit, privileged intent to OVERWRITE the\n * incoming concurrent write (\"Keep mine\" / merged). When the request asserts\n * one of these, the record-lock guard (which runs first, at priority 0) has\n * already authorized the override against `canOverrideIncoming`, so the\n * stale-`updated_at` floor MUST defer to it \u2014 otherwise it rejects exactly the\n * overwrite the user just authorized, looping the merge dialog on a fresh 409\n * (issue #3601). `accept_incoming` is intentionally NOT here: that path reloads\n * with a fresh `updated_at` and must still pass through the floor normally.\n */\nconst RECORD_LOCK_OVERRIDE_RESOLUTIONS: ReadonlySet<string> = new Set(['accept_mine', 'merged'])\n\nexport function isAuthorizedRecordLockOverride(headers: Headers): boolean {\n const raw = headers.get(RECORD_LOCK_RESOLUTION_HEADER_NAME)\n if (typeof raw !== 'string') return false\n return RECORD_LOCK_OVERRIDE_RESOLUTIONS.has(raw.trim().toLowerCase())\n}\n\nconst optimisticLockGuard: MutationGuard = {\n id: 'customers.optimistic-lock',\n targetEntity: '*',\n operations: ['update', 'delete'],\n priority: 100,\n async validate(input) {\n const config = parseOptimisticLockEnv(process.env[OPTIMISTIC_LOCK_ENV_VAR])\n if (config.mode === 'off') return { ok: true }\n // A privileged record-lock \"Keep mine\" override deliberately overwrites the\n // concurrent write; the floor must not block it on the now-stale timestamp.\n if (isAuthorizedRecordLockOverride(input.requestHeaders)) return { ok: true }\n const enabled = config.mode === 'all' || config.entities.has(input.resourceKind.toLowerCase())\n if (!enabled) return { ok: true }\n const readers = getAllOptimisticLockReaders()\n const reader = readers[input.resourceKind]\n if (!reader) return { ok: true }\n const expectedRaw = input.requestHeaders.get(OPTIMISTIC_LOCK_HEADER_NAME)\n if (!expectedRaw || expectedRaw.trim().length === 0) return { ok: true }\n const expectedIso = normalizeIsoToken(expectedRaw.trim())\n if (!expectedIso) return { ok: true }\n if (!input.resourceId) return { ok: true }\n const container = await createRequestContainer()\n let em: EntityManager\n try {\n em = container.resolve('em') as EntityManager\n } catch {\n return { ok: true }\n }\n const currentRaw = await reader(em, {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n })\n if (currentRaw == null) return { ok: true }\n const currentIso = normalizeIsoToken(currentRaw)\n if (currentIso == null) return { ok: true }\n if (currentIso === expectedIso) return { ok: true }\n return {\n ok: false,\n status: 409,\n body: {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n },\n }\n },\n}\n\nexport const guards: MutationGuard[] = [optimisticLockGuard]\n"],
5
+ "mappings": "AAEA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AACvC,SAAS,mCAAmC;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,kBAAkB,KAA4B;AACrD,QAAM,KAAK,KAAK,MAAM,GAAG;AACzB,MAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AACjC,SAAO,IAAI,KAAK,EAAE,EAAE,YAAY;AAClC;AAQA,MAAM,qCAAqC;AAY3C,MAAM,mCAAwD,oBAAI,IAAI,CAAC,eAAe,QAAQ,CAAC;AAExF,SAAS,+BAA+B,SAA2B;AACxE,QAAM,MAAM,QAAQ,IAAI,kCAAkC;AAC1D,MAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,SAAO,iCAAiC,IAAI,IAAI,KAAK,EAAE,YAAY,CAAC;AACtE;AAEA,MAAM,sBAAqC;AAAA,EACzC,IAAI;AAAA,EACJ,cAAc;AAAA,EACd,YAAY,CAAC,UAAU,QAAQ;AAAA,EAC/B,UAAU;AAAA,EACV,MAAM,SAAS,OAAO;AACpB,UAAM,SAAS,uBAAuB,QAAQ,IAAI,uBAAuB,CAAC;AAC1E,QAAI,OAAO,SAAS,MAAO,QAAO,EAAE,IAAI,KAAK;AAG7C,QAAI,+BAA+B,MAAM,cAAc,EAAG,QAAO,EAAE,IAAI,KAAK;AAC5E,UAAM,UAAU,OAAO,SAAS,SAAS,OAAO,SAAS,IAAI,MAAM,aAAa,YAAY,CAAC;AAC7F,QAAI,CAAC,QAAS,QAAO,EAAE,IAAI,KAAK;AAChC,UAAM,UAAU,4BAA4B;AAC5C,UAAM,SAAS,QAAQ,MAAM,YAAY;AACzC,QAAI,CAAC,OAAQ,QAAO,EAAE,IAAI,KAAK;AAC/B,UAAM,cAAc,MAAM,eAAe,IAAI,2BAA2B;AACxE,QAAI,CAAC,eAAe,YAAY,KAAK,EAAE,WAAW,EAAG,QAAO,EAAE,IAAI,KAAK;AACvE,UAAM,cAAc,kBAAkB,YAAY,KAAK,CAAC;AACxD,QAAI,CAAC,YAAa,QAAO,EAAE,IAAI,KAAK;AACpC,QAAI,CAAC,MAAM,WAAY,QAAO,EAAE,IAAI,KAAK;AACzC,UAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAI;AACJ,QAAI;AACF,WAAK,UAAU,QAAQ,IAAI;AAAA,IAC7B,QAAQ;AACN,aAAO,EAAE,IAAI,KAAK;AAAA,IACpB;AACA,UAAM,aAAa,MAAM,OAAO,IAAI;AAAA,MAClC,cAAc,MAAM;AAAA,MACpB,YAAY,MAAM;AAAA,MAClB,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM,kBAAkB;AAAA,IAC1C,CAAC;AACD,QAAI,cAAc,KAAM,QAAO,EAAE,IAAI,KAAK;AAC1C,UAAM,aAAa,kBAAkB,UAAU;AAC/C,QAAI,cAAc,KAAM,QAAO,EAAE,IAAI,KAAK;AAC1C,QAAI,eAAe,YAAa,QAAO,EAAE,IAAI,KAAK;AAClD,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,MAAM;AAAA,QACJ,OAAO;AAAA,QACP,MAAM;AAAA,QACN,kBAAkB;AAAA,QAClB,mBAAmB;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,SAA0B,CAAC,mBAAmB;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6305.1.4503070c9d",
3
+ "version": "0.6.6-develop.6309.1.983aeec27a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -246,16 +246,16 @@
246
246
  "zod": "^4.4.3"
247
247
  },
248
248
  "peerDependencies": {
249
- "@open-mercato/ai-assistant": "0.6.6-develop.6305.1.4503070c9d",
250
- "@open-mercato/shared": "0.6.6-develop.6305.1.4503070c9d",
251
- "@open-mercato/ui": "0.6.6-develop.6305.1.4503070c9d",
249
+ "@open-mercato/ai-assistant": "0.6.6-develop.6309.1.983aeec27a",
250
+ "@open-mercato/shared": "0.6.6-develop.6309.1.983aeec27a",
251
+ "@open-mercato/ui": "0.6.6-develop.6309.1.983aeec27a",
252
252
  "react": "^19.0.0",
253
253
  "react-dom": "^19.0.0"
254
254
  },
255
255
  "devDependencies": {
256
- "@open-mercato/ai-assistant": "0.6.6-develop.6305.1.4503070c9d",
257
- "@open-mercato/shared": "0.6.6-develop.6305.1.4503070c9d",
258
- "@open-mercato/ui": "0.6.6-develop.6305.1.4503070c9d",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6309.1.983aeec27a",
257
+ "@open-mercato/shared": "0.6.6-develop.6309.1.983aeec27a",
258
+ "@open-mercato/ui": "0.6.6-develop.6309.1.983aeec27a",
259
259
  "@testing-library/dom": "^10.4.1",
260
260
  "@testing-library/jest-dom": "^6.9.1",
261
261
  "@testing-library/react": "^16.3.1",
@@ -16,6 +16,32 @@ function normalizeIsoToken(raw: string): string | null {
16
16
  return new Date(ms).toISOString()
17
17
  }
18
18
 
19
+ /**
20
+ * HTTP header carrying the enterprise `record_locks` conflict resolution the
21
+ * client chose in the merge dialog. This guard only reads the header value off
22
+ * the request (no enterprise import) — it is the integration contract between
23
+ * the record-lock layer and this universal optimistic-lock floor.
24
+ */
25
+ const RECORD_LOCK_RESOLUTION_HEADER_NAME = 'x-om-record-lock-resolution'
26
+
27
+ /**
28
+ * Resolutions that express an explicit, privileged intent to OVERWRITE the
29
+ * incoming concurrent write ("Keep mine" / merged). When the request asserts
30
+ * one of these, the record-lock guard (which runs first, at priority 0) has
31
+ * already authorized the override against `canOverrideIncoming`, so the
32
+ * stale-`updated_at` floor MUST defer to it — otherwise it rejects exactly the
33
+ * overwrite the user just authorized, looping the merge dialog on a fresh 409
34
+ * (issue #3601). `accept_incoming` is intentionally NOT here: that path reloads
35
+ * with a fresh `updated_at` and must still pass through the floor normally.
36
+ */
37
+ const RECORD_LOCK_OVERRIDE_RESOLUTIONS: ReadonlySet<string> = new Set(['accept_mine', 'merged'])
38
+
39
+ export function isAuthorizedRecordLockOverride(headers: Headers): boolean {
40
+ const raw = headers.get(RECORD_LOCK_RESOLUTION_HEADER_NAME)
41
+ if (typeof raw !== 'string') return false
42
+ return RECORD_LOCK_OVERRIDE_RESOLUTIONS.has(raw.trim().toLowerCase())
43
+ }
44
+
19
45
  const optimisticLockGuard: MutationGuard = {
20
46
  id: 'customers.optimistic-lock',
21
47
  targetEntity: '*',
@@ -24,6 +50,9 @@ const optimisticLockGuard: MutationGuard = {
24
50
  async validate(input) {
25
51
  const config = parseOptimisticLockEnv(process.env[OPTIMISTIC_LOCK_ENV_VAR])
26
52
  if (config.mode === 'off') return { ok: true }
53
+ // A privileged record-lock "Keep mine" override deliberately overwrites the
54
+ // concurrent write; the floor must not block it on the now-stale timestamp.
55
+ if (isAuthorizedRecordLockOverride(input.requestHeaders)) return { ok: true }
27
56
  const enabled = config.mode === 'all' || config.entities.has(input.resourceKind.toLowerCase())
28
57
  if (!enabled) return { ok: true }
29
58
  const readers = getAllOptimisticLockReaders()