OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / gate-policy.js

81 lines · javascript ref:
// 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 }