@open-mercato/shared 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82
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/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/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
|
@@ -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
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { buildOpenApiDocument } from '../generator'
|
|
3
|
+
import type { Module } from '../../../modules/registry'
|
|
4
|
+
|
|
5
|
+
type SchemaNode = {
|
|
6
|
+
type?: string
|
|
7
|
+
description?: string
|
|
8
|
+
properties?: Record<string, SchemaNode>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type OperationNode = {
|
|
12
|
+
responses?: Record<string, { content?: Record<string, { schema?: SchemaNode }> }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const noopHandler = async () => new Response(null)
|
|
16
|
+
|
|
17
|
+
function makeModules(): Module[] {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: 'example',
|
|
21
|
+
apis: [
|
|
22
|
+
{
|
|
23
|
+
path: '/example/undeclared',
|
|
24
|
+
handlers: { GET: noopHandler },
|
|
25
|
+
docs: {
|
|
26
|
+
methods: {
|
|
27
|
+
GET: {
|
|
28
|
+
summary: 'Undeclared schema',
|
|
29
|
+
responses: [{ status: 200, description: 'Success' }],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: '/example/declared',
|
|
36
|
+
handlers: { GET: noopHandler },
|
|
37
|
+
docs: {
|
|
38
|
+
methods: {
|
|
39
|
+
GET: {
|
|
40
|
+
summary: 'Declared schema',
|
|
41
|
+
responses: [
|
|
42
|
+
{ status: 200, description: 'Success', schema: z.object({ ok: z.boolean() }) },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
} as unknown as Module,
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function responseSchema(doc: ReturnType<typeof buildOpenApiDocument>, path: string): SchemaNode | undefined {
|
|
54
|
+
const pathItem = doc.paths[path] as Record<string, OperationNode> | undefined
|
|
55
|
+
return pathItem?.get?.responses?.['200']?.content?.['application/json']?.schema
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('buildOpenApiDocument response schema fallback', () => {
|
|
59
|
+
it('marks responses without a declared schema with an advisory description', () => {
|
|
60
|
+
const doc = buildOpenApiDocument(makeModules())
|
|
61
|
+
expect(responseSchema(doc, '/example/undeclared')).toEqual({
|
|
62
|
+
type: 'object',
|
|
63
|
+
description: 'Schema not declared',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('leaves declared schemas untouched', () => {
|
|
68
|
+
const doc = buildOpenApiDocument(makeModules())
|
|
69
|
+
const schema = responseSchema(doc, '/example/declared')
|
|
70
|
+
expect(schema?.description).toBeUndefined()
|
|
71
|
+
expect(schema?.properties?.ok?.type).toBe('boolean')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -17,7 +17,7 @@ type PathParamInfo = {
|
|
|
17
17
|
|
|
18
18
|
type ParameterLocation = 'query' | 'path' | 'header'
|
|
19
19
|
|
|
20
|
-
type JsonSchema = Record<string, unknown>
|
|
20
|
+
export type JsonSchema = Record<string, unknown>
|
|
21
21
|
|
|
22
22
|
type SchemaConversionContext = {
|
|
23
23
|
memo: WeakMap<ZodTypeAny, JsonSchema>
|
|
@@ -258,7 +258,7 @@ function extractZodDescription(schema?: ZodTypeAny): string | undefined {
|
|
|
258
258
|
return undefined
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
function zodToJsonSchema(schema?: ZodTypeAny, ctx?: SchemaConversionContext): JsonSchema | undefined {
|
|
261
|
+
export function zodToJsonSchema(schema?: ZodTypeAny, ctx?: SchemaConversionContext): JsonSchema | undefined {
|
|
262
262
|
if (!schema) return undefined
|
|
263
263
|
const context: SchemaConversionContext = ctx ?? { memo: new WeakMap<ZodTypeAny, JsonSchema>() }
|
|
264
264
|
|
|
@@ -823,7 +823,7 @@ function buildResponses(
|
|
|
823
823
|
: {
|
|
824
824
|
content: {
|
|
825
825
|
[mediaType]: {
|
|
826
|
-
schema: schema ?? { type: 'object' },
|
|
826
|
+
schema: schema ?? { type: 'object', description: 'Schema not declared' },
|
|
827
827
|
...(example !== undefined ? { example } : {}),
|
|
828
828
|
},
|
|
829
829
|
},
|
package/src/lib/openapi/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from './types'
|
|
2
|
-
export { buildOpenApiDocument, generateMarkdownFromOpenApi } from './generator'
|
|
2
|
+
export { buildOpenApiDocument, generateMarkdownFromOpenApi, zodToJsonSchema, type JsonSchema } from './generator'
|
|
3
3
|
export * from './crud'
|
|
4
4
|
export { sanitizeOpenApiDocument } from './sanitize'
|
|
5
5
|
export { attachOpenApiDocsToModules } from './attach-docs'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decryptSeedEnvelope,
|
|
3
|
+
encryptSeedDocument,
|
|
4
|
+
generateSeedKey,
|
|
5
|
+
resolveSeedKey,
|
|
6
|
+
SEED_KEY_ENV,
|
|
7
|
+
} from '../crypto'
|
|
8
|
+
import { encryptedSeedEnvelopeSchema, type SeedDocument } from '../types'
|
|
9
|
+
|
|
10
|
+
const sampleDocument: SeedDocument = {
|
|
11
|
+
format: 'om-seed',
|
|
12
|
+
version: 1,
|
|
13
|
+
records: [
|
|
14
|
+
{ entity: 'customers:customer_entity', match: ['id'], data: { id: 'abc', displayName: 'Jane Doe' } },
|
|
15
|
+
{ entity: 'customers:customer_address', data: { name: 'HQ', city: 'Warsaw' } },
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('seed crypto', () => {
|
|
20
|
+
const originalEnv = process.env[SEED_KEY_ENV]
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (originalEnv === undefined) delete process.env[SEED_KEY_ENV]
|
|
23
|
+
else process.env[SEED_KEY_ENV] = originalEnv
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('generates a 32-byte base64 key', () => {
|
|
27
|
+
const key = generateSeedKey()
|
|
28
|
+
expect(Buffer.from(key, 'base64')).toHaveLength(32)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('round-trips a document through encrypt/decrypt', () => {
|
|
32
|
+
const key = generateSeedKey()
|
|
33
|
+
const envelope = encryptSeedDocument(sampleDocument, key)
|
|
34
|
+
expect(() => encryptedSeedEnvelopeSchema.parse(envelope)).not.toThrow()
|
|
35
|
+
// The envelope must not leak plaintext.
|
|
36
|
+
expect(envelope.payload).not.toContain('Jane Doe')
|
|
37
|
+
const decrypted = decryptSeedEnvelope(envelope, key)
|
|
38
|
+
expect(decrypted).toEqual(sampleDocument)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('produces a different ciphertext each time (randomized IV)', () => {
|
|
42
|
+
const key = generateSeedKey()
|
|
43
|
+
const a = encryptSeedDocument(sampleDocument, key)
|
|
44
|
+
const b = encryptSeedDocument(sampleDocument, key)
|
|
45
|
+
expect(a.payload).not.toEqual(b.payload)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('fails to decrypt with the wrong key', () => {
|
|
49
|
+
const envelope = encryptSeedDocument(sampleDocument, generateSeedKey())
|
|
50
|
+
expect(() => decryptSeedEnvelope(envelope, generateSeedKey())).toThrow()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('resolves the key from OM_SEED_KEY', () => {
|
|
54
|
+
const key = generateSeedKey()
|
|
55
|
+
process.env[SEED_KEY_ENV] = key
|
|
56
|
+
expect(resolveSeedKey()).toBe(key)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('rejects a missing or malformed key', () => {
|
|
60
|
+
delete process.env[SEED_KEY_ENV]
|
|
61
|
+
expect(() => resolveSeedKey()).toThrow()
|
|
62
|
+
expect(() => resolveSeedKey('not-32-bytes')).toThrow()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { decryptWithAesGcm, encryptWithAesGcm } from '../encryption/aes'
|
|
3
|
+
import {
|
|
4
|
+
ENCRYPTED_SEED_ALGORITHM,
|
|
5
|
+
ENCRYPTED_SEED_FORMAT,
|
|
6
|
+
ENCRYPTED_SEED_VERSION,
|
|
7
|
+
encryptedSeedEnvelopeSchema,
|
|
8
|
+
seedDocumentSchema,
|
|
9
|
+
type EncryptedSeedEnvelope,
|
|
10
|
+
type SeedDocument,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Distribution-layer key for encrypting seed blobs committed to the repo. This is
|
|
15
|
+
* deliberately separate from the platform's per-tenant field-encryption keys
|
|
16
|
+
* (Vault / TENANT_DATA_ENCRYPTION_*): rotating or sharing the seed key never
|
|
17
|
+
* touches data-at-rest encryption.
|
|
18
|
+
*/
|
|
19
|
+
export const SEED_KEY_ENV = 'OM_SEED_KEY'
|
|
20
|
+
|
|
21
|
+
const SEED_KEY_BYTES = 32
|
|
22
|
+
|
|
23
|
+
function normalizeKey(value: string): string {
|
|
24
|
+
return value.trim().replace(/(?:^['"]|['"]$)/g, '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Generate a fresh base64-encoded 32-byte seed key. */
|
|
28
|
+
export function generateSeedKey(): string {
|
|
29
|
+
return crypto.randomBytes(SEED_KEY_BYTES).toString('base64')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve and validate the seed key from an explicit value or `OM_SEED_KEY`.
|
|
34
|
+
* Throws a clear, actionable error when missing or malformed.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveSeedKey(explicit?: string | null): string {
|
|
37
|
+
const raw = normalizeKey(explicit ?? process.env[SEED_KEY_ENV] ?? '')
|
|
38
|
+
if (!raw) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[internal] Seed key missing: set ${SEED_KEY_ENV} (base64, 32 bytes) or pass --key. Generate one with "mercato seeds keygen".`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
let decoded: Buffer
|
|
44
|
+
try {
|
|
45
|
+
decoded = Buffer.from(raw, 'base64')
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error(`[internal] ${SEED_KEY_ENV} must be base64-encoded.`)
|
|
48
|
+
}
|
|
49
|
+
if (decoded.length !== SEED_KEY_BYTES) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[internal] ${SEED_KEY_ENV} must decode to ${SEED_KEY_BYTES} bytes (got ${decoded.length}). Generate one with "mercato seeds keygen".`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
return raw
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Encrypt a validated seed document into the opaque, committable envelope. */
|
|
58
|
+
export function encryptSeedDocument(document: SeedDocument, key: string): EncryptedSeedEnvelope {
|
|
59
|
+
const doc = seedDocumentSchema.parse(document)
|
|
60
|
+
const json = JSON.stringify(doc)
|
|
61
|
+
const { value } = encryptWithAesGcm(json, key)
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error('[internal] Seed encryption produced no payload.')
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
format: ENCRYPTED_SEED_FORMAT,
|
|
67
|
+
version: ENCRYPTED_SEED_VERSION,
|
|
68
|
+
algorithm: ENCRYPTED_SEED_ALGORITHM,
|
|
69
|
+
payload: value,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Decrypt and validate an envelope back into a seed document. */
|
|
74
|
+
export function decryptSeedEnvelope(envelope: unknown, key: string): SeedDocument {
|
|
75
|
+
const parsed = encryptedSeedEnvelopeSchema.parse(envelope)
|
|
76
|
+
const json = decryptWithAesGcm(parsed.payload, key)
|
|
77
|
+
if (json === null) {
|
|
78
|
+
throw new Error('[internal] Seed decryption failed — wrong key or corrupted payload.')
|
|
79
|
+
}
|
|
80
|
+
let raw: unknown
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(json)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('[internal] Decrypted seed is not valid JSON.')
|
|
85
|
+
}
|
|
86
|
+
return seedDocumentSchema.parse(raw)
|
|
87
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { EntityManager, EntityMetadata } from '@mikro-orm/postgresql'
|
|
2
|
+
import { resolveEntityIdFromMetadata } from '../encryption/entityIds'
|
|
3
|
+
import { seedDocumentSchema, type SeedDocument } from './types'
|
|
4
|
+
|
|
5
|
+
export type SeedLoadScope = {
|
|
6
|
+
tenantId: string
|
|
7
|
+
organizationId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SeedLoadProgress = {
|
|
11
|
+
index: number
|
|
12
|
+
total: number
|
|
13
|
+
entity: string
|
|
14
|
+
action: 'created' | 'skipped'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SeedLoadOptions = {
|
|
18
|
+
/** Apply inside a transaction and roll it back; reports what would happen. */
|
|
19
|
+
dryRun?: boolean
|
|
20
|
+
onProgress?: (progress: SeedLoadProgress) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SeedLoadResult = {
|
|
24
|
+
total: number
|
|
25
|
+
created: number
|
|
26
|
+
skipped: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class SeedDryRunRollback extends Error {}
|
|
30
|
+
|
|
31
|
+
function resolveEntityClass(meta: EntityMetadata<any>): unknown {
|
|
32
|
+
return (meta as any).class ?? meta.className ?? meta.name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildEntityIdIndex(em: EntityManager): Map<string, EntityMetadata<any>> {
|
|
36
|
+
const storage = em.getMetadata() as unknown as {
|
|
37
|
+
getAll?: () =>
|
|
38
|
+
| Map<unknown, EntityMetadata<any>>
|
|
39
|
+
| Record<string, EntityMetadata<any>>
|
|
40
|
+
| EntityMetadata<any>[]
|
|
41
|
+
metadata?: Record<string, EntityMetadata<any>>
|
|
42
|
+
}
|
|
43
|
+
const all = (typeof storage.getAll === 'function' ? storage.getAll() : storage.metadata) ?? {}
|
|
44
|
+
// MikroORM v7's instance getAll() returns a Map; older shapes returned a plain
|
|
45
|
+
// object or array. Normalize all three to a flat list.
|
|
46
|
+
const list: EntityMetadata<any>[] =
|
|
47
|
+
all instanceof Map ? [...all.values()] : Array.isArray(all) ? all : Object.values(all)
|
|
48
|
+
const index = new Map<string, EntityMetadata<any>>()
|
|
49
|
+
for (const meta of list) {
|
|
50
|
+
if (!meta || (meta as any).abstract) continue
|
|
51
|
+
const entityId = resolveEntityIdFromMetadata(meta)
|
|
52
|
+
if (!entityId) continue
|
|
53
|
+
if (!index.has(entityId)) index.set(entityId, meta)
|
|
54
|
+
}
|
|
55
|
+
return index
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasProperty(meta: EntityMetadata<any>, name: string): boolean {
|
|
59
|
+
return Boolean(meta.properties && (meta.properties as Record<string, unknown>)[name])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Insert seed records through the ORM so the tenant-data-encryption subscriber
|
|
64
|
+
* encrypts marked fields at rest automatically. Records are applied in order;
|
|
65
|
+
* `tenantId`/`organizationId` are injected from `scope` for every entity that
|
|
66
|
+
* declares them. Records with a `match` list are skipped when an existing row
|
|
67
|
+
* matches (idempotent re-runs); match fields MUST be non-encrypted natural keys.
|
|
68
|
+
*/
|
|
69
|
+
export async function loadSeedDocument(
|
|
70
|
+
em: EntityManager,
|
|
71
|
+
document: SeedDocument,
|
|
72
|
+
scope: SeedLoadScope,
|
|
73
|
+
options: SeedLoadOptions = {},
|
|
74
|
+
): Promise<SeedLoadResult> {
|
|
75
|
+
const doc = seedDocumentSchema.parse(document)
|
|
76
|
+
const index = buildEntityIdIndex(em)
|
|
77
|
+
const total = doc.records.length
|
|
78
|
+
let created = 0
|
|
79
|
+
let skipped = 0
|
|
80
|
+
|
|
81
|
+
const run = async (tem: EntityManager) => {
|
|
82
|
+
for (let i = 0; i < doc.records.length; i += 1) {
|
|
83
|
+
const record = doc.records[i]
|
|
84
|
+
const meta = index.get(record.entity)
|
|
85
|
+
if (!meta) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[internal] Unknown seed entity "${record.entity}" at record ${i}: not a registered entity id.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
const entityClass = resolveEntityClass(meta)
|
|
91
|
+
const data: Record<string, unknown> = { ...record.data }
|
|
92
|
+
if (hasProperty(meta, 'tenantId')) data.tenantId = scope.tenantId
|
|
93
|
+
if (hasProperty(meta, 'organizationId')) data.organizationId = scope.organizationId
|
|
94
|
+
|
|
95
|
+
if (record.match && record.match.length) {
|
|
96
|
+
const where: Record<string, unknown> = {}
|
|
97
|
+
for (const field of record.match) where[field] = data[field]
|
|
98
|
+
if (hasProperty(meta, 'tenantId')) where.tenantId = scope.tenantId
|
|
99
|
+
if (hasProperty(meta, 'organizationId')) where.organizationId = scope.organizationId
|
|
100
|
+
const existing = await tem.findOne(entityClass as any, where as any)
|
|
101
|
+
if (existing) {
|
|
102
|
+
skipped += 1
|
|
103
|
+
options.onProgress?.({ index: i, total, entity: record.entity, action: 'skipped' })
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const entity = tem.create(entityClass as any, data as any)
|
|
109
|
+
tem.persist(entity)
|
|
110
|
+
await tem.flush()
|
|
111
|
+
created += 1
|
|
112
|
+
options.onProgress?.({ index: i, total, entity: record.entity, action: 'created' })
|
|
113
|
+
}
|
|
114
|
+
if (options.dryRun) throw new SeedDryRunRollback()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await em.transactional(run)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (!(err instanceof SeedDryRunRollback)) throw err
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { total, created, skipped }
|
|
124
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const SEED_DOCUMENT_FORMAT = 'om-seed'
|
|
4
|
+
export const SEED_DOCUMENT_VERSION = 1
|
|
5
|
+
|
|
6
|
+
export const ENCRYPTED_SEED_FORMAT = 'om-encrypted-seed'
|
|
7
|
+
export const ENCRYPTED_SEED_VERSION = 1
|
|
8
|
+
export const ENCRYPTED_SEED_ALGORITHM = 'aes-256-gcm'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single record to seed. `entity` is the platform entity id (`module:entity`,
|
|
12
|
+
* e.g. `customers:customer_entity`). `data` keys are the entity's own property
|
|
13
|
+
* names (camelCase, as declared on the MikroORM entity). `match`, when present,
|
|
14
|
+
* lists property names used for an idempotent existence check before insert —
|
|
15
|
+
* these MUST be non-encrypted natural keys (id, slug, code, *_hash); encrypted
|
|
16
|
+
* fields cannot be matched because their ciphertext is non-deterministic.
|
|
17
|
+
*/
|
|
18
|
+
export const seedRecordSchema = z.object({
|
|
19
|
+
entity: z.string().min(1),
|
|
20
|
+
match: z.array(z.string().min(1)).optional(),
|
|
21
|
+
data: z.record(z.string(), z.unknown()),
|
|
22
|
+
})
|
|
23
|
+
export type SeedRecord = z.infer<typeof seedRecordSchema>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The plaintext seed document. Records are applied in array order so authors can
|
|
27
|
+
* satisfy foreign-key dependencies (create the parent before the child). The
|
|
28
|
+
* document MUST NOT hard-code `tenantId`/`organizationId` — the loader injects
|
|
29
|
+
* the target scope at load time so the same document seeds any tenant.
|
|
30
|
+
*/
|
|
31
|
+
export const seedDocumentSchema = z.object({
|
|
32
|
+
format: z.literal(SEED_DOCUMENT_FORMAT),
|
|
33
|
+
version: z.literal(SEED_DOCUMENT_VERSION),
|
|
34
|
+
records: z.array(seedRecordSchema),
|
|
35
|
+
})
|
|
36
|
+
export type SeedDocument = z.infer<typeof seedDocumentSchema>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The committed-to-repo, opaque envelope. `payload` is the encrypted seed
|
|
40
|
+
* document in the shared AES-GCM `iv:ct:tag:v1` wire format.
|
|
41
|
+
*/
|
|
42
|
+
export const encryptedSeedEnvelopeSchema = z.object({
|
|
43
|
+
format: z.literal(ENCRYPTED_SEED_FORMAT),
|
|
44
|
+
version: z.literal(ENCRYPTED_SEED_VERSION),
|
|
45
|
+
algorithm: z.literal(ENCRYPTED_SEED_ALGORITHM),
|
|
46
|
+
payload: z.string().min(1),
|
|
47
|
+
})
|
|
48
|
+
export type EncryptedSeedEnvelope = z.infer<typeof encryptedSeedEnvelopeSchema>
|