@open-mercato/shared 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7195.1.b5efd1d51a

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 (58) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/ai/opencode-tool-parts.js +48 -0
  3. package/dist/lib/ai/opencode-tool-parts.js.map +7 -0
  4. package/dist/lib/ai/token-count.js +11 -0
  5. package/dist/lib/ai/token-count.js.map +7 -0
  6. package/dist/lib/bootstrap/dynamicLoader.js +14 -1
  7. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  8. package/dist/lib/commands/command-bus.js +9 -1
  9. package/dist/lib/commands/command-bus.js.map +2 -2
  10. package/dist/lib/commands/registry.js +9 -0
  11. package/dist/lib/commands/registry.js.map +2 -2
  12. package/dist/lib/commands/types.js.map +2 -2
  13. package/dist/lib/di/container.js +7 -10
  14. package/dist/lib/di/container.js.map +2 -2
  15. package/dist/lib/encryption/subscriber.js +22 -2
  16. package/dist/lib/encryption/subscriber.js.map +2 -2
  17. package/dist/lib/openapi/generator.js +3 -2
  18. package/dist/lib/openapi/generator.js.map +2 -2
  19. package/dist/lib/openapi/index.js +3 -2
  20. package/dist/lib/openapi/index.js.map +2 -2
  21. package/dist/lib/seed/crypto.js +73 -0
  22. package/dist/lib/seed/crypto.js.map +7 -0
  23. package/dist/lib/seed/index.js +4 -0
  24. package/dist/lib/seed/index.js.map +7 -0
  25. package/dist/lib/seed/loader.js +73 -0
  26. package/dist/lib/seed/loader.js.map +7 -0
  27. package/dist/lib/seed/types.js +33 -0
  28. package/dist/lib/seed/types.js.map +7 -0
  29. package/dist/lib/version.js +1 -1
  30. package/dist/lib/version.js.map +1 -1
  31. package/dist/modules/events/factory.js +28 -9
  32. package/dist/modules/events/factory.js.map +2 -2
  33. package/package.json +3 -2
  34. package/src/lib/ai/__tests__/opencode-tool-parts.test.ts +81 -0
  35. package/src/lib/ai/__tests__/token-count.test.ts +20 -0
  36. package/src/lib/ai/opencode-tool-parts.ts +80 -0
  37. package/src/lib/ai/token-count.ts +21 -0
  38. package/src/lib/bootstrap/dynamicLoader.ts +24 -1
  39. package/src/lib/commands/__tests__/command-bus.test.ts +64 -0
  40. package/src/lib/commands/__tests__/registry.test.ts +35 -0
  41. package/src/lib/commands/command-bus.ts +16 -1
  42. package/src/lib/commands/registry.ts +11 -0
  43. package/src/lib/commands/types.ts +31 -0
  44. package/src/lib/di/__tests__/bootstrap-cache.test.ts +59 -3
  45. package/src/lib/di/container.ts +19 -15
  46. package/src/lib/encryption/__tests__/subscriber.kms-recovery.test.ts +112 -0
  47. package/src/lib/encryption/subscriber.ts +28 -1
  48. package/src/lib/openapi/__tests__/generator-response-fallback.test.ts +73 -0
  49. package/src/lib/openapi/generator.ts +3 -3
  50. package/src/lib/openapi/index.ts +1 -1
  51. package/src/lib/seed/__tests__/seed-crypto.test.ts +64 -0
  52. package/src/lib/seed/crypto.ts +87 -0
  53. package/src/lib/seed/index.ts +3 -0
  54. package/src/lib/seed/loader.ts +124 -0
  55. package/src/lib/seed/types.ts +48 -0
  56. package/src/modules/events/__tests__/factory.test.ts +96 -0
  57. package/src/modules/events/factory.ts +41 -10
  58. package/src/modules/events/types.ts +44 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Normalizes an OpenCode `message.part.updated` part into a tool-call lifecycle
