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>
This commit is contained in:
@@ -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<string, string> },
|
||||
): Promise<Array<{ threadId: string; subject: string | null; metadata: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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 } });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<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)
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user