// 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 }
}