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 actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
const alreadyMember =
|
const alreadyMember =
|
||||||
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
|
(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);
|
// 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.
|
// support/inbox/generic threads (no membership attr) keep open-join, unchanged. A PUBLIC channel
|
||||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
// is the one membership type that also allows open self-join (policy reads `visibility`).
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember });
|
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);
|
await this.actors.ensureParticipant(threadId, actor.id);
|
||||||
return { threadId, status: thread.status, history: await this.history(threadId) };
|
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. */
|
/** Toggle the caller's per-thread notification mute flag. */
|
||||||
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
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);
|
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 () => {
|
it('listThreads returns the caller’s threads with membership, count, last message + unread', async () => {
|
||||||
const s = gov();
|
const s = gov();
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
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/);
|
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 () => {
|
it('self-join is governed only on membership threads', async () => {
|
||||||
// generic / support thread (no membership attr) → open join, unchanged
|
// generic / support thread (no membership attr) → open join, unchanged
|
||||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
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 {
|
export interface OpaInput {
|
||||||
action?: string;
|
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;
|
participantCount?: number;
|
||||||
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
||||||
alreadyMember?: boolean;
|
alreadyMember?: boolean;
|
||||||
@@ -55,12 +56,21 @@ export class DevOpaPort {
|
|||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
case 'iios.thread.join': {
|
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. A PUBLIC channel is the one
|
||||||
// generic/support threads (no membership attr) keep open-join. For a governed
|
// membership type that also allows open self-join. dm/group/private-channel are closed:
|
||||||
// thread, new members must be added first (governed add-participant).
|
// new members must be added first (governed add-participant).
|
||||||
if (!i.membership) return allow();
|
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');
|
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': {
|
case 'iios.interaction.annotate': {
|
||||||
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
|
// 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');
|
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);
|
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. */
|
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
@@ -79,6 +91,13 @@ export class ThreadsController {
|
|||||||
return this.messages.renameThread(id, this.principal(auth), body.subject);
|
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')
|
@Get(':id/messages')
|
||||||
async listMessages(@Param('id') id: string) {
|
async listMessages(@Param('id') id: string) {
|
||||||
return this.threads.getMessages(id);
|
return this.threads.getMessages(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user