3
+ * update, shielding callers from OpenCode's wire schema.
4
+ *
5
+ * OpenCode (Go server) streams MCP tool invocations as parts of `type: 'tool'`
6
+ * carrying a `callID`, the `tool` name, and a `state` machine
7
+ * (`state.status: pending|running|completed|error`, `state.input`,
8
+ * `state.output`/`state.error`). The same part id is re-emitted on each state
9
+ * transition, so a tool call surfaces as one or more `progress` updates followed
10
+ * by a single `finish` once the state reaches a terminal status.
11
+ *
12
+ * Older OpenCode builds emitted Anthropic-style `tool_use` / `tool_result`
13
+ * blocks instead; those are still recognized as a fallback so a downgrade does
14
+ * not silently drop traces again.
15
+ *
16
+ * Returns `null` for any part that is not a tool invocation (text, thinking,
17
+ * step markers, …) so callers can ignore it.
18
+ */
19
+ export type OpenCodeToolPartUpdate =
20
+ | { phase: 'progress'; callId: string; toolName: string; input?: unknown }
21
+ | {
22
+ phase: 'finish'
23
+ callId: string
24
+ toolName?: string
25
+ input?: unknown
26
+ output?: unknown
27
+ status: 'ok' | 'error'
28
+ }
29
+
30
+ function asString(value: unknown): string | undefined {
31
+ return typeof value === 'string' && value.length > 0 ? value : undefined
32
+ }
33
+
34
+ function asRecord(value: unknown): Record<string, unknown> {
35
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
36
+ }
37
+
38
+ export function normalizeOpenCodeToolPart(rawPart: unknown): OpenCodeToolPartUpdate | null {
39
+ if (!rawPart || typeof rawPart !== 'object') return null
40
+ const part = rawPart as Record<string, unknown>
41
+ const type = asString(part.type)
42
+ if (!type) return null
43
+
44
+ // Native OpenCode tool part with a state machine.
45
+ if (type === 'tool') {
46
+ const callId = asString(part.callID) ?? asString(part.id)
47
+ const toolName = asString(part.tool)
48
+ if (!callId || !toolName) return null
49
+ const state = asRecord(part.state)
50
+ const status = asString(state.status)
51
+ const input = 'input' in state ? state.input : undefined
52
+ if (status === 'completed' || status === 'error') {
53
+ const output = status === 'error' ? state.error ?? state.output : state.output
54
+ return {
55
+ phase: 'finish',
56
+ callId,
57
+ toolName,
58
+ input,
59
+ output,
60
+ status: status === 'error' ? 'error' : 'ok',
61
+ }
62
+ }
63
+ return { phase: 'progress', callId, toolName, input }
64
+ }
65
+
66
+ // Legacy Anthropic-style parts (older OpenCode builds).
67
+ if (type === 'tool_use') {
68
+ const callId = asString(part.id)
69
+ const toolName = asString(part.name)
70
+ if (!callId || !toolName) return null
71
+ return { phase: 'progress', callId, toolName, input: part.input }
72
+ }
73
+ if (type === 'tool_result') {
74
+ const callId = asString(part.tool_use_id) ?? asString(part.id)
75
+ if (!callId) return null
76
+ return { phase: 'finish', callId, output: part.content, status: 'ok' }
77
+ }
78
+
79
+ return null
80
+ }
@@ -0,0 +1,21 @@
1
+ import { encode } from 'gpt-tokenizer/encoding/o200k_base'
2
+
3
+ /**
4
+ * Model-agnostic offline token estimate.
5
+ *
6
+ * Uses the `o200k_base` BPE encoding (GPT-4o / GPT-5 family) as a proxy. It is
7
+ * NOT exact for non-OpenAI models — notably Claude, whose tokenizer is not
8
+ * available offline — but it is deterministic, dependency-light, and a far
9
+ * closer estimate than a chars/4 heuristic. Treat the result as an estimate.
10
+ *
11
+ * Infrastructure only: this file knows nothing about any domain shape. Callers
12
+ * that need to break a structure down into elements assemble their own totals
13
+ * on top of this primitive.
14
+ */
15
+ export function countTokens(text: string | null | undefined): number {
16
+ if (!text) return 0
17
+ return encode(text).length
18
+ }
19
+
20
+ /** The BPE encoding backing {@link countTokens}, surfaced so callers can label estimates. */
21
+ export const TOKEN_ENCODING = 'o200k_base' as const
@@ -1,3 +1,4 @@
1
+ import { asValue } from 'awilix'
1
2
  import type { BootstrapData } from './types'
2
3
  import type { AppDiRegistrar } from '../di/container'
3
4
  import { findAppRoot, type AppRoot } from './appResolver'
@@ -497,6 +498,21 @@ async function compileAndImport(
497
498
  }
498
499
 
499
500
 
