Compare commits

6 Commits

Author SHA1 Message Date
maaz519 00d22be714 Merge pull request 'feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads' (#5) from feat/s3-storage into dev
Reviewed-on: #5
2026-07-20 09:01:56 +00:00
maaz519 b4104b9769 feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads
- media upload policy now allows text/html, text/markdown, text/x-markdown,
  text/csv (in addition to images/av, pdf, txt, zip, office docs)
- blob endpoint adds X-Content-Type-Options: nosniff, and forces
  Content-Disposition: attachment for script-capable types (html, xhtml, svg,
  xml) so an uploaded file can't render/execute inline from the IIOS origin
  (stored-XSS). Images/video/audio/pdf still serve inline for preview.
- dev-opa test covering the allowed types + unknown/oversize denials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:30:25 +05:30
maaz519 32aa04503d Merge pull request 'Feat/s3 storage' (#4) from feat/s3-storage into dev
Reviewed-on: #4
2026-07-18 12:28:22 +00:00
maaz519 c8c2d0811b Merge remote-tracking branch 'origin/dev' into feat/s3-storage 2026-07-18 17:57:21 +05:30
maaz519 ce33834d56 feat(threads): governed group settings — rename, list members, remove participant
- MessageService.renameThread / removeParticipant / listParticipants,
  each fail-closed via OPA (kernel stays generic — no dm/group branching)
- dev OPA: iios.thread.update + iios.thread.participant.remove rules
  (group requires ADMIN; ungoverned threads unchanged)
- REST: PATCH /v1/threads/:id, GET + DELETE /v1/threads/:id/participants
- tests: admin renames/removes, member is denied, member list carries roles
  (message.spec + dev-opa.port.spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:34:28 +05:30
maaz519 b164ef945c feat(mail): in-app attachments on internal mail + surface media parts in thread reads
- MailInternalDto accepts attachments[]; deposit() stores each as a media
  part (image/video→MEDIA_REF, audio→VOICE_REF, else FILE_REF)
- ingest now persists part sizeBytes; contract + DTO carry it
- threads.getMessages returns mimeType + sizeBytes so mail readers can
  render inline images / file chips
- test: internal mail stores an image attachment as a MEDIA_REF part

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:24:49 +05:30
14 changed files with 205 additions and 7 deletions
+1
View File
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
bodyText?: string; bodyText?: string;
contentRef?: string; contentRef?: string;
mimeType?: string; mimeType?: string;
sizeBytes?: number;
}>; }>;
occurredAt: string; occurredAt: string;
providerEventId?: string; providerEventId?: string;
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsInt,
IsISO8601, IsISO8601,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -40,6 +41,7 @@ class PartDto {
@IsOptional() @IsString() bodyText?: string; @IsOptional() @IsString() bodyText?: string;
@IsOptional() @IsString() contentRef?: string; @IsOptional() @IsString() contentRef?: string;
@IsOptional() @IsString() mimeType?: string; @IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
} }
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */ /** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
@@ -178,6 +178,7 @@ export class IngestService {
bodyText: p.bodyText, bodyText: p.bodyText,
contentRef: p.contentRef, contentRef: p.contentRef,
mimeType: p.mimeType, mimeType: p.mimeType,
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
})), })),
}); });
@@ -22,6 +22,7 @@ export class MailController {
vars: body.vars ?? {}, vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}), ...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey, idempotencyKey: body.idempotencyKey,
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
}); });
} }
@@ -7,6 +7,7 @@ export class AttachmentDto {
@IsOptional() @IsString() filename?: string; @IsOptional() @IsString() filename?: string;
@IsString() @IsNotEmpty() contentRef!: string; @IsString() @IsNotEmpty() contentRef!: string;
@IsOptional() @IsString() mimeType?: string; @IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
} }
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */ /** 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<string, unknown>; @IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string; @IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: 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. */ /** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
@@ -66,6 +66,20 @@ describe('MailService.postInternal', () => {
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId); 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 () => { 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 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' }); const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
+16 -4
View File
@@ -20,12 +20,16 @@ export function contentToParts(content: RenderedContent): { subject?: string; pa
return { ...(content.subject != null ? { subject: content.subject } : {}), parts }; return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
} }
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
export interface PostInternalInput { export interface PostInternalInput {
source: TemplateSource; source: TemplateSource;
recipientUserId: string; recipientUserId: string;
vars?: Record<string, unknown>; vars?: Record<string, unknown>;
locale?: string; locale?: string;
idempotencyKey: string; idempotencyKey: string;
/** In-app attachment refs — stored as FILE message parts alongside the body. */
attachments?: MailAttachment[];
} }
export interface SendExternalInput { export interface SendExternalInput {
@@ -65,7 +69,7 @@ export class MailService {
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> { 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 } : {}) }); 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 } }> { 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 }; 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 { 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 }; 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. */ /** 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 { subject, parts } = contentToParts(content);
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
const res = await this.ingest.ingest( const res = await this.ingest.ingest(
{ {
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) }, 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 } : {}) }, source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
kind: 'EMAIL', kind: 'EMAIL',
thread: { externalThreadId: key, ...(subject ? { subject } : {}) }, thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
parts, parts: allParts,
occurredAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
providerEventId: key, providerEventId: key,
}, },
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
import { PresignDownloadDto, PresignUploadDto } from './media.dto'; import { PresignDownloadDto, PresignUploadDto } from './media.dto';
import type { MessagePrincipal } from '../identity/actor.resolver'; import type { MessagePrincipal } from '../identity/actor.resolver';
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
@Controller('v1/media') @Controller('v1/media')
export class MediaController { export class MediaController {
constructor( constructor(
@@ -37,6 +40,12 @@ export class MediaController {
async blob(@Param('token') token: string, @Res() res: Response) { async blob(@Param('token') token: string, @Res() res: Response) {
const { data, mime } = await this.media.get(token); const { data, mime } = await this.media.get(token);
res.setHeader('Content-Type', mime); res.setHeader('Content-Type', mime);
// Never let the browser MIME-sniff an upload into something executable.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
// fine even with attachment disposition; only top-level navigation to the blob is affected.
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(data); res.send(data);
} }
@@ -159,6 +159,75 @@ export class MessageService {
return { threadId, participantCount: participantCount + 1 }; 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<Array<{ userId: string; displayName: string; role: string }>> {
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. */ /** Toggle the caller's per-thread notification mute flag. */
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> { async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
@@ -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 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 () => { it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -33,6 +33,35 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
expect(asAdmin.allow).toBe(true); 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('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
expect((await up(mime)).allow).toBe(true);
}
const bad = await up('application/x-msdownload');
expect(bad.allow).toBe(false);
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
const big = await up('image/png', 30 * 1024 * 1024);
expect(big.allow).toBe(false);
expect(big.obligations[0]?.reason).toMatch(/too large/);
});
it('self-join is governed only on membership threads', async () => { it('self-join is governed only on membership threads', async () => {
// generic / support thread (no membership attr) → open join, unchanged // generic / support thread (no membership attr) → open join, unchanged
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true); expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
@@ -22,7 +22,7 @@ export interface OpaInput {
const MEDIA_MAX_BYTES = 25 * 1024 * 1024; const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
const MEDIA_ALLOWED = /^(image|video|audio)\//; const MEDIA_ALLOWED = /^(image|video|audio)\//;
const MEDIA_ALLOWED_DOCS = new Set([ const MEDIA_ALLOWED_DOCS = new Set([
'application/pdf', 'text/plain', 'application/zip', 'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -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'); if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
return allow(); 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': { case 'iios.thread.join': {
// Only threads that opted into a membership model (chat dm/group) are governed; // 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 // generic/support threads (no membership attr) keep open-join. For a governed
@@ -3,10 +3,12 @@ import {
BadRequestException, BadRequestException,
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Headers, Headers,
HttpCode, HttpCode,
Param, Param,
Patch,
Post, Post,
Query, Query,
UseGuards, UseGuards,
@@ -56,6 +58,27 @@ export class ThreadsController {
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role); 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') @Get(':id/messages')
async listMessages(@Param('id') id: string) { async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id); return this.threads.getMessages(id);
@@ -9,7 +9,7 @@ export interface ThreadMessage {
actorId: string | null; actorId: string | null;
kind: string; kind: string;
occurredAt: Date; 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() @Injectable()
@@ -39,7 +39,7 @@ export class ThreadsService {
actorId: i.actorId, actorId: i.actorId,
kind: i.kind, kind: i.kind,
occurredAt: i.occurredAt, 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 })),
})), })),
}; };
} }