Files
iios/packages/iios-service/src/threads/threads.controller.ts
T
maaz519 ba745bb71a feat(iios): policy-enforced membership + threaded replies + dev IdP (generic)
Enriches the platform PLANE, not the kernel:
- DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the
  same opa.decide) — DM capped at 2, add-participant requires member/group-admin,
  governed self-join for membership threads. Real OPA swaps in unchanged.
- Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would.
- PolicyDeniedFilter maps fail-closed denials to HTTP 403.

Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA
policy + an opaque thread attribute):
- MessageService.addParticipant (governed membership by userId), governed
  openThread self-join (scoped to threads with a membership attribute),
  parentInteractionId on send (reply link), and a generic listThreads.
- REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants;
  socket add_participant + membership/parentInteractionId. ensureParticipant
  gains a role.

Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join /
listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed
join. 175 unit tests + all smokes green; kernel free of dm/group literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:27:47 +05:30

75 lines
2.5 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Body,
Controller,
Get,
Headers,
HttpCode,
Param,
Post,
} from '@nestjs/common';
import { ThreadsService } from './threads.service';
import { MessageService } from '../messaging/message.service';
import { SessionVerifier } from '../platform/session.verifier';
import { SendMessageDto } from './send-message.dto';
@Controller('v1/threads')
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). */
@Get()
async listThreads(@Headers('authorization') auth?: string) {
return this.messages.listThreads(this.principal(auth));
}
/** Create a thread; `membership`/`creatorRole` are opaque, app-supplied thread attributes the kernel stores but never interprets. */
@Post()
@HttpCode(201)
async createThread(@Body() body: { membership?: string; creatorRole?: string }, @Headers('authorization') auth?: string) {
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole });
}
/** 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);
}
@Get(':id/messages')
async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id);
}
/** 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 },
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);
}
}