diff --git a/packages/iios-contracts/src/ingest.ts b/packages/iios-contracts/src/ingest.ts index 5c5577e..72e790d 100644 --- a/packages/iios-contracts/src/ingest.ts +++ b/packages/iios-contracts/src/ingest.ts @@ -27,6 +27,7 @@ export interface IngestInteractionRequest { bodyText?: string; contentRef?: string; mimeType?: string; + sizeBytes?: number; }>; occurredAt: string; providerEventId?: string; diff --git a/packages/iios-service/src/interactions/ingest.dto.ts b/packages/iios-service/src/interactions/ingest.dto.ts index c092fb4..455b350 100644 --- a/packages/iios-service/src/interactions/ingest.dto.ts +++ b/packages/iios-service/src/interactions/ingest.dto.ts @@ -1,6 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, + IsInt, IsISO8601, IsObject, IsOptional, @@ -40,6 +41,7 @@ class PartDto { @IsOptional() @IsString() bodyText?: string; @IsOptional() @IsString() contentRef?: string; @IsOptional() @IsString() mimeType?: string; + @IsOptional() @IsInt() sizeBytes?: number; } /** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */ diff --git a/packages/iios-service/src/interactions/ingest.service.ts b/packages/iios-service/src/interactions/ingest.service.ts index 1240282..79b47cc 100644 --- a/packages/iios-service/src/interactions/ingest.service.ts +++ b/packages/iios-service/src/interactions/ingest.service.ts @@ -178,6 +178,7 @@ export class IngestService { bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, + sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined, })), }); diff --git a/packages/iios-service/src/mail/mail.controller.ts b/packages/iios-service/src/mail/mail.controller.ts index 8cc2b1c..6d5936d 100644 --- a/packages/iios-service/src/mail/mail.controller.ts +++ b/packages/iios-service/src/mail/mail.controller.ts @@ -22,6 +22,7 @@ export class MailController { vars: body.vars ?? {}, ...(body.locale ? { locale: body.locale } : {}), idempotencyKey: body.idempotencyKey, + ...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}), }); } diff --git a/packages/iios-service/src/mail/mail.dto.ts b/packages/iios-service/src/mail/mail.dto.ts index 0847751..8251539 100644 --- a/packages/iios-service/src/mail/mail.dto.ts +++ b/packages/iios-service/src/mail/mail.dto.ts @@ -7,6 +7,7 @@ export class AttachmentDto { @IsOptional() @IsString() filename?: string; @IsString() @IsNotEmpty() contentRef!: string; @IsOptional() @IsString() mimeType?: string; + @IsOptional() @IsInt() sizeBytes?: number; } /** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */ @@ -19,6 +20,8 @@ export class MailInternalDto { @IsOptional() @IsObject() vars?: Record; @IsOptional() @IsString() locale?: string; @IsString() @IsNotEmpty() idempotencyKey!: string; + /** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */ + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[]; } /** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */ diff --git a/packages/iios-service/src/mail/mail.service.spec.ts b/packages/iios-service/src/mail/mail.service.spec.ts index 21c2b8f..e59965a 100644 --- a/packages/iios-service/src/mail/mail.service.spec.ts +++ b/packages/iios-service/src/mail/mail.service.spec.ts @@ -66,6 +66,20 @@ describe('MailService.postInternal', () => { expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId); }); + it('stores an in-app attachment as a media part alongside the body', async () => { + const res = await mail().postInternal(alice, { + source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att', + attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }], + }); + const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } }); + const file = interaction.parts.find((p) => p.contentRef); + expect(file).toBeDefined(); + expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF + expect(file!.contentRef).toBe('scope/roof.png'); + expect(file!.mimeType).toBe('image/png'); + expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048); + }); + it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => { const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); diff --git a/packages/iios-service/src/mail/mail.service.ts b/packages/iios-service/src/mail/mail.service.ts index 9aeb4b4..1d8b59e 100644 --- a/packages/iios-service/src/mail/mail.service.ts +++ b/packages/iios-service/src/mail/mail.service.ts @@ -20,12 +20,16 @@ export function contentToParts(content: RenderedContent): { subject?: string; pa return { ...(content.subject != null ? { subject: content.subject } : {}), parts }; } +export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number } + export interface PostInternalInput { source: TemplateSource; recipientUserId: string; vars?: Record; locale?: string; idempotencyKey: string; + /** In-app attachment refs — stored as FILE message parts alongside the body. */ + attachments?: MailAttachment[]; } export interface SendExternalInput { @@ -65,7 +69,7 @@ export class MailService { async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> { const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) }); - return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL'); + return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments); } async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> { @@ -79,13 +83,21 @@ export class MailService { if (!input.mirrorToUserId) return { commandId: command.id }; const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) }); - const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL'); + const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments); return { commandId: command.id, mirror }; } + /** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */ + private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } { + const mime = a.mimeType ?? ''; + const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF'; + return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) }; + } + /** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */ - private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string): Promise<{ threadId: string; interactionId: string }> { + private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> { const { subject, parts } = contentToParts(content); + const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))]; const res = await this.ingest.ingest( { scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) }, @@ -93,7 +105,7 @@ export class MailService { source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) }, kind: 'EMAIL', thread: { externalThreadId: key, ...(subject ? { subject } : {}) }, - parts, + parts: allParts, occurredAt: new Date().toISOString(), providerEventId: key, }, diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index 7752796..f1699d6 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -159,6 +159,75 @@ export class MessageService { return { threadId, participantCount: participantCount + 1 }; } + /** + * Governed thread rename (a generic subject update). Policy decides who may rename — for a + * membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes + * the subject; "group settings" meaning lives in the app + policy, not here. + */ + async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> { + const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); + if (!thread) throw new NotFoundException('thread not found'); + const caller = await this.actors.resolveActor(thread.scopeId, principal); + const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } }); + const membership = (thread.metadata as { membership?: string } | null)?.membership; + + await decideOrThrow(this.ports, { + action: 'iios.thread.update', + threadId, + scopeId: thread.scopeId, + membership, + callerRole: callerP?.participantRole, + }); + + await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } }); + return { threadId, subject }; + } + + /** + * Governed participant removal. Policy decides who may remove — for a membership thread the dev + * OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success. + */ + async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> { + const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); + if (!thread) throw new NotFoundException('thread not found'); + const caller = await this.actors.resolveActor(thread.scopeId, principal); + const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } }); + const membership = (thread.metadata as { membership?: string } | null)?.membership; + + await decideOrThrow(this.ports, { + action: 'iios.thread.participant.remove', + threadId, + scopeId: thread.scopeId, + membership, + callerRole: callerP?.participantRole, + targetUserId, + }); + + const scope = await this.actors.resolveScope(principal); + const target = await this.actors.resolveActor(scope.id, { + userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId, + }); + await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } }); + const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } }); + return { threadId, participantCount }; + } + + /** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */ + async listParticipants(threadId: string, principal: MessagePrincipal): Promise> { + const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); + if (!thread) throw new NotFoundException('thread not found'); + await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId }); + const parts = await this.prisma.iiosThreadParticipant.findMany({ + where: { threadId }, + include: { actor: { include: { sourceHandle: true } } }, + }); + return parts.map((p) => ({ + userId: p.actor?.sourceHandle?.externalId ?? '', + displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown', + role: p.participantRole ?? 'MEMBER', + })); + } + /** Toggle the caller's per-thread notification mute flag. */ async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> { const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); diff --git a/packages/iios-service/src/messaging/message.spec.ts b/packages/iios-service/src/messaging/message.spec.ts index 660a4ae..46101ee 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -124,6 +124,28 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => { await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER }); + it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' }); + await s.addParticipant(threadId, alice, 'bob'); + + // admin renames + expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team'); + // a plain member cannot rename + await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError); + + // member list carries roles + const members = await s.listParticipants(threadId, alice); + expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']); + expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN'); + + // a plain member cannot remove + await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError); + // admin removes bob + expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1); + expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1); + }); + it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => { const s = gov(); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); diff --git a/packages/iios-service/src/platform/dev-opa.port.spec.ts b/packages/iios-service/src/platform/dev-opa.port.spec.ts index 43ae472..d7a05b5 100644 --- a/packages/iios-service/src/platform/dev-opa.port.spec.ts +++ b/packages/iios-service/src/platform/dev-opa.port.spec.ts @@ -33,6 +33,22 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => { expect(asAdmin.allow).toBe(true); }); + it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => { + const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' }); + expect(asMember.allow).toBe(false); + expect(asMember.obligations[0]?.reason).toMatch(/admin/); + expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true); + expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow + }); + + it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => { + const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' }); + expect(asMember.allow).toBe(false); + expect(asMember.obligations[0]?.reason).toMatch(/admin/); + expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true); + expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true); + }); + it('self-join is governed only on membership threads', async () => { // generic / support thread (no membership attr) → open join, unchanged expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true); diff --git a/packages/iios-service/src/platform/dev-opa.port.ts b/packages/iios-service/src/platform/dev-opa.port.ts index 38a1616..b7e1dba 100644 --- a/packages/iios-service/src/platform/dev-opa.port.ts +++ b/packages/iios-service/src/platform/dev-opa.port.ts @@ -42,6 +42,18 @@ export class DevOpaPort { if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members'); return allow(); } + case 'iios.thread.participant.remove': { + // Same governance as add: for a group, only an ADMIN removes members. Ungoverned + // (no membership attr) threads allow removal by any member. + if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members'); + if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants'); + return allow(); + } + case 'iios.thread.update': { + // Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it. + if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings'); + return allow(); + } case 'iios.thread.join': { // Only threads that opted into a membership model (chat dm/group) are governed; // generic/support threads (no membership attr) keep open-join. For a governed diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 83d28f4..afc32e5 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -3,10 +3,12 @@ import { BadRequestException, Body, Controller, + Delete, Get, Headers, HttpCode, Param, + Patch, Post, Query, UseGuards, @@ -56,6 +58,27 @@ export class ThreadsController { return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role); } + /** Members of a thread with their role — drives the group settings member list. */ + @Get(':id/participants') + async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.messages.listParticipants(id, this.principal(auth)); + } + + /** Governed removal: remove a user from a thread — policy enforces group-admin. */ + @Delete(':id/participants/:userId') + @HttpCode(200) + async removeParticipant(@Param('id') id: string, @Param('userId') userId: string, @Headers('authorization') auth?: string) { + return this.messages.removeParticipant(id, this.principal(auth), userId); + } + + /** Governed rename (group settings): change a thread subject — policy enforces group-admin. */ + @Patch(':id') + @HttpCode(200) + async updateThread(@Param('id') id: string, @Body() body: { subject?: string }, @Headers('authorization') auth?: string) { + if (body?.subject == null) throw new BadRequestException('subject is required'); + return this.messages.renameThread(id, this.principal(auth), body.subject); + } + @Get(':id/messages') async listMessages(@Param('id') id: string) { return this.threads.getMessages(id); diff --git a/packages/iios-service/src/threads/threads.service.ts b/packages/iios-service/src/threads/threads.service.ts index 2f7da82..88bd0b0 100644 --- a/packages/iios-service/src/threads/threads.service.ts +++ b/packages/iios-service/src/threads/threads.service.ts @@ -9,7 +9,7 @@ export interface ThreadMessage { actorId: string | null; kind: string; 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: number | null }>; } @Injectable() @@ -39,7 +39,7 @@ export class ThreadsService { actorId: i.actorId, kind: i.kind, occurredAt: i.occurredAt, - parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })), + parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, sizeBytes: p.sizeBytes != null ? Number(p.sizeBytes) : null })), })), }; }