501
+ /**
502
+ * Registers an app-owned generated value on the request container.
503
+ *
504
+ * The app registers these statically from `src/di.ts`, which `createRequestContainer`
505
+ * reaches through the `@/` alias — and that alias only exists under the bundler.
506
+ * A CLI or MCP process runs plain Node, so the import fails, the failure is
507
+ * swallowed, and the value is simply absent with no diagnostic. Routing it through
508
+ * a registrar built from the same generated file keeps both processes in step.
509
+ */
510
+ function appValueRegistrar(key: string, value: unknown): BootstrapData['diRegistrars'][number] {
511
+ return (container) => {
512
+ container.register({ [key]: asValue(value) })
513
+ }
514
+ }
515
+
500
516
  /**
501
517
  * Load a generated registry that older apps may not have generated yet.
502
518
  *
@@ -716,6 +732,7 @@ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<Boo
716
732
  diModule,
717
733
  searchModule,
718
734
  commandLoadersModule,
735
+ webResearchModule,
719
736
  commandInterceptorsModule,
720
737
  workflowsModule,
721
738
  ] = await Promise.all([
@@ -724,6 +741,9 @@ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<Boo
724
741
  compileAndImport(path.join(generatedDir, 'di.generated.ts')),
725
742
  loadOptionalGeneratedModule(path.join(generatedDir, 'search.generated.ts'), { searchModuleConfigs: [] }),
726
743
  loadOptionalGeneratedModule(path.join(generatedDir, 'command-loaders.generated.ts'), { commandLoaderEntries: [] }),
744
+ loadOptionalGeneratedModule(path.join(generatedDir, 'web-research-adapters.generated.ts'), {
745
+ webResearchAdapterEntries: [],
746
+ }),
727
747
  loadOptionalGeneratedModule(path.join(generatedDir, 'command-interceptors.generated.ts'), {
728
748
  commandInterceptorEntries: [],
729
749
  }),
@@ -733,7 +753,10 @@ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<Boo
733
753
  return {
734
754
  modules: modulesModule.modules as BootstrapData['modules'],
735
755
  entities: entitiesModule.entities as BootstrapData['entities'],
736
- diRegistrars: diModule.diRegistrars as BootstrapData['diRegistrars'],
756
+ diRegistrars: [
757
+ ...(diModule.diRegistrars as BootstrapData['diRegistrars']),
758
+ appValueRegistrar('webResearchAdapterEntries', webResearchModule.webResearchAdapterEntries ?? []),
759
+ ],
737
760
  entityIds: entityIdsModule.E as BootstrapData['entityIds'],
738
761
  // Search configs are needed by workers for indexing
739
762
  searchModuleConfigs: (searchModule.searchModuleConfigs ?? []) as BootstrapData['searchModuleConfigs'],
@@ -298,6 +298,70 @@ describe('CommandBus', () => {
298
298
  )
299
299
  })
300
300
 
301
+ // Agent Identity & On-Behalf-Of (Wave 4 P2): when ctx.runAs is set the SAME
302
+ // audit path attributes the write to the agent principal on behalf of the human,
303
+ // sourced 'agent' — not a parallel audit route.
304
+ it('stamps actorUserId=agent + onBehalfOfUserId=human + source=agent when ctx.runAs is set', async () => {
305
+ const logMock = jest.fn(async () => ({ id: 'log-runas' }))
306
+ registerCommand({
307
+ id: 'test.command.runas',
308
+ execute: jest.fn(async () => ({ ok: true })),
309
+ buildLog: jest.fn(() => ({ actionLabel: 'Agent write', resourceKind: 'deal', resourceId: 'deal-9' })),
310
+ })
311
+
312
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
313
+ container.register({ actionLogService: asValue({ log: logMock }) })
314
+
315
+ const bus = new CommandBus()
316
+ const ctx = {
317
+ container,
318
+ // The invoking human still carries the JWT auth, but runAs overrides the actor.
319
+ auth: { sub: 'human-1', tenantId: 'tenant-1', orgId: 'org-1' },
320
+ organizationScope: null,
321
+ selectedOrganizationId: 'org-1',
322
+ organizationIds: ['org-1'],
323
+ runAs: { actorUserId: 'agent-user-1', onBehalfOfUserId: 'human-1', source: 'agent' as const },
324
+ }
325
+
326
+ await bus.execute('test.command.runas', { input: {}, ctx })
327
+
328
+ expect(logMock).toHaveBeenCalledWith(
329
+ expect.objectContaining({
330
+ commandId: 'test.command.runas',
331
+ actorUserId: 'agent-user-1',
332
+ onBehalfOfUserId: 'human-1',
333
+ context: expect.objectContaining({ source: 'agent' }),
334
+ })
335
+ )
336
+ })
337
+
338
+ it('does not set onBehalfOfUserId for ordinary (non-runAs) human writes — additive default', async () => {
339
+ const logMock = jest.fn(async () => ({ id: 'log-plain' }))
340
+ registerCommand({
341
+ id: 'test.command',
342
+ execute: jest.fn(async () => ({ ok: true })),
343
+ buildLog: jest.fn(() => ({ actionLabel: 'Plain', resourceKind: 'test', resourceId: '7' })),
344
+ })
345
+
346
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
347
+ container.register({ actionLogService: asValue({ log: logMock }) })
348
+
349
+ const bus = new CommandBus()
350
+ const ctx = {
351
+ container,
352
+ auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
353
+ organizationScope: null,
354
+ selectedOrganizationId: null,
355
+ organizationIds: null,
356
+ }
357
+
358
+ await bus.execute('test.command', { input: {}, ctx })
359
+
360
+ const payload = logMock.mock.calls[0][0] as Record<string, unknown>
361
+ expect(payload.actorUserId).toBe('user-1')
362
+ expect(payload.onBehalfOfUserId).toBeUndefined()
363
+ })
364
+
301
365
  describe('interceptor rejections', () => {
302
366
  const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
303
367
  id: 'test.block',
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod'
1
2
  import { commandRegistry, registerCommand, registerCommandLoaders } from '@open-mercato/shared/lib/commands'
2
3
  import { createLogger } from '@open-mercato/shared/lib/logger'
3
4
 
@@ -104,6 +105,40 @@ describe('command registry registration', () => {
104
105
  expect(commandRegistry.list()).not.toContain('test:commands:fallback')
105
106
  })
106
107
 
108
+ it('returns the outputSchema for a registered handler that declares one and null otherwise', () => {
109
+ const outputSchema = z.object({ dealId: z.string().uuid() })
110
+
111
+ registerCommand({
112
+ id: 'test.command.with-output',
113
+ execute: jest.fn(),
114
+ outputSchema,
115
+ })
116
+ registerCommand({
117
+ id: 'test.command.without-output',
118
+ execute: jest.fn(),
119
+ })
120
+
121
+ expect(commandRegistry.outputSchemaOf('test.command.with-output')).toBe(outputSchema)
122
+ expect(commandRegistry.outputSchemaOf('test.command.without-output')).toBeNull()
123
+ expect(commandRegistry.outputSchemaOf('test.command.never-registered')).toBeNull()
124
+ })
125
+
126
+ it('does not trigger lazy loaders when resolving output schemas', () => {
127
+ const load = jest.fn(async () => {})
128
+
129
+ registerCommandLoaders([
130
+ {
131
+ moduleId: 'test',
132
+ id: 'test.command.lazy-output',
133
+ key: 'test:commands:lazy-output',
134
+ load,
135
+ },
136
+ ])
137
+
138
+ expect(commandRegistry.outputSchemaOf('test.command.lazy-output')).toBeNull()
139
+ expect(load).not.toHaveBeenCalled()
140
+ })
141
+
107
142
  it('loads sibling module command files with an exact lazy command', async () => {
108
143
  registerCommandLoaders([
109
144
  {
@@ -543,6 +543,7 @@ export class CommandBus {
543
543
  tenantId: secondary?.tenantId ?? primary?.tenantId ?? null,
544
544
  organizationId: secondary?.organizationId ?? primary?.organizationId ?? null,
545
545
  actorUserId: secondary?.actorUserId ?? primary?.actorUserId ?? null,
546
+ onBehalfOfUserId: secondary?.onBehalfOfUserId ?? primary?.onBehalfOfUserId ?? null,
546
547
  actionLabel: secondary?.actionLabel ?? primary?.actionLabel ?? null,
547
548
  resourceKind: secondary?.resourceKind ?? primary?.resourceKind ?? null,
548
549
  resourceId: secondary?.resourceId ?? primary?.resourceId ?? null,
@@ -582,7 +583,13 @@ export class CommandBus {
582
583
  const tenantId = metadata.tenantId ?? options.ctx.auth?.tenantId ?? null
583
584
  const organizationId =
584
585
  metadata.organizationId ?? options.ctx.selectedOrganizationId ?? options.ctx.auth?.orgId ?? null
585
- const actorUserId = metadata.actorUserId ?? options.ctx.auth?.sub ?? null
586
+ // On-behalf-of attribution (Wave 4 P2): when `ctx.runAs` is set the actor is
587
+ // the agent principal and the human it acts for is recorded separately. This
588
+ // funnels agent writes through the SAME ActionLog path as a human's — only the
589
+ // attribution differs (actorUserId=agent, onBehalfOfUserId=human, source='agent').
590
+ const runAs = options.ctx.runAs ?? null
591
+ const actorUserId = runAs?.actorUserId ?? metadata.actorUserId ?? options.ctx.auth?.sub ?? null
592
+ const onBehalfOfUserId = runAs ? (runAs.onBehalfOfUserId ?? null) : (metadata.onBehalfOfUserId ?? null)
586
593
  const systemActorContext = !actorUserId && options.ctx.systemActor === true
587
594
  ? { systemActor: 'system:command' }
588
595
  : null
@@ -590,6 +597,7 @@ export class CommandBus {
590
597
  tenantId: tenantId ?? undefined,
591
598
  organizationId: organizationId ?? undefined,
592
599
  actorUserId: actorUserId ?? undefined,
600
+ onBehalfOfUserId: onBehalfOfUserId ?? undefined,
593
601
  commandId,
594
602
  }
595
603
 
@@ -613,6 +621,13 @@ export class CommandBus {
613
621
  }
614
622
  }
615
623
 
624
+ if (runAs) {
625
+ // Stamp the audit source so `deriveActionLogSource` projects `sourceKey='agent'`.
626
+ // Merge into any caller-provided context rather than replacing it.
627
+ const baseContext = asRecord(payload.context) ?? {}
628
+ payload.context = { ...baseContext, source: runAs.source }
629
+ }
630
+
616
631
  const redoEnvelope = wrapRedoPayload('commandPayload' in payload ? (payload.commandPayload as unknown) : undefined, options.input)
617
632
  payload.commandPayload = redoEnvelope
618
633
 
@@ -1,3 +1,4 @@
1
+ import type { ZodTypeAny } from 'zod'
1
2
  import type { CommandHandler } from './types'
2
3
  import { createLogger } from '../logger'
3
4
 
@@ -78,6 +79,16 @@ class CommandRegistry {
78
79
  return this.handlers.has(id) || this.loadersById.has(id)
79
80
  }
80
81
 
82
+ /**
83
+ * Returns the `outputSchema` declared by an already-registered handler, or
84
+ * `null` when the handler declares none. Sync over registered handlers only:
85
+ * it never triggers lazy loaders, so a handler that is known but not yet
86
+ * loaded also yields `null` — call `load(id)` first when that matters.
87
+ */
88
+ outputSchemaOf(id: string): ZodTypeAny | null {
89
+ return this.get(id)?.outputSchema ?? null
90
+ }
91
+
81
92
  /**
82
93
  * List all known command IDs, including exact lazy loaders that have not
83
94
  * been imported yet.
@@ -1,5 +1,6 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { ZodTypeAny } from 'zod'
3
4
  import { randomUUID } from 'crypto'
4
5
  import type { AuthContext } from '../auth/server'
5
6
  import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
@@ -57,6 +58,28 @@ export type CommandRuntimeContext = {
57
58
  * surrounding work as a single atomic, single-locked operation.
58
59
  */
