OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / lenses.js

124 lines · javascript ref:
// lenses.js — which remote AppViews this node reads, if any.
//
// DEFAULT IS NONE. An unconfigured favela reads no remote AppView and behaves exactly as it does today:
// local-indexed data only. That is a deliberate default, not laziness — favela's premise is P2P-native
// with bsky as an optional export, so reaching out to a hosted AppView has to be something an operator
// opts INTO. The cost of opting out is that a thread's view is partial, which the UI must say out loud
// rather than leave implied (a thread with three unseen bsky replies looks identical to one with none).
//
// Three config forms. The first is the one a person can actually reach:
//
//   THE SETTINGS PAGE (lensPref below)
//     A node-level trust decision that only someone with shell access could change is not a setting, it
//     is a deployment detail. The stored preference wins over env so that changing it in the UI is not
//     silently overridden by whatever the process was started with, and clearing it falls back to env.
//
// The env forms remain, for provisioning a node before anyone opens it:
//
//   FAVELA_BRIDGE_URL=https://pds.example.net
//     Shorthand for the common case: one lens named 'bsky', reached THROUGH the bridge, which resolves
//     the `atproto-proxy` header to the real AppView. One variable, keyless, no bsky URL in our config.
//
//   FAVELA_LENSES=[{"name":"blacksky","base":"https://…","proxy":"did:web:…#bsky_appview"}]
//     Explicit list, for multiple lenses or a non-bsky AppView. `base` may be a bridge (with `proxy`) or
//     an AppView directly (without).
//
// NOT MERGED. Each lens stays a named, attributed source. atproto assumes one AppView per client session
// and two AppViews can legitimately disagree — different visibility, different moderation, one blocking
// what another serves. A registry of lenses is not a quorum; presentation must keep them distinguishable.

const BSKY_APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'

export function validBase (base) {
  const s = String(base || '')
  return /^https?:\/\/[^\s/]+/.test(s) ? s.replace(/\/$/, '') : null
}

// One lens entry, or null with a reason. Malformed entries are SKIPPED rather than fatal: a typo in one
// lens must not stop a daemon booting, and a silently-dropped lens is why `errors` is returned too.
function normalize (raw) {
  if (!raw || typeof raw !== 'object') return { error: 'not an object' }
  const name = typeof raw.name === 'string' ? raw.name.trim() : ''
  if (!name) return { error: 'missing name' }
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) return { error: `invalid name "${name}"` }
  const base = validBase(raw.base)
  if (!base) return { error: `lens "${name}": base must be an http(s) url` }
  const proxy = typeof raw.proxy === 'string' && raw.proxy.trim() ? raw.proxy.trim() : null
  return { lens: { name, base, proxy } }
}

// Parse the lens registry from an env-like object. Returns { lenses, errors } — never throws.
export function parseLenses (env = {}) {
  const out = []
  const errors = []
  const seen = new Set()

  const add = (raw) => {
    const r = normalize(raw)
    if (r.error) { errors.push(r.error); return }
    // First definition of a name wins, so the explicit list can be reasoned about top-down and a
    // duplicate is reported rather than silently shadowing.
    if (seen.has(r.lens.name)) { errors.push(`duplicate lens "${r.lens.name}" ignored`); return }
    seen.add(r.lens.name)
    out.push(r.lens)
  }

  const raw = env.FAVELA_LENSES
  if (raw) {
    let parsed = null
    try { parsed = JSON.parse(raw) } catch { errors.push('FAVELA_LENSES is not valid JSON — ignored') }
    if (parsed !== null) {
      if (Array.isArray(parsed)) for (const e of parsed) add(e)
      else errors.push('FAVELA_LENSES must be a JSON array — ignored')
    }
  }

  // Shorthand last, so an explicit `bsky` entry in FAVELA_LENSES takes precedence over it.
  const bridge = validBase(env.FAVELA_BRIDGE_URL)
  if (bridge) add({ name: 'bsky', base: bridge, proxy: env.FAVELA_BSKY_APPVIEW || BSKY_APPVIEW_PROXY })
  else if (env.FAVELA_BRIDGE_URL) errors.push('FAVELA_BRIDGE_URL must be an http(s) url — ignored')

  return { lenses: out, errors }
}

// Is remote reading configured at all? The UI needs this to distinguish "no replies" from "we did not
// look" — the honesty requirement of the opted-out default.
export function lensesEnabled (lenses) {
  return Array.isArray(lenses) && lenses.length > 0
}

export function findLens (lenses, name) {
  return (Array.isArray(lenses) ? lenses : []).find((l) => l.name === name) || null
}


// ── the stored preference ────────────────────────────────────────────────────────────────────────────
// Node-LOCAL on purpose, unlike the subscription list, which is a property of the identity and belongs in
// its repo. Which remote AppView this node may read is a trust and privacy decision about THIS machine on
// THIS network: a phone on a hostile network can reasonably answer differently from a server, and a fresh
// install must start closed regardless of what any other device chose.
//
// Pure: the caller owns the file I/O, so the precedence rule is testable without a store.
export const LENS_PREF_FILE = 'lens-pref.json'

/**
 * Merge a stored preference into an env-like object, for parseLenses.
 *
 * Stored wins when set — otherwise saving in the UI would appear to do nothing on a node started with
 * FAVELA_BRIDGE_URL. An explicitly cleared preference ('') means "off", NOT "fall back to env", or the
 * UI could never turn off what env turned on.
 */
export function lensEnv (env = {}, pref = null) {
  if (!pref || typeof pref !== 'object' || pref.bridgeUrl === undefined) return env
  return { ...env, FAVELA_BRIDGE_URL: pref.bridgeUrl || '' }
}

/** Validate what the settings form submitted. Returns { bridgeUrl } or { error }. */
export function parseLensPref (raw) {
  const v = String(raw == null ? '' : raw).trim()
  if (!v) return { bridgeUrl: '' } // cleared — reading remotely is off
  const base = validBase(v)
  if (!base) return { error: 'that is not an http(s) url' }
  return { bridgeUrl: base }
}