Files
iios/packages/iios-service/src/threads/threads.controller.ts
T
maaz519 ebf553eb68 feat(threads): channel backend — discover (browse), public self-join, leave
The generic primitives channels need beyond dm/group, kept policy-governed and
scope-fenced (kernel never interprets 'channel'/'public'):

- discoverThreads(principal, filter): scope-wide browse (NOT membership-scoped)
  matching an opaque metadata filter, each result flagged joined; governed by
  iios.thread.discover (scope fence in the query). REST: GET /v1/threads/discover
- open self-join for PUBLIC channels: openThread now passes visibility into the
  join decision; dev-OPA iios.thread.join allows membership=channel+visibility=
  public (dm/group/private-channel stay invite-only)
- leaveThread + iios.thread.leave + REST DELETE /v1/threads/:id/me
- tests: public self-join vs private denied, discover joined-flags + group
  excluded + leave; dev-opa join-public/discover/leave rules (29 green)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30

144 lines
5.9 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ThreadsService } from './threads.service';
import { MessageService } from '../messaging/message.service';
import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import { SendMessageDto } from './send-message.dto';
@Controller('v1/threads')
@UseGuards(ContextAttestationGuard)
export class ThreadsController {
constructor(
private readonly threads: ThreadsService,
private readonly messages: MessageService,
private readonly session: SessionVerifier,
) {}
/**
* Generic "my threads" — every thread the caller participates in (last message + unread).
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
*/
@Get()
async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
}
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
@Get('my-annotations')
async myAnnotations(@Headers('authorization') auth?: string, @Query('type') type = 'save') {
return this.messages.listMyAnnotated(this.principal(auth), type);
}
/**
* Discover threads across the caller's scope (not just ones they're in) matching an opaque
* metadata filter — e.g. `?metadata[membership]=channel&metadata[visibility]=public` to browse
* public channels. Each result is flagged `joined`. Declared before `:id` routes so the static
* "discover" segment wins.
*/
@Get('discover')
async discover(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.discoverThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
}
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
@Post()
@HttpCode(201)
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
}
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
@Post(':id/participants')
@HttpCode(201)
async addParticipant(@Param('id') id: string, @Body() body: { userId: string; role?: string }, @Headers('authorization') auth?: string) {
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);
}
/** Self-leave: remove the caller from a thread (e.g. leave a channel). */
@Delete(':id/me')
@HttpCode(200)
async leave(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.leaveThread(id, this.principal(auth));
}
@Get(':id/messages')
async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id);
}
/** Mute / unmute notifications from this thread for the caller. */
@Post(':id/mute')
@HttpCode(200)
async mute(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.muteThread(id, this.principal(auth), true);
}
@Post(':id/unmute')
@HttpCode(200)
async unmute(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.muteThread(id, this.principal(auth), false);
}
/** REST/polling fallback for native send (same write as the socket path). */
@Post(':id/messages')
@HttpCode(201)
async send(
@Param('id') id: string,
@Body() body: SendMessageDto,
@Headers('authorization') authorization?: string,
@Headers('idempotency-key') idempotencyKey?: string,
) {
return this.messages.send(
id,
this.principal(authorization),
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
idempotencyKey ?? randomUUID(),
undefined,
body.parentInteractionId,
);
}
private principal(authorization?: string) {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}