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:
2026-07-21 23:23:18 +05:30
parent cb9a0b9250
commit ebf553eb68
5 changed files with 141 additions and 8 deletions
@@ -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);