59
60
  transactionalEm?: EntityManager
61
+ /**
62
+ * On-behalf-of attribution for non-human principals (Agent Identity &
63
+ * On-Behalf-Of, Wave 4 P2). When an agent runs on behalf of a human, the
64
+ * orchestrator's `runAs` wrapper sets this so every `ActionLog` the command
65
+ * path writes records `actorUserId = runAs.actorUserId` (the agent principal's
66
+ * `auth.User` id), `onBehalfOfUserId = runAs.onBehalfOfUserId` (the invoking
67
+ * human, or null for system-invoked agents), and `sourceKey = runAs.source`
68
+ * (`'agent'`). Additive + optional: callers that omit it keep the existing
69
+ * `ctx.auth.sub`-derived attribution unchanged. This threads agent attribution
70
+ * through the SAME audited Command/CRUD path as a human action — not a parallel
71
+ * audit path.
72
+ */
73
+ runAs?: CommandRunAsContext
74
+ }
75
+
76
+ export type CommandRunAsContext = {
77
+ /** The actor stamped on every ActionLog this context produces (agent `auth.User` id). */
78
+ actorUserId: string
79
+ /** The human (or system) principal the actor acts on behalf of; null when system-invoked. */
80
+ onBehalfOfUserId?: string | null
81
+ /** The audit source key for the attributed writes; `'agent'` for agent runs. */
82
+ source: 'agent'
60
83
  }
