From ebf553eb682c97ddc450c9ffa65facc9988e5eed Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 23:23:18 +0530 Subject: [PATCH] =?UTF-8?q?feat(threads):=20channel=20backend=20=E2=80=94?= =?UTF-8?q?=20discover=20(browse),=20public=20self-join,=20leave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/messaging/message.service.ts | 70 +++++++++++++++++-- .../src/messaging/message.spec.ts | 28 ++++++++ .../src/platform/dev-opa.port.spec.ts | 14 ++++ .../iios-service/src/platform/dev-opa.port.ts | 18 +++-- .../src/threads/threads.controller.ts | 19 +++++ 5 files changed, 141 insertions(+), 8 deletions(-) diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index f1699d6..450aba8 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -114,10 +114,18 @@ export class MessageService { const actor = await this.actors.resolveActor(thread.scopeId, principal); const alreadyMember = (await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null; - // Join is governed ONLY for threads that opted into a membership model (chat dm/group); - // support/inbox/generic threads (no membership attr) keep open-join, unchanged. - const membership = (thread.metadata as { membership?: string } | null)?.membership; - await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember }); + // Join is governed ONLY for threads that opted into a membership model (chat dm/group/channel); + // support/inbox/generic threads (no membership attr) keep open-join, unchanged. A PUBLIC channel + // is the one membership type that also allows open self-join (policy reads `visibility`). + const bag = (thread.metadata as { membership?: string; visibility?: string } | null) ?? {}; + await decideOrThrow(this.ports, { + action: 'iios.thread.join', + threadId, + scopeId: thread.scopeId, + membership: bag.membership, + visibility: bag.visibility, + alreadyMember, + }); await this.actors.ensureParticipant(threadId, actor.id); return { threadId, status: thread.status, history: await this.history(threadId) }; } @@ -228,6 +236,60 @@ export class MessageService { })); } + /** + * Scope-wide thread discovery — the ONE generic primitive channels need beyond dm/group. + * Unlike listThreads (membership-scoped), this returns threads in the caller's scope matching an + * opaque metadata filter (e.g. {membership:'channel', visibility:'public'}) REGARDLESS of whether + * the caller is a participant, each flagged `joined`. Governed by policy (fail-closed) + fenced to + * the caller's own scope. The kernel never interprets the filter keys. + */ + async discoverThreads( + principal: MessagePrincipal, + filter?: { metadata?: Record }, + ): Promise | null; participantCount: number; joined: boolean }>> { + const scope = await this.actors.findScope(principal); + if (!scope) return []; + await decideOrThrow(this.ports, { action: 'iios.thread.discover', scopeId: scope.id }); + const actor = await this.actors.resolveActor(scope.id, principal); + + const inScope = await this.prisma.iiosThread.findMany({ where: { scopeId: scope.id } }); + const metaFilter = filter?.metadata; + const matched = metaFilter + ? inScope.filter((t) => { + const bag = (t.metadata as Record | null) ?? {}; + return Object.entries(metaFilter).every(([k, v]) => bag[k] === v); + }) + : inScope; + if (matched.length === 0) return []; + + const ids = matched.map((t) => t.id); + const parts = await this.prisma.iiosThreadParticipant.groupBy({ by: ['threadId'], where: { threadId: { in: ids } }, _count: { actorId: true } }); + const countBy = new Map(parts.map((p) => [p.threadId, p._count.actorId])); + const mine = await this.prisma.iiosThreadParticipant.findMany({ where: { threadId: { in: ids }, actorId: actor.id }, select: { threadId: true } }); + const joinedSet = new Set(mine.map((m) => m.threadId)); + + return matched.map((t) => ({ + threadId: t.id, + subject: t.subject, + metadata: (t.metadata as Record | null) ?? null, + participantCount: countBy.get(t.id) ?? 0, + joined: joinedSet.has(t.id), + })); + } + + /** Self-leave: remove the caller's own participant row. Governed (a member may always leave). */ + async leaveThread(threadId: string, principal: MessagePrincipal): Promise<{ threadId: string; participantCount: number }> { + const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); + if (!thread) throw new NotFoundException('thread not found'); + const actor = await this.actors.resolveActor(thread.scopeId, principal); + const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } }); + const membership = (thread.metadata as { membership?: string } | null)?.membership; + await decideOrThrow(this.ports, { action: 'iios.thread.leave', threadId, scopeId: thread.scopeId, membership, callerRole: callerP?.participantRole }); + await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: actor.id } }); + const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } }); + return { threadId, participantCount }; + } + /** 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 46101ee..9514ba8 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -154,6 +154,34 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => { expect((await s.openThread(threadId, bob)).threadId).toBe(threadId); }); + it('channels: a PUBLIC channel allows open self-join; a PRIVATE one does not', async () => { + const s = gov(); + const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' }); + expect((await s.openThread(pub, bob)).threadId).toBe(pub); // bob self-joins a public channel + + const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' }); + await expect(s.openThread(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError); // private = invite-only + }); + + it('discoverThreads browses same-scope channels with a joined flag; leave removes me', async () => { + const s = gov(); + const { threadId: gen } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' }); + await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); // a group must NOT appear in a channel browse + + // bob (non-member, same scope) discovers the public channel as not-joined. + const seen = await s.discoverThreads(bob, { metadata: { membership: 'channel', visibility: 'public' } }); + expect(seen.map((t) => t.threadId)).toEqual([gen]); + expect(seen[0]!.joined).toBe(false); + // alice (member) sees it joined. + expect((await s.discoverThreads(alice, { metadata: { membership: 'channel', visibility: 'public' } }))[0]!.joined).toBe(true); + + // bob joins, appears in his list, then leaves. + await s.openThread(gen, bob); + expect((await s.listThreads(bob)).map((t) => t.threadId)).toContain(gen); + await s.leaveThread(gen, bob); + expect((await s.listThreads(bob)).map((t) => t.threadId)).not.toContain(gen); + }); + it('listThreads returns the caller’s threads with membership, count, last message + unread', 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 f2741b6..614d5bb 100644 --- a/packages/iios-service/src/platform/dev-opa.port.spec.ts +++ b/packages/iios-service/src/platform/dev-opa.port.spec.ts @@ -62,6 +62,20 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => { expect(big.obligations[0]?.reason).toMatch(/too large/); }); + it('a PUBLIC channel allows open self-join; private channel and group do not', async () => { + expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'public', alreadyMember: false })).allow).toBe(true); + const priv = await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: false }); + expect(priv.allow).toBe(false); + expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false })).allow).toBe(false); + // an existing member of a private channel can still (re)open it + expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: true })).allow).toBe(true); + }); + + it('discover and leave are allowed (scope fence / member-implied)', async () => { + expect((await opa.decide({ action: 'iios.thread.discover' })).allow).toBe(true); + expect((await opa.decide({ action: 'iios.thread.leave', membership: 'channel' })).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 91f129f..c96c33a 100644 --- a/packages/iios-service/src/platform/dev-opa.port.ts +++ b/packages/iios-service/src/platform/dev-opa.port.ts @@ -9,7 +9,8 @@ import type { PolicyDecision } from '@insignia/iios-contracts'; */ export interface OpaInput { action?: string; - membership?: string; // app-set generic thread attribute: 'dm' | 'group' + membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel' + visibility?: string; // 'public' | 'private' (channels) participantCount?: number; callerRole?: string; // 'MEMBER' | 'ADMIN' alreadyMember?: boolean; @@ -55,12 +56,21 @@ export class DevOpaPort { 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 - // thread, new members must be added first (governed add-participant). + // Generic/support threads (no membership attr) keep open-join. A PUBLIC channel is the one + // membership type that also allows open self-join. dm/group/private-channel are closed: + // new members must be added first (governed add-participant). if (!i.membership) return allow(); + if (i.membership === 'channel' && i.visibility === 'public') return allow(); return i.alreadyMember ? allow() : deny('you are not a member of this thread'); } + case 'iios.thread.discover': { + // Browsing discoverable threads — the query fences to the caller's own scope, so allow. + return allow(); + } + case 'iios.thread.leave': { + // Anyone may leave a thread they're in. (A membership thread implies the caller is a member.) + return allow(); + } case 'iios.interaction.annotate': { // Annotating (e.g. reacting to) a message requires being a participant of its thread. return i.isMember ? allow() : deny('you are not a member of this thread'); diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index afc32e5..5a8d023 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -44,6 +44,18 @@ export class ThreadsController { 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) { + 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) @@ -79,6 +91,13 @@ export class ThreadsController { 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);