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