@open-mercato/shared 0.6.4-develop.4371.1.8f3030407e → 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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +10 -0
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/command-bus.js +56 -47
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +6 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +106 -0
- package/dist/lib/commands/redo.js.map +7 -0
- package/dist/lib/commands/runCrudCommandWrite.js +38 -0
- package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/errors.js +22 -0
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/data/engine.js +2 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +57 -23
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +41 -8
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/context.js +5 -0
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/package.json +8 -9
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
- package/src/lib/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/__tests__/redo.test.ts +265 -0
- package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/command-bus.ts +62 -44
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/redo.ts +235 -0
- package/src/lib/commands/runCrudCommandWrite.ts +82 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/commands/types.ts +54 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/errors.ts +29 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/data/engine.ts +11 -8
- package/src/lib/di/container.ts +17 -1
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +44 -6
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +76 -24
- package/src/lib/encryption/subscriber.ts +54 -9
- package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
- package/src/lib/i18n/context.tsx +11 -0
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/integrations/types.ts +14 -0
- package/src/modules/notifications/handler.ts +7 -0
- package/src/modules/search.ts +9 -0
- package/src/modules/vector.ts +7 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSS optimistic-locking guard service.
|
|
3
|
+
*
|
|
4
|
+
* Registered as `crudMutationGuardService` (by the platform DI bootstrap and
|
|
5
|
+
* by hand-wiring modules that override the default reader). Compares the
|
|
6
|
+
* client-sent expected `updated_at` (carried via the extension header
|
|
7
|
+
* defined in `optimistic-lock-headers.ts`) against the current DB
|
|
8
|
+
* `updated_at` for the target entity; on mismatch returns HTTP 409 with the
|
|
9
|
+
* structured `OptimisticLockConflictBody`.
|
|
10
|
+
*
|
|
11
|
+
* **Default ON** (Phase 14, 2026-05-27). Activate / scope / disable via
|
|
12
|
+
* `OM_OPTIMISTIC_LOCK`:
|
|
13
|
+
* - unset / empty / whitespace → ON for every CRUD entity (`{ mode: 'all' }`)
|
|
14
|
+
* - `all` → all entities (explicit form of the default)
|
|
15
|
+
* - `customers.company,sales.order` → allow-list (lowercased, trimmed, deduped)
|
|
16
|
+
* - `off` / `false` / `0` / `no` / `disabled` / `none` → fully disabled
|
|
17
|
+
*
|
|
18
|
+
* The guard is still strictly additive at runtime: clients that do not send
|
|
19
|
+
* the `x-om-ext-optimistic-lock-expected-updated-at` header pass through
|
|
20
|
+
* unchanged, so flipping the default to ON cannot introduce new 409s on
|
|
21
|
+
* existing API consumers. Pages that opt into the round-trip (via
|
|
22
|
+
* `CrudForm`'s `optimisticLockUpdatedAt` prop or by calling
|
|
23
|
+
* `buildOptimisticLockHeader`) gain protection without any per-deployment
|
|
24
|
+
* env change.
|
|
25
|
+
*
|
|
26
|
+
* Cannot be registered as a static `data/guards.ts` `MutationGuard` because
|
|
27
|
+
* the static `validate(input)` receives only `MutationGuardInput` — no
|
|
28
|
+
* container / em access. Stateful checks that need to read current DB state
|
|
29
|
+
* MUST go through the DI service path (this file).
|
|
30
|
+
*
|
|
31
|
+
* Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
|
|
32
|
+
*/
|
|
33
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
34
|
+
import type {
|
|
35
|
+
CrudMutationGuardValidateInput,
|
|
36
|
+
CrudMutationGuardValidationResult,
|
|
37
|
+
CrudMutationGuardAfterSuccessInput,
|
|
38
|
+
} from './mutation-guard'
|
|
39
|
+
import {
|
|
40
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
41
|
+
OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
42
|
+
OPTIMISTIC_LOCK_ENV_VAR,
|
|
43
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
44
|
+
type OptimisticLockConflictBody,
|
|
45
|
+
} from './optimistic-lock-headers'
|
|
46
|
+
import { getAllOptimisticLockReaders } from './optimistic-lock-store'
|
|
47
|
+
|
|
48
|
+
export type OptimisticLockConfig =
|
|
49
|
+
| { mode: 'off' }
|
|
50
|
+
| { mode: 'all' }
|
|
51
|
+
| { mode: 'allowlist'; entities: ReadonlySet<string> }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tokens (case-insensitive, single-token-only) that explicitly disable the
|
|
55
|
+
* guard. Spelled out as a fixed set so tests can pin them; deliberately the
|
|
56
|
+
* same shape `parseBooleanToken` recognises so operators can mirror existing
|
|
57
|
+
* habit. Mixing an off-token with other entities is invalid input — we treat
|
|
58
|
+
* any presence of an off-token in the comma list as a request to disable.
|
|
59
|
+
*/
|
|
60
|
+
const OPTIMISTIC_LOCK_OFF_TOKENS: ReadonlySet<string> = new Set([
|
|
61
|
+
'off',
|
|
62
|
+
'false',
|
|
63
|
+
'0',
|
|
64
|
+
'no',
|
|
65
|
+
'disabled',
|
|
66
|
+
'none',
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pure parser for `OM_OPTIMISTIC_LOCK`. Exported separately so tests can
|
|
71
|
+
* exercise the grammar without spinning up the full service.
|
|
72
|
+
*
|
|
73
|
+
* Default is **ON** (`{ mode: 'all' }`) — unset / empty / whitespace input
|
|
74
|
+
* activates the guard for every CRUD entity. Operators opt out via
|
|
75
|
+
* `OM_OPTIMISTIC_LOCK=off` (or `false` / `0` / `no` / `disabled` / `none`).
|
|
76
|
+
*/
|
|
77
|
+
export function parseOptimisticLockEnv(raw: string | undefined | null): OptimisticLockConfig {
|
|
78
|
+
if (raw == null) return { mode: 'all' }
|
|
79
|
+
const trimmed = String(raw).trim()
|
|
80
|
+
if (trimmed === '') return { mode: 'all' }
|
|
81
|
+
|
|
82
|
+
const tokens = trimmed
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((token) => token.trim().toLowerCase())
|
|
85
|
+
.filter((token) => token.length > 0)
|
|
86
|
+
|
|
87
|
+
if (tokens.length === 0) return { mode: 'all' }
|
|
88
|
+
if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: 'off' }
|
|
89
|
+
if (tokens.includes('all')) return { mode: 'all' }
|
|
90
|
+
|
|
91
|
+
return { mode: 'allowlist', entities: new Set(tokens) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type OptimisticLockResolverInput = {
|
|
95
|
+
expectedFromHeader: string | null
|
|
96
|
+
resourceKind: string
|
|
97
|
+
resourceId: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook reserved for the enterprise `record_locks` module to override token
|
|
102
|
+
* resolution (e.g. read the expected token from a lock record instead of
|
|
103
|
+
* the request header). OSS keeps the default = "what the client sent".
|
|
104
|
+
*
|
|
105
|
+
* Documented as part of the enterprise extension contract; not used in OSS
|
|
106
|
+
* itself.
|
|
107
|
+
*/
|
|
108
|
+
export type ResolveExpectedUpdatedAt = (
|
|
109
|
+
input: OptimisticLockResolverInput,
|
|
110
|
+
) => Promise<string | null> | string | null
|
|
111
|
+
|
|
112
|
+
const defaultResolveExpectedUpdatedAt: ResolveExpectedUpdatedAt = ({ expectedFromHeader }) =>
|
|
113
|
+
expectedFromHeader
|
|
114
|
+
|
|
115
|
+
export type OptimisticLockCurrentReader = (
|
|
116
|
+
em: EntityManager,
|
|
117
|
+
input: { resourceKind: string; resourceId: string; tenantId: string; organizationId: string | null },
|
|
118
|
+
) => Promise<string | null>
|
|
119
|
+
|
|
120
|
+
export type GenericOptimisticLockReaderOptions = {
|
|
121
|
+
/** MikroORM entity class. */
|
|
122
|
+
entity: unknown
|
|
123
|
+
/** Primary key field. Defaults to `id`. */
|
|
124
|
+
idField?: string
|
|
125
|
+
/** Tenant scope field. Defaults to `tenantId`. Pass `null` to skip tenant scoping (rare — only when the entity itself has no `tenantId` column). */
|
|
126
|
+
tenantField?: string | null
|
|
127
|
+
/** Organization scope field. Defaults to `organizationId`. Pass `null` to skip organization scoping. */
|
|
128
|
+
orgField?: string | null
|
|
129
|
+
/** Soft-delete column. Defaults to `deletedAt`. Pass `null` to skip the implicit not-deleted filter. */
|
|
130
|
+
softDeleteField?: string | null
|
|
131
|
+
/** Optional fixed filter merged into every query (e.g. `{ kind: 'company' }` for a discriminated table). */
|
|
132
|
+
extraFilter?: Record<string, unknown>
|
|
133
|
+
/** Optional ORM field name carrying the timestamp. Defaults to `updatedAt`. */
|
|
134
|
+
updatedAtField?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build a generic optimistic-lock reader for any ORM entity that follows the
|
|
139
|
+
* platform conventions (`id` + `tenantId` + `organizationId` + `deletedAt` +
|
|
140
|
+
* `updatedAt`). The reader projects only the timestamp column so PII never
|
|
141
|
+
* materializes.
|
|
142
|
+
*
|
|
143
|
+
* Used by `makeCrudRoute` to auto-register one reader per CRUD route at
|
|
144
|
+
* module-load time (see Phase 13 of the OSS optimistic-locking spec).
|
|
145
|
+
* Module authors who need bespoke filtering (e.g. discriminator on a shared
|
|
146
|
+
* table) keep registering their own reader via `registerOptimisticLockReaders`
|
|
147
|
+
* — those hand-wired registrations win because they land first.
|
|
148
|
+
*
|
|
149
|
+
* Fail-open contract: if the underlying `findOne` throws (missing column,
|
|
150
|
+
* schema drift, mid-migration) the reader returns `null`, which the guard
|
|
151
|
+
* treats as "entity already gone" and lets the CRUD path's own 404 fire.
|
|
152
|
+
* We MUST NOT throw out of the reader — that would 500 every mutation on
|
|
153
|
+
* the affected entity instead of opting it out of the optimistic check.
|
|
154
|
+
*/
|
|
155
|
+
export function createGenericOptimisticLockReader(
|
|
156
|
+
opts: GenericOptimisticLockReaderOptions,
|
|
157
|
+
): OptimisticLockCurrentReader {
|
|
158
|
+
const idField = opts.idField ?? 'id'
|
|
159
|
+
const tenantField = opts.tenantField === null ? null : opts.tenantField ?? 'tenantId'
|
|
160
|
+
const orgField = opts.orgField === null ? null : opts.orgField ?? 'organizationId'
|
|
161
|
+
const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? 'deletedAt'
|
|
162
|
+
const updatedAtField = opts.updatedAtField ?? 'updatedAt'
|
|
163
|
+
const extraFilter = opts.extraFilter ?? {}
|
|
164
|
+
|
|
165
|
+
return async (em, { resourceId, tenantId, organizationId }) => {
|
|
166
|
+
const filter: Record<string, unknown> = { [idField]: resourceId }
|
|
167
|
+
if (tenantField) filter[tenantField] = tenantId
|
|
168
|
+
if (orgField && organizationId) filter[orgField] = organizationId
|
|
169
|
+
if (softDeleteField) filter[softDeleteField] = null
|
|
170
|
+
for (const [key, value] of Object.entries(extraFilter)) filter[key] = value
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const row = await em.findOne(opts.entity as never, filter as never, {
|
|
174
|
+
fields: [updatedAtField] as never,
|
|
175
|
+
})
|
|
176
|
+
if (!row || typeof row !== 'object') return null
|
|
177
|
+
const value = (row as Record<string, unknown>)[updatedAtField]
|
|
178
|
+
if (value instanceof Date) return value.toISOString()
|
|
179
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
180
|
+
return null
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type OptimisticLockGuardOptions = {
|
|
188
|
+
/** EntityManager resolver. Container-bound via DI in real usage. */
|
|
189
|
+
getEm: () => EntityManager
|
|
190
|
+
/**
|
|
191
|
+
* Maps `resourceKind` → reader that returns the current
|
|
192
|
+
* `updated_at` as an ISO string (or null when not found).
|
|
193
|
+
*
|
|
194
|
+
* The reader receives the EM so module authors can choose the
|
|
195
|
+
* right `findOne` shape for their entity (`findOneWithDecryption`
|
|
196
|
+
* when sensitive, plain `findOne` otherwise — but only requesting
|
|
197
|
+
* `updated_at` so no PII materializes).
|
|
198
|
+
*
|
|
199
|
+
* When omitted, the service pulls readers from the shared
|
|
200
|
+
* `optimistic-lock-store` (the recommended pattern for multi-module
|
|
201
|
+
* deployments — each module registers its own readers via
|
|
202
|
+
* `registerOptimisticLockReaders(...)` at module-load time).
|
|
203
|
+
*/
|
|
204
|
+
readers?: Record<string, OptimisticLockCurrentReader>
|
|
205
|
+
/** Override env source (mostly for tests). Defaults to `process.env`. */
|
|
206
|
+
envValue?: string | null
|
|
207
|
+
/** Override the token resolver. Defaults to "use the header value". */
|
|
208
|
+
resolveExpected?: ResolveExpectedUpdatedAt
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type OptimisticLockGuardService = {
|
|
212
|
+
validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>
|
|
213
|
+
afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>
|
|
214
|
+
/** Exposed for tests / introspection. */
|
|
215
|
+
getConfig: () => OptimisticLockConfig
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readHeader(headers: Headers, name: string): string | null {
|
|
219
|
+
const direct = headers.get(name)
|
|
220
|
+
if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Normalize an `updated_at` token to a canonical ISO-8601 string, or `null`
|
|
226
|
+
* when the input cannot be parsed. Exported so the command-level helper
|
|
227
|
+
* (`optimistic-lock-command.ts`) compares timestamps with the EXACT same
|
|
228
|
+
* normalization as the CRUD guard — otherwise the same instant could compare
|
|
229
|
+
* unequal across the two paths.
|
|
230
|
+
*/
|
|
231
|
+
export function normalizeIsoToken(raw: string): string | null {
|
|
232
|
+
const ms = Date.parse(raw)
|
|
233
|
+
if (!Number.isFinite(ms)) return null
|
|
234
|
+
return new Date(ms).toISOString()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildConflictBody(currentIso: string, expectedIso: string): OptimisticLockConflictBody {
|
|
238
|
+
return {
|
|
239
|
+
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
240
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
241
|
+
currentUpdatedAt: currentIso,
|
|
242
|
+
expectedUpdatedAt: expectedIso,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Factory for the optimistic-lock guard service.
|
|
248
|
+
*
|
|
249
|
+
* Usage from a module's `di.ts`:
|
|
250
|
+
*
|
|
251
|
+
* ```ts
|
|
252
|
+
* import { asFunction } from 'awilix'
|
|
253
|
+
* import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
254
|
+
*
|
|
255
|
+
* container.register({
|
|
256
|
+
* crudMutationGuardService: asFunction((cradle) => createOptimisticLockGuardService({
|
|
257
|
+
* getEm: () => cradle.em,
|
|
258
|
+
* readers: {
|
|
259
|
+
* 'customers.company': async (em, { resourceId, tenantId }) => {
|
|
260
|
+
* const row = await em.findOne(Company, { id: resourceId, tenantId }, { fields: ['updatedAt'] })
|
|
261
|
+
* return row?.updatedAt ? row.updatedAt.toISOString() : null
|
|
262
|
+
* },
|
|
263
|
+
* },
|
|
264
|
+
* })).singleton(),
|
|
265
|
+
* })
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export function createOptimisticLockGuardService(
|
|
269
|
+
opts: OptimisticLockGuardOptions,
|
|
270
|
+
): OptimisticLockGuardService {
|
|
271
|
+
const envValue = opts.envValue !== undefined ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR]
|
|
272
|
+
const config = parseOptimisticLockEnv(envValue)
|
|
273
|
+
const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt
|
|
274
|
+
const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === '1'
|
|
275
|
+
|
|
276
|
+
function isEntityEnabled(resourceKind: string): boolean {
|
|
277
|
+
if (config.mode === 'off') return false
|
|
278
|
+
if (config.mode === 'all') return true
|
|
279
|
+
return config.entities.has(resourceKind.toLowerCase())
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function validateMutation(
|
|
283
|
+
input: CrudMutationGuardValidateInput,
|
|
284
|
+
): Promise<CrudMutationGuardValidationResult> {
|
|
285
|
+
if (config.mode === 'off') {
|
|
286
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
287
|
+
}
|
|
288
|
+
if (input.operation !== 'update' && input.operation !== 'delete') {
|
|
289
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
290
|
+
}
|
|
291
|
+
if (!isEntityEnabled(input.resourceKind)) {
|
|
292
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
293
|
+
}
|
|
294
|
+
const readers = opts.readers ?? getAllOptimisticLockReaders()
|
|
295
|
+
const reader = readers[input.resourceKind]
|
|
296
|
+
if (!reader) {
|
|
297
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME)
|
|
301
|
+
const resolvedExpected = await resolveExpected({
|
|
302
|
+
expectedFromHeader: expectedRaw,
|
|
303
|
+
resourceKind: input.resourceKind,
|
|
304
|
+
resourceId: input.resourceId,
|
|
305
|
+
})
|
|
306
|
+
if (resolvedExpected == null) {
|
|
307
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const expectedIso = normalizeIsoToken(resolvedExpected)
|
|
311
|
+
if (expectedIso == null) {
|
|
312
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const em = opts.getEm()
|
|
316
|
+
const currentRaw = await reader(em, {
|
|
317
|
+
resourceKind: input.resourceKind,
|
|
318
|
+
resourceId: input.resourceId,
|
|
319
|
+
tenantId: input.tenantId,
|
|
320
|
+
organizationId: input.organizationId ?? null,
|
|
321
|
+
})
|
|
322
|
+
if (currentRaw == null) {
|
|
323
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
324
|
+
}
|
|
325
|
+
const currentIso = normalizeIsoToken(currentRaw)
|
|
326
|
+
if (currentIso == null) {
|
|
327
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (currentIso === expectedIso) {
|
|
331
|
+
if (debugEnabled) {
|
|
332
|
+
// eslint-disable-next-line no-console
|
|
333
|
+
console.log('[optimistic-lock] match', {
|
|
334
|
+
resourceKind: input.resourceKind,
|
|
335
|
+
resourceId: input.resourceId,
|
|
336
|
+
operation: input.operation,
|
|
337
|
+
currentIso,
|
|
338
|
+
expectedIso,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, shouldRunAfterSuccess: false }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (debugEnabled) {
|
|
345
|
+
// eslint-disable-next-line no-console
|
|
346
|
+
console.log('[optimistic-lock] CONFLICT', {
|
|
347
|
+
resourceKind: input.resourceKind,
|
|
348
|
+
resourceId: input.resourceId,
|
|
349
|
+
operation: input.operation,
|
|
350
|
+
tenantId: input.tenantId,
|
|
351
|
+
organizationId: input.organizationId ?? null,
|
|
352
|
+
expectedRaw: resolvedExpected,
|
|
353
|
+
expectedIso,
|
|
354
|
+
currentRaw,
|
|
355
|
+
currentIso,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
status: 409,
|
|
362
|
+
body: buildConflictBody(currentIso, expectedIso),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function afterMutationSuccess(_input: CrudMutationGuardAfterSuccessInput): Promise<void> {
|
|
367
|
+
// no-op: optimistic check has no post-success cleanup
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getConfig(): OptimisticLockConfig {
|
|
371
|
+
return config
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
validateMutation,
|
|
376
|
+
afterMutationSuccess,
|
|
377
|
+
getConfig,
|
|
378
|
+
}
|
|
379
|
+
}
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -572,10 +572,12 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
572
572
|
enrichedPayload.crudAction = action
|
|
573
573
|
if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta
|
|
574
574
|
if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
|
|
575
|
+
// Await the index update so query-index reads (the `customValues`/scalar
|
|
576
|
+
// projection that list endpoints serve) are consistent the moment the write
|
|
577
|
+
// returns. The subscriber removes the projection row + tokens synchronously and
|
|
578
|
+
// defers the coverage recompute + fulltext delete, so this stays bounded.
|
|
579
|
+
// Errors are logged, not thrown — index drift never fails the originating write.
|
|
580
|
+
await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
|
|
579
581
|
console.error('[data-engine] query_index.delete_one emit failed', err)
|
|
580
582
|
})
|
|
581
583
|
} else {
|
|
@@ -591,10 +593,11 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
591
593
|
enrichedPayload.crudAction = action
|
|
592
594
|
if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta
|
|
593
595
|
if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin
|
|
594
|
-
//
|
|
595
|
-
// (
|
|
596
|
-
//
|
|
597
|
-
|
|
596
|
+
// Await the projection upsert so list reads observe the new doc immediately
|
|
597
|
+
// (see delete_one above). The subscriber updates `entity_indexes` synchronously
|
|
598
|
+
// and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
|
|
599
|
+
// tokenize + DELETE + chunked INSERT) so write latency stays bounded.
|
|
600
|
+
await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
|
|
598
601
|
console.error('[data-engine] query_index.upsert_one emit failed', err)
|
|
599
602
|
})
|
|
600
603
|
}
|
package/src/lib/di/container.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'
|
|
1
|
+
import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'
|
|
2
2
|
import { RequestContext } from '@mikro-orm/core'
|
|
3
3
|
import { getOrm } from '@open-mercato/shared/lib/db/mikro'
|
|
4
4
|
import { EntityManager } from '@mikro-orm/postgresql'
|
|
@@ -6,6 +6,8 @@ import { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'
|
|
|
6
6
|
import { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
7
7
|
import { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'
|
|
8
8
|
import { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'
|
|
9
|
+
import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
10
|
+
import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
9
11
|
|
|
10
12
|
type DynamicCradle = Record<string, any>
|
|
11
13
|
|
|
@@ -155,6 +157,20 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
155
157
|
dataEngine: asValue(new DefaultDataEngine(em, container as any)),
|
|
156
158
|
commandRegistry: asValue(commandRegistry),
|
|
157
159
|
commandBus: asValue(new CommandBus()),
|
|
160
|
+
// Default OSS optimistic-lock guard. Reads from the global reader store
|
|
161
|
+
// (populated by `makeCrudRoute` auto-registration + any module-DI
|
|
162
|
+
// hand-wired calls to `registerOptimisticLockReaders`). Service is
|
|
163
|
+
// strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is
|
|
164
|
+
// sent) it short-circuits at validateMutation. Module-level di.ts
|
|
165
|
+
// registrations override this default via Awilix replace semantics —
|
|
166
|
+
// see the enterprise `record_locks` module for the canonical override.
|
|
167
|
+
// Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
|
|
168
|
+
crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>
|
|
169
|
+
createOptimisticLockGuardService({
|
|
170
|
+
getEm: () => scopedEm,
|
|
171
|
+
readers: getAllOptimisticLockReaders(),
|
|
172
|
+
}),
|
|
173
|
+
).scoped(),
|
|
158
174
|
})
|
|
159
175
|
// Allow modules to override/extend
|
|
160
176
|
for (const reg of diRegistrars) {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { decryptWithAesGcm, generateDek } from '../aes'
|
|
2
|
+
import { HashicorpVaultKmsService, type KmsService, type TenantDek } from '../kms'
|
|
3
|
+
import { TenantDataEncryptionService } from '../tenantDataEncryptionService'
|
|
4
|
+
|
|
5
|
+
const originalEnv = { ...process.env }
|
|
6
|
+
|
|
7
|
+
type DuckResponse = { ok: boolean; status: number; json: () => Promise<unknown> }
|
|
8
|
+
|
|
9
|
+
function jsonResponse(status: number, body: Record<string, unknown>): DuckResponse {
|
|
10
|
+
return { ok: status >= 200 && status < 300, status, json: async () => body }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Minimal in-memory Vault KV v2 simulator: GET reads the stored key, POST writes
|
|
14
|
+
// it unless a `cas: 0` write loses to an existing version (concurrent create).
|
|
15
|
+
function makeVaultSim() {
|
|
16
|
+
const store = new Map<string, string>()
|
|
17
|
+
const counts = { reads: 0, posts: 0, persisted: 0 }
|
|
18
|
+
const fetchMock = jest.fn(async (input: unknown, init?: RequestInit): Promise<DuckResponse> => {
|
|
19
|
+
const url = String(input)
|
|
20
|
+
const path = url.slice(url.indexOf('/v1/') + '/v1/'.length)
|
|
21
|
+
const method = (init?.method || 'GET').toUpperCase()
|
|
22
|
+
if (method === 'GET') {
|
|
23
|
+
counts.reads++
|
|
24
|
+
const key = store.get(path)
|
|
25
|
+
if (!key) return jsonResponse(404, { errors: [] })
|
|
26
|
+
return jsonResponse(200, { data: { data: { key } } })
|
|
27
|
+
}
|
|
28
|
+
counts.posts++
|
|
29
|
+
const body = init?.body ? (JSON.parse(String(init.body)) as { data?: { key?: string }; options?: { cas?: number } }) : {}
|
|
30
|
+
const cas = body.options?.cas
|
|
31
|
+
if (cas === 0 && store.has(path)) {
|
|
32
|
+
return jsonResponse(400, { errors: ['check-and-set parameter did not match the current version'] })
|
|
33
|
+
}
|
|
34
|
+
store.set(path, body.data?.key ?? '')
|
|
35
|
+
counts.persisted++
|
|
36
|
+
return jsonResponse(200, { data: { version: 1 } })
|
|
37
|
+
})
|
|
38
|
+
return { store, counts, fetchMock }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function installFetch(fetchMock: jest.Mock) {
|
|
42
|
+
;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fixedKey = (fill: number) => Buffer.alloc(32, fill).toString('base64')
|
|
46
|
+
|
|
47
|
+
describe('HashicorpVaultKmsService DEK creation race (issue #2746)', () => {
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
process.env = { ...originalEnv }
|
|
50
|
+
jest.restoreAllMocks()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reuses an existing Vault DEK instead of overwriting it (read-before-write)', async () => {
|
|
54
|
+
const { store, counts, fetchMock } = makeVaultSim()
|
|
55
|
+
installFetch(fetchMock)
|
|
56
|
+
const service = new HashicorpVaultKmsService({ vaultAddr: 'http://vault.test', vaultToken: 'token' })
|
|
57
|
+
const existingKey = fixedKey(7)
|
|
58
|
+
store.set('secret/data/tenant_key_tenant-existing', existingKey)
|
|
59
|
+
|
|
60
|
+
const dek = await service.createTenantDek('tenant-existing')
|
|
61
|
+
|
|
62
|
+
expect(dek?.key).toBe(existingKey)
|
|
63
|
+
expect(counts.persisted).toBe(0) // never overwrote the active key
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('converges concurrent createTenantDek calls on a single key via CAS', async () => {
|
|
67
|
+
const { counts, fetchMock } = makeVaultSim()
|
|
68
|
+
installFetch(fetchMock)
|
|
69
|
+
const service = new HashicorpVaultKmsService({ vaultAddr: 'http://vault.test', vaultToken: 'token' })
|
|
70
|
+
|
|
71
|
+
const [a, b] = await Promise.all([
|
|
72
|
+
service.createTenantDek('tenant-cas'),
|
|
73
|
+
service.createTenantDek('tenant-cas'),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
expect(a?.key).toBeTruthy()
|
|
77
|
+
expect(a?.key).toBe(b?.key) // both adopt the same winning key — no orphaned rows
|
|
78
|
+
expect(counts.persisted).toBe(1) // exactly one key persisted, last-write-wins eliminated
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('invalidateDek forces a re-read from Vault', async () => {
|
|
82
|
+
const { store, counts, fetchMock } = makeVaultSim()
|
|
83
|
+
installFetch(fetchMock)
|
|
84
|
+
const service = new HashicorpVaultKmsService({ vaultAddr: 'http://vault.test', vaultToken: 'token' })
|
|
85
|
+
const path = 'secret/data/tenant_key_tenant-inv'
|
|
86
|
+
store.set(path, fixedKey(3))
|
|
87
|
+
|
|
88
|
+
const first = await service.getTenantDek('tenant-inv')
|
|
89
|
+
await service.getTenantDek('tenant-inv') // served from the KMS TTL cache
|
|
90
|
+
expect(counts.reads).toBe(1)
|
|
91
|
+
|
|
92
|
+
store.set(path, fixedKey(9)) // operator rotates the key in Vault
|
|
93
|
+
service.invalidateDek('tenant-inv')
|
|
94
|
+
const rotated = await service.getTenantDek('tenant-inv')
|
|
95
|
+
|
|
96
|
+
expect(counts.reads).toBe(2)
|
|
97
|
+
expect(rotated?.key).not.toBe(first?.key)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
|
|
102
|
+
const entityId = 'customers:person'
|
|
103
|
+
let tenantSeq = 0
|
|
104
|
+
const uniqueTenant = (label: string) => `tenant-${label}-2746-${tenantSeq++}`
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
jest.restoreAllMocks()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
function makeCreatingKms() {
|
|
111
|
+
const created: string[] = []
|
|
112
|
+
const kms: KmsService = {
|
|
113
|
+
isHealthy: () => true,
|
|
114
|
+
getTenantDek: jest.fn(async (): Promise<TenantDek | null> => null),
|
|
115
|
+
createTenantDek: jest.fn(async (tenantId: string): Promise<TenantDek | null> => {
|
|
116
|
+
const key = generateDek()
|
|
117
|
+
created.push(key)
|
|
118
|
+
return { tenantId, key, fetchedAt: Date.now() }
|
|
119
|
+
}),
|
|
120
|
+
}
|
|
121
|
+
return { kms, created }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makeFetchingKms() {
|
|
125
|
+
const kms: KmsService = {
|
|
126
|
+
isHealthy: () => true,
|
|
127
|
+
getTenantDek: jest.fn(async (tenantId: string): Promise<TenantDek | null> => ({
|
|
128
|
+
tenantId,
|
|
129
|
+
key: generateDek(),
|
|
130
|
+
fetchedAt: Date.now(),
|
|
131
|
+
})),
|
|
132
|
+
createTenantDek: jest.fn(async (): Promise<TenantDek | null> => null),
|
|
133
|
+
}
|
|
134
|
+
return { kms }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it('dedupes concurrent first-time DEK creation so no row is orphaned', async () => {
|
|
138
|
+
const { kms, created } = makeCreatingKms()
|
|
139
|
+
const service = new TenantDataEncryptionService({} as never, { kms })
|
|
140
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
141
|
+
;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
|
|
142
|
+
jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
|
|
143
|
+
const tenantId = uniqueTenant('race')
|
|
144
|
+
|
|
145
|
+
const rows = await Promise.all(
|
|
146
|
+
Array.from({ length: 12 }, (_, i) =>
|
|
147
|
+
service.encryptEntityPayload(entityId, { secret: `value-${i}` }, tenantId),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect((kms.createTenantDek as jest.Mock)).toHaveBeenCalledTimes(1)
|
|
152
|
+
expect(new Set(created).size).toBe(1)
|
|
153
|
+
|
|
154
|
+
const dek = await service.getDek(tenantId)
|
|
155
|
+
expect(dek).not.toBeNull()
|
|
156
|
+
rows.forEach((row, i) => {
|
|
157
|
+
expect(decryptWithAesGcm(String(row.secret), (dek as TenantDek).key)).toBe(`value-${i}`)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('re-fetches a cached DEK after the TTL elapses (no stale key after rotation)', async () => {
|
|
162
|
+
const { kms } = makeFetchingKms()
|
|
163
|
+
const service = new TenantDataEncryptionService({} as never, { kms })
|
|
164
|
+
const tenantId = uniqueTenant('ttl')
|
|
165
|
+
let now = 1_700_000_000_000
|
|
166
|
+
jest.spyOn(Date, 'now').mockImplementation(() => now)
|
|
167
|
+
|
|
168
|
+
await service.getDek(tenantId)
|
|
169
|
+
await service.getDek(tenantId)
|
|
170
|
+
expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(1) // cached within TTL
|
|
171
|
+
|
|
172
|
+
now += 15 * 60 * 1000 + 1
|
|
173
|
+
await service.getDek(tenantId)
|
|
174
|
+
expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(2) // expired → re-fetch
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('invalidateDek clears both the service cache and the KMS cache', async () => {
|
|
178
|
+
const { kms } = makeFetchingKms()
|
|
179
|
+
const kmsInvalidate = jest.fn()
|
|
180
|
+
;(kms as KmsService).invalidateDek = kmsInvalidate
|
|
181
|
+
const service = new TenantDataEncryptionService({} as never, { kms })
|
|
182
|
+
const tenantId = uniqueTenant('invalidate')
|
|
183
|
+
|
|
184
|
+
await service.getDek(tenantId)
|
|
185
|
+
await service.getDek(tenantId)
|
|
186
|
+
expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(1)
|
|
187
|
+
|
|
188
|
+
service.invalidateDek(tenantId)
|
|
189
|
+
expect(kmsInvalidate).toHaveBeenCalledWith(tenantId)
|
|
190
|
+
|
|
191
|
+
await service.getDek(tenantId)
|
|
192
|
+
expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(2)
|
|
193
|
+
})
|
|
194
|
+
})
|