// Gate policy — the two decisions 5B adds, kept PURE so they can be reasoned about without a swarm.
//
// makeIsMember(communities) -> isMember(did) WHO may participate
// siteVisibility(metas) -> 'public'|'members' WHETHER an anonymous visitor sees the site
//
// Both read state that already exists: the community room's roster (`member:<did>`, via
// Communities.communitiesFor) and its meta (`set-meta {key,value}`, already in favela-community's
// schema — `visibility` is just another key, so this needs NO schema change and no migration).
//
// ⛔ These gate the BROWSER-FACING site only. `getFeedSkeleton` and the rest of the lens export stay
// public regardless: bsky's AppView calls them anonymously, so gating them on membership would
// silently break the community's bsky feed — the exact surface the community publishes ON PURPOSE.
export const PUBLIC = 'public'
export const MEMBERS = 'members'
/**
* Build the `isMember(did)` policy `requireMember` takes.
*
* FAILS CLOSED. Communities.rosterOf already stale-serves a last-good roster and yields an empty Set
* when a room cannot be read, so an unreachable room reports "not a member" rather than "everyone".
* A resolver that throws outright is caught here to the same effect — an unreachable room is not
* consent, and this is the one place where being wrong grants someone else's rights.
*/
export function makeIsMember (communities) {
return async function isMember (did) {
if (!did || !communities || typeof communities.communitiesFor !== 'function') return false
try {
const mine = await communities.communitiesFor(did)
return Array.isArray(mine) && mine.length > 0
} catch {
return false
}
}
}
/**
* Should the SSR site render for an anonymous visitor?
*
* DEFAULT IS PUBLIC, deliberately: this flag exists so a community can CLOSE its site, and a node
* that never sets it must behave exactly as it does today. A flag that changed behaviour on arrival
* would be an outage dressed as a feature.
*
* With several communities on one node, ONE public community keeps the site open — closing it would
* hide content another community chose to publish. Per-community closure is the stricter rule and
* wants per-community routing, which this node does not have yet.
*
* @param {Array<{visibility?: string}>} metas - each configured community's room meta
*/
export function siteVisibility (metas) {
if (!Array.isArray(metas) || metas.length === 0) return PUBLIC
const closed = (m) => String((m && m.visibility) || '').trim().toLowerCase() === MEMBERS
// Every community must ASK to be closed. An unrecognised value is not a request to close.
return metas.every(closed) ? MEMBERS : PUBLIC
}
// Paths that must stay reachable even when a community closes its site.
//
// ⛔ THIS LIST IS LOAD-BEARING. Close the site without it and you brick the way IN: a visitor who
// cannot reach /login or /api/auth/* can never stop being anonymous, so "members only" becomes
// "nobody, ever". The lens export is exempt for a different reason — bsky's AppView calls /xrpc
// ANONYMOUSLY, so gating it would silently break the very feed the community publishes on purpose.
const EXEMPT_PREFIXES = [
'/xrpc/', // lens export (getFeedSkeleton, describeFeedGenerator, queryLabels) — bsky reads these
'/.well-known/', // did:web + atproto-did identity resolution
'/api/auth/', // challenge -> sign -> session
'/static/', // the assets /login itself needs
'/auth/handoff' // embedded-WebView session handoff (loopback-gated on its own)
]
const EXEMPT_EXACT = new Set(['/login', '/logout', '/favicon.ico', '/robots.txt'])
/** May an ANONYMOUS request reach this path even on a closed site? */
export function isPublicPath (path) {
const p = String(path || '')
if (EXEMPT_EXACT.has(p)) return true
return EXEMPT_PREFIXES.some((pre) => p.startsWith(pre))
}
export default { makeIsMember, siteVisibility, isPublicPath, PUBLIC, MEMBERS }