| … | … | @@ -0,0 +1,88 @@ |
| 1 | // maxReach — the lattice. library.md §6.1, record-vocabulary.md §5.2, interaction.md §5.2 | |
| 2 | // | |
| 3 | // ⛔ THIS IS THE ONE PLACE A DEFECT IS A PRIVACY BUG rather than a rendering bug (library §6.1). | |
| 4 | // Written here once, this check would otherwise be written three times — favela-web, | |
| 5 | // hyper-proto-peer, ap-bridge — and the third one would eventually be written wrong. | |
| 6 | // | |
| 7 | // Everything is deny-by-default. Unknown room type, unknown verb, missing argument, hostile input: | |
| 8 | // the answer is {favela}, which is not a restriction but a fact — "every post is local; the | |
| 9 | // canonical record always exists on the P2P side; that is what authoring one means" | |
| 10 | // (record-vocabulary §5.4). Local is the substrate, not a scope you opt into. | |
| 11 | // | |
| 12 | // SIGNATURE CHANGED 2026-09-05. library.md §6.1 signs this `maxReach(visibility)`, from when | |
| 13 | // visibility was a frontmatter field. Visibility is now a PLACE — the room you wrote in | |
| 14 | // (SOCIAL-MODEL §4) — so the inputs are the room's kind and the record's verb. This library has no | |
| 15 | // storage and cannot inspect a room, so the CALLER supplies the kind. That is honest about the | |
| 16 | // library boundary (§1: no storage, no network) rather than pretending to knowledge it lacks. | |
| 17 | ||
| 18 | export const NETWORKS = ['favela', 'bsky', 'fediverse'] | |
| 19 | ||
| 20 | // What each kind of room can be expressed as, on each network. | |
| 21 | // | |
| 22 | // public → everywhere. AS2 Public; an atproto repo is world-readable by construction. | |
| 23 | // followers → the fediverse ONLY. AP has addressing, so a followers room maps onto the followers | |
| 24 | // collection. ⛔ atproto has NO addressing at all (record-vocabulary §5.2), so a | |
| 25 | // projection it cannot restrict is a leak, and the only safe answer is to withhold. | |
| 26 | // members → favela only. AS2 *can* address actors individually, but an arbitrary room's | |
| 27 | // dm membership is not expressible as an audience a receiving server will honour, and | |
| 28 | // approximating it is how a private post reaches a stranger. Fails closed. | |
| 29 | const ROOM_REACH = { | |
| 30 | public: ['favela', 'bsky', 'fediverse'], | |
| 31 | followers: ['favela', 'fediverse'], | |
| 32 | members: ['favela'], | |
| 33 | dm: ['favela'] | |
| 34 | } | |
| 35 | ||
| 36 | // What each verb can be expressed as. interaction.md §5.4 (the signed set) and §5.5 (native). | |
| 37 | // ⛔ A verb absent from this table reaches {favela} and nothing else — that IS the extension | |
| 38 | // mechanism (interaction §5.2): a native-only verb cannot fork a signed projection, because it | |
| 39 | // never reaches a shared derivation. Adding a verb here is a spec change, not a plugin point. | |
| 40 | const VERB_REACH = { | |
| 41 | post: ['favela', 'bsky', 'fediverse'], | |
| 42 | reply: ['favela', 'bsky', 'fediverse'], | |
| 43 | like: ['favela', 'bsky', 'fediverse'], | |
| 44 | repost: ['favela', 'bsky', 'fediverse'], | |
| 45 | follow: ['favela', 'bsky', 'fediverse'], | |
| 46 | // AS2 `Block` exists but Mastodon does not federate it, so it projects with a loss entry rather | |
| 47 | // than being withheld — the receiving server simply does nothing with it. | |
| 48 | block: ['favela', 'bsky', 'fediverse'], | |
| 49 | // AS2 `Flag` is a REPORT to the origin's moderators, a different act from a shared signal, so | |
| 50 | // conflating them would misrepresent the moderator (SOCIAL-MODEL §6.7). atproto has a real | |
| 51 | // equivalent — a signed com.atproto.label, which we already emit. | |
| 52 | flag: ['favela', 'bsky'], | |
| 53 | // Internal: how a node knows two names, or two byte-sets, denote one thing. No projection | |
| 54 | // anywhere, by design (interaction §5.5, record-vocabulary §3.5). | |
| 55 | alias: ['favela'], | |
| 56 | 'variant-of': ['favela'] | |
| 57 | } | |
| 58 | ||
| 59 | const LOCAL = () => new Set(['favela']) | |
| 60 | ||
| 61 | /** | |
| 62 | * maxReach({ roomType, verb }) -> Set<network> | |
| 63 | * | |
| 64 | * The MEET of what the room can express and what the verb can express. TOTAL: never throws, and | |
| 65 | * always contains 'favela'. | |
| 66 | */ | |
| 67 | export function maxReach (opts) { | |
| 68 | const roomType = opts && typeof opts.roomType === 'string' ? opts.roomType : null | |
| 69 | const verb = opts && typeof opts.verb === 'string' ? opts.verb : null | |
| 70 | ||
| 71 | // Exact match only. 'PUBLIC' and 'public ' are NOT 'public': normalising input here would mean a | |
| 72 | // typo could widen an audience, which is precisely the class of bug §6.1 exists to prevent. | |
| 73 | const room = (roomType && Object.prototype.hasOwnProperty.call(ROOM_REACH, roomType)) | |
| 74 | ? ROOM_REACH[roomType] | |
| 75 | : null | |
| 76 | const verbs = (verb && Object.prototype.hasOwnProperty.call(VERB_REACH, verb)) | |
| 77 | ? VERB_REACH[verb] | |
| 78 | : null | |
| 79 | ||
| 80 | if (!room || !verbs) return LOCAL() | |
| 81 | ||
| 82 | const out = new Set() | |
| 83 | for (const n of room) if (verbs.includes(n)) out.add(n) | |
| 84 | out.add('favela') // §5.4 — local is the substrate; there is no way to opt out of it | |
| 85 | return out | |
| 86 | } | |
| 87 | ||
| 88 | export default { maxReach, NETWORKS } |
| … | … | @@ -16,6 +16,6 @@ |
| 16 | 16 | "micromark-extension-gfm-table": "^2.1.1" |
| 17 | 17 | }, |
| 18 | 18 | "scripts": { |
| 19 | "test": "node test/strip.test.js && node test/record.test.js && node test/strip-fuzz.test.js" | |
| 19 | "test": "node test/strip.test.js && node test/record.test.js && node test/reach.test.js && node test/strip-fuzz.test.js" | |
| 20 | 20 | } |
| 21 | 21 | } |
| … | … | @@ -163,7 +163,17 @@ record that uses it. It is not a plugin point. Do not build a registry. |
| 163 | 163 | |
| 164 | 164 | ### 6.1 The lattice |
| 165 | 165 | |
| 166 | `maxReach(visibility) -> Set<network>`, total and deny-by-default. `project()` **refuses** to emit | |
| 166 | `maxReach({ roomType, verb }) -> Set<network>`, total and deny-by-default. | |
| 167 | ||
| 168 | ⚠ **Signature revised 2026-09-05.** This was `maxReach(visibility)`, from when visibility was a | |
| 169 | frontmatter field. Visibility is now a **place** — the room you wrote in (`SOCIAL-MODEL.md` §4) — so | |
| 170 | the inputs are the room's kind and the record's verb, and the result is their **meet**. This library | |
| 171 | has no storage and cannot inspect a room, so the **caller supplies the kind**: honest about the | |
| 172 | §1 boundary rather than pretending to knowledge it lacks. | |
| 173 | ||
| 174 | ⛔ **Exact match only.** `'PUBLIC'` and `'public '` are not `'public'`. Normalising input here would | |
| 175 | mean a typo could *widen* an audience, which is the exact class of bug this section exists to | |
| 176 | prevent. `project()` **refuses** to emit | |
| 167 | 177 | a bsky projection for a record whose `visibility` is not Public, because atproto cannot express |
| 168 | 178 | addressing at all (`record-vocabulary.md` §5.2) and a projection it cannot restrict is a leak. |
| 169 | 179 |
| … | … | @@ -0,0 +1,94 @@ |
| 1 | // maxReach — library.md §6.1, record-vocabulary.md §5.2, interaction.md §5.2 | |
| 2 | // | |
| 3 | // ⛔ THIS IS THE ONE PLACE A DEFECT IS A PRIVACY BUG RATHER THAN A RENDERING BUG (library §6.1), | |
| 4 | // so every case below asserts the DENY direction as well as the allow direction, and the default | |
| 5 | // for anything unrecognised is the empty-est answer the model has: {favela}. | |
| 6 | // | |
| 7 | // Signature changed 2026-09-05. §6.1 signs it `maxReach(visibility)`, from when visibility was a | |
| 8 | // frontmatter field. Visibility is now a PLACE — the room you wrote in (SOCIAL-MODEL §4) — so the | |
| 9 | // inputs are the room's kind and the verb. The codec has no storage and cannot inspect a room, so | |
| 10 | // the caller passes the kind; that is honest about the library boundary rather than pretending. | |
| 11 | ||
| 12 | import test from 'brittle' | |
| 13 | import { maxReach, NETWORKS } from '../lib/reach.js' | |
| 14 | ||
| 15 | const set = (r) => [...r].sort().join(',') | |
| 16 | ||
| 17 | test('a PUBLIC room reaches everywhere a verb allows', (t) => { | |
| 18 | t.is(set(maxReach({ roomType: 'public', verb: 'post' })), 'bsky,favela,fediverse') | |
| 19 | t.is(set(maxReach({ roomType: 'public', verb: 'like' })), 'bsky,favela,fediverse') | |
| 20 | }) | |
| 21 | ||
| 22 | test('⛔ a FOLLOWERS room reaches the fediverse but NEVER bsky (§5.2)', (t) => { | |
| 23 | // AP has addressing — a followers room maps onto the followers collection. atproto has NONE, so | |
| 24 | // a projection it cannot restrict is a leak, and the only safe answer is to withhold it. | |
| 25 | t.is(set(maxReach({ roomType: 'followers', verb: 'post' })), 'favela,fediverse') | |
| 26 | }) | |
| 27 | ||
| 28 | test('⛔ a MEMBERS room and a DM reach nothing but favela', (t) => { | |
| 29 | // AS2 can address actors, but an arbitrary room's membership is not expressible as an audience | |
| 30 | // the receiving server will honour, so it fails CLOSED rather than approximating. | |
| 31 | t.is(set(maxReach({ roomType: 'members', verb: 'post' })), 'favela') | |
| 32 | t.is(set(maxReach({ roomType: 'dm', verb: 'post' })), 'favela') | |
| 33 | }) | |
| 34 | ||
| 35 | test('⛔ an UNKNOWN room type reaches only favela — deny by default', (t) => { | |
| 36 | for (const roomType of ['', null, undefined, 'PUBLIC', 'public ', 'anything', 42, {}]) { | |
| 37 | t.is(set(maxReach({ roomType, verb: 'post' })), 'favela', | |
| 38 | 'unrecognised room type ' + JSON.stringify(roomType) + ' fails closed') | |
| 39 | } | |
| 40 | }) | |
| 41 | ||
| 42 | test('⛔ an UNKNOWN verb reaches only favela (interaction §5.2)', (t) => { | |
| 43 | // "An unknown verb's maximum reach is {favela}." That IS the extension mechanism: a native-only | |
| 44 | // verb cannot fork a signed projection because it never reaches a shared derivation. | |
| 45 | for (const verb of ['net.example.rsvp', 'bookmark', 'pin', '', null, undefined, 99]) { | |
| 46 | t.is(set(maxReach({ roomType: 'public', verb })), 'favela', | |
| 47 | 'unknown verb ' + JSON.stringify(verb) + ' is native-only') | |
| 48 | } | |
| 49 | }) | |
| 50 | ||
| 51 | test('internal verbs never leave, even from a public room', (t) => { | |
| 52 | // alias and variant-of are how a node knows two names/byte-sets denote one thing. They have no | |
| 53 | // projection anywhere, by design (interaction §5.5, record-vocabulary §3.5). | |
| 54 | t.is(set(maxReach({ roomType: 'public', verb: 'alias' })), 'favela') | |
| 55 | t.is(set(maxReach({ roomType: 'public', verb: 'variant-of' })), 'favela') | |
| 56 | }) | |
| 57 | ||
| 58 | test('flag projects to bsky as a label, and nowhere on the fediverse', (t) => { | |
| 59 | // AS2 `Flag` is a REPORT to the origin's moderators — a different act from a shared signal, so | |
| 60 | // conflating them would misrepresent the moderator (SOCIAL-MODEL §6.7). | |
| 61 | t.is(set(maxReach({ roomType: 'public', verb: 'flag' })), 'bsky,favela') | |
| 62 | }) | |
| 63 | ||
| 64 | test('the meet is per-input: a restrictive room narrows a permissive verb', (t) => { | |
| 65 | t.is(set(maxReach({ roomType: 'followers', verb: 'flag' })), 'favela', | |
| 66 | 'flag would reach bsky, but a followers room cannot') | |
| 67 | }) | |
| 68 | ||
| 69 | test('maxReach is TOTAL — never throws, always returns a Set containing favela', (t) => { | |
| 70 | const hostile = [undefined, null, {}, { roomType: 'public' }, { verb: 'post' }, | |
| 71 | { roomType: [], verb: {} }, { roomType: 'dm', verb: 'alias' }] | |
| 72 | for (const arg of hostile) { | |
| 73 | try { | |
| 74 | const r = maxReach(arg) | |
| 75 | t.ok(r instanceof Set && r.has('favela'), 'total on ' + JSON.stringify(arg)) | |
| 76 | } catch (err) { | |
| 77 | t.fail('threw on ' + JSON.stringify(arg) + ': ' + err.message) | |
| 78 | } | |
| 79 | } | |
| 80 | }) | |
| 81 | ||
| 82 | test('favela is ALWAYS reachable — local is the substrate, not a scope (§5.4)', (t) => { | |
| 83 | // "Every post is local. The canonical record always exists on the P2P side; that is what authoring | |
| 84 | // one means. 'Local' is therefore not a destination you opt into, and there is no way to opt out." | |
| 85 | for (const roomType of ['public', 'followers', 'members', 'dm', 'nonsense']) { | |
| 86 | for (const verb of ['post', 'like', 'alias', 'net.example.unknown']) { | |
| 87 | t.ok(maxReach({ roomType, verb }).has('favela'), roomType + '/' + verb + ' is always local') | |
| 88 | } | |
| 89 | } | |
| 90 | }) | |
| 91 | ||
| 92 | test('NETWORKS is exported and favela leads it', (t) => { | |
| 93 | t.ok(NETWORKS.includes('favela') && NETWORKS.includes('bsky') && NETWORKS.includes('fediverse')) | |
| 94 | }) |