OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / b7cf3c8

auth 5B: membership from the room roster, and a community's right to close its site
Membership and visibility were the two policies the 5A gate left injectable. Both read state that already exists — the room's roster (`member:<did>`) and its meta (`set-meta {key,value}`) — so neither needs a schema change or a migration. - lib/gate-policy.js (pure, no P2P, no HTTP): * makeIsMember(communities) — a DID is a member if it appears on any configured room's roster. FAILS CLOSED: Communities.rosterOf already stale-serves and yields an empty Set for an unreachable room, and a resolver that throws is caught to the same effect. An unreachable room is not consent, and this is the one place where being wrong grants someone else's rights. * siteVisibility(metas) — 'members' only when EVERY configured community asks to close, else 'public'. Default public is deliberate: a flag that changed behaviour the moment it shipped would be an outage dressed as a feature. With several communities, one public one keeps the door open rather than hiding content another community chose to publish. * isPublicPath(path) — the exemptions, and they are load-bearing. Gate /login or /api/auth/* and a visitor can never STOP being anonymous, so "members only" becomes "nobody, ever". /xrpc stays open because bsky's AppView reads the lens ANONYMOUSLY — gating it would silently break the community feed the room exists to publish. - bin/favela-web.js: * READ GATE on GET: anonymous + non-exempt + closed -> /login. Inert unless a community opts in. * WRITE tier gains membership, OPT-IN via FAVELA_REQUIRE_MEMBERSHIP=1, default OFF. Enabling it blind would refuse the operator's own posts if their DID is not on a roster — a self-inflicted outage shipped as a security fix. Flip it per node once the roster is confirmed. * Visibility is cached 60s: room meta replicates asynchronously and this is read per render. Tests: 31 assertions in gate-policy.test.mjs, wired into the chain. Boot-verified: with no community configured the site renders 200, /login 200, /xrpc public, anonymous POST still 401. NOT yet exercised end-to-end: the CLOSED branch. It needs a room whose meta says visibility=members; the decision logic is unit-tested, the wiring is not. Worth doing against a scratch room before any community relies on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b7cf3c89af73917f58670f1da9992602f236edc8
by plankatron <fleeky@gmail.com> · 2026-08-16T20:57:13.000Z

Changed files (4) +186 -3

