diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 9e16610..3b48892 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -9,6 +9,7 @@ import { OutboxModule } from './outbox/outbox.module'; import { ThreadsModule } from './threads/threads.module'; import { MessageModule } from './messaging/message.module'; import { InboxModule } from './inbox/inbox.module'; +import { MediaModule } from './media/media.module'; import { SupportModule } from './support/support.module'; import { AdaptersModule } from './adapters/adapters.module'; import { RoutingModule } from './routing/routing.module'; @@ -33,6 +34,7 @@ import { DevController } from './dev/dev.controller'; ThreadsModule, MessageModule, InboxModule, + MediaModule, SupportModule, AdaptersModule, RoutingModule, diff --git a/packages/iios-service/src/media/local-disk.storage.ts b/packages/iios-service/src/media/local-disk.storage.ts new file mode 100644 index 0000000..1d4b55f --- /dev/null +++ b/packages/iios-service/src/media/local-disk.storage.ts @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import type { StoragePort } from './storage.port'; + +/** + * Dev storage: bytes on local disk under MEDIA_DIR, with a sidecar `.meta.json` + * holding the mime/size/checksum. Object keys may contain a scope subdirectory. + * A drop-in for an S3/Supabase adapter in prod. + */ +@Injectable() +export class LocalDiskStorage implements StoragePort { + private readonly root = process.env.MEDIA_DIR?.trim() || path.join(os.tmpdir(), 'iios-media'); + + private full(objectKey: string): string { + // Prevent path traversal: keep everything under root. + const p = path.normalize(path.join(this.root, objectKey)); + if (!p.startsWith(path.normalize(this.root))) throw new Error('invalid object key'); + return p; + } + + async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> { + const file = this.full(objectKey); + await fs.mkdir(path.dirname(file), { recursive: true }); + const checksumSha256 = createHash('sha256').update(data).digest('hex'); + await fs.writeFile(file, data); + await fs.writeFile(`${file}.meta.json`, JSON.stringify({ mime, sizeBytes: data.length, checksumSha256 })); + return { sizeBytes: data.length, checksumSha256 }; + } + + async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> { + const file = this.full(objectKey); + try { + const data = await fs.readFile(file); + const meta = JSON.parse(await fs.readFile(`${file}.meta.json`, 'utf8')) as { mime: string }; + return { data, mime: meta.mime || 'application/octet-stream', sizeBytes: data.length }; + } catch { + return null; + } + } + + async remove(objectKey: string): Promise { + const file = this.full(objectKey); + await fs.rm(file, { force: true }); + await fs.rm(`${file}.meta.json`, { force: true }); + } +} diff --git a/packages/iios-service/src/media/media.controller.ts b/packages/iios-service/src/media/media.controller.ts new file mode 100644 index 0000000..666886f --- /dev/null +++ b/packages/iios-service/src/media/media.controller.ts @@ -0,0 +1,61 @@ +import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { MediaService } from './media.service'; +import { SessionVerifier } from '../platform/session.verifier'; +import { PresignDownloadDto, PresignUploadDto } from './media.dto'; +import type { MessagePrincipal } from '../identity/actor.resolver'; + +@Controller('v1/media') +export class MediaController { + constructor( + private readonly media: MediaService, + private readonly session: SessionVerifier, + ) {} + + /** Authorize an upload → short-lived signed PUT url + object key. */ + @Post('presign-upload') + async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) { + return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes }); + } + + /** Authorize a download → short-lived signed GET url. */ + @Post('presign-download') + async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) { + return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime); + } + + /** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */ + @Put('upload/:token') + async upload(@Param('token') token: string, @Req() req: Request) { + const data = await readBody(req); + if (data.length === 0) throw new BadRequestException('empty upload body'); + return this.media.put(token, data); + } + + /** The presigned GET target — token IS the auth. Streams the bytes with their mime. */ + @Get('blob/:token') + async blob(@Param('token') token: string, @Res() res: Response) { + const { data, mime } = await this.media.get(token); + res.setHeader('Content-Type', mime); + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.send(data); + } + + private principal(auth?: string): MessagePrincipal { + const token = (auth ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} + +/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */ +function readBody(req: Request): Promise { + const raw = (req as Request & { rawBody?: Buffer }).rawBody; + if (raw && raw.length) return Promise.resolve(raw); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} diff --git a/packages/iios-service/src/media/media.dto.ts b/packages/iios-service/src/media/media.dto.ts new file mode 100644 index 0000000..b30b830 --- /dev/null +++ b/packages/iios-service/src/media/media.dto.ts @@ -0,0 +1,11 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class PresignUploadDto { + @IsString() mime!: string; + @IsInt() @Min(1) @Max(26 * 1024 * 1024) sizeBytes!: number; +} + +export class PresignDownloadDto { + @IsString() contentRef!: string; + @IsOptional() @IsString() mime?: string; +} diff --git a/packages/iios-service/src/media/media.module.ts b/packages/iios-service/src/media/media.module.ts new file mode 100644 index 0000000..7f1df2a --- /dev/null +++ b/packages/iios-service/src/media/media.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { PlatformModule } from '../platform/platform.module'; +import { IdentityModule } from '../identity/identity.module'; +import { MediaController } from './media.controller'; +import { MediaService } from './media.service'; +import { LocalDiskStorage } from './local-disk.storage'; +import { STORAGE_PORT } from './storage.port'; + +@Module({ + imports: [PlatformModule, IdentityModule], + controllers: [MediaController], + // Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter. + providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }], +}) +export class MediaModule {} diff --git a/packages/iios-service/src/media/media.service.spec.ts b/packages/iios-service/src/media/media.service.spec.ts new file mode 100644 index 0000000..8778671 --- /dev/null +++ b/packages/iios-service/src/media/media.service.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { DevOpaPort } from '../platform/dev-opa.port'; +import { LocalDiskStorage } from './local-disk.storage'; +import { MediaService } from './media.service'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; +const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts; +const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService)); +const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; + +// point storage at an isolated temp dir for the test run +process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test'; + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +function tokenFrom(url: string): string { + return url.split('/').pop()!; +} + +describe('MediaService (presigned local storage)', () => { + it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => { + const s = svc(); + const bytes = Buffer.from('hello-image-bytes'); + const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length }); + expect(objectKey).toContain('/'); // scopeId/uuid + + const put = await s.put(tokenFrom(uploadUrl), bytes); + expect(put.sizeBytes).toBe(bytes.length); + expect(put.checksumSha256).toHaveLength(64); + + const { url } = await s.presignDownload(alice, objectKey, 'image/png'); + const got = await s.get(tokenFrom(url)); + expect(got.data.equals(bytes)).toBe(true); + expect(got.mime).toBe('image/png'); + }); + + it('rejects an over-the-cap upload (OPA policy)', async () => { + await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError); + }); + + it('rejects a disallowed file type (OPA policy)', async () => { + await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError); + }); + + it('a PUT larger than the presigned size is refused', async () => { + const s = svc(); + const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 }); + await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow(); + }); + + it('rejects a tampered / non-media token', async () => { + await expect(svc().get('not-a-real-token')).rejects.toThrow(); + // an upload token cannot be used to download + const s = svc(); + const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 }); + await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow(); + }); + + it('tenant-fences downloads: an object outside my scope is forbidden', async () => { + await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow(); + }); +}); diff --git a/packages/iios-service/src/media/media.service.ts b/packages/iios-service/src/media/media.service.ts new file mode 100644 index 0000000..ad7f8be --- /dev/null +++ b/packages/iios-service/src/media/media.service.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto'; +import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import type { IiosPlatformPorts } from '@insignia/iios-contracts'; +import { PLATFORM_PORTS } from '../platform/platform-ports'; +import { decideOrThrow } from '../platform/fail-closed'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { STORAGE_PORT, type StoragePort } from './storage.port'; + +interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number } +interface DownloadToken { op: 'get'; objectKey: string; mime: string } + +/** + * Presigned media. IIOS never trusts the client with storage — it authorizes an + * upload (OPA: size/type/scope), then hands back a short-lived signed URL that + * points at its OWN storage endpoints. The bytes live behind the StoragePort; the + * kernel only ever stores the object key (contentRef) on a MessagePart. + */ +@Injectable() +export class MediaService { + private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret'; + private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, ''); + + constructor( + @Inject(STORAGE_PORT) private readonly storage: StoragePort, + @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, + private readonly actors: ActorResolver, + ) {} + + /** Authorize an upload and return a short-lived signed PUT url + the object key. */ + async presignUpload( + principal: MessagePrincipal, + input: { mime: string; sizeBytes: number }, + ): Promise<{ objectKey: string; uploadUrl: string }> { + const scope = await this.actors.resolveScope(principal); + await decideOrThrow(this.ports, { action: 'iios.media.upload', scopeId: scope.id, mime: input.mime, sizeBytes: input.sizeBytes }); + const objectKey = `${scope.id}/${randomUUID()}`; + const token = jwt.sign({ op: 'put', objectKey, mime: input.mime, maxBytes: input.sizeBytes } satisfies UploadToken, this.secret, { expiresIn: '5m' }); + return { objectKey, uploadUrl: `${this.publicUrl}/v1/media/upload/${token}` }; + } + + /** Store bytes for a valid, unexpired upload token (enforcing the declared size cap). */ + async put(token: string, data: Buffer): Promise<{ objectKey: string; sizeBytes: number; checksumSha256: string }> { + const t = this.verify(token, 'put'); + if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size'); + const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime); + return { objectKey: t.objectKey, sizeBytes, checksumSha256 }; + } + + /** Authorize a download and return a short-lived signed GET url (tenant-fenced). */ + async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> { + const scope = await this.actors.resolveScope(principal); + if (!contentRef.startsWith(`${scope.id}/`)) throw new ForbiddenException('object not in your scope'); + const token = jwt.sign({ op: 'get', objectKey: contentRef, mime } satisfies DownloadToken, this.secret, { expiresIn: '1h' }); + return { url: `${this.publicUrl}/v1/media/blob/${token}` }; + } + + /** Fetch bytes for a valid, unexpired download token. */ + async get(token: string): Promise<{ data: Buffer; mime: string; sizeBytes: number }> { + const t = this.verify(token, 'get'); + const obj = await this.storage.get(t.objectKey); + if (!obj) throw new BadRequestException('object not found'); + return obj; + } + + private verify(token: string, op: T['op']): T { + let payload: T; + try { + payload = jwt.verify(token, this.secret) as T; + } catch { + throw new ForbiddenException('invalid or expired media token'); + } + if (payload.op !== op) throw new ForbiddenException('wrong media token'); + return payload; + } +} diff --git a/packages/iios-service/src/media/storage.port.ts b/packages/iios-service/src/media/storage.port.ts new file mode 100644 index 0000000..77267b2 --- /dev/null +++ b/packages/iios-service/src/media/storage.port.ts @@ -0,0 +1,12 @@ +/** + * Byte storage seam. The kernel stores a `contentRef` (an opaque object key) on a + * MessagePart; the actual bytes live behind this port. Dev binds LocalDiskStorage; + * prod swaps to S3/R2/Supabase Storage with zero changes to the media service or app. + */ +export interface StoragePort { + put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }>; + get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null>; + remove(objectKey: string): Promise; +} + +export const STORAGE_PORT = Symbol('STORAGE_PORT'); diff --git a/packages/iios-service/src/messaging/message.gateway.ts b/packages/iios-service/src/messaging/message.gateway.ts index 2d0e81b..7962dbb 100644 --- a/packages/iios-service/src/messaging/message.gateway.ts +++ b/packages/iios-service/src/messaging/message.gateway.ts @@ -91,13 +91,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection { @SubscribeMessage('send_message') async sendMessage( @ConnectedSocket() client: Socket, - @MessageBody() body: { threadId: string; content: string; contentRef?: string; parentInteractionId?: string; mentions?: string[] }, + @MessageBody() + body: { threadId: string; content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string; parentInteractionId?: string; mentions?: string[] }, ) { const { principal } = client.data as SocketState; const msg = await this.messages.send( body.threadId, principal, - { content: body.content, contentRef: body.contentRef }, + { content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 }, randomUUID(), undefined, body.parentInteractionId, diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index 954285f..213aae2 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -16,6 +16,21 @@ export interface AnnotationDto { users: string[]; } +/** A media attachment on a message (the app renders it by `kind`). */ +export interface AttachmentDto { + contentRef: string; + mimeType: string; + sizeBytes: number; + kind: 'image' | 'video' | 'audio' | 'file'; +} + +function mediaKind(mime: string): AttachmentDto['kind'] { + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + if (mime.startsWith('audio/')) return 'audio'; + return 'file'; +} + export interface MessageDto { id: string; threadId: string; @@ -24,6 +39,7 @@ export interface MessageDto { senderName: string; content: string; contentRef?: string; + attachment?: AttachmentDto; parentInteractionId?: string; annotations: AnnotationDto[]; traceId: string; @@ -188,7 +204,7 @@ export class MessageService { async send( threadId: string, principal: MessagePrincipal, - body: { content: string; contentRef?: string }, + body: { content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string }, idempotencyKey: string, traceId: string = randomUUID(), parentInteractionId?: string, @@ -233,7 +249,18 @@ export class MessageService { { interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content }, ]; if (body.contentRef) { - parts.push({ interactionId: created.id, partIndex: 1, kind: 'FILE_REF', contentRef: body.contentRef }); + const mime = body.mimeType ?? ''; + // Generic part kind from mime — the kernel stores media as opaque parts. + const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF'; + parts.push({ + interactionId: created.id, + partIndex: 1, + kind, + contentRef: body.contentRef, + mimeType: body.mimeType, + sizeBytes: body.sizeBytes != null ? BigInt(body.sizeBytes) : undefined, + checksumSha256: body.checksumSha256, + }); } await tx.iiosMessagePart.createMany({ data: parts }); @@ -496,13 +523,21 @@ export class MessageService { parentInteractionId?: string | null; traceId: string | null; occurredAt: Date; - parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>; + parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType?: string | null; sizeBytes?: bigint | null }>; }, threadId: string, annotations: AnnotationDto[] = [], ): MessageDto { const text = interaction.parts.find((p) => p.kind === 'TEXT'); const file = interaction.parts.find((p) => p.contentRef); + const attachment: AttachmentDto | undefined = file?.contentRef + ? { + contentRef: file.contentRef, + mimeType: file.mimeType ?? 'application/octet-stream', + sizeBytes: file.sizeBytes != null ? Number(file.sizeBytes) : 0, + kind: mediaKind(file.mimeType ?? ''), + } + : undefined; return { id: interaction.id, threadId, @@ -511,6 +546,7 @@ export class MessageService { senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown', content: text?.bodyText ?? '', contentRef: file?.contentRef ?? undefined, + attachment, parentInteractionId: interaction.parentInteractionId ?? undefined, annotations, traceId: interaction.traceId ?? '', diff --git a/packages/iios-service/src/platform/dev-opa.port.ts b/packages/iios-service/src/platform/dev-opa.port.ts index 3ea0b4d..38a1616 100644 --- a/packages/iios-service/src/platform/dev-opa.port.ts +++ b/packages/iios-service/src/platform/dev-opa.port.ts @@ -14,9 +14,20 @@ export interface OpaInput { callerRole?: string; // 'MEMBER' | 'ADMIN' alreadyMember?: boolean; isMember?: boolean; + mime?: string; + sizeBytes?: number; [k: string]: unknown; } +const MEDIA_MAX_BYTES = 25 * 1024 * 1024; +const MEDIA_ALLOWED = /^(image|video|audio)\//; +const MEDIA_ALLOWED_DOCS = new Set([ + 'application/pdf', 'text/plain', 'application/zip', + 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +]); + const allow = (): PolicyDecision => ({ decisionRef: 'dev-allow', allow: true, obligations: [], ttlSeconds: 60 }); const deny = (reason: string): PolicyDecision => ({ decisionRef: 'dev-deny', allow: false, obligations: [{ kind: 'AUDIT', reason }], ttlSeconds: 0 }); @@ -42,6 +53,12 @@ export class DevOpaPort { // Annotating (e.g. reacting to) a message requires being a participant of its thread. return i.isMember ? allow() : deny('you are not a member of this thread'); } + case 'iios.media.upload': { + const mime = i.mime ?? ''; + if ((i.sizeBytes ?? 0) > MEDIA_MAX_BYTES) return deny('file too large (max 25 MB)'); + if (!MEDIA_ALLOWED.test(mime) && !MEDIA_ALLOWED_DOCS.has(mime)) return deny(`file type not allowed: ${mime || 'unknown'}`); + return allow(); + } default: return allow(); } diff --git a/packages/iios-service/src/threads/send-message.dto.ts b/packages/iios-service/src/threads/send-message.dto.ts index 9a330ce..1a0a372 100644 --- a/packages/iios-service/src/threads/send-message.dto.ts +++ b/packages/iios-service/src/threads/send-message.dto.ts @@ -1,7 +1,10 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsInt, IsOptional, IsString } from 'class-validator'; export class SendMessageDto { @IsString() content!: string; @IsOptional() @IsString() contentRef?: string; + @IsOptional() @IsString() mimeType?: string; + @IsOptional() @IsInt() sizeBytes?: number; + @IsOptional() @IsString() checksumSha256?: string; @IsOptional() @IsString() parentInteractionId?: string; } diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 0dd9da9..73ad083 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -66,7 +66,7 @@ export class ThreadsController { return this.messages.send( id, this.principal(authorization), - { content: body.content, contentRef: body.contentRef }, + { content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 }, idempotencyKey ?? randomUUID(), undefined, body.parentInteractionId,