| … | … | @@ -52,7 +52,12 @@ import crypto from 'hypercore-crypto' |
| 52 | 52 | import bareWs from 'bare-ws' |
| 53 | 53 | import { timelinePage, profilePage, tagPage, renderAtom, esc } from '../lib/render.js' |
| 54 | 54 | import { FavelaAuth, handoffDecision } from '../lib/auth.js' |
| 55 | import { extractToken, extractIdentity, requireIdentity, requireRepoOwner } from '../lib/check.js' | |
| 55 | import { extractToken, extractIdentity, requireIdentity, requireRepoOwner, requireMember } from '../lib/check.js' | |
| 56 | import { 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. | |
| 60 | const REQUIRE_MEMBERSHIP = String(process.env.FAVELA_REQUIRE_MEMBERSHIP || '').trim() === '1' | |
| 56 | 61 | import { joinPairInvite, createPairInvite } from '../lib/pairing.js' |
| 57 | 62 | |
| 58 | 63 | // 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 |
| 814 | 819 | const commStore = store.namespace('comm') // namespace of the ONE store — see the note at its creation |
| 815 | 820 | const communityRooms = new CommunityRooms(commStore, swarm) |
| 816 | 821 | const 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). | |
| 825 | const 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. | |
| 829 | let _visCache = { value: PUBLIC, exp: 0 } | |
| 830 | const 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 | } | |
| 817 | 840 | // Lens export: one bsky custom feed per community, served from favela's own appview index. Online only when |
| 818 | 841 | // both feedgen envs are set; each community registers as a feed (rkey = slug of its id), and getFeedSkeleton |
| 819 | 842 | // 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) => { |
| 1033 | 1056 | const isDeviceRevoked = (k) => writer.isDeviceRevoked(k) |
| 1034 | 1057 | try { |
| 1035 | 1058 | 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 | } | |
| 1037 | 1063 | } catch (e) { |
| 1038 | 1064 | const status = e.status || 403 |
| 1039 | 1065 | 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) => { |
| 1041 | 1067 | } |
| 1042 | 1068 | } |
| 1043 | 1069 | |
| 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 | ||
| 1044 | 1080 | // ── auth (the browser client drives these; favela verifies keylessly) ── |
| 1045 | 1081 | if (path === '/api/auth/challenge') { |
| 1046 | 1082 | return json(res, 200, JSON.stringify({ challenge: auth.mintChallenge() })) |
| … | … | @@ -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 | ||
| 14 | export const PUBLIC = 'public' | |
| 15 | export 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 | */ | |
| 25 | export 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 | */ | |
| 50 | export 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. | |
| 64 | const 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 | ] | |
| 71 | const EXEMPT_EXACT = new Set(['/login', '/logout', '/favicon.ico', '/robots.txt']) | |
| 72 | ||
| 73 | /** May an ANONYMOUS request reach this path even on a closed site? */ | |
| 74 | export 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 | ||
| 80 | export default { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS } |
| … | … | @@ -56,7 +56,7 @@ |
| 56 | 56 | "spike:seam": "node spike/atcute-seam.mjs", |
| 57 | 57 | "spike:p2p": "node spike/p2p-read.mjs", |
| 58 | 58 | "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", | |
| 60 | 60 | "test:ws": "bare test/subscribe-labels-ws.test.mjs", |
| 61 | 61 | "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", |
| 62 | 62 | "spike:pack": "bare spike/mobile-pack-probe.mjs" |
| … | … | @@ -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 | |
| 4 | import { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS } from '../lib/gate-policy.js' | |
| 5 | ||
| 6 | let pass = 0 | |
| 7 | const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ } | |
| 8 | const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`) | |
| 9 | ||
| 10 | const DID = 'did:plc:memberaaaaaaaaaaaaaaaaa' | |
| 11 | const 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 | ||
| 67 | console.log(`\n${pass} assertions passed`) |