@open-mercato/shared 0.9.0 → 0.9.1-develop.7462.1.94c09f158f
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/dist/lib/bootstrap/factory.js +4 -3
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-bus.js +45 -13
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +7 -3
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +2 -0
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +14 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +14 -3
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +4 -3
- package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
- package/src/lib/bootstrap/factory.ts +8 -3
- package/src/lib/bootstrap/types.ts +5 -1
- package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
- package/src/lib/commands/command-bus.ts +42 -10
- package/src/lib/commands/command-interceptor.ts +4 -0
- package/src/lib/commands/types.ts +14 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
- package/src/lib/webhooks/__tests__/body.test.ts +48 -0
- package/src/lib/webhooks/body.ts +11 -4
- package/src/lib/webhooks/index.ts +1 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
- package/src/modules/payment_gateways/types.ts +52 -6
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
- package/src/modules/widgets/injection-loader.ts +33 -3
|
@@ -143,9 +143,14 @@ async function registerWidgetsAndOptionalPackages(data: BootstrapData, options:
|
|
|
143
143
|
// browser-facing UI registries. API-only bootstraps avoid loading @open-mercato/ui.
|
|
144
144
|
try {
|
|
145
145
|
const coreInjection = await import('@open-mercato/core/modules/widgets/lib/injection')
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
// A partitioned runtime still needs its own entries — server-side resolvers read this
|
|
147
|
+
// registry on request paths — it just must not shrink what a fuller partition already
|
|
148
|
+
// published in the same process. Merge does both; skipping registration altogether left
|
|
149
|
+
// API-only processes with an unregistered registry that throws on first read.
|
|
150
|
+
coreInjection.registerCoreInjectionWidgets(
|
|
151
|
+
data.injectionWidgetEntries,
|
|
152
|
+
options.skipCoreInjectionWidgets ? { mode: 'merge' } : undefined,
|
|
153
|
+
)
|
|
149
154
|
coreInjection.registerCoreInjectionTables(data.injectionTables, data.injectionWidgetEntries)
|
|
150
155
|
coreInjection.registerEnabledModuleIds(
|
|
151
156
|
data.modules.map((module) => module.id).filter((id): id is string => typeof id === 'string' && id.length > 0),
|
|
@@ -73,7 +73,11 @@ export interface BootstrapOptions {
|
|
|
73
73
|
registrationKey?: string
|
|
74
74
|
/** Skip browser-facing dashboard/injection registries in API-only runtimes. */
|
|
75
75
|
skipUiRegistries?: boolean
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Keep an existing core injection-widget registry intact in partitioned runtimes: this
|
|
78
|
+
* bootstrap's entries are merged in rather than published as the whole registry, so a
|
|
79
|
+
* partition that registers second never shrinks what the first one already exposed.
|
|
80
|
+
*/
|
|
77
81
|
skipCoreInjectionWidgets?: boolean
|
|
78
82
|
onRegistrationComplete?: () => void
|
|
79
83
|
appDiRegistrar?: AppDiRegistrar
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
isCommandInterceptorError,
|
|
8
8
|
} from '@open-mercato/shared/lib/commands'
|
|
9
9
|
import { registerCommandInterceptors } from '@open-mercato/shared/lib/commands/command-interceptor-store'
|
|
10
|
-
import type { CommandInterceptor } from '@open-mercato/shared/lib/commands/command-interceptor'
|
|
10
|
+
import type { CommandInterceptor, CommandInterceptorContext } from '@open-mercato/shared/lib/commands/command-interceptor'
|
|
11
11
|
|
|
12
12
|
describe('CommandBus', () => {
|
|
13
13
|
afterEach(() => {
|
|
@@ -296,6 +296,57 @@ describe('CommandBus', () => {
|
|
|
296
296
|
)
|
|
297
297
|
})
|
|
298
298
|
|
|
299
|
+
it('defers opted-in before snapshots until after the transactional write guard', async () => {
|
|
300
|
+
const calls: string[] = []
|
|
301
|
+
const transactionalEm = { id: 'transaction-em' }
|
|
302
|
+
const buildLogMock = jest.fn(() => ({
|
|
303
|
+
actionLabel: 'Transaction snapshot',
|
|
304
|
+
resourceKind: 'test',
|
|
305
|
+
resourceId: 'transactional',
|
|
306
|
+
}))
|
|
307
|
+
const prepare = jest.fn(async (_input, ctx) => {
|
|
308
|
+
calls.push('prepare')
|
|
309
|
+
expect(ctx.transactionalEm).toBe(transactionalEm)
|
|
310
|
+
return { before: { state: 'locked-before' } }
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
registerCommand({
|
|
314
|
+
id: 'test.command.transactional-snapshot',
|
|
315
|
+
prepare,
|
|
316
|
+
prepareSnapshotInsideTransaction: true,
|
|
317
|
+
execute: jest.fn(async (_input, ctx) => {
|
|
318
|
+
calls.push('execute')
|
|
319
|
+
await ctx.beforeTransactionalWrite?.(transactionalEm as never)
|
|
320
|
+
calls.push('mutate')
|
|
321
|
+
return { ok: true }
|
|
322
|
+
}),
|
|
323
|
+
buildLog: buildLogMock,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const logMock = jest.fn(async () => ({ id: 'transaction-log' }))
|
|
327
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
328
|
+
container.register({ actionLogService: asValue({ log: logMock }) })
|
|
329
|
+
const bus = new CommandBus()
|
|
330
|
+
const ctx = {
|
|
331
|
+
container,
|
|
332
|
+
auth: { sub: 'user-transaction', tenantId: 'tenant-transaction', orgId: null },
|
|
333
|
+
organizationScope: null,
|
|
334
|
+
selectedOrganizationId: null,
|
|
335
|
+
organizationIds: null,
|
|
336
|
+
beforeTransactionalWrite: jest.fn(async () => {
|
|
337
|
+
calls.push('lock')
|
|
338
|
+
}),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await bus.execute('test.command.transactional-snapshot', { input: {}, ctx })
|
|
342
|
+
|
|
343
|
+
expect(calls).toEqual(['execute', 'lock', 'prepare', 'mutate'])
|
|
344
|
+
expect(prepare).toHaveBeenCalledTimes(1)
|
|
345
|
+
expect(buildLogMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
346
|
+
snapshots: { before: { state: 'locked-before' }, after: undefined },
|
|
347
|
+
}))
|
|
348
|
+
})
|
|
349
|
+
|
|
299
350
|
it('loads a command file lazily before execution', async () => {
|
|
300
351
|
const execute = jest.fn(async () => ({ ok: true }))
|
|
301
352
|
registerCommandLoaders([
|
|
@@ -538,6 +589,54 @@ describe('CommandBus', () => {
|
|
|
538
589
|
})
|
|
539
590
|
|
|
540
591
|
describe('interceptor rejections', () => {
|
|
592
|
+
it('passes the original request to beforeExecute for preflight version checks', async () => {
|
|
593
|
+
registerCommand({ id: 'test.request-context', execute: jest.fn(async () => ({ ok: true })) })
|
|
594
|
+
const beforeExecute = jest.fn(async (_input: unknown, context: { request?: Request | null }) => {
|
|
595
|
+
expect(context.request?.headers.get('x-om-ext-optimistic-lock-expected-updated-at')).toBe('2026-09-29T11:00:00.000Z')
|
|
596
|
+
return { ok: false, status: 422 }
|
|
597
|
+
})
|
|
598
|
+
registerCommandInterceptors([{ moduleId: 'test', interceptors: [{
|
|
599
|
+
id: 'test.request-context-interceptor', targetCommand: 'test.request-context', beforeExecute,
|
|
600
|
+
}] }])
|
|
601
|
+
const request = new Request('http://localhost/test', { headers: { 'x-om-ext-optimistic-lock-expected-updated-at': '2026-09-29T11:00:00.000Z' } })
|
|
602
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
603
|
+
const bus = new CommandBus()
|
|
604
|
+
await expect(bus.execute('test.request-context', { input: {}, ctx: {
|
|
605
|
+
container,
|
|
606
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
607
|
+
organizationScope: null,
|
|
608
|
+
selectedOrganizationId: null,
|
|
609
|
+
organizationIds: null,
|
|
610
|
+
request,
|
|
611
|
+
} })).rejects.toMatchObject({ status: 422 })
|
|
612
|
+
expect(beforeExecute).toHaveBeenCalledTimes(1)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('passes request and organization scope to both execution lifecycle hooks', async () => {
|
|
616
|
+
registerCommand({ id: 'test.lifecycle-context', execute: async () => ({ ok: true }) })
|
|
617
|
+
const request = new Request('http://localhost/test')
|
|
618
|
+
const organizationScope = { selectedId: 'parent', filterIds: ['parent', 'child'], allowedIds: ['parent', 'child'], tenantId: 'tenant-1' }
|
|
619
|
+
const beforeExecute = jest.fn(async (_input: unknown, context: CommandInterceptorContext) => {
|
|
620
|
+
expect(context.request).toBe(request)
|
|
621
|
+
expect(context.organizationScope).toBe(organizationScope)
|
|
622
|
+
return { ok: true }
|
|
623
|
+
})
|
|
624
|
+
const afterExecute = jest.fn(async (_input: unknown, _result: unknown, context: CommandInterceptorContext) => {
|
|
625
|
+
expect(context.request).toBe(request)
|
|
626
|
+
expect(context.organizationScope).toBe(organizationScope)
|
|
627
|
+
})
|
|
628
|
+
registerCommandInterceptors([{ moduleId: 'test', interceptors: [{
|
|
629
|
+
id: 'test.lifecycle-context-interceptor', targetCommand: 'test.lifecycle-context', beforeExecute, afterExecute,
|
|
630
|
+
}] }])
|
|
631
|
+
await new CommandBus().execute('test.lifecycle-context', { input: {}, ctx: {
|
|
632
|
+
container: createContainer({ injectionMode: InjectionMode.CLASSIC }),
|
|
633
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: 'parent' },
|
|
634
|
+
organizationScope, selectedOrganizationId: 'parent', organizationIds: ['parent', 'child'], request,
|
|
635
|
+
} })
|
|
636
|
+
expect(beforeExecute).toHaveBeenCalledTimes(1)
|
|
637
|
+
expect(afterExecute).toHaveBeenCalledTimes(1)
|
|
638
|
+
})
|
|
639
|
+
|
|
541
640
|
const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
|
|
542
641
|
id: 'test.block',
|
|
543
642
|
targetCommand: 'test.*',
|
|
@@ -297,6 +297,8 @@ export class CommandBus {
|
|
|
297
297
|
auth: baseOptions.ctx.auth ?? null,
|
|
298
298
|
selectedOrganizationId: baseOptions.ctx.selectedOrganizationId ?? baseOptions.ctx.auth?.orgId ?? null,
|
|
299
299
|
container: baseOptions.ctx.container,
|
|
300
|
+
organizationScope: baseOptions.ctx.organizationScope ?? null,
|
|
301
|
+
request: baseOptions.ctx.request ?? null,
|
|
300
302
|
}
|
|
301
303
|
const beforeResult = await runCommandInterceptorsBefore(
|
|
302
304
|
allInterceptors, commandId, baseOptions.input, interceptorCtx, resolvedUserFeatures,
|
|
@@ -338,16 +340,40 @@ export class CommandBus {
|
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
const executeCore = async (coreOptions: CommandExecutionOptions<TInput>) => {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
+
const snapshots: { before?: unknown } = {}
|
|
344
|
+
let executionOptions = coreOptions
|
|
345
|
+
if (handler.prepareSnapshotInsideTransaction && handler.prepare) {
|
|
346
|
+
const prepare = handler.prepare
|
|
347
|
+
const originalCtx = coreOptions.ctx
|
|
348
|
+
const originalBeforeTransactionalWrite = originalCtx.beforeTransactionalWrite
|
|
349
|
+
let prepared = false
|
|
350
|
+
let transactionalCtx: CommandRuntimeContext
|
|
351
|
+
transactionalCtx = {
|
|
352
|
+
...originalCtx,
|
|
353
|
+
beforeTransactionalWrite: async (em, input) => {
|
|
354
|
+
await originalBeforeTransactionalWrite?.(em, input)
|
|
355
|
+
if (prepared) return
|
|
356
|
+
Object.assign(
|
|
357
|
+
snapshots,
|
|
358
|
+
(await prepare(coreOptions.input, { ...transactionalCtx, transactionalEm: em })) ?? {},
|
|
359
|
+
)
|
|
360
|
+
prepared = true
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
executionOptions = { ...coreOptions, ctx: transactionalCtx }
|
|
364
|
+
} else {
|
|
365
|
+
Object.assign(snapshots, await this.prepareSnapshots(handler, coreOptions))
|
|
366
|
+
}
|
|
367
|
+
effectiveOptions = executionOptions
|
|
368
|
+
const sourceLog = executionOptions.redoLogEntry ?? null
|
|
343
369
|
const result =
|
|
344
370
|
sourceLog && typeof handler.redo === 'function'
|
|
345
|
-
? await handler.redo({ input:
|
|
346
|
-
: await handler.execute(
|
|
347
|
-
const afterSnapshot = await this.captureAfter(handler,
|
|
371
|
+
? await handler.redo({ input: executionOptions.input, ctx: executionOptions.ctx, logEntry: sourceLog })
|
|
372
|
+
: await handler.execute(executionOptions.input, executionOptions.ctx)
|
|
373
|
+
const afterSnapshot = await this.captureAfter(handler, executionOptions, result)
|
|
348
374
|
const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
|
|
349
|
-
const logMeta = await this.buildLog(handler,
|
|
350
|
-
let mergedMeta = this.mergeMetadata(
|
|
375
|
+
const logMeta = await this.buildLog(handler, executionOptions, result, snapshotsWithAfter)
|
|
376
|
+
let mergedMeta = this.mergeMetadata(executionOptions.metadata, logMeta)
|
|
351
377
|
// Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
|
|
352
378
|
// than the generic `context` one, so the metadata an interceptor already passes to its
|
|
353
379
|
// own afterExecute hook is never silently promoted into audit storage.
|
|
@@ -362,7 +388,7 @@ export class CommandBus {
|
|
|
362
388
|
...logContextRecord,
|
|
363
389
|
}
|
|
364
390
|
}
|
|
365
|
-
const baseContext = asRecord(
|
|
391
|
+
const baseContext = asRecord(executionOptions.metadata?.context) ?? {}
|
|
366
392
|
const logMetaContext = asRecord(logMeta?.context) ?? {}
|
|
367
393
|
if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
|
|
368
394
|
mergedMeta = mergedMeta ?? {}
|
|
@@ -376,7 +402,7 @@ export class CommandBus {
|
|
|
376
402
|
if (undoable && mergedMeta?.replayable !== false) {
|
|
377
403
|
mergedMeta = mergedMeta ?? {}
|
|
378
404
|
if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken()
|
|
379
|
-
if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId =
|
|
405
|
+
if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = executionOptions.ctx.auth?.sub ?? null
|
|
380
406
|
} else if (mergedMeta?.replayable === false) {
|
|
381
407
|
mergedMeta.undoToken = null
|
|
382
408
|
}
|
|
@@ -405,7 +431,7 @@ export class CommandBus {
|
|
|
405
431
|
if (inferred) mergedMeta.changes = inferred
|
|
406
432
|
}
|
|
407
433
|
}
|
|
408
|
-
const logEntry = await this.persistLog(commandId,
|
|
434
|
+
const logEntry = await this.persistLog(commandId, executionOptions, mergedMeta)
|
|
409
435
|
return { result, mergedMeta, logEntry }
|
|
410
436
|
}
|
|
411
437
|
|
|
@@ -474,6 +500,8 @@ export class CommandBus {
|
|
|
474
500
|
auth: effectiveOptions.ctx.auth ?? null,
|
|
475
501
|
selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
|
|
476
502
|
container: effectiveOptions.ctx.container,
|
|
503
|
+
organizationScope: effectiveOptions.ctx.organizationScope ?? null,
|
|
504
|
+
request: effectiveOptions.ctx.request ?? null,
|
|
477
505
|
}
|
|
478
506
|
const afterResult = await runCommandInterceptorsAfter(
|
|
479
507
|
allInterceptors, commandId, effectiveOptions.input, result, interceptorCtx,
|
|
@@ -545,6 +573,8 @@ export class CommandBus {
|
|
|
545
573
|
auth: replayCtx.auth ?? null,
|
|
546
574
|
selectedOrganizationId: replayCtx.selectedOrganizationId ?? replayCtx.auth?.orgId ?? null,
|
|
547
575
|
container: replayCtx.container,
|
|
576
|
+
organizationScope: replayCtx.organizationScope ?? null,
|
|
577
|
+
request: replayCtx.request ?? null,
|
|
548
578
|
}
|
|
549
579
|
const beforeResult = await runCommandInterceptorsBeforeUndo(
|
|
550
580
|
allInterceptors, log.commandId, undoCtx, interceptorCtx, resolvedUserFeatures,
|
|
@@ -659,6 +689,8 @@ export class CommandBus {
|
|
|
659
689
|
auth: ctx.auth ?? null,
|
|
660
690
|
selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
661
691
|
container: ctx.container,
|
|
692
|
+
organizationScope: ctx.organizationScope ?? null,
|
|
693
|
+
request: ctx.request ?? null,
|
|
662
694
|
}
|
|
663
695
|
await runCommandInterceptorsAfterUndo(
|
|
664
696
|
allInterceptors, log.commandId, undoCtx, interceptorCtx,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'
|
|
2
3
|
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
4
|
|
|
4
5
|
export interface CommandInterceptor {
|
|
@@ -47,8 +48,11 @@ export interface CommandInterceptorContext {
|
|
|
47
48
|
auth: AuthContext | null
|
|
48
49
|
/** Selected organization ID */
|
|
49
50
|
selectedOrganizationId: string | null
|
|
51
|
+
organizationScope?: OrganizationScope | null
|
|
50
52
|
/** DI container (read-only usage recommended) */
|
|
51
53
|
container: AwilixContainer
|
|
54
|
+
/** Original request, when the command was called through HTTP. */
|
|
55
|
+
request?: Request | null
|
|
52
56
|
/** Metadata passthrough from beforeExecute to afterExecute (or beforeUndo to afterUndo) */
|
|
53
57
|
metadata?: Record<string, unknown>
|
|
54
58
|
}
|
|
@@ -67,6 +67,14 @@ export type CommandRuntimeContext = {
|
|
|
67
67
|
* surrounding work as a single atomic, single-locked operation.
|
|
68
68
|
*/
|
|
69
69
|
transactionalEm?: EntityManager
|
|
70
|
+
/**
|
|
71
|
+
* Optional server-side guard that supported command handlers invoke after
|
|
72
|
+
* opening their write transaction and before reading or mutating records.
|
|
73
|
+
* The guard receives that same transaction-bound EntityManager and, when the
|
|
74
|
+
* handler provides it, the final input after command interceptors. The input
|
|
75
|
+
* stays optional so existing handlers and callers remain source-compatible.
|
|
76
|
+
*/
|
|
77
|
+
beforeTransactionalWrite?: (em: EntityManager, input?: unknown) => Promise<void>
|
|
70
78
|
/**
|
|
71
79
|
* Identifies the owner of an already-active {@link transactionalEm} lifetime.
|
|
72
80
|
* Atomic replay accepts an ambient EntityManager only when this is the exact
|
|
@@ -243,6 +251,12 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
|
|
|
243
251
|
*/
|
|
244
252
|
readonly outputSchema?: ZodTypeAny
|
|
245
253
|
prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null
|
|
254
|
+
/**
|
|
255
|
+
* Opt in to taking the undo snapshot inside the write transaction. The bus
|
|
256
|
+
* defers `prepare()` into `ctx.beforeTransactionalWrite`, after the handler's
|
|
257
|
+
* transaction guard and before its first read or mutation.
|
|
258
|
+
*/
|
|
259
|
+
readonly prepareSnapshotInsideTransaction?: boolean
|
|
246
260
|
execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
|
|
247
261
|
buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
|
|
248
262
|
captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
|
|
@@ -883,3 +883,26 @@ describe('TenantDataEncryptionService map read failures (issue #6334)', () => {
|
|
|
883
883
|
expect(execute).toHaveBeenCalledTimes(2)
|
|
884
884
|
})
|
|
885
885
|
})
|
|
886
|
+
|
|
887
|
+
describe('TenantDataEncryptionService transaction-aware map reads', () => {
|
|
888
|
+
it('prefers the EntityManager executor over the bare connection', async () => {
|
|
889
|
+
const entityId = 'test:transaction_aware_map_read'
|
|
890
|
+
const execute = jest.fn(async () => [{
|
|
891
|
+
entity_id: entityId,
|
|
892
|
+
fields_json: [{ field: 'display_name' }],
|
|
893
|
+
}])
|
|
894
|
+
const bareExecute = jest.fn(async () => {
|
|
895
|
+
throw new Error('bare connection must not be used')
|
|
896
|
+
})
|
|
897
|
+
const service = new TenantDataEncryptionService({
|
|
898
|
+
execute,
|
|
899
|
+
getConnection: () => ({ execute: bareExecute }),
|
|
900
|
+
} as never)
|
|
901
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
902
|
+
|
|
903
|
+
await expect(service.getEncryptedFieldNames(entityId, 'tenant-transaction', 'org-transaction'))
|
|
904
|
+
.resolves.toEqual(['display_name'])
|
|
905
|
+
expect(execute).toHaveBeenCalled()
|
|
906
|
+
expect(bareExecute).not.toHaveBeenCalled()
|
|
907
|
+
})
|
|
908
|
+
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
|
|
4
4
|
readBoundedRequestBody,
|
|
5
|
+
readBoundedRequestBytes,
|
|
5
6
|
resolveWebhookBodyLimitBytes,
|
|
6
7
|
WebhookBodyTooLargeError,
|
|
7
8
|
} from '../body'
|
|
@@ -77,6 +78,53 @@ describe('readBoundedRequestBody', () => {
|
|
|
77
78
|
})
|
|
78
79
|
})
|
|
79
80
|
|
|
81
|
+
describe('readBoundedRequestBytes', () => {
|
|
82
|
+
it('preserves exact bytes including invalid UTF-8', async () => {
|
|
83
|
+
const bytes = new Uint8Array([0xff, 0xfe, 0x41, 0xc3, 0x28])
|
|
84
|
+
const { request } = makeStreamingRequest([bytes.slice(0, 2), bytes.slice(2)])
|
|
85
|
+
|
|
86
|
+
const result = await readBoundedRequestBytes(request, { maxBytes: bytes.byteLength })
|
|
87
|
+
|
|
88
|
+
expect(Array.from(result)).toEqual(Array.from(bytes))
|
|
89
|
+
expect(new TextDecoder().decode(result)).toContain('\uFFFD')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns an empty array for a missing body', async () => {
|
|
93
|
+
const request = new Request('http://localhost/webhook', { method: 'POST' })
|
|
94
|
+
|
|
95
|
+
expect((await readBoundedRequestBytes(request)).byteLength).toBe(0)
|
|
96
|
+
await expect(readBoundedRequestBody(request)).resolves.toBe('')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('enforces the declared and streamed limits and cancels the stream', async () => {
|
|
100
|
+
const declared = new Request('http://localhost/webhook', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-length': '6' },
|
|
103
|
+
body: 'ok',
|
|
104
|
+
})
|
|
105
|
+
await expect(readBoundedRequestBytes(declared, { maxBytes: 5 })).rejects.toBeInstanceOf(
|
|
106
|
+
WebhookBodyTooLargeError,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const { request, cancel } = makeStreamingRequest([
|
|
110
|
+
new Uint8Array([1, 2, 3]),
|
|
111
|
+
new Uint8Array([4, 5, 6]),
|
|
112
|
+
new Uint8Array([7, 8, 9]),
|
|
113
|
+
])
|
|
114
|
+
await expect(readBoundedRequestBytes(request, { maxBytes: 5 })).rejects.toBeInstanceOf(
|
|
115
|
+
WebhookBodyTooLargeError,
|
|
116
|
+
)
|
|
117
|
+
expect(cancel).toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keeps the string reader decoding the same bytes', async () => {
|
|
121
|
+
const bytes = new TextEncoder().encode('zażółć')
|
|
122
|
+
const { request } = makeStreamingRequest([bytes])
|
|
123
|
+
|
|
124
|
+
await expect(readBoundedRequestBody(request)).resolves.toBe('zażółć')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
80
128
|
describe('resolveWebhookBodyLimitBytes', () => {
|
|
81
129
|
it('uses the documented default for missing or invalid configuration', () => {
|
|
82
130
|
expect(resolveWebhookBodyLimitBytes(undefined)).toBe(DEFAULT_WEBHOOK_BODY_LIMIT_BYTES)
|
package/src/lib/webhooks/body.ts
CHANGED
|
@@ -23,10 +23,10 @@ export function resolveWebhookBodyLimitBytes(
|
|
|
23
23
|
return Number.isSafeInteger(parsed) ? parsed : fallback
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export async function
|
|
26
|
+
export async function readBoundedRequestBytes(
|
|
27
27
|
request: Request,
|
|
28
28
|
options?: { maxBytes?: number },
|
|
29
|
-
): Promise<
|
|
29
|
+
): Promise<Uint8Array> {
|
|
30
30
|
const configuredLimit = options?.maxBytes ?? resolveWebhookBodyLimitBytes()
|
|
31
31
|
const maxBytes = Number.isSafeInteger(configuredLimit) && configuredLimit > 0
|
|
32
32
|
? configuredLimit
|
|
@@ -39,7 +39,7 @@ export async function readBoundedRequestBody(
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
if (!request.body) return
|
|
42
|
+
if (!request.body) return new Uint8Array(0)
|
|
43
43
|
|
|
44
44
|
const reader = request.body.getReader()
|
|
45
45
|
const chunks: Uint8Array[] = []
|
|
@@ -66,5 +66,12 @@ export async function readBoundedRequestBody(
|
|
|
66
66
|
body.set(chunk, offset)
|
|
67
67
|
offset += chunk.byteLength
|
|
68
68
|
}
|
|
69
|
-
return
|
|
69
|
+
return body
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readBoundedRequestBody(
|
|
73
|
+
request: Request,
|
|
74
|
+
options?: { maxBytes?: number },
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
return new TextDecoder().decode(await readBoundedRequestBytes(request, options))
|
|
70
77
|
}
|
|
@@ -3,6 +3,9 @@ import {
|
|
|
3
3
|
getWebhookHandler,
|
|
4
4
|
registerWebhookHandler,
|
|
5
5
|
type VerifyWebhookInput,
|
|
6
|
+
type WebhookLocatorContext,
|
|
7
|
+
type WebhookResponseOutcome,
|
|
8
|
+
WebhookVerificationUnavailableError,
|
|
6
9
|
type WebhookEvent,
|
|
7
10
|
} from '../types'
|
|
8
11
|
|
|
@@ -34,4 +37,45 @@ describe('payment gateway webhook registration', () => {
|
|
|
34
37
|
expect(getWebhookHandler('invalid')).toBeUndefined()
|
|
35
38
|
},
|
|
36
39
|
)
|
|
40
|
+
|
|
41
|
+
it('stays compatible with one-argument locator hints', () => {
|
|
42
|
+
const readSessionIdHint = (payload: Record<string, unknown> | null) => (payload ? 'sess' : null)
|
|
43
|
+
registerWebhookHandler('legacy', handler, { readSessionIdHint })
|
|
44
|
+
|
|
45
|
+
expect(getWebhookHandler('legacy')?.readSessionIdHint?.({})).toBe('sess')
|
|
46
|
+
expect(getWebhookHandler('legacy')?.rawBody).toBeUndefined()
|
|
47
|
+
expect(getWebhookHandler('legacy')?.formatResponse).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('passes through the optional extension hooks', () => {
|
|
51
|
+
const readSessionIdHint = (_payload: Record<string, unknown> | null, context?: WebhookLocatorContext) =>
|
|
52
|
+
context ? String(context.headers['x-id']) : null
|
|
53
|
+
const readPaymentIdHint = () => 'pay'
|
|
54
|
+
const formatResponse = (outcome: WebhookResponseOutcome) => ({ status: 200, body: outcome })
|
|
55
|
+
registerWebhookHandler('extended', handler, {
|
|
56
|
+
readSessionIdHint,
|
|
57
|
+
readPaymentIdHint,
|
|
58
|
+
rawBody: 'bytes',
|
|
59
|
+
formatResponse,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const registration = getWebhookHandler('extended')
|
|
63
|
+
expect(registration?.rawBody).toBe('bytes')
|
|
64
|
+
expect(registration?.readPaymentIdHint).toBe(readPaymentIdHint)
|
|
65
|
+
expect(registration?.formatResponse).toBe(formatResponse)
|
|
66
|
+
expect(registration?.readSessionIdHint?.(null, { rawBody: '', headers: { 'x-id': 'abc' } })).toBe('abc')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects an unknown rawBody mode', () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
registerWebhookHandler('bad', handler, { rawBody: 'stream' as unknown as 'text' }),
|
|
72
|
+
).toThrow('[internal] Payment gateway webhook rawBody must be')
|
|
73
|
+
expect(getWebhookHandler('bad')).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('exposes a typed verification-unavailable error', () => {
|
|
77
|
+
const error = new WebhookVerificationUnavailableError()
|
|
78
|
+
expect(error).toBeInstanceOf(Error)
|
|
79
|
+
expect(error.name).toBe('WebhookVerificationUnavailableError')
|
|
80
|
+
})
|
|
37
81
|
})
|
|
@@ -237,6 +237,45 @@ export interface VerifyWebhookInput {
|
|
|
237
237
|
rawBody: string | Buffer
|
|
238
238
|
headers: Record<string, string | string[] | undefined>
|
|
239
239
|
credentials: Record<string, unknown>
|
|
240
|
+
/** Platform-resolved payment the webhook is expected to concern, when a locator found one. */
|
|
241
|
+
candidate?: WebhookCandidateSnapshot
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Raw request material handed to webhook locator hints. */
|
|
245
|
+
export interface WebhookLocatorContext {
|
|
246
|
+
rawBody: string | Buffer
|
|
247
|
+
headers: Record<string, string | string[] | undefined>
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface WebhookCandidateSnapshot {
|
|
251
|
+
transactionId: string
|
|
252
|
+
paymentId: string
|
|
253
|
+
providerSessionId: string | null
|
|
254
|
+
amount: string
|
|
255
|
+
currencyCode: string
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type WebhookResponseOutcome =
|
|
259
|
+
| 'accepted'
|
|
260
|
+
| 'no_candidate'
|
|
261
|
+
| 'verification_failed'
|
|
262
|
+
| 'verification_unavailable'
|
|
263
|
+
| 'processing_failed'
|
|
264
|
+
| 'payload_too_large'
|
|
265
|
+
| 'rate_limited'
|
|
266
|
+
|
|
267
|
+
export interface WebhookHttpResponse {
|
|
268
|
+
status: number
|
|
269
|
+
body: string | Record<string, unknown>
|
|
270
|
+
contentType?: string
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Thrown by a webhook handler when verification cannot be performed (for example a provider outage). */
|
|
274
|
+
export class WebhookVerificationUnavailableError extends Error {
|
|
275
|
+
constructor(message = 'Webhook verification is unavailable') {
|
|
276
|
+
super(message)
|
|
277
|
+
this.name = 'WebhookVerificationUnavailableError'
|
|
278
|
+
}
|
|
240
279
|
}
|
|
241
280
|
|
|
242
281
|
export interface WebhookEvent {
|
|
@@ -252,10 +291,15 @@ export interface WebhookEvent {
|
|
|
252
291
|
export interface WebhookHandlerRegistration {
|
|
253
292
|
handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>
|
|
254
293
|
queue?: string
|
|
255
|
-
readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null
|
|
294
|
+
readSessionIdHint?: (payload: Record<string, unknown> | null, context?: WebhookLocatorContext) => string | null
|
|
295
|
+
readPaymentIdHint?: (payload: Record<string, unknown> | null, context?: WebhookLocatorContext) => string | null
|
|
256
296
|
maxBodyBytes?: number
|
|
297
|
+
rawBody?: WebhookRawBodyMode
|
|
298
|
+
formatResponse?: (outcome: WebhookResponseOutcome) => WebhookHttpResponse
|
|
257
299
|
}
|
|
258
300
|
|
|
301
|
+
export type WebhookRawBodyMode = 'text' | 'bytes'
|
|
302
|
+
|
|
259
303
|
// ── Adapter Registry Options ────────────────────────────────────────────────
|
|
260
304
|
|
|
261
305
|
export interface RegisterAdapterOptions {
|
|
@@ -345,12 +389,11 @@ export function clearGatewayAdapters(): void {
|
|
|
345
389
|
export function registerWebhookHandler(
|
|
346
390
|
providerKey: string,
|
|
347
391
|
handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>,
|
|
348
|
-
options?:
|
|
349
|
-
queue?: string
|
|
350
|
-
readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null
|
|
351
|
-
maxBodyBytes?: number
|
|
352
|
-
},
|
|
392
|
+
options?: Omit<WebhookHandlerRegistration, 'handler'>,
|
|
353
393
|
): () => void {
|
|
394
|
+
if (options?.rawBody !== undefined && options.rawBody !== 'text' && options.rawBody !== 'bytes') {
|
|
395
|
+
throw new Error("[internal] Payment gateway webhook rawBody must be 'text' or 'bytes'")
|
|
396
|
+
}
|
|
354
397
|
if (
|
|
355
398
|
options?.maxBodyBytes !== undefined
|
|
356
399
|
&& (!Number.isSafeInteger(options.maxBodyBytes)
|
|
@@ -366,7 +409,10 @@ export function registerWebhookHandler(
|
|
|
366
409
|
handler,
|
|
367
410
|
queue: options?.queue,
|
|
368
411
|
readSessionIdHint: options?.readSessionIdHint,
|
|
412
|
+
readPaymentIdHint: options?.readPaymentIdHint,
|
|
369
413
|
maxBodyBytes: options?.maxBodyBytes,
|
|
414
|
+
rawBody: options?.rawBody,
|
|
415
|
+
formatResponse: options?.formatResponse,
|
|
370
416
|
})
|
|
371
417
|
return () => {
|
|
372
418
|
webhookHandlerRegistry.delete(providerKey)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression coverage for the partitioned-bootstrap registry contract.
|
|
3
|
+
*
|
|
4
|
+
* A Node process can run more than one bootstrap partition: the API-only one
|
|
5
|
+
* (`skipCoreInjectionWidgets: true`) and the full page one. Two invariants have to hold
|
|
6
|
+
* together, and skipping registration outright only satisfied the first:
|
|
7
|
+
*
|
|
8
|
+
* 1. the partition that registers second must never shrink the registry the first published;
|
|
9
|
+
* 2. an API-only process must still be able to READ the registry, because module API routes
|
|
10
|
+
* resolve widget contributions on the request path.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
|
|
13
|
+
import type { ModuleInjectionWidgetEntry } from '@open-mercato/shared/modules/registry'
|
|
14
|
+
|
|
15
|
+
const GLOBAL_INJECTION_WIDGETS_KEY = '__openMercatoCoreInjectionWidgetEntries__'
|
|
16
|
+
|
|
17
|
+
function entry(key: string): ModuleInjectionWidgetEntry {
|
|
18
|
+
return { moduleId: key.split(':')[0]!, key, source: 'package', widgetId: key, loader: async () => ({}) as never }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Loader = typeof import('@open-mercato/shared/modules/widgets/injection-loader')
|
|
22
|
+
|
|
23
|
+
function freshLoader(): Loader {
|
|
24
|
+
delete (globalThis as Record<string, unknown>)[GLOBAL_INJECTION_WIDGETS_KEY]
|
|
25
|
+
let loader!: Loader
|
|
26
|
+
jest.isolateModules(() => {
|
|
27
|
+
loader = require('@open-mercato/shared/modules/widgets/injection-loader') as Loader
|
|
28
|
+
})
|
|
29
|
+
return loader
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('core injection widget registration modes', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
delete (globalThis as Record<string, unknown>)[GLOBAL_INJECTION_WIDGETS_KEY]
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('replaces the registry by default', () => {
|
|
38
|
+
const loader = freshLoader()
|
|
39
|
+
loader.registerCoreInjectionWidgets([entry('a:one'), entry('b:two')])
|
|
40
|
+
loader.registerCoreInjectionWidgets([entry('a:one')])
|
|
41
|
+
|
|
42
|
+
expect(loader.getCoreInjectionWidgets().map((it) => it.key)).toEqual(['a:one'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('populates an unregistered registry in merge mode instead of leaving it throwing', () => {
|
|
46
|
+
const loader = freshLoader()
|
|
47
|
+
expect(() => loader.getCoreInjectionWidgets()).toThrow(/not registered/)
|
|
48
|
+
|
|
49
|
+
loader.registerCoreInjectionWidgets([entry('a:one')], { mode: 'merge' })
|
|
50
|
+
|
|
51
|
+
expect(loader.getCoreInjectionWidgets().map((it) => it.key)).toEqual(['a:one'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('never shrinks a registry a fuller partition already published', () => {
|
|
55
|
+
const loader = freshLoader()
|
|
56
|
+
loader.registerCoreInjectionWidgets([entry('a:one'), entry('b:two'), entry('c:three')])
|
|
57
|
+
loader.registerCoreInjectionWidgets([entry('a:one')], { mode: 'merge' })
|
|
58
|
+
|
|
59
|
+
expect(loader.getCoreInjectionWidgets().map((it) => it.key)).toEqual(['a:one', 'b:two', 'c:three'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('adds only the entries the registry does not already carry', () => {
|
|
63
|
+
const loader = freshLoader()
|
|
64
|
+
loader.registerCoreInjectionWidgets([entry('a:one')])
|
|
65
|
+
loader.registerCoreInjectionWidgets([entry('a:one'), entry('d:four')], { mode: 'merge' })
|
|
66
|
+
|
|
67
|
+
expect(loader.getCoreInjectionWidgets().map((it) => it.key)).toEqual(['a:one', 'd:four'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('leaves an unregistered registry untouched when it has nothing to contribute', () => {
|
|
71
|
+
const loader = freshLoader()
|
|
72
|
+
loader.registerCoreInjectionWidgets([], { mode: 'merge' })
|
|
73
|
+
|
|
74
|
+
// Publishing an empty array here would turn the loud "not registered" bootstrap error into
|
|
75
|
+
// silently missing contributions on every request.
|
|
76
|
+
expect(() => loader.getCoreInjectionWidgets()).toThrow(/not registered/)
|
|
77
|
+
})
|
|
78
|
+
})
|