@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/ai/opencode-tool-parts.js +48 -0
- package/dist/lib/ai/opencode-tool-parts.js.map +7 -0
- package/dist/lib/ai/token-count.js +11 -0
- package/dist/lib/ai/token-count.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +14 -1
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/registry.js +9 -0
- package/dist/lib/commands/registry.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/di/container.js +7 -10
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +22 -2
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/openapi/generator.js +3 -2
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/openapi/index.js +3 -2
- package/dist/lib/openapi/index.js.map +2 -2
- package/dist/lib/seed/crypto.js +73 -0
- package/dist/lib/seed/crypto.js.map +7 -0
- package/dist/lib/seed/index.js +4 -0
- package/dist/lib/seed/index.js.map +7 -0
- package/dist/lib/seed/loader.js +73 -0
- package/dist/lib/seed/loader.js.map +7 -0
- package/dist/lib/seed/types.js +33 -0
- package/dist/lib/seed/types.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +28 -9
- package/dist/modules/events/factory.js.map +2 -2
- package/package.json +3 -2
- package/src/lib/ai/__tests__/opencode-tool-parts.test.ts +81 -0
- package/src/lib/ai/__tests__/token-count.test.ts +20 -0
- package/src/lib/ai/opencode-tool-parts.ts +80 -0
- package/src/lib/ai/token-count.ts +21 -0
- package/src/lib/bootstrap/dynamicLoader.ts +24 -1
- package/src/lib/commands/__tests__/command-bus.test.ts +64 -0
- package/src/lib/commands/__tests__/registry.test.ts +35 -0
- package/src/lib/commands/command-bus.ts +16 -1
- package/src/lib/commands/registry.ts +11 -0
- package/src/lib/commands/types.ts +31 -0
- package/src/lib/di/__tests__/bootstrap-cache.test.ts +59 -3
- package/src/lib/di/container.ts +19 -15
- package/src/lib/encryption/__tests__/subscriber.kms-recovery.test.ts +112 -0
- package/src/lib/encryption/subscriber.ts +28 -1
- package/src/lib/openapi/__tests__/generator-response-fallback.test.ts +73 -0
- package/src/lib/openapi/generator.ts +3 -3
- package/src/lib/openapi/index.ts +1 -1
- package/src/lib/seed/__tests__/seed-crypto.test.ts +64 -0
- package/src/lib/seed/crypto.ts +87 -0
- package/src/lib/seed/index.ts +3 -0
- package/src/lib/seed/loader.ts +124 -0
- package/src/lib/seed/types.ts +48 -0
- package/src/modules/events/__tests__/factory.test.ts +96 -0
- package/src/modules/events/factory.ts +41 -10
- 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:
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
233
|
-
|
|
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
|
})
|
package/src/lib/di/container.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
323
|
-
// lifetime.
|
|
324
|
-
//
|
|
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
|
-
|
|
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
|
+
})
|