OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / eca7f45

0.3.87: the lens is a setting in the UI, not a line in apps.json
Which remote AppView a node may read was reachable only by editing ~/.config/pgh/apps.json and restarting the daemon — and on a handset there is no apps.json at all, so it could not be changed there by any means. That is a deployment detail wearing a setting's clothes, and the consequence was visible: every node had lenses OFF, so a bsky account's page rendered a real name and avatar above "no posts from this author". - Settings gains a "Remote reading" section: current state, the bridge url, save. A plain form, so it works with scripting off like themeControl. - POST /api/lens writes the preference and REBUILDS the lens registry and reader in place. A setting that needs a daemon restart is a config file with extra steps, and on a server nobody restarts for one. - The stored preference wins over env, or saving in the UI would appear to do nothing on a node started with FAVELA_BRIDGE_URL. Explicitly CLEARED means off, not "fall back to env" — otherwise the UI could never turn off what env turned on. ⛔ OPERATOR ONLY, and the page never offers what the route would refuse: both check that the viewer is the identity this node signs for, the same test /api/follow uses. Repointing where a node sends the DIDs it looks up is an operator act, not a viewer preference — on a public instance a signed-in guest must not have it. ⛔ THE POST WAS DEAD CODE AT FIRST. /api/lens already existed as a diagnostics route with no method check, ~250 lines earlier, so it swallowed the POST and returned diagnostics — a save that looks like it worked and changes nothing. It now excludes POST explicitly. Classified as IDENTITY_POST in the write gate: node-local, nothing signed into a repo (#45's rule is that a new POST route is a repo write until classified). ⚠ A READ-ONLY INSTANCE STILL NEEDS ENV. The gate refuses IDENTITY_POST where there is no write transport, and the operator check cannot be satisfied without a writer either — a node with no identity has nobody to authorise the change. Pure readers keep using FAVELA_BRIDGE_URL. Worth revisiting: that is exactly the instance that most wants a lens. Node-local storage on purpose, unlike the subscription list: which remote AppView this machine may read is a trust decision about THIS node on THIS network, and a fresh install must start closed regardless of what another device chose. Verified: 10 new render assertions; suite green; the diagnostics route still answers GET and no longer eats POST; an anonymous POST is refused. NOT verified: the save applying live, which needs a writer and a session — the local probe has neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eca7f457e67193b2629ab3694dcfab0dc649fdc2
by plankatron <fleeky@gmail.com> · 2026-08-25T19:04:29.000Z

Changed files (5) +177 -12

bin/favela-web.js +68 -6
……@@ -25,7 +25,7 @@ import { join } from 'bare-path'
2525import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
2626import { decodeFrame } from 'atproto-store/lib/firehose.js'
2727import { Appview, INGEST_VERSION, threadPostList } from '../lib/appview.js'
28import { parseLenses, lensesEnabled } from '../lib/lenses.js'
28import { parseLenses, lensesEnabled, lensEnv, parseLensPref, LENS_PREF_FILE } from '../lib/lenses.js'
2929import { createLensReader, extractThreadReplies, extractThreadParents } from '../lib/lens-import.js'
3030import { frontsDiag } from '../lib/fronts-diag.js'
3131import { fromApItem, fromReply, mergeNotifications } from '../lib/notifications.js'
……@@ -138,13 +138,20 @@ const WRITE_SEED = process.env.FAVELA_WRITER_SEED || 'wj62-swarm-test-writer-202
138138// THROUGH our own bridge so no bsky URL or credential lands in this config) or FAVELA_LENSES.
139139// Malformed entries are reported and skipped rather than fatal — a typo in one lens must not stop the
140140// daemon booting, but it must not vanish silently either.
141const { lenses: LENSES, errors: LENS_ERRORS } = parseLenses(process.env)
141// The stored preference (settings page) layered over env. `let`, not `const`, because saving in the UI
142// rebuilds these in place — a setting that needs a daemon restart to take effect is a config file with
143// extra steps, and on a server nobody is going to restart for it.
144function readLensPref () {
145 try { return JSON.parse(fs.readFileSync(STORE(LENS_PREF_FILE), 'utf8')) } catch { return null }
146}
147let LENS_PREF = readLensPref()
148let { lenses: LENSES, errors: LENS_ERRORS } = parseLenses(lensEnv(process.env, LENS_PREF))
142149for (const e of LENS_ERRORS) console.error(JSON.stringify({ msg: 'lens config problem — entry ignored', error: e }))
143const LENSES_ON = lensesEnabled(LENSES)
150let LENSES_ON = lensesEnabled(LENSES)
144151// One reader for the process: it owns the cache, the single-flight coalescing, the per-lens circuit
145152// breaker and the concurrency cap, so those are properties of THIS NODE rather than of how many people
146153// happen to open a thread at once.
147const lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null
154let lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null
148155console.log(JSON.stringify({ msg: 'lens import', enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) }))
149156
150157const LIST = (process.env.FAVELA_LIST || '').trim()
……@@ -1252,6 +1259,10 @@ const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logou
12521259// so repo ownership is not the relevant question, but anonymity still is.
12531260const IDENTITY_POST = new Set([
12541261 '/api/devices/pair', '/api/devices/invite', '/api/devices/revoke',
1262 // /api/lens changes where THIS NODE reads from — node-local, nothing signed into a repo, so repo
1263 // ownership is not the question. It carries its own stricter check (viewer must be the identity this
1264 // node signs for), because repointing a node's reads is an operator act, not a viewer preference.
1265 '/api/lens',
12551266 '/api/track', '/api/bookmark', '/api/mute'
12561267])
12571268// Everything else POSTed authors a record (or a blob) into the repo and falls through to the
……@@ -1528,7 +1539,12 @@ const server = http.createServer(async (req, res) => {
15281539 if (process.env.FAVELA_SHELL_STATE) {
15291540 try { shell = JSON.parse(fs.readFileSync(process.env.FAVELA_SHELL_STATE, 'utf8')) } catch { shell = null }
15301541 }
1531 return html(res, 200, settingsPage({ viewer, profile, did, devices, theme: res._theme, shell }))
1542 // The lens control is shown only to the identity this node signs for — the same test /api/lens
1543 // itself enforces, so the page never offers a control the route will refuse.
1544 let isOperator = false
1545 if (viewer) { try { const st = await writer.state(); isOperator = !!(st && st.did && st.did === viewer.did) } catch {} }
1546 const lens = { enabled: LENSES_ON, lenses: LENSES.map((l) => ({ name: l.name, base: l.base })) }
1547 return html(res, 200, settingsPage({ viewer, profile, did, devices, theme: res._theme, shell, lens, isOperator }))
15321548 }
15331549 if (path === '/login') {
15341550 // Sign-in UI: the client script (/static/login.js) generates the browser's secp256k1 sub-key,
……@@ -2170,7 +2186,11 @@ const server = http.createServer(async (req, res) => {
21702186 }
21712187 // Lens config + live reader counters (cache hits, failures, open breakers). The boot log said only
21722188 // whether lenses were enabled; this says whether they are WORKING.
2173 if (path === '/api/lens') {
2189 //
2190 // ⛔ EXCLUDE POST, or this swallows it. This route carries no method check and sits ~250 lines ahead
2191 // of the POST handler that SETS the lens, so without the guard the setter is unreachable code that
2192 // returns diagnostics and looks like a successful save.
2193 if (path === '/api/lens' && req.method !== 'POST') {
21742194 return json(res, 200, JSON.stringify({
21752195 enabled: LENSES_ON,
21762196 lenses: LENSES.map((l) => ({ name: l.name, base: l.base, proxy: l.proxy || null })),
……@@ -2422,6 +2442,48 @@ const server = http.createServer(async (req, res) => {
24222442 }
24232443
24242444 // Follow / unfollow a DID. Same toggle shape. A follow is what scopes the Following feed below.
2445 // ── the lens setting, from the settings page ─────────────────────────────────────────────────
2446 // Which remote AppView this node may read was reachable only by editing apps.json and restarting,
2447 // which makes it a deployment detail rather than a setting — and on a handset there is no apps.json
2448 // at all, so it could not be changed there by any means.
2449 //
2450 // ⛔ OPERATOR ONLY. This decides where this node sends the DIDs it looks up, so it is gated the same
2451 // way /api/follow is: the viewer must be the identity this node signs for. A signed-in guest on
2452 // somebody else's instance must not be able to repoint its reads.
2453 //
2454 // Applied IN PLACE. A setting that needs a daemon restart is a config file with extra steps.
2455 if (req.method === 'POST' && path === '/api/lens') {
2456 const isForm = !(req.headers['content-type'] || '').includes('application/json')
2457 const raw = (await readBody(req)) || ''
2458 let value, back
2459 try {
2460 if (isForm) { const q = new URLSearchParams(raw); value = q.get('bridgeUrl'); back = q.get('return') }
2461 else { const b = JSON.parse(raw || '{}'); value = b.bridgeUrl; back = b.return }
2462 } catch { value = null }
2463 const home = safeReturn(back) || '/settings'
2464 const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg }))
2465 if (!viewer) return fail(401, 'sign in first')
2466 const st = await writer.state().catch(() => null)
2467 if (!st || !st.did || st.did !== viewer.did) return fail(403, 'only this node\'s own identity can change where it reads')
2468 const parsed = parseLensPref(value)
2469 if (parsed.error) return fail(400, parsed.error)
2470 try {
2471 fs.writeFileSync(STORE(LENS_PREF_FILE), JSON.stringify({ bridgeUrl: parsed.bridgeUrl }, null, 2) + '\n')
2472 LENS_PREF = { bridgeUrl: parsed.bridgeUrl }
2473 const next = parseLenses(lensEnv(process.env, LENS_PREF))
2474 for (const e of next.errors) console.error(JSON.stringify({ msg: 'lens config problem — entry ignored', error: e }))
2475 LENSES = next.lenses
2476 LENS_ERRORS = next.errors // or /api/lens keeps reporting the errors from boot
2477 LENSES_ON = lensesEnabled(LENSES)
2478 // Rebuilt rather than kept: the reader owns the cache, the circuit breaker and the concurrency
2479 // cap, and carrying those across a change of source would answer the new lens from the old one.
2480 lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null
2481 console.log(JSON.stringify({ msg: 'lens import reconfigured', enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) }))
2482 } catch (e) { return fail(502, 'could not save: ' + e.message) }
2483 if (isForm) return redirect(res, home)
2484 return json(res, 200, JSON.stringify({ ok: true, enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) }))
2485 }
2486
24252487 if (req.method === 'POST' && path === '/api/follow') {
24262488 const isForm = !(req.headers['content-type'] || '').includes('application/json')
24272489 const raw = (await readBody(req)) || ''
lib/lenses.js +40 -2
……@@ -6,7 +6,14 @@
66// opts INTO. The cost of opting out is that a thread's view is partial, which the UI must say out loud
77// rather than leave implied (a thread with three unseen bsky replies looks identical to one with none).
88//
9// Two config forms, both env-driven:
9// Three config forms. The first is the one a person can actually reach:
10//
11// THE SETTINGS PAGE (lensPref below)
12// A node-level trust decision that only someone with shell access could change is not a setting, it
13// is a deployment detail. The stored preference wins over env so that changing it in the UI is not
14// silently overridden by whatever the process was started with, and clearing it falls back to env.
15//
16// The env forms remain, for provisioning a node before anyone opens it:
1017//
1118// FAVELA_BRIDGE_URL=https://pds.example.net
1219// Shorthand for the common case: one lens named 'bsky', reached THROUGH the bridge, which resolves
……@@ -22,7 +29,7 @@
2229
2330const BSKY_APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
2431
25function validBase (base) {
32export function validBase (base) {
2633 const s = String(base || '')
2734 return /^https?:\/\/[^\s/]+/.test(s) ? s.replace(/\/$/, '') : null
2835}
……@@ -83,3 +90,34 @@ export function lensesEnabled (lenses) {
8390export function findLens (lenses, name) {
8491 return (Array.isArray(lenses) ? lenses : []).find((l) => l.name === name) || null
8592}
93
94
95// ── the stored preference ────────────────────────────────────────────────────────────────────────────
96// Node-LOCAL on purpose, unlike the subscription list, which is a property of the identity and belongs in
97// its repo. Which remote AppView this node may read is a trust and privacy decision about THIS machine on
98// THIS network: a phone on a hostile network can reasonably answer differently from a server, and a fresh
99// install must start closed regardless of what any other device chose.
100//
101// Pure: the caller owns the file I/O, so the precedence rule is testable without a store.
102export const LENS_PREF_FILE = 'lens-pref.json'
103
104/**
105 * Merge a stored preference into an env-like object, for parseLenses.
106 *
107 * Stored wins when set — otherwise saving in the UI would appear to do nothing on a node started with
108 * FAVELA_BRIDGE_URL. An explicitly cleared preference ('') means "off", NOT "fall back to env", or the
109 * UI could never turn off what env turned on.
110 */
111export function lensEnv (env = {}, pref = null) {
112 if (!pref || typeof pref !== 'object' || pref.bridgeUrl === undefined) return env
113 return { ...env, FAVELA_BRIDGE_URL: pref.bridgeUrl || '' }
114}
115
116/** Validate what the settings form submitted. Returns { bridgeUrl } or { error }. */
117export function parseLensPref (raw) {
118 const v = String(raw == null ? '' : raw).trim()
119 if (!v) return { bridgeUrl: '' } // cleared — reading remotely is off
120 const base = validBase(v)
121 if (!base) return { error: 'that is not an http(s) url' }
122 return { bridgeUrl: base }
123}
lib/render.js +39 -2
……@@ -1290,12 +1290,48 @@ document.querySelectorAll('[data-ota="pair"]').forEach(function (b) {
12901290</section>`
12911291}
12921292
1293export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null } = {}) {
1293// Where this node may read from, as a setting rather than a deployment detail.
1294//
1295// ⛔ THIS WAS ONLY REACHABLE BY EDITING apps.json AND RESTARTING — and on a handset there is no
1296// apps.json at all, so it could not be changed there by any means. The consequence was visible rather
1297// than theoretical: a bsky account's page rendered a real name and avatar and "no posts from this
1298// author", because reading their posts needs a lens and no node had one configured (hyper-social #53).
1299//
1300// Shown to the node's OWN identity only. Repointing where a node sends the DIDs it looks up is an
1301// operator act, not a viewer preference, and on a public instance a signed-in guest must not have it.
1302//
1303// A plain form: it works with scripting off, like themeControl and the profile form.
1304export function lensControl ({ lens = null, isOperator = false, returnTo = '/settings' } = {}) {
1305 if (!isOperator) return ''
1306 const on = !!(lens && lens.enabled)
1307 const current = (lens && lens.lenses && lens.lenses[0] && lens.lenses[0].base) || ''
1308 const names = (lens && lens.lenses ? lens.lenses : []).map((l) => l.name).filter(Boolean)
1309 const state = on
1310 ? `<p class="empty">Reading from <strong>${esc(names.join(', ') || 'a lens')}</strong> at <code>${esc(current)}</code>.</p>`
1311 : '<p class="empty">Off. Posts by people this node does not index \u2014 anyone on bsky \u2014 show as an empty profile.</p>'
1312 return `<section class="authbox lenssec">
1313 <h3>Remote reading</h3>
1314 <p class="empty">Point this at a bridge and profiles this node has never indexed can show their posts,
1315 clearly marked as coming from that source. Nothing is stored and nothing is federated \u2014 it fills
1316 in a page that would otherwise be blank. Leave it empty to read nothing remote, the default.</p>
1317 ${state}
1318 <form method="POST" action="/api/lens" class="rowform">
1319 <input type="hidden" name="return" value="${esc(returnTo)}">
1320 <p><label for="lens-url">bridge url</label><br>
1321 <input id="lens-url" name="bridgeUrl" type="url" inputmode="url" placeholder="https://pds.example.net"
1322 value="${esc(current)}" size="34"></p>
1323 <p><button type="submit">save</button>
1324 <span class="empty">applies immediately \u2014 no restart</span></p>
1325 </form>
1326</section>`
1327}
1328
1329export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null, lens = null, isOperator = false } = {}) {
12941330 if (!viewer) {
12951331 // Appearance still renders here — it is not an account setting. So does App updates: a restart is
12961332 // what repairs a session this page has forgotten, and hiding it behind sign-in hides it exactly when
12971333 // it is needed.
1298 return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + pairSection(shell) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
1334 return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + pairSection(shell) + lensControl({ lens, isOperator }) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' })
12991335 }
13001336 const cid = profile && profile.avatar && blobCid(profile.avatar.ref)
13011337 const current = (cid && did && isSafeCid(cid))
……@@ -1322,6 +1358,7 @@ ${themeControl(theme)}
13221358${versionNote()}
13231359${appUpdateSection(shell)}
13241360${pairSection(shell)}
1361${lensControl({ lens, isOperator })}
13251362<section class="authbox">
13261363 <h3>Profile picture</h3>
13271364 <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p>
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.86",
3 "version": "0.3.87",
44 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
test/render.test.mjs +29 -1
……@@ -1,5 +1,5 @@
11// favela SSR render layer — pure, runtime-agnostic (no P2P/index/Bare). Run: node test/render.test.mjs
2import { pairSection, withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState, appUpdateSection } from '../lib/render.js'
2import { pairSection, lensControl, withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState, appUpdateSection } from '../lib/render.js'
33import { SCOPE_COLLECTION } from '../lib/scopes.js'
44
55let pass = 0
……@@ -971,3 +971,31 @@ function t_is_empty (s, m) { ok(s === '', m) }
971971}
972972
973973console.log(`\nRENDER OK: ${pass} assertions`)
974
975
976// ── the lens setting, which used to be reachable only by editing apps.json ────────────────────────
977// A node-level trust decision that needs shell access is a deployment detail, not a setting — and on a
978// handset there is no apps.json at all. The visible consequence was a bsky account rendering a real name
979// and avatar with "no posts from this author" (hyper-social #53).
980{
981 const on = { enabled: true, lenses: [{ name: 'bsky', base: 'https://pds.7rnx.net' }] }
982
983 // ⛔ Repointing where a node sends the DIDs it looks up is an OPERATOR act. On a public instance a
984 // signed-in guest must never see this, and the page must not offer a control /api/lens would refuse.
985 ok(lensControl({ lens: on, isOperator: false }) === '', 'a viewer who is not this node\'s identity sees nothing')
986 ok(lensControl({ lens: on }) === '', 'and absent by default — not shown unless proven operator')
987
988 const shown = lensControl({ lens: on, isOperator: true })
989 has(shown, 'Remote reading', 'the operator gets the control')
990 has(shown, 'https://pds.7rnx.net', 'with the current bridge filled in, so saving does not blank it')
991 has(shown, 'action="/api/lens"', 'posting to the route that applies it')
992 has(shown, 'method="POST"', 'a plain form — works with scripting off')
993
994 const off = lensControl({ lens: { enabled: false, lenses: [] }, isOperator: true })
995 has(off, 'Off.', 'the off state says so')
996 has(off, 'empty profile', 'and names the consequence rather than leaving it implied')
997
998 // It reaches the real page, and only for the operator.
999 has(settingsPage({ viewer: { did: 'did:plc:alice' }, lens: on, isOperator: true }), 'Remote reading', 'rendered on the settings page')
1000 not(settingsPage({ viewer: { did: 'did:plc:alice' }, lens: on, isOperator: false }), 'Remote reading', 'and never for a guest')
1001}