@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.
Files changed (50) 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/openapi/generator.js +3 -2
  14. package/dist/lib/openapi/generator.js.map +2 -2
  15. package/dist/lib/openapi/index.js +3 -2
  16. package/dist/lib/openapi/index.js.map +2 -2
  17. package/dist/lib/seed/crypto.js +73 -0
  18. package/dist/lib/seed/crypto.js.map +7 -0
  19. package/dist/lib/seed/index.js +4 -0
  20. package/dist/lib/seed/index.js.map +7 -0
  21. package/dist/lib/seed/loader.js +73 -0
  22. package/dist/lib/seed/loader.js.map +7 -0
  23. package/dist/lib/seed/types.js +33 -0
  24. package/dist/lib/seed/types.js.map +7 -0
  25. package/dist/lib/version.js +1 -1
  26. package/dist/lib/version.js.map +1 -1
  27. package/dist/modules/events/factory.js +28 -9
  28. package/dist/modules/events/factory.js.map +2 -2
  29. package/package.json +3 -2
  30. package/src/lib/ai/__tests__/opencode-tool-parts.test.ts +81 -0
  31. package/src/lib/ai/__tests__/token-count.test.ts +20 -0
  32. package/src/lib/ai/opencode-tool-parts.ts +80 -0
  33. package/src/lib/ai/token-count.ts +21 -0
  34. package/src/lib/bootstrap/dynamicLoader.ts +24 -1
  35. package/src/lib/commands/__tests__/command-bus.test.ts +64 -0
  36. package/src/lib/commands/__tests__/registry.test.ts +35 -0
  37. package/src/lib/commands/command-bus.ts +16 -1
  38. package/src/lib/commands/registry.ts +11 -0
  39. package/src/lib/commands/types.ts +31 -0
  40. package/src/lib/openapi/__tests__/generator-response-fallback.test.ts +73 -0
  41. package/src/lib/openapi/generator.ts +3 -3
  42. package/src/lib/openapi/index.ts +1 -1
  43. package/src/lib/seed/__tests__/seed-crypto.test.ts +64 -0
  44. package/src/lib/seed/crypto.ts +87 -0
  45. package/src/lib/seed/index.ts +3 -0
  46. package/src/lib/seed/loader.ts +124 -0
  47. package/src/lib/seed/types.ts +48 -0
  48. package/src/modules/events/__tests__/factory.test.ts +96 -0
  49. package/src/modules/events/factory.ts +41 -10
  50. 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
- 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
@@ -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
  },
@@ -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,3 @@
1
+ export * from './types'
2
+ export * from './crypto'
3
+ export * from './loader'
@@ -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>