bin/favela-web.js +38 -2
……@@ -52,7 +52,12 @@ import crypto from 'hypercore-crypto'
5252import bareWs from 'bare-ws'
5353import { timelinePage, profilePage, tagPage, renderAtom, esc } from '../lib/render.js'
5454import { FavelaAuth, handoffDecision } from '../lib/auth.js'
55import { extractToken, extractIdentity, requireIdentity, requireRepoOwner } from '../lib/check.js'
55import { extractToken, extractIdentity, requireIdentity, requireRepoOwner, requireMember } from '../lib/check.js'
56import { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS } from '../lib/gate-policy.js'
57// Membership on top of repo ownership is OPT-IN per node. Default OFF on purpose: turning it on
58// without the operator's DID on a roster would refuse their OWN posts — a self-inflicted outage
59// shipped as a security fix. Flip it once the roster is confirmed.
60const REQUIRE_MEMBERSHIP = String(process.env.FAVELA_REQUIRE_MEMBERSHIP || '').trim() === '1'
5661import { joinPairInvite, createPairInvite } from '../lib/pairing.js'
5762
5863// Live device-invite tunnels, did -> { inviteHex, state, done, src }. In memory because a blind-pairing
……@@ -814,6 +819,24 @@ const NODE_CAN_WRITE = true // the node relay writer; per-viewer write authority
814819const commStore = store.namespace('comm') // namespace of the ONE store — see the note at its creation
815820const communityRooms = new CommunityRooms(commStore, swarm)
816821const communities = new Communities({ communities: COMMUNITY_ROOMS, resolve: (c) => communityRooms.members(c.roomKey) })
822
823// ── 5B policy: who participates, and who may LOOK ───────────────────────────────────────────────
824// isMember reads the room roster favela already replicates; it fails closed (see gate-policy.js).
825const isMember = makeIsMember(communities)
826// A community closes its site with `set-meta visibility members` — an ordinary meta key, so no schema
827// change. Cached: room meta replicates asynchronously and this is consulted per page render. Default
828// (and every failure path) is PUBLIC, so a node that never sets it renders exactly as it does today.
829let _visCache = { value: PUBLIC, exp: 0 }
830const siteVisibilityOnce = async () => {
831 if (Date.now() < _visCache.exp) return _visCache.value
832 let value = PUBLIC
833 try {
834 const metas = await Promise.all(COMMUNITY_ROOMS.map((c) => communityRooms.meta(c.roomKey).catch(() => ({}))))
835 value = siteVisibility(metas)
836 } catch { value = PUBLIC }
837 _visCache = { value, exp: Date.now() + 60000 }
838 return value
839}
817840// Lens export: one bsky custom feed per community, served from favela's own appview index. Online only when
818841// both feedgen envs are set; each community registers as a feed (rkey = slug of its id), and getFeedSkeleton
819842// reads the roster → the appview → bsky-only post URIs. registerFeed is idempotent (Map by rkey).
……@@ -1033,7 +1056,10 @@ const server = http.createServer(async (req, res) => {
10331056 const isDeviceRevoked = (k) => writer.isDeviceRevoked(k)
10341057 try {
10351058 if (IDENTITY_POST.has(path)) await requireIdentity(ctx, { isDeviceRevoked, failOpen: false })
1036 else await requireRepoOwner(ctx, { repoDid: await repoDidOnce(), isDeviceRevoked })
1059 else {
1060 await requireRepoOwner(ctx, { repoDid: await repoDidOnce(), isDeviceRevoked })
1061 if (REQUIRE_MEMBERSHIP) await requireMember(ctx, { isMember, isDeviceRevoked })
1062 }
10371063 } catch (e) {
10381064 const status = e.status || 403
10391065 console.log(JSON.stringify({ msg: 'write refused', path, viewer: viewer ? viewer.did : null, status, reason: e.message }))
……@@ -1041,6 +1067,16 @@ const server = http.createServer(async (req, res) => {
10411067 }
10421068 }
10431069
1070 // ── READ GATE — a community may CLOSE its site (`set-meta visibility members`) ──────────────
1071 // INERT unless every configured community asks to close (gate-policy.siteVisibility defaults to
1072 // public), so a node that never sets the flag renders exactly as it does today. Exemptions live in
1073 // isPublicPath and are load-bearing: gate /login or /api/auth/* and a visitor can never STOP being
1074 // anonymous, turning "members only" into "nobody, ever". /xrpc stays open because bsky's AppView
1075 // reads the lens anonymously. POSTs are already covered by the write gate above.
1076 if (req.method === 'GET' && !viewer && !isPublicPath(path) && (await siteVisibilityOnce()) === MEMBERS) {
1077 return redirect(res, '/login')
1078 }
1079
10441080 // ── auth (the browser client drives these; favela verifies keylessly) ──
10451081 if (path === '/api/auth/challenge') {
10461082 return json(res, 200, JSON.stringify({ challenge: auth.mintChallenge() }))
lib/gate-policy.js +80 -0
……@@ -0,0 +1,80 @@
1// Gate policy — the two decisions 5B adds, kept PURE so they can be reasoned about without a swarm.
2//
3// makeIsMember(communities) -> isMember(did) WHO may participate
4// siteVisibility(metas) -> 'public'|'members' WHETHER an anonymous visitor sees the site
5//
6// Both read state that already exists: the community room's roster (`member:<did>`, via
7// Communities.communitiesFor) and its meta (`set-meta {key,value}`, already in favela-community's
8// schema — `visibility` is just another key, so this needs NO schema change and no migration).
9//
10// ⛔ These gate the BROWSER-FACING site only. `getFeedSkeleton` and the rest of the lens export stay
11// public regardless: bsky's AppView calls them anonymously, so gating them on membership would
12// silently break the community's bsky feed — the exact surface the community publishes ON PURPOSE.
13
14export const PUBLIC = 'public'
15export const MEMBERS = 'members'
16
17/**
18 * Build the `isMember(did)` policy `requireMember` takes.
19 *
20 * FAILS CLOSED. Communities.rosterOf already stale-serves a last-good roster and yields an empty Set
21 * when a room cannot be read, so an unreachable room reports "not a member" rather than "everyone".
22 * A resolver that throws outright is caught here to the same effect — an unreachable room is not
23 * consent, and this is the one place where being wrong grants someone else's rights.
24 */
25export function makeIsMember (communities) {
26 return async function isMember (did) {
27 if (!did || !communities || typeof communities.communitiesFor !== 'function') return false
28 try {
29 const mine = await communities.communitiesFor(did)
30 return Array.isArray(mine) && mine.length > 0
31 } catch {
32 return false
33 }
34 }
35}
36
37/**
38 * Should the SSR site render for an anonymous visitor?
39 *
40 * DEFAULT IS PUBLIC, deliberately: this flag exists so a community can CLOSE its site, and a node
41 * that never sets it must behave exactly as it does today. A flag that changed behaviour on arrival
42 * would be an outage dressed as a feature.
43 *
44 * With several communities on one node, ONE public community keeps the site open — closing it would
45 * hide content another community chose to publish. Per-community closure is the stricter rule and
46 * wants per-community routing, which this node does not have yet.
47 *
48 * @param {Array<{visibility?: string}>} metas - each configured community's room meta
49 */
50export function siteVisibility (metas) {
51 if (!Array.isArray(metas) || metas.length === 0) return PUBLIC
52 const closed = (m) => String((m && m.visibility) || '').trim().toLowerCase() === MEMBERS
53 // Every community must ASK to be closed. An unrecognised value is not a request to close.
54 return metas.every(closed) ? MEMBERS : PUBLIC
55}
56
57
58// Paths that must stay reachable even when a community closes its site.
59//
60// ⛔ THIS LIST IS LOAD-BEARING. Close the site without it and you brick the way IN: a visitor who
61// cannot reach /login or /api/auth/* can never stop being anonymous, so "members only" becomes
62// "nobody, ever". The lens export is exempt for a different reason — bsky's AppView calls /xrpc
63// ANONYMOUSLY, so gating it would silently break the very feed the community publishes on purpose.
64const EXEMPT_PREFIXES = [
65 '/xrpc/', // lens export (getFeedSkeleton, describeFeedGenerator, queryLabels) — bsky reads these
66 '/.well-known/', // did:web + atproto-did identity resolution
67 '/api/auth/', // challenge -> sign -> session
68 '/static/', // the assets /login itself needs
69 '/auth/handoff' // embedded-WebView session handoff (loopback-gated on its own)
70]
71const EXEMPT_EXACT = new Set(['/login', '/logout', '/favicon.ico', '/robots.txt'])
72
73/** May an ANONYMOUS request reach this path even on a closed site? */
74export function isPublicPath (path) {
75 const p = String(path || '')
76 if (EXEMPT_EXACT.has(p)) return true
77 return EXEMPT_PREFIXES.some((pre) => p.startsWith(pre))
78}
79
80export default { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS }
package.json +1 -1
……@@ -56,7 +56,7 @@
5656 "spike:seam": "node spike/atcute-seam.mjs",
5757 "spike:p2p": "node spike/p2p-read.mjs",
5858 "web": "bare bin/favela-web.js",
59 "test": "node test/render.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
59 "test": "node test/render.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
6060 "test:ws": "bare test/subscribe-labels-ws.test.mjs",
6161 "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs",
6262 "spike:pack": "bare spike/mobile-pack-probe.mjs"
test/gate-policy.test.mjs +67 -0
……@@ -0,0 +1,67 @@
1// Gate policy — the two pure decisions behind 5B: who counts as a member, and whether the SSR site
2// renders for an anonymous visitor. Kept pure (no P2P, no HTTP) so the rules are testable directly.
3// Run: node test/gate-policy.test.mjs
4import { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS } from '../lib/gate-policy.js'
5
6let pass = 0
7const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
8const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`)
9
10const DID = 'did:plc:memberaaaaaaaaaaaaaaaaa'
11const OTHER = 'did:plc:strangerbbbbbbbbbbbbbbb'
12
13// ── makeIsMember ────────────────────────────────────────────────────
14{
15 const communities = { communitiesFor: async (did) => (did === DID ? [{ id: 'loser-club' }] : []) }
16 const isMember = makeIsMember(communities)
17 eq(await isMember(DID), true, 'a DID on a roster is a member')
18 eq(await isMember(OTHER), false, 'a DID on no roster is not')
19 eq(await isMember(null), false, 'no DID is not a member')
20}
21{
22 // Communities.rosterOf already stale-serves and returns an empty Set on failure, so "not a member"
23 // is what an unreachable room produces. Membership must therefore FAIL CLOSED here too — and a
24 // resolver that throws outright must not become an open door.
25 const boom = { communitiesFor: async () => { throw new Error('room unreachable') } }
26 eq(await makeIsMember(boom)(DID), false, 'a membership lookup that throws denies')
27 eq(await makeIsMember(null)(DID), false, 'no community registry at all denies')
28}
29
30// ── siteVisibility ──────────────────────────────────────────────────
31{
32 // DEFAULT IS PUBLIC. This flag exists to let a community CLOSE its site; a node that has never set
33 // it must keep rendering exactly as it does today, or shipping the flag is itself an outage.
34 eq(siteVisibility([]), PUBLIC, 'no communities configured → public (unchanged behaviour)')
35 eq(siteVisibility([{}]), PUBLIC, 'a community with no visibility meta → public')
36 eq(siteVisibility([{ visibility: 'public' }]), PUBLIC, 'explicitly public → public')
37 eq(siteVisibility([{ visibility: 'members' }]), MEMBERS, 'a lone members-only community closes the site')
38 // A node hosting several communities: closing the site would hide the OTHER community's public
39 // content, so one public community keeps the door open. The stricter rule belongs per-community,
40 // and that needs per-community routing this node does not have yet.
41 eq(siteVisibility([{ visibility: 'members' }, { visibility: 'public' }]), PUBLIC,
42 'mixed → public, because closing would hide a community that chose to be open')
43 eq(siteVisibility([{ visibility: 'members' }, { visibility: 'members' }]), MEMBERS, 'all members-only → closed')
44 eq(siteVisibility([{ visibility: 'MEMBERS' }]), MEMBERS, 'value is case-insensitive')
45 eq(siteVisibility([{ visibility: ' members ' }]), MEMBERS, 'and whitespace-tolerant')
46 eq(siteVisibility([{ visibility: 'nonsense' }]), PUBLIC, 'an unrecognised value is NOT treated as closed')
47 eq(siteVisibility(null), PUBLIC, 'null input → public, never a crash')
48}
49
50// ── isPublicPath — the exemptions that keep a closed site usable ────
51{
52 // The way IN must never be gated, or "members only" becomes "nobody, ever".
53 for (const p of ['/login', '/logout', '/api/auth/challenge', '/api/auth/login', '/api/auth/pair', '/static/app.css', '/auth/handoff']) {
54 ok(isPublicPath(p), `sign-in path stays reachable: ${p}`)
55 }
56 // bsky calls the lens ANONYMOUSLY — gating it would break the feed the community publishes on purpose.
57 ok(isPublicPath('/xrpc/app.bsky.feed.getFeedSkeleton'), 'lens export stays public')
58 ok(isPublicPath('/.well-known/did.json'), 'identity resolution stays public')
59 // Everything the site actually renders IS gated.
60 for (const p of ['/', '/profile/did:plc:x', '/post/abc', '/search', '/notifications']) {
61 ok(!isPublicPath(p), `content path is gated: ${p}`)
62 }
63 ok(!isPublicPath('/api/timeline'), 'a JSON read of content is gated too')
64 ok(!isPublicPath(''), 'empty path is not exempt')
65}
66
67console.log(`\n${pass} assertions passed`)