61
84
 
62
85
  export type CommandLogMetadata = {
@@ -64,6 +87,7 @@ export type CommandLogMetadata = {
64
87
  tenantId?: string | null
65
88
  organizationId?: string | null
66
89
  actorUserId?: string | null
90
+ onBehalfOfUserId?: string | null
67
91
  actionLabel?: string | null
68
92
  resourceKind?: string | null
69
93
  resourceId?: string | null
@@ -129,6 +153,13 @@ export type CommandLogBuilderArgs<TInput, TResult> = {
129
153
  export interface CommandHandler<TInput = unknown, TResult = unknown> {
130
154
  readonly id: string
131
155
  readonly isUndoable?: boolean
156
+ /**
157
+ * Optional Zod schema describing the command's return value. Feeds the
158
+ * workflows context ledger so downstream activities can reason about the
159
+ * shape a command produces; when absent the ledger renders the output as
160
+ * unknown.
161
+ */
162
+ readonly outputSchema?: ZodTypeAny
132
163
  prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null
133
164
  execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
134
165
  buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
@@ -210,7 +210,7 @@ describe('bootstrap once-guard cache', () => {
210
210
  expect(bootstrapMock).toHaveBeenCalledTimes(2)
211
211
  })
212
212
 
213
- it('memoizes tenantEncryptionService.isEnabled() across requests', async () => {
213
+ it('registers the encryption subscriber per request without consulting tenantEncryptionService.isEnabled()', async () => {
214
214
  process.env.OM_BOOTSTRAP_CACHE = '1'
215
215
  let isEnabledCalls = 0
216
216
  bootstrapMock.mockImplementationOnce(async (container: any) => {
@@ -229,8 +229,64 @@ describe('bootstrap once-guard cache', () => {
229
229
  await createRequestContainer()
230
230
  await createRequestContainer()
231
231
  await createRequestContainer()
232
- // Called once during the first bootstrap, then cached on globalThis.
233
- expect(isEnabledCalls).toBe(1)
232
+ // The registration decision reads the static config toggle (memoized on
233
+ // globalThis), never the service's health-sensitive isEnabled() — see #5948.
234
+ expect(isEnabledCalls).toBe(0)
234
235
  expect(subscriberRegistered).toHaveBeenCalledTimes(3)
235
236
  })
237
+
238
+ // Regression for issue #5948: `isEnabled()` is `config && kms.isHealthy()`, so
239
+ // memoizing it for the process lifetime pinned a transient Vault outage into a
240
+ // permanent "encryption off" verdict — the subscriber was never registered
241
+ // again on any later request, and every ORM write stayed plaintext until the
242
+ // process restarted.
243
+ it('keeps registering the encryption subscriber while the KMS is unhealthy', async () => {
244
+ process.env.OM_BOOTSTRAP_CACHE = '1'
245
+ bootstrapMock.mockImplementationOnce(async (container: any) => {
246
+ container.register({
247
+ cache: asValue({ __value: 'cache-value' }),
248
+ eventBus: asValue({ __value: 'event-bus-value' }),
249
+ // KMS is down for the whole run: isEnabled() never returns true.
250
+ tenantEncryptionService: asValue({ isEnabled: () => false }),
251
+ })
252
+ })
253
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
254
+ await createRequestContainer()
255
+ await createRequestContainer()
256
+
257
+ // Registration must not depend on live KMS health — the subscriber itself
258
+ // re-checks it per read/write, so it resumes encrypting on recovery.
259
+ expect(subscriberRegistered).toHaveBeenCalledTimes(2)
260
+ })
261
+
262
+ it('skips registration when the encryption service cannot report its enabled state', async () => {
263
+ process.env.OM_BOOTSTRAP_CACHE = '1'
264
+ bootstrapMock.mockImplementationOnce(async (container: any) => {
265
+ container.register({
266
+ cache: asValue({ __value: 'cache-value' }),
267
+ eventBus: asValue({ __value: 'event-bus-value' }),
268
+ // A DI override supplying a partial service: the subscriber would throw
269
+ // on every read/write calling isEnabled(), so it must not be registered.
270
+ tenantEncryptionService: asValue({ __value: 'no-isEnabled' }),
271
+ })
272
+ })
273
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
274
+ await createRequestContainer()
275
+ expect(subscriberRegistered).not.toHaveBeenCalled()
276
+ })
277
+
278
+ it('does not register the encryption subscriber when encryption is disabled by config', async () => {
279
+ process.env.OM_BOOTSTRAP_CACHE = '1'
280
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
281
+ process.env.TENANT_DATA_ENCRYPTION = 'false'
282
+ try {
283
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
284
+ await createRequestContainer()
285
+ await createRequestContainer()
286
+ expect(subscriberRegistered).not.toHaveBeenCalled()
287
+ } finally {
288
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
289
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
290
+ }
291
+ })
236
292
  })
@@ -9,6 +9,7 @@ import { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overri
9
9
  import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
10
10
  import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
11
11
  import { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
12
+ import { isTenantDataEncryptionEnabled } from '../encryption/toggles'
12
13
  import { createLogger } from '../logger'
13
14
 
14
15
  const logger = createLogger('shared').child({ component: 'di' })
@@ -93,19 +94,18 @@ function harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry
93
94
  return entry
94
95
  }
95
96
 
96
- type EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined
97
-
98
- function getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {
99
- if (!service || typeof service.isEnabled !== 'function') return false
97
+ // Caches the STATIC config toggle only. It deliberately does not consult
98
+ // `tenantEncryptionService.isEnabled()`, which ANDs the toggle with the
99
+ // volatile `kms.isHealthy()` — memoizing that for the process lifetime pinned a
100
+ // transient Vault outage into a permanent "encryption off" verdict, so the
101
+ // subscriber was never registered again until a restart (#5948). KMS health is
102
+ // re-checked by the subscriber on every read/write instead.
103
+ function getCachedEncryptionConfigured(): boolean {
100
104
  const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]
101
105
  if (typeof cached === 'boolean') return cached
102
- try {
103
- const result = !!service.isEnabled()
104
- ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result
105
- return result
106
- } catch {
107
- return null
108
- }
106
+ const result = isTenantDataEncryptionEnabled()
107
+ ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result
108
+ return result
109
109
  }
110
110
 
111
111
  function getGlobalRegistrars(): DiRegistrar[] | null {
@@ -319,15 +319,19 @@ export async function createRequestContainer(): Promise<AppContainer> {
319
319
  unregister: (key) => container.register({ [key]: asValue(undefined) }),
320
320
  })
321
321
  // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM
322
- // Phase 5 — cache `tenantEncryptionService.isEnabled()` for the process
323
- // lifetime. The result depends only on config that does not change at
324
- // runtime, so reading it once skips a config lookup per request.
322
+ // Phase 5 — cache the tenant-encryption config toggle for the process
323
+ // lifetime. That toggle does not change at runtime, so reading it once skips
324
+ // a config lookup per request; KMS health is NOT part of this decision.
325
325
  try {
326
326
  const emForEnc = container.resolve('em') as any
327
327
  const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')
328
328
  ? (container.resolve('tenantEncryptionService') as any)
329
329
  : null
330
- if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {
330
+ // The subscriber calls `service.isEnabled()` on every read/write, so a DI
331
+ // override supplying a service without it would throw per operation. Keep
332
+ // the shape check that the old health-probe helper performed.
333
+ const serviceCanReportEnabled = typeof tenantEncryptionService?.isEnabled === 'function'
334
+ if (emForEnc && serviceCanReportEnabled && getCachedEncryptionConfigured()) {
331
335
  const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')
332
336
  registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)
333
337
  }
@@ -0,0 +1,112 @@
1
+ // Regression coverage for issue #5948: registration of the tenant-encryption ORM
2
+ // subscriber used to be gated on `kmsService.isHealthy()`, evaluated exactly once
3
+ // at bootstrap. A KMS outage overlapping process startup therefore left the
4
+ // subscriber unregistered — and at-rest encryption off — for the whole process
5
+ // lifetime, recoverable only by a restart.
6
+ //
7
+ // The fix registers on the static config toggle alone and leans on the
8
+ // subscriber's own per-operation `service.isEnabled()` check. These tests pin
9
+ // that contract: a subscriber registered while the KMS is down must start
10
+ // encrypting the moment the KMS recovers, with no re-registration, and must
11
+ // leave an ongoing (throttled) signal while it is failing open.
12
+
13
+ const warn = jest.fn()
14
+
15
+ jest.mock('../../logger', () => {
16
+ const logger: Record<string, unknown> = {
17
+ debug: jest.fn(),
18
+ info: jest.fn(),
19
+ warn: (...args: unknown[]) => warn(...args),
20
+ error: jest.fn(),
21
+ }
22
+ logger.child = () => logger
23
+ return { createLogger: () => logger }
24
+ })
25
+
26
+ import { TenantEncryptionSubscriber, resetEncryptionPausedWarnThrottle } from '../subscriber'
27
+ import { registerEntityIds } from '../entityIds'
28
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
29
+
30
+ const META = {
31
+ className: 'Thing',
32
+ tableName: 'things',
33
+ properties: { secret: { name: 'secret', fieldName: 'secret' } },
34
+ } as any
35
+
36
+ function makeEm() {
37
+ return { getComparator: () => undefined, getMetadata: () => undefined }
38
+ }
39
+
40
+ // Mirrors the production service: `isEnabled()` ANDs the static config toggle
41
+ // with the volatile KMS health flag, so it flips as Vault goes down and recovers.
42
+ function makeService(kms: { healthy: boolean }): TenantDataEncryptionService {
43
+ return {
44
+ isEnabled: () => kms.healthy,
45
+ async encryptEntityPayload(_entityId: string, target: Record<string, unknown>) {
46
+ return { secret: `enc:${String(target.secret)}` }
47
+ },
48
+ async decryptEntityPayload() {
49
+ return {}
50
+ },
51
+ } as unknown as TenantDataEncryptionService
52
+ }
53
+
54
+ describe('TenantEncryptionSubscriber KMS recovery (issue #5948)', () => {
55
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
56
+
57
+ beforeEach(() => {
58
+ delete process.env.TENANT_DATA_ENCRYPTION // default => encryption enabled
59
+ registerEntityIds({ test: { thing: 'test:thing' } })
60
+ resetEncryptionPausedWarnThrottle()
61
+ warn.mockClear()
62
+ })
63
+
64
+ afterEach(() => {
65
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
66
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
67
+ jest.restoreAllMocks()
68
+ })
69
+
70
+ async function create(subscriber: TenantEncryptionSubscriber, entity: Record<string, unknown>) {
71
+ await subscriber.beforeCreate({ entity, meta: META, em: makeEm() } as any)
72
+ }
73
+
74
+ it('resumes encrypting after the KMS recovers, without re-registering the subscriber', async () => {
75
+ const kms = { healthy: false }
76
+ // The single subscriber instance a boot-time registration would have produced.
77
+ const subscriber = new TenantEncryptionSubscriber(makeService(kms))
78
+
79
+ const duringOutage: Record<string, unknown> = { tenantId: 't1', secret: 'plain' }
80
+ await create(subscriber, duringOutage)
81
+ expect(duringOutage.secret).toBe('plain')
82
+
83
+ // Vault comes back — the very same subscriber instance must start encrypting.
84
+ kms.healthy = true
85
+ const afterRecovery: Record<string, unknown> = { tenantId: 't1', secret: 'plain' }
86
+ await create(subscriber, afterRecovery)
87
+ expect(afterRecovery.secret).toBe('enc:plain')
88
+ })
89
+
90
+ it('warns while writes are failing open to plaintext, throttled to one line per window', async () => {
91
+ const subscriber = new TenantEncryptionSubscriber(makeService({ healthy: false }))
92
+
93
+ await create(subscriber, { tenantId: 't1', secret: 'a' })
94
+ await create(subscriber, { tenantId: 't1', secret: 'b' })
95
+ await create(subscriber, { tenantId: 't1', secret: 'c' })
96
+
97
+ expect(warn).toHaveBeenCalledTimes(1)
98
+ expect(warn.mock.calls[0][0]).toMatch(/KMS is unavailable/)
99
+ expect(warn.mock.calls[0][1]).toEqual({ entity: 'Thing' })
100
+ })
101
+
102
+ it('stays silent when encryption is deliberately disabled by config', async () => {
103
+ process.env.TENANT_DATA_ENCRYPTION = 'false'
104
+ const subscriber = new TenantEncryptionSubscriber(makeService({ healthy: false }))
105
+
106
+ const entity: Record<string, unknown> = { tenantId: 't1', secret: 'plain' }
107
+ await create(subscriber, entity)
108
+
109
+ expect(entity.secret).toBe('plain')
110
+ expect(warn).not.toHaveBeenCalled()
111
+ })
112
+ })