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 } });
|
||||
|
||||
Reference in New Issue
Block a user