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>
This commit is contained in:
2026-07-06 15:28:01 +05:30
parent 2056391f9d
commit ba745bb71a
15 changed files with 418 additions and 28 deletions
@@ -66,17 +66,23 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
}
@SubscribeMessage('open_thread')
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string }) {
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string }) {
const { principal } = client.data as SocketState;
const result = await this.messages.openThread(body?.threadId ?? null, principal);
const result = await this.messages.openThread(body?.threadId ?? null, principal, { membership: body?.membership, creatorRole: body?.creatorRole });
await client.join(result.threadId);
return result;
}
@SubscribeMessage('add_participant')
async addParticipant(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; userId: string; role?: string }) {
const { principal } = client.data as SocketState;
return this.messages.addParticipant(body.threadId, principal, body.userId, body.role);
}
@SubscribeMessage('send_message')
async sendMessage(
@ConnectedSocket() client: Socket,
@MessageBody() body: { threadId: string; content: string; contentRef?: string },
@MessageBody() body: { threadId: string; content: string; contentRef?: string; parentInteractionId?: string },
) {
const { principal } = client.data as SocketState;
const msg = await this.messages.send(
@@ -84,6 +90,8 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
principal,
{ content: body.content, contentRef: body.contentRef },
randomUUID(),
undefined,
body.parentInteractionId,
);
this.emittedLocally.add(msg.id);
this.server.to(body.threadId).emit('message', msg);