// The router — every route this surface serves, and nothing about how it is booted.
//
// ⛔ Split out of `index.js` so a caller can have the ROUTES without starting a SERVER. Importing
// the entrypoint to reach `route` also bound its port, which is wrong for anyone embedding this:
// a bare-kit worklet wants the routes and supplies its own listener, and on a machine already
// running favela-surface the import simply failed with EADDRINUSE. An entrypoint boots; a library
// exports.
//
// ⛔ `route(req, res, ctx)` has NO default context. This file has no opinion about where the daemon
// is — `ctx.client.call(cmd, args) -> Promise` is the whole contract, satisfied by a socket client
// on a desktop and by a direct `dispatch` binding in a worklet, and nothing here can tell.
import os from '#node:os'
import { join } from '#node:path'
import fs from '#node:fs'
import { sessionIdFrom, setCookie, clearCookie } from './session.js'
import * as pages from './pages.js'
import { embeds } from 'favela-markdown/lib/render.js'
import { recordUri } from 'favela-markdown/lib/uri.js'
import { parse as parseMultipart } from './multipart.js'
import { STATIC } from './static-files.js'
import b4a from 'b4a'
// ⚠ Both spellings: bare-tcp reports IPv4 loopback as `::ffff:127.0.0.1` when the listener is dual
// stack, and a check that knew only one form would refuse the very machine it is running on.
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
// ⛔ VENDORED in static/, not taken as a dependency. Depending on hyper-identity to serve four
// browser files pulled a whole multi-writer log engine into this renderer's published
// closure — in a process whose entire boundary is "no storage, no keys, no swarm". See
// static/README.md. IMPORTED as text (lib/static-files.js), so a bundle carries them too.
/**
* ⛔ Per-RESPONSE, keyed by the response object itself. A module-level "current theme" is request
* state in a shared place, and two interleaved requests would hand one reader the other's setting.
* A WeakMap keyed by `res` is scoped to exactly one exchange and needs no cleanup.
*/
const THEME = new WeakMap()
// ⛔ The same per-response shape as THEME, for the same reason: applied once at send time so no page
// has to know the banner exists and none can forget it.
const UPDATE = new WeakMap()
const send = (res, html, status = 200) => {
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' })
res.end(pages.withUpdateBanner(pages.withTheme(html, THEME.get(res) || null), UPDATE.get(res) || null))
}
// The reader's theme, or null for system. A plain cookie — no JS, and the server needs to know it
// before it writes the page or the first paint is the wrong colour.
export function themeFrom (req) {
const raw = String((req.headers && req.headers.cookie) || '')
const m = /(?:^|;\s*)favela_theme=(light|dark)(?:;|$)/.exec(raw)
return m ? m[1] : null
}
const seeOther = (res, to) => { res.writeHead(303, { location: to }); res.end() }
// application/x-www-form-urlencoded, which is what a no-JS form sends.
const body = (req) => new Promise((resolve) => {
let buf = ''
req.on('data', (c) => { buf += c.toString() })
req.on('end', () => {
const out = {}
for (const pair of buf.split('&')) {
if (!pair) continue
const i = pair.indexOf('=')
const k = decodeURIComponent((i === -1 ? pair : pair.slice(0, i)).replace(/\+/g, ' '))
const v = i === -1 ? '' : decodeURIComponent(pair.slice(i + 1).replace(/\+/g, ' '))
out[k] = v
}
resolve(out)
})
})
/**
* The settings page, built once and reachable from every route that has to re-render it.
*
* ⛔ `whoami` gives this node's own feed key, and the CONFIRMED handle for it comes back through
* `profiles` like every other byline — the same path, so settings can never show a handle the
* timeline would not, or the other way round.
*/
async function settingsPage (client, message, token = null, theme = null, appUpdate = null) {
const me = await client.call('whoami')
const mine = (await client.call('profiles'))[me.feed] || {}
const ns = await client.call('namespace')
// ⚠ Tolerated missing, like `reach`: an older daemon has no `namespaces` command and settings must
// still render. An empty list makes the section absent rather than claiming this node holds none.
const known = (await client.call('namespaces').catch(() => ({ namespaces: [] }))).namespaces || []
// Invites are a separate call: only an admin has any, and the namespace summary is fetched on
// every settings load by anyone.
if (ns && ns.carrying) {
ns.invites = (await client.call('namespace-invites').catch(() => ({ invites: [] }))).invites || []
}
// ⚠ Tolerated missing: an older daemon has no `reach` command, and settings must still render.
// Passing null makes the section absent rather than showing every network as off, which would be
// a claim this surface cannot support.
const reach = await client.call('reach').catch(() => null)
// ⚠ Tolerated missing, and this one is missing in a REAL arrangement rather than only an old one:
// a surface pointed at a plain favela-peer daemon gets "unknown command", because the atproto verbs
// live in the composition (favela-host) and not in the daemon. Absent means the section does not
// render, which is correct — that node genuinely cannot mint one.
const did = await client.call('did-status').catch(() => null)
// ⛔ SUPPLIED BY THE EMBEDDER, never read from here. `test/router-import.test.js` asserts this file
// touches no environment — "an embedder has no env to read" — and it is right: a shell writes
// `app-update.json`, its own entrypoint knows where, and this file must not learn what a worklet is.
// Absent on every desktop, where there is no shell and no APK to update, so the section does not
// render at all rather than being fabricated from what this surface can see.
let update = null
try { update = appUpdate ? await appUpdate() : null } catch { /* stays absent rather than guessing */ }
return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update, known, did)
}
/**
* A same-site path, or `/`.
*
* ⛔ `//evil.com` is a protocol-relative URL and a browser follows it off-site, so "starts with a
* slash" is not enough on its own. Anything else — an absolute URL, a scheme, a backslash a browser
* may normalise — falls back to the timeline.
*/
function local (to) {
const s = String(to == null ? '' : to)
if (!s.startsWith('/') || s.startsWith('//') || s.startsWith('/\\')) return '/'
// ⚠ The FRAGMENT survives. It is what returns the reader to the post they acted on instead of the
// top of the page, and stripping it here would quietly undo that.
return s
}
// "<feed>/<collection>/<name>", the form the post page emits.
function parseTarget (s) {
const parts = String(s || '').split('/')
if (parts.length !== 3) return null
const [feed, collection, name] = parts.map(decodeURIComponent)
return feed && collection && name ? { feed, collection, name } : null
}
const json = (res, obj, status = 200) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(obj))
}
// ⛔ Bytes. An image posted through a form is binary, and decoding it to UTF-8 anywhere on this
// path corrupts it silently — the symptom is a broken picture, not an error.
const readBytes = (req, limit) => new Promise((resolve, reject) => {
const chunks = []
let n = 0
req.on('data', (c) => {
n += c.length
if (n > limit) return reject(new Error('upload too large'))
chunks.push(c)
})
req.on('end', () => resolve(b4a.concat(chunks)))
})
const readJson = (req) => new Promise((resolve) => {
let buf = ''
req.on('data', (c) => { buf += c.toString() })
req.on('end', () => { try { resolve(JSON.parse(buf)) } catch { resolve({}) } })
})
/**
* Types for every file the given rows reference: oid -> mimeType.
*
* ⛔ Resolved BEFORE rendering, because `render` is synchronous and only the daemon knows what a
* file is. One call per distinct oid, deduped across the whole page.
*
* ⚠ Which is one round trip per file on a timeline. Fine at these sizes and not measured; if a page
* ever holds hundreds of attachments this is the thing to batch.
*/
// ⚠ A REAL limit, and it belongs to this process rather than to the stack: `parseMultipart` needs
// the whole body in memory to find its boundaries, so an upload is buffered here before anything
// else sees it. The content store behind the daemon chunks and has no ceiling of its own, and the
// daemon takes a path — so removing this means streaming the multipart parse to a temp file rather
// than changing any protocol.
const MAX_UPLOAD = 64 * 1024 * 1024
async function fileTypes (rows, client) {
const oids = new Set()
for (const r of rows || []) for (const e of embeds(r.body || '')) oids.add(e.oid)
const out = {}
for (const oid of oids) {
const r = await client.call('file-get', { oid }).catch(() => null)
// ⛔ An unresolved oid is simply absent, and renders as nothing. A file that has not replicated
// yet is a missing picture, not a broken page.
if (r && r.mimeType) out[oid] = r.mimeType
}
return out
}
export async function route (req, res, ctx) {
// Read once, before anything renders — the server has to know the theme before it writes the
// page, or the first paint is the wrong colour and then corrects itself.
THEME.set(res, themeFrom(req))
// ⚠ Per request, because the state changes underneath a long-lived page: a node that stages an
// update while someone is reading should tell them on their next navigation, not on their next
// restart. It is a small local file, and absent everywhere there is no shell.
try { if (ctx.appUpdate) UPDATE.set(res, await ctx.appUpdate()) } catch { /* no shell */ }
const { client, sessions } = ctx
const url = new URL(req.url, 'http://localhost')
const path = url.pathname
// ── the login surface: the only routes reachable without a session ──────────────────────────
//
// ⛔ Vendored crypto is served rather than fetched from a CDN. A page that loaded its signer from
// someone else's server would let that server mint signatures with your key.
if (req.method === 'GET' && STATIC.has(path)) {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
return res.end(STATIC.get(path))
}
if (req.method === 'GET' && path === '/login') {
// Asked of the daemon, not remembered here: the surface holds no state, and a stale "unclaimed"
// would offer a claim that then fails.
const { unclaimed } = await client.call('browser-unclaimed').catch(() => ({ unclaimed: false }))
return send(res, pages.login(url.searchParams.get('m'), unclaimed))
}
if (req.method === 'POST' && path === '/auth/challenge') return json(res, await client.call('browser-challenge'))
// ⛔ AUTO-LOGIN. A browser that already holds a registered key must be able to get a session back
// without an invite, or a surface restart locks out its own owner: sessions are in memory, claim
// is refused once the instance is claimed, and only the daemon's operator can mint an invite.
//
// This existed in the daemon (`browser-auth`) from the start and simply had no route. That gap
// did exactly what it sounds like.
if (req.method === 'POST' && path === '/auth/login') {
const body = await readJson(req)
try {
await client.call('browser-auth', body)
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': setCookie(sessions.create(body.publicKey)) })
return res.end(JSON.stringify({ ok: true }))
} catch (err) {
return json(res, { error: err.message }, 401)
}
}
// ⛔ THE EMBEDDED-WEBVIEW HANDOFF. The session cookie is HttpOnly, so it cannot be handed to a
// WebView from outside the browser — `document.cookie` cannot write one. It has to arrive on an
// HTTP response, which is why this route exists at all and why a worklet minting a session is not
// by itself enough. favela-web reached the same conclusion for the same reason.
//
// ⛔ LOOPBACK ONLY. A handoff turns a token in a URL into a signed-in session, and that is only
// defensible from the machine the surface is running on.
//
// ⛔ The token is ONE-TIME (`redeemHandoff` burns it). favela-web's equivalent accepts a token
// repeatedly and relies on loopback alone — a real gate on a desktop, and not one on Android,
// where every app on the device shares loopback. A reusable token in a query string is a
// replayable credential; a burned one is a race someone has to win once.
if (req.method === 'GET' && path === '/auth/handoff') {
const remote = req.socket && req.socket.remoteAddress
if (!LOOPBACK.has(String(remote || ''))) return send(res, pages.error('Not found'), 404)
const id = sessions.redeemHandoff(url.searchParams.get('token'))
if (!id) return seeOther(res, '/login?m=' + encodeURIComponent('That sign-in link was already used.'))
// ⚠ `next` is sanitised rather than trusted: it must be a path on this surface, so `//evil.com`
// — which a browser reads as a protocol-relative URL — is refused along with anything absolute.
const raw = url.searchParams.get('next')
const next = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
res.writeHead(303, { location: next, 'set-cookie': setCookie(id) })
return res.end()
}
if (req.method === 'POST' && path === '/auth/claim') {
const body = await readJson(req)
try {
await client.call('browser-claim', body)
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': setCookie(sessions.create(body.publicKey)) })
return res.end(JSON.stringify({ ok: true }))
} catch (err) {
return json(res, { error: err.message }, 400)
}
}
if (req.method === 'POST' && path === '/auth/redeem') {
const body = await readJson(req)
try {
await client.call('browser-redeem', body)
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': setCookie(sessions.create(body.publicKey)) })
return res.end(JSON.stringify({ ok: true }))
} catch (err) {
return json(res, { error: err.message }, 400)
}
}
if (path === '/logout') {
const id = sessionIdFrom(req.headers.cookie)
sessions.destroy(id)
res.writeHead(303, { location: '/login', 'set-cookie': clearCookie() })
return res.end()
}
// ── the gate ────────────────────────────────────────────────────────────────────────────────
//
// ⛔ A session is not a credential. It names the key that proved itself at login, and the daemon
// is asked on EVERY request whether that key is still allowed — so a revoke lands on the next
// page load rather than when something expires. That is why there is no expiry to configure.
const publicKey = sessions.keyFor(sessionIdFrom(req.headers.cookie))
if (!publicKey) return seeOther(res, '/login')
const standing = await client.call('browser-status', { publicKey })
if (!standing.known || standing.revoked) {
sessions.destroyKey(publicKey) // and no other tab keeps working either
res.writeHead(303, { location: '/login?m=' + encodeURIComponent('This browser was revoked.'), 'set-cookie': clearCookie() })
return res.end()
}
// ── the identity gate ───────────────────────────────────────────────────────────────────────
//
// ⛔ A SIGNED-IN BROWSER IS NOT AN IDENTITY. The gate above proves who is at the keyboard; this
// one asks whether the NODE is anybody yet. They are different questions with different answers,
// and conflating them is why an unlinked node used to render a whole timeline and only reveal the
// problem when a name claim failed with the daemon's error passed through.
//
// ⚠ Asked every request, like `browser-status` above and for the same reason: linking happens on
// another route (or another device, via pairing) and a cached "not linked" would strand someone on
// this page after they had already succeeded.
// ⚠ `=== false`, NOT falsy. A daemon that does not report `linked` at all must not be treated as
// unlinked and lock its owner out of their own node — the same tolerance settings applies to a
// daemon with no `reach` command. The live daemon always returns a boolean; absence means old, and
// old is not the same as unlinked.
const identity = await client.call('whoami')
if (identity.linked === false) {
if (req.method === 'GET' && path === '/welcome') {
return send(res, pages.welcome(url.searchParams.get('m')))
}
if (req.method === 'POST' && (path === '/welcome/create' || path === '/welcome/restore')) {
const form = await body(req)
const restoring = path === '/welcome/restore'
if (restoring && !(form.mnemonic || '').trim()) {
return seeOther(res, '/welcome?m=' + encodeURIComponent('paste your recovery phrase'))
}
// ⛔ Same rule as /did/create, and for the same reason: a vault passphrase is typed blind and
// cannot be reset, so it is confirmed — and confirmed on the server, because a check that
// lives only in a script does not run when the script does not.
if (String(form.passphrase || '') !== String(form['passphrase-confirm'] || '')) {
return seeOther(res, '/welcome?m=' + encodeURIComponent('the two passphrases do not match'))
}
try {
// ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. It is rendered straight into the
// response and never written, cached or logged here — there is deliberately no route that
// can show it a second time.
const r = await client.call('mint-identity', {
passphrase: (form.passphrase || '').trim() || null,
mnemonic: restoring ? form.mnemonic.trim() : null
})
return send(res, pages.minted(r.mnemonic, r.restored))
} catch (err) {
// The daemon's message verbatim — it is the one that knows whether the phrase was invalid,
// the vault already existed, or the passphrase was wrong.
return seeOther(res, '/welcome?m=' + encodeURIComponent(err.message))
}
}
if (req.method === 'POST' && path === '/welcome/pair') {
const form = await body(req)
if (!(form.invite || '').trim()) {
return seeOther(res, '/welcome?m=' + encodeURIComponent('paste an invite'))
}
try {
await client.call('pair-accept', { invite: form.invite.trim() })
return seeOther(res, '/')
} catch (err) {
return seeOther(res, '/welcome?m=' + encodeURIComponent(err.message))
}
}
// Everything else is unreachable until this node is somebody.
return seeOther(res, '/welcome')
}
if (req.method === 'GET' && path === '/') {
const rows = await client.call('timeline', { limit: 50 })
const un = await client.call('unresolved')
// ⛔ EVERY readable feed's profile, in one call — not just this node's. Asking only for our own
// is what left other authors rendering as bare hex.
const profiles = await client.call('profiles')
// ⛔ `?quote=` is a COMPOSER SEED, not a mode. It becomes editable text in the box and nothing
// downstream knows a quote happened — which is what "quotes are just links" means in practice.
const q = url.searchParams.get('quote')
// ⛔ THE AUTHORITY IS THE IDENTITY ROOT, not the feed key (hyper-social #61 §12 #1). This built
// `'favela://' + feed + '/' + collection + '/' + name` — a FEED key in the position where a
// mention carries a ROOT. Both are 64 hex, so nothing downstream could tell which it had, and
// `isMention` (bare `^favela://`) then classified the whole thing as a mention of a user.
//
// A feed key is a LOCATOR; the root is the identity, and it survives device changes and feed
// migration. `profiles` is already fetched above and carries feed → root for every readable
// feed, so the resolution costs nothing here.
//
// ⚠ If the root is unknown we emit NOTHING rather than falling back to the feed key. A URI goes
// into a record's bytes and SOCIAL-MODEL §8 forbids rewriting a record you did not author — a
// wrong address written once is permanent, and an empty composer is recoverable.
const qt = q ? parseTarget(q) : null
const qroot = qt && profiles && profiles[qt.feed] ? profiles[qt.feed].root : null
const quote = qt && qroot ? recordUri({ root: qroot, collection: qt.collection, name: qt.name }) : null
// ⛔ `[@name](favela://<root>)` — the canonical form. Built here rather than left to the author,
// because nobody is going to type a 64-character identity root.
const mr = url.searchParams.get('mention')
const mh = (url.searchParams.get('handle') || 'them').replace(/[^\w.@-]/g, '')
const mention = mr && /^[0-9a-f]{64}$/i.test(mr) ? '[@' + mh + '](favela://' + mr + ')' : null
/**
* ⛔ An empty timeline has TWO causes and the page cannot tell them apart on its own.
*
* Following nobody and following people whose feeds have not arrived are different problems
* with different fixes, and one sentence for both is the UI version of drawing a converged edge
* and a resolved one the same way. MEASURED 2026-09-07: stortron follows nobody, its timeline
* has always been blank, and nothing on the page has ever said why.
*
* ⚠ Asked for ONLY when the timeline is empty. It is a scan of our own follow set, and the
* common path — a timeline with posts in it — must not pay for a message it will not render.
*/
const emptyState = rows.length ? null : {
follows: await client.call('following')
.then((f) => (Array.isArray(f) ? f.length : ((f && f.following) || []).length))
.catch(() => 0)
}
return send(res, pages.timeline(rows, {
quote, mention, unreadable: un.length, profiles, files: await fileTypes(rows, client),
message: url.searchParams.get('m'), empty: emptyState
}))
}
if (req.method === 'GET' && path === '/search') {
const q = url.searchParams.get('q') || ''
if (!q) return send(res, pages.search('', [], 0, []))
const r = await client.call('search', { query: q, limit: 25 })
const profiles = await client.call('profiles')
// ⛔ The namespace too. `search` only sees records and profiles of feeds this node HOLDS, so a
// peer you have never followed is invisible to it — which made a shared handle registry
// unreachable from the one box a person would type a name into.
const named = await client.call('search-names', { query: q }).catch(() => ({ handles: [] }))
return send(res, pages.search(q, r.results, r.unreadable, r.people || [], profiles,
await fileTypes(r.results, client), named.handles || []))
}
// One conversation. ⛔ The daemon has had `reply` and `replies` from the beginning and nothing
// could reach either — a conversation you can only have from a CLI is not shipped.
if (req.method === 'GET' && path.startsWith('/t/')) {
const raw = decodeURIComponent(path.slice('/t/'.length))
const target = parseTarget(raw)
if (!target) return send(res, pages.error('Not a record'), 404)
const replies = await client.call('replies', { target })
// ⚠ The subject may be a post we do NOT hold — we can hold a reply to it and not it. `posts-by`
// returns what we have of that author, which is empty in that case, and the page says so.
const subject = (await client.call('posts-by', { feed: target.feed }))
.find((r) => r.collection === target.collection && r.name === target.name) || null
const profiles = await client.call('profiles')
const rows = subject ? [subject, ...replies] : replies
return send(res, pages.thread(subject, replies, profiles, await fileTypes(rows, client), raw))
}
if (req.method === 'GET' && path === '/notifications') {
const { notifications } = await client.call('notifications')
return send(res, pages.notifications(notifications, await client.call('profiles')))
}
/**
* Handle suggestions for the composer. JSON, because the only caller is a script.
*
* ⛔ CONFIRMED only, and it returns the NAME rather than a root. The picker inserts `@alice` —
* ordinary text a person could have typed — and the daemon resolves it on the write path exactly
* as it does for anything typed by hand. So the script is an accelerant and never an authority:
* with it off, typing the name still works, and with it on it cannot produce a mention the
* server would not have produced anyway.
*/
if (req.method === 'GET' && path === '/api/mentions') {
const q = (url.searchParams.get('q') || '').trim()
if (!q) return json(res, { people: [] })
const { handles } = await client.call('search-names', { query: q }).catch(() => ({ handles: [] }))
const profiles = await client.call('profiles').catch(() => ({}))
const people = (handles || [])
.filter((h) => h.verdict === 'confirmed')
.slice(0, 6)
.map((h) => ({ handle: h.handle, name: h.name, label: (profiles[h.feed] || {}).name || null }))
return json(res, { people })
}
// JSON for the explorer. ⛔ Same projection as the debug view: two renderings of ONE substrate,
// which is the claim SOCIAL-MODEL makes about every view. They may disagree about layout, never
// about facts.
if (req.method === 'GET' && path === '/api/graph') {
const g = await client.call('graph')
/**
* ⛔ LABELS ARE ATTACHED HERE, NOT IN THE PROJECTION.
*
* `graphOf` renders the substrate, and the debug view draws truncated hex on purpose so a
* diagnosis reads identically on any machine. A name is a display concern of THIS surface, so
* it travels beside the projection rather than inside it — the two views still cannot disagree
* about a fact, because the facts are the part that passed through untouched.
*
* ⛔ A handle becomes a label only when CONFIRMED. Drawing an unverified one would let anyone
* claim to be anyone in the single view whose whole job is showing you who is who. A profile
* name is different and is always shown: it is that feed saying what it calls itself, which is
* a claim the reader can see is a claim.
*/
const [profiles, handles] = await Promise.all([
client.call('profiles').catch(() => ({})),
client.call('handles').then((r) => (r && r.handles) || []).catch(() => [])
])
const labels = {}
for (const feed of Object.keys(profiles || {})) {
const p = profiles[feed] || {}
// ⚠ The avatar OID, not the bytes. /avatar/<oid> already streams it from the daemon's disk,
// content-addressed and immutable, so the picture can load it like any other image.
if (p.name || p.avatar) labels[feed] = { name: p.name || null, avatar: p.avatar || null }
}
for (const h of handles) {
if (!h || h.verdict !== 'confirmed' || !h.feed) continue
labels[h.feed] = Object.assign({}, labels[h.feed], { handle: h.handle })
}
return json(res, Object.assign({}, g, { labels }))
}
/**
* What ONE point is. The answer to "i have no idea what the points are", asked per click rather
* than shipped for every node — a graph payload carrying every post body would be the timeline
* again, and most of it is never looked at.
*
* ⛔ It reports UNREADABILITY as a fact. A pointer we hold whose bytes never arrived is not a
* missing post (EDGE-GRAPH §4 ③), and rendering it as an empty one destroys the one distinction
* this whole view exists to make. `readable:false` with `body:null` — never invented text.
*/
/**
* One feed's records, excerpted. The door the explorer's proximity reveal needs.
*
* ⛔ ONE SCAN PER FEED, not one per point. Revealing a post as the camera drifts near it would
* otherwise be a request and a `posts-by` scan for every point that came into range, repeatedly,
* for a picture that is mostly the same twenty records.
*
* ⛔ EXCERPTED. A reveal is a few lines on a small plane; shipping whole bodies to render two
* hundred characters of them turns this back into the timeline, which is what the graph payload
* was deliberately kept from being. The full text is one click away through /api/node.
*/
if (req.method === 'GET' && path === '/api/records') {
const feed = String(url.searchParams.get('feed') || '')
if (!/^[0-9a-f]{64}$/i.test(feed)) return json(res, { error: 'not a feed key' }, 404)
const rows = await client.call('posts-by', { feed }).catch(() => [])
const records = {}
for (const r of rows || []) {
const text = String(r.body || '')
records[r.collection + '/' + r.name] = {
excerpt: text.length > 260 ? text.slice(0, 260) + '…' : text,
createdAt: r.createdAt || null
}
}
return json(res, { feed, records })
}
if (req.method === 'GET' && path === '/api/node') {
const id = String(url.searchParams.get('id') || '')
if (/^[0-9a-f]{64}$/i.test(id)) {
const [profiles, handles, holding] = await Promise.all([
client.call('profiles').catch(() => ({})),
client.call('handles').then((r) => (r && r.handles) || []).catch(() => []),
client.call('holding', { feed: id }).catch(() => ({}))
])
const p = (profiles || {})[id] || {}
const h = handles.find((x) => x && x.feed === id && x.verdict === 'confirmed')
return json(res, {
type: 'actor',
id,
handle: h ? h.handle : null,
name: p.name || null,
bio: p.bio || null,
avatar: p.avatar || null,
// ⚠ Why a point can sit there with no edges: we know OF them without holding them.
holding: holding || {},
href: '/u/' + id
})
}
const t = parseTarget(id)
if (!t) return json(res, { error: 'not a node' }, 404)
/**
* ⛔ WHO INTERACTED, not just that something did. A line between two points says an interaction
* happened and cannot say what it was or who made it — so the card is where an interaction
* becomes readable, and the counts and the people have to reach it.
*
* ⚠ Three extra scans, paid ONCE PER CLICK on a record. They are not paid for an actor, which
* has no target to scan for, and never for the graph payload — putting them there would be a
* scan per edge on every load of the page.
*/
const [rows, labels, reactions, reposts, replies] = await Promise.all([
client.call('posts-by', { feed: t.feed }).catch(() => []),
client.call('profiles').catch(() => ({})),
client.call('reactions', { target: t }).then((r) => (r && r.reactions) || []).catch(() => []),
client.call('reposts', { target: t }).then((r) => (r && r.reposts) || []).catch(() => []),
client.call('replies', { target: t }).catch(() => [])
])
const row = (rows || []).find((r) => r.collection === t.collection && r.name === t.name) || null
// ⚠ Named where we can name them. A key is an answer, just a poor one — and inventing a name
// for a feed with no profile would be worse than showing the key.
const named = (a) => ({ author: a.author, name: ((labels || {})[a.author] || {}).name || null })
return json(res, {
interactions: {
reactions: reactions.map((r) => Object.assign(named(r), { value: r.value || 'like' })),
reposts: reposts.map(named),
replies: Array.isArray(replies) ? replies.length : 0
},
type: 'record',
id,
author: t.feed,
authorName: ((labels || {})[t.feed] || {}).name || null,
collection: t.collection,
readable: !!row,
body: row ? (row.body || '') : null,
createdAt: row ? row.createdAt || null : null,
href: '/t/' + encodeURIComponent(id)
})
}
if (req.method === 'GET' && path === '/explore') {
return send(res, pages.explore())
}
if (req.method === 'GET' && path === '/graph') {
return send(res, pages.graph(await client.call('graph')))
}
if (req.method === 'GET' && path === '/rooms') {
const { rooms } = await client.call('rooms')
return send(res, pages.rooms(rooms))
}
if (req.method === 'GET' && path.startsWith('/r/')) {
const key = path.slice('/r/'.length)
if (!/^[0-9a-f]{64}$/i.test(key)) return send(res, pages.error('Not a room key'), 404)
let r
try { r = await client.call('room', { key }) } catch (err) { return send(res, pages.error(err.message), 404) }
return send(res, pages.room(key, r, await client.call('profiles'), { message: url.searchParams.get('m') }))
}
if (req.method === 'GET' && path === '/mentions') {
const rows = await client.call('mentions')
const profiles = await client.call('profiles')
return send(res, pages.mentions(rows, profiles, await fileTypes(rows, client)))
}
// ⛔ NO QUERY, DELIBERATELY. `/search` returns without asking the daemon when the box is empty,
// which is right there — an empty query is not a request for the whole world. A namespace is the
// one scope where "show me everyone" IS the question, so this asks with none and gets the full
// claim list. Without it, joining a namespace and then finding nobody was the reported experience.
// ⛔ WHERE EVERY DEAD-END WARNING SHOULD LEAD. "the log arrived, the bytes did not" says nothing
// about whose feed or what fixes it, and until now the only way to find out was a shell.
if (req.method === 'GET' && path === '/status') {
const [me, ns, following, missing, profiles] = await Promise.all([
client.call('whoami').catch(() => null),
client.call('namespace').catch(() => null),
client.call('following').catch(() => []),
client.call('missing').catch(() => []),
client.call('profiles').catch(() => ({}))
])
let update = null
try { update = ctx.appUpdate ? await ctx.appUpdate() : null } catch { /* no shell */ }
return send(res, pages.status({ me, ns, following, missing, profiles, update }))
}
if (req.method === 'GET' && path === '/discover') {
const named = await client.call('search-names', {}).catch(() => ({ handles: [] }))
const ns = await client.call('namespace').catch(() => null)
// Profiles are only available for feeds this node HOLDS, so most rows here have none — a
// stranger's display name is unreachable until you follow them. The handle is what identifies
// them until then, which is why it leads each row.
const profiles = await client.call('profiles').catch(() => ({}))
const following = await client.call('following').catch(() => [])
return send(res, pages.discover(named.handles || [], following, profiles, ns))
}
if (req.method === 'GET' && path === '/following') {
// ⛔ Profiles too. A following list that showed keys could not say WHO you follow, which is the
// only question that page answers.
return send(res, pages.following(await client.call('following'), await client.call('missing'),
await client.call('profiles')))
}
// ⚠ Served through the daemon, not from disk: the surface holds no storage, and an avatar may
// belong to a feed this node replicated rather than one it authored.
// ⛔ Served through the daemon and streamed FROM DISK. The daemon returns a PATH, not bytes: both
// processes are on one machine (the control channel is a unix socket), so pushing a file through
// it as base64 was an encode/decode round trip for nothing. The bytes reached this machine over
// the peer's own replication, which is where every byte on this machine came from.
if (req.method === 'GET' && (path.startsWith('/avatar/') || path.startsWith('/blob/'))) {
const isAvatar = path.startsWith('/avatar/')
const oid = path.slice((isAvatar ? '/avatar/' : '/blob/').length)
const r = await client.call(isAvatar ? 'avatar-get' : 'file-get', { oid }).catch(() => null)
if (!r || !r.path) { res.writeHead(404); return res.end() }
res.writeHead(200, {
'content-type': r.mimeType || 'application/octet-stream',
'content-length': r.size,
// ⛔ The browser must not second-guess the type. We sniffed the bytes; letting it sniff too is
// how a text file becomes markup.
'x-content-type-options': 'nosniff',
// Content-addressed, so the bytes for an oid can never change.
'cache-control': 'public, max-age=31536000, immutable'
})
return fs.createReadStream(r.path).pipe(res)
}
if (req.method === 'GET' && path.startsWith('/u/')) {
const author = path.slice('/u/'.length)
if (!/^[0-9a-f]{64}$/i.test(author)) return send(res, pages.error('Not a feed key'), 404)
// ⛔ Both: `profile` is that feed's own record, `profiles` carries the CONFIRMED handle the
// daemon attached. Rendering the page from the record alone is what left the handle off it.
/**
* ⛔ MERGED, not cherry-picked. This used to copy `handle` across by hand and nothing else, so
* every field the profiles map gained had to be remembered here — and twice now it was not.
* #64 §2 records the first time, with `handle` itself: "rendering the page from the record
* alone is what left the handle off it". `root` was the second, and it silently removed the
* only way to @ anybody.
*
* `profiles` is a superset of `profile` for any feed that has published one; `profile` supplies
* the empty shape for a feed that has not, so the fallback survives.
*/
const profile = { ...(await client.call('profile', { feed: author })),
...((await client.call('profiles'))[author] || {}) }
// ⛔ `posts-by`, not a filtered timeline. The timeline excludes PEEKED feeds, so filtering it
// could never render the page of somebody you had only looked at — which is the case this page
// exists to serve.
const rows = await client.call('posts-by', { feed: author })
const holding = await client.call('holding', { feed: author })
return send(res, pages.person(author, profile, rows, await fileTypes(rows, client), holding))
}
if (req.method === 'GET' && path === '/settings') {
return send(res, await settingsPage(client, url.searchParams.get('m'), null, themeFrom(req), ctx.appUpdate))
}
// ── the namespace ───────────────────────────────────────────────────────────────────────────
//
// ⛔ These are the only door on an installed node. `pgh.bin` is one string and favela-peer spends
// it on the daemon, so `favela namespace --join` cannot be run on a machine that installed the
// app. A ceremony reachable only from a checkout is a ceremony most people cannot perform.
// The theme. ⚠ A cookie rather than a stored preference: it must be readable BEFORE the page is
// written, and it is a property of this browser rather than of the identity.
if (req.method === 'POST' && path === '/rooms/create') {
const { key } = await client.call('room-create')
return seeOther(res, '/r/' + key)
}
if (req.method === 'POST' && path === '/rooms/join') {
const form = await body(req)
try { await client.call('room-join', { key: String(form.key || '').trim() }) } catch (err) {
return seeOther(res, '/rooms?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/r/' + String(form.key || '').trim())
}
if (req.method === 'POST' && path.startsWith('/r/')) {
const [, , key, action] = path.split('/')
if (!/^[0-9a-f]{64}$/i.test(key || '')) return send(res, pages.error('Not a room key'), 404)
const form = await body(req)
// ⛔ ROOM-CONTRACT v2. Every action is carried by THIS node's own writer core; nobody adds anybody
// else's machine. A joiner joins with an invitation, a member's other device binds itself.
try {
if (action === 'invite') {
const inv = await client.call('room-invite', {
key, role: form.role === 'moderator' ? 'moderator' : 'peer', requiresConfirmation: form.confirm === 'on'
})
// ⛔ RENDERED, not redirected. The token is the invitation's signing secret: a redirect would
// write it into a location header and the browser's history.
const r = await client.call('room', { key })
return send(res, pages.room(key, r, await client.call('profiles'), { invitation: inv }))
}
if (action === 'post' && String(form.body || '').trim()) await client.call('room-post', { key, body: form.body })
else if (action === 'join') await client.call('room-join-invite', { key, token: String(form.token || '').trim() })
else if (action === 'add-device') await client.call('room-add-device', { key })
else if (action === 'accept') await client.call('room-accept', { key, writer: form.writer })
else if (action === 'reject') await client.call('room-reject', { key, writer: form.writer })
} catch (err) {
return seeOther(res, '/r/' + key + '?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/r/' + key)
}
if (req.method === 'POST' && path === '/theme') {
const form = await body(req)
const v = String(form.theme || '')
const attrs = '; Path=/; Max-Age=31536000; SameSite=Lax'
res.writeHead(303, {
location: '/settings',
// "system" CLEARS it. A third value stored as a value would be a setting that shadows the
// reader's own; absence is what lets prefers-color-scheme through.
'set-cookie': v === 'light' || v === 'dark'
? 'favela_theme=' + v + attrs
: 'favela_theme=; Path=/; Max-Age=0; SameSite=Lax'
})
return res.end()
}
// ⛔ REMOVES A LIST ENTRY, NEVER A LOG. The daemon refuses the current namespace and refuses a
// founded one, and says why in both cases — the message is passed through verbatim because it is
// the half that knows.
if (req.method === 'POST' && path === '/namespace/forget') {
const form = await body(req)
try {
await client.call('namespace-forget', { key: (form.key || '').trim() })
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings')
}
if (req.method === 'POST' && path === '/namespace/join') {
const form = await body(req)
try {
await client.call('namespace-join', { key: String(form.key || '').trim() })
} catch (err) {
// The daemon's message verbatim — it is the one that knows which rule was broken.
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings')
}
if (req.method === 'POST' && path === '/namespace/request') {
const form = await body(req)
const token = String(form.token || '').trim()
try {
await client.call('namespace-request', token ? { token } : {})
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings?m=' + encodeURIComponent(
token ? 'asked, with your invite' : 'asked — an admin has to answer it by hand'))
}
// ⚠ Renders instead of redirecting, on purpose. The token is a secret shown once, and a redirect
// would have to carry it in the query string — into browser history and every log that keeps
// request lines.
if (req.method === 'POST' && path === '/namespace/invite') {
let minted
try {
minted = await client.call('namespace-invite')
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return send(res, await settingsPage(client, null, minted.token, themeFrom(req), ctx.appUpdate))
}
if (req.method === 'POST' && path === '/namespace/admit') {
const form = await body(req)
try {
await client.call('namespace-admit', { writer: String(form.writer || '').trim() })
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings')
}
if (req.method === 'POST' && path === '/namespace/withdraw') {
const form = await body(req)
try {
await client.call('namespace-uninvite', { hash: String(form.hash || '').trim() })
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings')
}
// Claim or release a handle. ⚠ Its own route rather than a field on the settings form: that form
// is multipart because of the avatar, and a name claim is an ordinary urlencoded post. Folding
// them together would make claiming a name depend on the file-upload parser.
if (req.method === 'POST' && path === '/handle') {
const form = await body(req)
try {
if (form.release) await client.call('release-name', { handle: form.release })
else if (form.name && form.name.trim()) await client.call('claim-name', { name: form.name.trim() })
else return seeOther(res, '/settings?m=' + encodeURIComponent('type a name to claim'))
} catch (err) {
// ⛔ The daemon's message is passed through verbatim — it is the one that knows why. An
// unlinked device says so, and a malformed name says which rule it broke.
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings')
}
// ── Flow 2 — a name on a bridge ────────────────────────────────────────────────────────────────
//
// ⛔ THE PEER PERFORMS IT. This page collects a key and a name; `bridge-claim` fetches a challenge,
// signs bytes binding both, and sends them. The credential never leaves the daemon and this file
// never sees one — identity.md §7.5: "favela-web renders the form; the peer performs the op".
if (req.method === 'POST' && path === '/bridge/claim') {
const form = await body(req)
const bridge = String(form.bridge || '').trim()
const name = String(form.name || '').trim()
if (!bridge || !name) {
return seeOther(res, '/settings?m=' + encodeURIComponent('a bridge key and a name are both needed'))
}
try {
const r = await client.call('bridge-claim', { bridge, name, invite: String(form.invite || '').trim() || null })
// ⛔ RECORDED ONLY AFTER THE BRIDGE AGREED. Writing the name first would leave this node
// claiming an address it does not hold — the exact thing the old text box did every time.
if (r.acct) await client.call('set-reach', { fediverse: r.acct }).catch(() => {})
// ⚠ 202 means claimed but not yet servable. Saying "done" would promise an actor that is dead,
// and saying "failed" would lose a name that IS held.
return seeOther(res, '/settings?m=' + encodeURIComponent(r.pending
? 'the name ' + (r.acct || name) + ' is yours, but the bridge could not start serving it yet'
: 'the bridge carries you as ' + (r.acct || name)))
} catch (err) {
// The daemon's message verbatim — it knows whether the name was taken, an invite was needed,
// or the bridge simply did not answer.
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
}
// ⛔ THE STEP NOTHING ELSE PERFORMS. Every other verb on the Bluesky card is about the IDENTITY —
// minting it, publishing it, having a bridge carry it, consenting to use it. None of them authors a
// record, and the keyless membrane serves only what this device signed, so all four can succeed
// while the repo stays empty. That is not a hypothetical: a handset reached "carried by
// fleeky.7rnx.net", relay active, switch on, with one bootstrap record in its repo and no profile.
//
// ⚠ The daemon's own log is rendered rather than summarised — the withheld lines say WHY a post did
// not go, and that is the question a person has after pressing this.
if (req.method === 'POST' && path === '/did/project') {
const form = await body(req)
// ⛔ SEND IS OPT-IN PER PRESS. The dry-run button submits an empty value, so anything other than an
// explicit "1" plans and publishes nothing — publication onto a network that does not forget is
// not a thing to do because a form field was missing.
// ⚠ NOT named `send` — that is this router's own response helper, and shadowing it here turns
// every successful run into "send is not a function" at the moment it reports what it published.
const doSend = String(form.send || '') === '1'
try {
const r = await client.call('bsky-project', { send: doSend })
return send(res, pages.projectResult(r))
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
}
if (req.method === 'GET' && path === '/bridge/release') {
const reach = await client.call('reach').catch(() => null)
if (!reach || !reach.fediverse) return seeOther(res, '/settings')
return send(res, pages.bridgeRelease(reach.fediverse, url.searchParams.get('m')))
}
if (req.method === 'POST' && path === '/bridge/release') {
const reach = await client.call('reach').catch(() => null)
if (!reach || !reach.fediverse) return seeOther(res, '/settings')
// `name@host` — the bridge knows it by the name alone, and the daemon remembers which bridge
// that was, so nobody has to paste a key again to perform the one irreversible act here.
const name = String(reach.fediverse).split('@')[0]
try {
await client.call('bridge-release', { acct: reach.fediverse, name })
} catch (err) {
return seeOther(res, '/bridge/release?m=' + encodeURIComponent(err.message))
}
await client.call('set-reach', { fediverse: null }).catch(() => {})
return seeOther(res, '/settings?m=' + encodeURIComponent('that name has been given up'))
}
// ── Flow 1 — the atproto identity ──────────────────────────────────────────────────────────────
//
// ⛔ THREE ROUTES FOR THREE COSTS. Minting reaches nothing and is free; showing the phrase is the
// one moment a secret exists outside the vault; publishing is permanent and public. Each gets its
// own screen so none of them can be reached by a mis-tap on the one before.
if (req.method === 'GET' && path === '/did/create') {
return send(res, pages.didCreate(url.searchParams.get('m')))
}
if (req.method === 'POST' && path === '/did/create') {
const form = await body(req)
const pass = String(form.passphrase || '')
// ⛔ CHECKED HERE TOO, not only in the browser. The confirmation field exists because a vault
// passphrase is typed blind and has no reset — a mistyped one seals the recovery secret away
// for good — and a check that lives only in a script is a check that does not run when the
// script does not.
if (pass !== String(form['passphrase-confirm'] || '')) {
return seeOther(res, '/did/create?m=' + encodeURIComponent('the two passphrases do not match'))
}
try {
// ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. Rendered straight into this response
// and never written, cached or logged here — there is deliberately no route that shows it
// again. Same contract as /welcome/create.
const r = await client.call('did-mint', { passphrase: pass })
return send(res, pages.didMinted(r))
} catch (err) {
// The daemon's message verbatim: it is the one that knows whether the passphrase was too
// short, an identity already existed, or the config could not be written.
return seeOther(res, '/did/create?m=' + encodeURIComponent(err.message))
}
}
// ⛔ DESIGNATION, and it is its own act. identity.md §5.1 gives the rule: a signer needs exactly
// one credential, and picking one on the author's behalf would publish under an identity they did
// not choose. Minting does not elect; this does.
//
// ⚠ Sends ONLY the credential. `setReach` merges — "a write naming only `networks` leaves the
// credential alone, and the reverse" — so this cannot switch a network on as a side effect, which
// is the one thing it must never do.
if (req.method === 'POST' && path === '/did/designate') {
const st = await client.call('did-status').catch(() => null)
if (!st || !st.did) return seeOther(res, '/settings')
try {
await client.call('set-reach', { credential: st.did })
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings?m=' + encodeURIComponent(
'Bluesky posts will be published under ' + st.did))
}
// ⛔ GETTING CARRIED — the step that makes a published DID resolvable at all. A mint is P2P-only,
// so the genesis names no handle and no server; until a bridge grants both and the peer puts them
// on-chain, Bluesky has nothing to fetch and the identity resolves for nobody.
if (req.method === 'GET' && path === '/did/carry') {
const st = await client.call('did-status').catch(() => null)
if (!st || !st.did) return seeOther(res, '/settings')
if (st.handle) return seeOther(res, '/settings?m=' + encodeURIComponent('a bridge already carries this identity'))
// ⚠ Not gated on `submitted`: that flag comes from a local record which only exists for identities
// minted after it did. Whether the DID is really in the directory is the directory's question, and
// the adopt step asks it.
return send(res, pages.didCarry(st.did, url.searchParams.get('m')))
}
if (req.method === 'POST' && path === '/did/carry') {
const form = await body(req)
const bridge = String(form.bridge || '').trim()
const name = String(form.name || '').trim()
const passphrase = String(form.passphrase || '')
if (!bridge || !name || !passphrase) {
return seeOther(res, '/did/carry?m=' + encodeURIComponent('a bridge key, a name and your passphrase are all needed'))
}
try {
// ⛔ ONE CALL. The daemon does both halves in the right order — reversible first — so a refusal
// from the bridge never leaves a permanent append behind it.
const r = await client.call('did-carry', { bridge, name, passphrase })
return seeOther(res, '/settings?m=' + encodeURIComponent(
r.pending
? 'published as ' + r.handle + ', but the bridge is not serving it yet'
: 'published as ' + r.handle))
} catch (err) {
// Verbatim: the daemon knows whether the name was taken, the passphrase was wrong, or the
// directory refused the operation — and those need different actions.
return seeOther(res, '/did/carry?m=' + encodeURIComponent(err.message))
}
}
if (req.method === 'GET' && path === '/did/publish') {
const st = await client.call('did-status').catch(() => null)
if (!st || !st.did) return seeOther(res, '/settings')
if (st.submitted) {
return seeOther(res, '/settings?m=' + encodeURIComponent('that identity is already published'))
}
return send(res, pages.didPublish(st.did, url.searchParams.get('m')))
}
if (req.method === 'POST' && path === '/did/publish') {
try {
const r = await client.call('did-submit', {})
return seeOther(res, '/settings?m=' + encodeURIComponent(
r.already ? 'that identity was already published' : 'published to the Bluesky network'))
} catch (err) {
// ⚠ Back to the CONFIRMATION, not to settings. Submitting reaches the network and the common
// failure is transient, so the page someone lands on should be the one with the button.
return seeOther(res, '/did/publish?m=' + encodeURIComponent(err.message))
}
}
/**
* Where this identity publishes.
*
* ⛔ The daemon decides, not this page. `set-reach` is gated on an ATTESTED device — the same gate
* as posting — so a browser that is merely signed in cannot switch a network on. The refusal
* arrives here as an error and is shown; it is not something to route around.
*
* ⚠ An unticked box sends NOTHING, so the absent-means-empty reading is the correct one for a
* checkbox form: no `reach` field at all means every network off.
*/
if (req.method === 'POST' && path === '/reach') {
const form = await body(req)
// ⛔ ONE NETWORK PER SUBMIT, MERGED. This read every checkbox from a single form, so with one
// section per network a save would have silently switched the OTHER network off — an unticked box
// sends nothing, and "absent means off" is only safe when the form carries them all.
// ⛔ DESIGNATION IS ITS OWN SUBMIT. `setReach` merges, so a form naming only `credential` leaves
// the networks alone and vice versa — which is what lets one section hold both without either
// clobbering the other.
if (Object.prototype.hasOwnProperty.call(form, 'credential')) {
const credential = String(form.credential || '').trim() || null
try {
await client.call('set-reach', { credential })
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings?m=' + encodeURIComponent(
credential ? 'posts go out under ' + credential : 'no identity is designated, so nothing is published to Bluesky'))
}
const network = String(form.network || '').trim()
if (network !== 'bsky' && network !== 'fediverse') return seeOther(res, '/settings')
const on = !!form.on
// ⚠ Read first: a merge needs the other network's current answer, and this page must not be the
// thing that remembers it.
const reach = await client.call('reach').catch(() => null)
const current = new Set(reach && Array.isArray(reach.networks) ? reach.networks : [])
if (on) current.add(network); else current.delete(network)
const named = network === 'bsky' ? 'Bluesky' : 'the fediverse'
try {
await client.call('set-reach', { networks: [...current] })
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings?m=' + encodeURIComponent(
on ? 'posts will be sent to ' + named : 'posts will no longer be sent to ' + named))
}
if (req.method === 'POST' && path === '/settings') {
// 3 MiB: each picture is capped at 512 KiB by the daemon and the form can now carry TWO, and
// the slack is so an oversized picture is refused by the daemon with a message about its size
// rather than by the transport with nothing useful.
let form
try {
form = parseMultipart(await readBytes(req, 3 * 1024 * 1024), req.headers['content-type'])
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
if (!form) return seeOther(res, '/settings?m=' + encodeURIComponent('expected a form upload'))
try {
const set = {}
if (typeof form.fields.name === 'string') set.name = form.fields.name
if (typeof form.fields.bio === 'string') set.bio = form.fields.bio
if (Object.keys(set).length) await client.call('set-profile', set)
// ⛔ TWO PICTURES, one form. The avatar is the round one; the banner is the wide header.
// They are independent — submitting the form with only one file set must not clear the other,
// which is why each is only touched when its own part carries bytes.
for (const [field, verb] of [['avatar', 'avatar-put'], ['banner', 'banner-put']]) {
const file = form.files[field]
if (!file || !file.bytes.length) continue
// ⛔ A PATH, for the same reason. Written where the daemon can read it; it copies what it
// keeps, so this temp file is ours to remove.
const tmp = join(os.tmpdir(), 'favela-upload-' + Date.now() + '-' + Math.random().toString(16).slice(2))
fs.writeFileSync(tmp, file.bytes)
try { await client.call(verb, { path: tmp }) } finally { try { fs.unlinkSync(tmp) } catch {} }
}
} catch (err) {
return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
}
return seeOther(res, '/settings')
}
// ⛔ ASKS FIRST. The delete reaches bsky and the fediverse, so the post's link lands here and only
// this page's form deletes. Only the record's NAME travels on: the daemon looks it up in MY feed.
if (req.method === 'GET' && path === '/delete') {
const t = parseTarget(url.searchParams.get('target'))
if (!t) return seeOther(res, '/')
return send(res, pages.deletePost(t.name, local(url.searchParams.get('from')), url.searchParams.get('m')))
}
if (req.method === 'GET' && path === '/knocks') {
return send(res, pages.knocks(await client.call('knocks')))
}
// Compose. multipart, because it may carry a file.
//
// ⛔ The file is stored FIRST and its oid appended to the body as markdown, so what gets published
// is an ordinary record. Nothing downstream — replication, rendering, projection — has to know
// that a post had an attachment; `` is just a link to a file the overlay
// holds, and every peer resolves it the same way.
if (req.method === 'POST' && path === '/post') {
let form
try {
form = parseMultipart(await readBytes(req, MAX_UPLOAD), req.headers['content-type'])
} catch (err) {
return seeOther(res, '/?m=' + encodeURIComponent(err.message))
}
if (!form) return seeOther(res, '/?m=' + encodeURIComponent('expected a form upload'))
let body = typeof form.fields.body === 'string' ? form.fields.body : ''
const file = form.files.file
if (file && file.bytes.length) {
// ⛔ A PATH over the socket, not bytes — both processes are on this machine and the daemon
// copies what it keeps, so this temp file is ours to remove.
const tmp = join(os.tmpdir(), 'favela-post-' + Date.now() + '-' + Math.random().toString(16).slice(2))
fs.writeFileSync(tmp, file.bytes)
// ⛔ THE PYRAMID, when the composer built one. The base is the author's own bytes and always
// level 0; `level-N` parts are the smaller levels the browser rendered at compose time
// (SOCIAL-MODEL §6.6.1 — encode once at authoring, so bridges SELECT rather than re-encode).
//
// ⚠ Absent levels are the ordinary case, not a failure: a browser without the script, an
// image already smaller than the lowest rung, or a non-image all send exactly one part. That
// path is today's behaviour and stays byte-identical.
const tmps = [tmp]
const levels = [{ path: tmp, w: Number(form.fields['file-w']) || 0, h: Number(form.fields['file-h']) || 0 }]
for (let n = 0; ; n++) {
const part = form.files['level-' + n]
if (!part || !part.bytes.length) break
const lp = join(os.tmpdir(), 'favela-level-' + Date.now() + '-' + n + '-' + Math.random().toString(16).slice(2))
fs.writeFileSync(lp, part.bytes)
tmps.push(lp)
levels.push({ path: lp, w: Number(form.fields['level-' + n + '-w']) || 0, h: Number(form.fields['level-' + n + '-h']) || 0 })
}
let stored
try {
stored = levels.length > 1
? await client.call('file-put-levels', { levels })
: await client.call('file-put', { path: tmp })
} catch (err) {
return seeOther(res, '/?m=' + encodeURIComponent(err.message))
} finally {
for (const t of tmps) { try { fs.unlinkSync(t) } catch {} }
}
// The alt text is the filename the browser sent — the only label we have, and it is escaped
// wherever it is rendered. ⚠ It is a CLAIM by the uploader; the TYPE came from the bytes.
const alt = String(file.filename || 'file').replace(/[[\]()]/g, '')
body = (body ? body.trimEnd() + '\n\n' : '') + ''
}
if (!body.trim()) return seeOther(res, '/?m=' + encodeURIComponent('a post needs words or a file'))
await client.call('post', { body })
return seeOther(res, '/')
}
if (req.method === 'POST') {
const form = await body(req)
if (path === '/post/delete') {
const back = local(form.from)
try {
await client.call('post-delete', { name: String(form.name || '') })
} catch (err) {
const page = back.split('#')[0]
return seeOther(res, page + (page.includes('?') ? '&' : '?') + 'm=' + encodeURIComponent(err.message))
}
return seeOther(res, back)
}
if (path === '/follow') {
if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('follow', { feed: form.feed })
return seeOther(res, '/following')
}
if (path === '/admit') {
if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('admit', { feed: form.feed })
return seeOther(res, '/knocks')
}
// ⛔ Two routes, because a like has to be reversible from the page that offered it. The daemon
// has had `unlike` all along and nothing on the surface could reach it, so a like was one-way
// from a browser — the same "method with no route" shape as `browser-auth`.
// Hold a feed to READ it. ⚠ Deliberately its own route rather than a silent side effect of
// visiting /u/<key>: holding replicates their feed to this disk and joins their discovery
// topic, so it is a decision a person makes, not one a link makes for them.
if (path === '/peek') {
if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('peek', { feed: form.feed })
return seeOther(res, local(form.from) === '/' ? '/u/' + form.feed : local(form.from))
}
if (path === '/reply') {
const target = parseTarget(form.target)
const body = String(form.body || '').trim()
if (!target || !body) return seeOther(res, local('/t/' + encodeURIComponent(String(form.target || ''))))
await client.call('reply', { target, body })
return seeOther(res, '/t/' + encodeURIComponent(String(form.target)))
}
if (path === '/block' || path === '/unblock') {
if (/^[0-9a-f]{64}$/i.test(form.feed || '')) {
try { await client.call(path === '/block' ? 'block' : 'unblock', { feed: form.feed }) } catch (err) {
return seeOther(res, '/u/' + form.feed + '?m=' + encodeURIComponent(err.message))
}
}
return seeOther(res, local(form.from) === '/' ? '/u/' + form.feed : local(form.from))
}
if (path === '/repost' || path === '/unrepost') {
const target = parseTarget(form.target)
if (target) await client.call(path === '/repost' ? 'repost' : 'unrepost', { target })
return seeOther(res, local(form.from))
}
if (path === '/react' || path === '/unreact') {
const target = parseTarget(form.target)
const value = String(form.value || '').trim()
// `like` keeps its own daemon command — it is the one value both target networks carry.
const cmd = value === 'like'
? (path === '/react' ? 'like' : 'unlike')
: (path === '/react' ? 'react' : 'unreact')
if (target && value) await client.call(cmd, value === 'like' ? { target } : { target, value })
return seeOther(res, local(form.from))
}
if (path === '/like' || path === '/unlike') {
const target = parseTarget(form.target)
if (target) await client.call(path === '/like' ? 'like' : 'unlike', { target })
// ⚠ Back where you came from. It used to always redirect to `/`, so liking a post found in
// search threw away the search. `local()` refuses anything but a same-site path — an
// attacker-supplied `from` would otherwise make this an open redirect.
return seeOther(res, local(form.from))
}
}
return send(res, pages.error('Not found'), 404)
}
export default { route }