// threadgate — who may reply. Run: node test/threadgate.test.mjs
//
// The trap this suite exists for: `allow: []` means NOBODY, and `allow` ABSENT means everyone. A
// serializer that helpfully drops an empty array turns "replies off" into "replies open" — the exact
// inverse of what the author asked for.
import { GATE_CHOICES, allowFor, choiceOf, describe, canReply, mentionsOf, gateRecord, listUriOf } from '../lib/threadgate.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
// ── the empty-array trap ─────────────────────────────────────────────────────────────────────────
ok(allowFor('everyone') === undefined, "'everyone' produces NO allow — a gate that allows everything would read as nobody")
ok(Array.isArray(allowFor('nobody')) && allowFor('nobody').length === 0, "'nobody' is an EMPTY array, which is a real value")
ok(choiceOf({ allow: [] }) === 'nobody', 'an empty allow reads back as nobody')
ok(choiceOf({}) === 'everyone', 'an absent allow reads back as everyone')
ok(choiceOf(null) === 'everyone', 'no gate record at all is everyone')
ok(gateRecord({ postUri: 'at://x', choice: 'everyone' }) === null, "'everyone' writes NO record")
ok(gateRecord({ postUri: 'at://x', choice: 'nobody' }).allow.length === 0, "'nobody' writes a record with an empty allow")
// ── round-trip ───────────────────────────────────────────────────────────────────────────────────
for (const c of GATE_CHOICES) {
const rec = gateRecord({ postUri: 'at://x', choice: c })
ok(choiceOf(rec) === (c === 'everyone' ? 'everyone' : c), `${c} round-trips through the record`)
}
// An unknown rule is 'limited', never guessed permissive — inventing a permissive reading of an unknown
// rule is how a gated thread ends up looking open.
// A rule type we genuinely do not implement (as #listRule was before 0.3.22) reads as 'limited' —
// never guessed permissive, since inventing a permissive reading is how a gated thread looks open.
ok(choiceOf({ allow: [{ $type: 'app.bsky.feed.threadgate#somethingNew' }] }) === 'limited', 'an unrecognised rule reads as limited')
ok(choiceOf({ allow: 'nonsense' }) === 'limited', 'a malformed allow reads as limited, not as everyone')
// ── who may reply ────────────────────────────────────────────────────────────────────────────────
const A = 'did:plc:author'; const V = 'did:plc:viewer'
ok(canReply({ viewerDid: A, authorDid: A, record: { allow: [] } }) === true, 'the author can always reply to their own thread, even with replies off')
ok(canReply({ viewerDid: V, authorDid: A, record: null }) === true, 'no gate → anyone signed in may reply')
ok(canReply({ viewerDid: null, authorDid: A, record: null }) === false, 'signed out → no')
ok(canReply({ viewerDid: V, authorDid: A, record: { allow: [] } }) === false, 'replies off → no')
// DIRECTION MATTERS: the rule is "people the AUTHOR follows", not "people who follow the author".
const following = { allow: [{ $type: 'app.bsky.feed.threadgate#followingRule' }] }
ok(canReply({ viewerDid: V, authorDid: A, record: following, authorFollowsViewer: true }) === true, 'following rule: author follows viewer → yes')
ok(canReply({ viewerDid: V, authorDid: A, record: following, authorFollowsViewer: false }) === false, '…and the reverse direction does NOT grant it')
const mention = { allow: [{ $type: 'app.bsky.feed.threadgate#mentionRule' }] }
ok(canReply({ viewerDid: V, authorDid: A, record: mention, mentionedDids: [V] }) === true, 'mention rule: mentioned → yes')
ok(canReply({ viewerDid: V, authorDid: A, record: mention, mentionedDids: [] }) === false, 'not mentioned → no')
// An unknown rule set is CLOSED for our own writes: refusing a reply that may be rejected downstream is
// a papercut; writing one the author asked to prevent is worse.
ok(canReply({ viewerDid: V, authorDid: A, record: { allow: [{ $type: 'x#somethingNew' }] } }) === false, 'an unrecognised rule fails CLOSED')
// Mentions come from the post's facets — the same facets we now write (0.3.11).
ok(mentionsOf({ facets: [{ features: [{ $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:bob' }] }] })[0] === 'did:plc:bob', 'mentions are read from facets')
ok(mentionsOf({}).length === 0 && mentionsOf(null).length === 0, 'no facets → no mentions, no throw')
ok(describe('nobody').includes('turned off'), 'each choice has a human-readable description')
ok(describe('everyone') === '', 'and an ungated post says nothing at all')
// ── #listRule ────────────────────────────────────────────────────────────────────────────────────
// Recognised as its own choice rather than lumped into 'limited', because we can name the list and can
// sometimes resolve its membership.
const listGate = { allow: [{ $type: 'app.bsky.feed.threadgate#listRule', list: 'at://did:plc:o/app.bsky.graph.list/abc' }] }
ok(choiceOf(listGate) === 'list', 'a listRule reads as its own choice')
ok(listUriOf(listGate) === 'at://did:plc:o/app.bsky.graph.list/abc', 'and the list uri is extractable')
ok(listUriOf({ allow: [] }) === null && listUriOf(null) === null, 'no list rule → no uri')
ok(describe('list').includes('list'), 'and it describes itself')
// UNKNOWN MEMBERSHIP IS NOT REFUSAL. null means "we could not resolve the list", which must fail closed
// for our own writes without claiming the viewer was excluded.
ok(canReply({ viewerDid: V, authorDid: A, record: listGate, listMembers: [V] }) === true, 'a member may reply')
ok(canReply({ viewerDid: V, authorDid: A, record: listGate, listMembers: ['did:plc:other'] }) === false, 'a non-member may not')
ok(canReply({ viewerDid: V, authorDid: A, record: listGate, listMembers: null }) === false, 'unresolvable membership fails CLOSED')
ok(canReply({ viewerDid: A, authorDid: A, record: listGate, listMembers: null }) === true, 'but the author can still reply to their own thread')
console.log(`\nTHREADGATE OK: ${pass} assertions`)