OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway commits / 601eaa3

0.5.0: the public read-only page — what really shows up on bsky
https://<server host>/ and /status (membrane HTTP extensions, atproto-bridge 0.9.56; declined on every other host, so the *.7rnx.net handle hosts are unchanged). Shows bsky's OWN view: the relay's status for our host; per account whether bsky's copy matches ours, posts count, and the last 3 posts as the AppView shows them (linked to bsky.app); each community feed as bsky's getFeed returns it; fediverse actors with follower counts and a link to ap.7rnx.net/timeline; open problems by TITLE only. No fix commands, details or process stats (those are /admin/status). Everything from the cached probes (now getAuthorFeed limit 3 and getFeed per lens feed), so a page view adds at most one probe per 5 min. Tests first: public 4, probes +2, gateway +1; suite 61/61. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
601eaa33c5ce3cfbe2ffef089503d18a9ef5a2fc
by plankatron <fleeky@gmail.com> · 2026-10-14T01:14:45.000Z

Changed files (8) +223 -10

lib/gateway.js +15 -1
……@@ -23,6 +23,7 @@ import { createProbes } from './probes.js'
2323import { createLens } from './lens.js'
2424import { createWatcher } from './watch.js'
2525import { renderStatusPage } from './panel.js'
26import { renderPublicPage } from './public.js'
2627
2728const ROLES = {
2829 membrane: async (cfg, opts) => startMembrane(cfg, opts),
……@@ -122,7 +123,8 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null,
122123 if (running.lens) out.lens = running.lens.metrics()
123124 if (probes) {
124125 const dids = out.membrane.dids.map((d) => ({ did: d.did, rev: d.rev }))
125 out.bsky = await probes.get({ dids, emitted: out.membrane.relay.emitted }, { force: !!params.refresh })
126 const feeds = out.lens ? out.lens.feeds.filter((f) => f.feedUri).map((f) => f.feedUri) : []
127 out.bsky = await probes.get({ dids, emitted: out.membrane.relay.emitted, feeds }, { force: !!params.refresh })
126128 }
127129 if (watcher) out.alerts = watcher.current()
128130 return out
……@@ -162,6 +164,18 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null,
162164 // The status page on the admin surface: both bridges' health, alerts first (lib/panel.js).
163165 adminPages.set('/admin/status', async () => renderStatusPage(await metrics({})))
164166
167 // The PUBLIC page (lib/public.js) on the membrane's own host only: https://<host>/ and /status. Any
168 // other host (a *.7rnx.net handle host) is declined and falls through to what it served before.
169 if (running.membrane && running.membrane.extensions && running.membrane.extensions.http && hostname) {
170 const page = async ({ host }) => {
171 if (host !== hostname) return null
172 return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, body: renderPublicPage(await metrics({})) }
173 }
174 running.membrane.extensions.http.set('/', page)
175 running.membrane.extensions.http.set('/status', page)
176 steps.unshift(['public-page', () => { running.membrane.extensions.http.delete('/'); running.membrane.extensions.http.delete('/status') }])
177 }
178
165179 // The verbs this gateway serves, for `favela-gateway help`. release-ready is the process owner's.
166180 handlers.verbs = async () => ({ verbs: [...Object.keys(handlers), 'release-ready'].sort() })
167181
lib/probes.js +26 -5
……@@ -20,6 +20,12 @@ const PLC = 'https://plc.directory'
2020const APPVIEW = 'https://public.api.bsky.app'
2121const TIMEOUT_MS = 8000
2222
23// A post as the page shows it: its uri, a short text and when it was written.
24function postView (p) {
25 const rec = p.record || {}
26 return { uri: p.uri, text: typeof rec.text === 'string' ? rec.text.slice(0, 300) : '', createdAt: rec.createdAt || p.indexedAt || null }
27}
28
2329export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 60 * 1000, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {
2430 if (!hostname) throw new Error('createProbes: hostname required')
2531 const doFetch = fetchImpl || ((...a) => globalThis.fetch(...a))
……@@ -43,7 +49,7 @@ export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 6
4349 }
4450
4551 async function probeDid ({ did, rev }, problems) {
46 const out = { did, ourRev: rev || null, relayRev: null, inSync: null, plcPds: null, handle: null, appview: { resolves: null, postsCount: null, newestPost: null, newestIndexedAt: null } }
52 const out = { did, ourRev: rev || null, relayRev: null, inSync: null, plcPds: null, handle: null, appview: { resolves: null, postsCount: null, newestPost: null, newestIndexedAt: null, recent: [] } }
4753
4854 const lc = await getJson(`${RELAY}/xrpc/com.atproto.sync.getLatestCommit?did=${encodeURIComponent(did)}`)
4955 if (lc.error) problems.push(`${did}: relay getLatestCommit unreachable (${lc.error})`)
……@@ -75,14 +81,27 @@ export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 6
7581 } else {
7682 out.appview.resolves = true
7783 out.appview.postsCount = prof.body.postsCount == null ? null : prof.body.postsCount
78 const feed = await getJson(`${APPVIEW}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(did)}&limit=1`)
79 const top = !feed.error && feed.body.feed && feed.body.feed[0] && feed.body.feed[0].post
84 // The last 3 posts AS THE APPVIEW SHOWS THEM — the public page's "what really shows up".
85 const feed = await getJson(`${APPVIEW}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(did)}&limit=3`)
86 const items = (!feed.error && feed.body.feed) || []
87 out.appview.recent = items.map((f) => f.post).filter(Boolean).map(postView)
88 const top = items[0] && items[0].post
8089 if (top) { out.appview.newestPost = top.uri; out.appview.newestIndexedAt = top.indexedAt || null }
8190 }
8291 return out
8392 }
8493
85 async function probe ({ dids = [], emitted = null } = {}) {
94 // A lens feed as bsky's getFeed returns it: the end-to-end check that the feed actually works on bsky.
95 async function probeFeed (uri, problems) {
96 const r = await getJson(`${APPVIEW}/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(uri)}&limit=10`)
97 if (r.error) {
98 problems.push(`feed ${uri.split('/').pop()}: bsky getFeed fails (${r.error})`)
99 return { uri, error: r.error, posts: [] }
100 }
101 return { uri, posts: (r.body.feed || []).map((f) => f.post).filter(Boolean).map((p) => ({ ...postView(p), author: (p.author && p.author.handle) || null })) }
102 }
103
104 async function probe ({ dids = [], emitted = null, feeds = [] } = {}) {
86105 const problems = []
87106 const relay = { status: null, seq: null, error: null }
88107 const hs = await getJson(`${RELAY}/xrpc/com.atproto.sync.getHostStatus?hostname=${encodeURIComponent(hostname)}`)
……@@ -97,7 +116,9 @@ export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 6
97116 }
98117 const rows = []
99118 for (const d of dids) rows.push(await probeDid(d, problems))
100 return { at: now(), hostname, relay, dids: rows, problems }
119 const feedRows = []
120 for (const uri of feeds) feedRows.push(await probeFeed(uri, problems))
121 return { at: now(), hostname, relay, dids: rows, feeds: feedRows, problems }
101122 }
102123
103124 // The cached read every caller uses. Concurrent callers share the probe in flight.
lib/public.js +88 -0
……@@ -0,0 +1,88 @@
1// The public read-only page — https://<server host>/ and /status (hyper-social #80; operator 2026-10-13:
2// "frontends for each bridge so that you could see what posts really show up, like a webui for a relay").
3//
4// It shows bsky's OWN view of what this gateway serves — the relay's status for our host, each account's
5// last posts as bsky's AppView shows them, each community feed as bsky's getFeed returns it — beside our
6// side of it (in sync or not). Everything comes from the cached probes (5 min); a page view never makes a
7// request to bsky.
8//
9// ⛔ PUBLIC: alert TITLES only. No fix commands, no alert details, no process stats, no peer internals —
10// those are for the operator page on the authenticated admin surface (/admin/status).
11
12const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
13
14const ago = (ts, now) => {
15 if (!ts) return 'never'
16 const s = Math.max(0, Math.round((now - (typeof ts === 'string' ? Date.parse(ts) : ts)) / 1000))
17 if (!Number.isFinite(s)) return '?'
18 if (s < 90) return s + 's ago'
19 if (s < 90 * 60) return Math.round(s / 60) + 'm ago'
20 if (s < 36 * 3600) return Math.round(s / 3600) + 'h ago'
21 return Math.round(s / 86400) + 'd ago'
22}
23
24// at://did/app.bsky.feed.post/rkey → where bsky shows it.
25const bskyLink = (uri) => {
26 const m = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/.exec(String(uri || ''))
27 return m ? `https://bsky.app/profile/${m[1]}/post/${m[2]}` : null
28}
29
30const STYLE = `body{font:15px/1.5 system-ui,sans-serif;margin:0 auto;padding:16px;max-width:820px;color:#111;background:#fff}
31h1{font-size:22px;margin:8px 0}h2{font-size:16px;margin-top:28px;border-bottom:1px solid #ddd;padding-bottom:4px}
32.who{color:#666;font-size:13px}.bad{color:#a00;font-weight:600}.good{color:#070}
33.post{border-left:3px solid #ddd;padding:2px 10px;margin:8px 0}.post a{color:inherit;text-decoration:none}
34.post .meta{color:#666;font-size:12px}code{font:12px ui-monospace,monospace;word-break:break-all}
35@media (prefers-color-scheme: dark){body{background:#111;color:#eee}.post{border-color:#444}h2{border-color:#333}.who,.post .meta{color:#999}}`
36
37const post = (p, now) => {
38 const link = bskyLink(p.uri)
39 const text = esc(p.text || '(no text)')
40 return '<div class="post">' + (link ? '<a href="' + esc(link) + '">' + text + '</a>' : text) +
41 '<div class="meta">' + (p.author ? '@' + esc(p.author) + ' · ' : '') + esc(ago(p.createdAt, now)) + '</div></div>'
42}
43
44export function renderPublicPage (m, { now = Date.now() } = {}) {
45 const b = m.bsky
46 const host = b ? b.hostname : '?'
47 const parts = []
48 parts.push('<h1>' + esc(host) + '</h1><p class="who">A favela gateway: it serves these accounts to bsky and the fediverse. This page shows what bsky itself reports — checked ' + esc(b ? ago(b.at, now) : 'not yet') + '.</p>')
49
50 const open = (m.alerts && m.alerts.open) || []
51 if (open.length) parts.push('<h2>Known problems</h2><ul>' + open.map((a) => '<li class="' + (a.severity === 'critical' ? 'bad' : '') + '">' + esc(a.title) + ' <span class="who">(since ' + esc(ago(a.since, now)) + ')</span></li>').join('') + '</ul>')
52
53 if (!b) {
54 parts.push('<p class="bad">bsky has not been checked yet.</p>')
55 } else {
56 parts.push('<h2>bsky</h2><p>bsky\'s relay sees this host as <span class="' + (b.relay.status === 'active' ? 'good' : 'bad') + '">' + esc(b.relay.error ? 'unreachable' : b.relay.status) + '</span>.</p>')
57
58 for (const d of b.dids || []) {
59 const own = ((m.membrane && m.membrane.dids) || []).find((x) => x.did === d.did) || {}
60 const name = d.handle || own.handle || d.did
61 const sync = d.inSync === true ? '<span class="good">in sync</span>' : d.inSync === false ? '<span class="bad">bsky has a different version</span>' : 'not compared'
62 const av = d.appview && d.appview.resolves ? esc(d.appview.postsCount) + ' posts on bsky' : '<span class="bad">bsky does not show this account</span>'
63 parts.push('<h2><a href="https://bsky.app/profile/' + esc(d.did) + '">@' + esc(name) + '</a></h2><p class="who">' + sync + ' · ' + av +
64 (d.plcPds && d.plcPds !== 'https://' + host ? ' · <span class="bad">its identity points elsewhere</span>' : '') + '</p>' +
65 ((d.appview && d.appview.recent) || []).map((p) => post(p, now)).join(''))
66 }
67
68 for (const f of b.feeds || []) {
69 const lens = ((m.lens && m.lens.feeds) || []).find((x) => x.feedUri === f.uri) || {}
70 const name = lens.id || f.uri.split('/').pop()
71 const parsed = /^at:\/\/([^/]+)\/app\.bsky\.feed\.generator\/([^/]+)$/.exec(f.uri)
72 const feedLink = parsed ? `https://bsky.app/profile/${parsed[1]}/feed/${parsed[2]}` : null
73 parts.push('<h2>Feed: ' + (feedLink ? '<a href="' + esc(feedLink) + '">' + esc(name) + '</a>' : esc(name)) + '</h2>' +
74 (f.error
75 ? '<p class="bad">bsky cannot load this feed (' + esc(f.error) + ').</p>'
76 : '<p class="who">As bsky shows it' + (lens.members ? ' — posts from ' + esc(lens.members) + ' members' : '') + '.</p>' + f.posts.map((p) => post(p, now)).join('')))
77 }
78 }
79
80 if (m.fediverse) {
81 parts.push('<h2>Fediverse</h2><ul>' + m.fediverse.users.map((u) => '<li><code>@' + esc(u.username) + '@ap.7rnx.net</code> — ' + esc(u.followers) + ' follower' + (u.followers === 1 ? '' : 's') + '</li>').join('') +
82 '</ul><p><a href="https://ap.7rnx.net/timeline">What the fediverse is sent</a></p>')
83 }
84
85 return '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(host) + '</title><style>' + STYLE + '</style>' + parts.join('')
86}
87
88export default renderPublicPage
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "favela-gateway",
3 "version": "0.4.0",
3 "version": "0.5.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "favela-gateway",
9 "version": "0.4.0",
9 "version": "0.5.0",
1010 "dependencies": {
1111 "ap-bridge": "file:../ap-bridge",
1212 "atproto-bridge": "file:../atproto-bridge",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-gateway",
3 "version": "0.4.0",
3 "version": "0.5.0",
44 "description": "The favela gateway: the bsky membrane (atproto-bridge) and the fediverse bridge (ap-bridge) in one process, keyless towards atproto. Later roles: the bsky lens and a blind seeder (hyper-social #80).",
55 "type": "module",
66 "main": "lib/gateway.js",
test/gateway.test.js +14 -1
……@@ -153,7 +153,8 @@ test('★ alerts: with the watcher on, `alerts refresh=true` checks now and `met
153153 t.ok(a.lastCheckAt > 0)
154154 const m = await g.handlers.metrics({})
155155 t.ok(m.alerts && m.alerts.open.length, 'metrics carries the alerts')
156 t.is(g.steps[0][0], 'alerts', 'the watcher stops first')
156 const names = g.steps.map(([n]) => n)
157 t.ok(names.indexOf('alerts') < names.findIndex((n) => n.startsWith('membrane:')), 'the watcher stops before any role')
157158})
158159
159160test('★ the status page is registered on the fediverse role\'s admin surface and renders every role', async (t) => {
……@@ -164,3 +165,15 @@ test('★ the status page is registered on the fediverse role\'s admin surface a
164165 t.is(p.title, 'Gateway status')
165166 for (const s of ['Roles', 'Served DIDs', 'Fediverse', 'membrane', 'fediverse']) t.ok(p.body.includes(s), s)
166167})
168
169test('★ the public page is served on the membrane\'s own host only', async (t) => {
170 const g = await startGateway({ roles: ['membrane'], membrane: membraneConfig(), fetch: offlineFetch, ...quiet })
171 t.teardown(() => stop(g))
172 const page = g.roles.membrane.extensions.http.get('/')
173 t.ok(page, 'registered at /')
174 t.ok(g.roles.membrane.extensions.http.get('/status'), 'and /status')
175 t.is(await page({ host: 'p2p.test' }), null, 'a handle host: declined, falls through as before')
176 const r = await page({ host: 'pds.test' })
177 t.is(r.status, 200)
178 t.ok(r.body.startsWith('<!doctype html>') && r.body.includes('pds.test'))
179})
test/probes.test.js +24 -0
……@@ -96,3 +96,27 @@ test('concurrent callers share one probe in flight', async (t) => {
9696 const hostCalls = calls.filter((u) => u.includes('getHostStatus')).length
9797 t.is(hostCalls, 1, 'one getHostStatus for two simultaneous asks')
9898})
99
100// ── the public page's view: what bsky actually shows ─────────────────────────────────────────────────
101const posts3 = { feed: [1, 2, 3].map((i) => ({ post: { uri: 'at://' + WJ + '/app.bsky.feed.post/3mr' + i + 'aaaaaaaaa', indexedAt: '2026-09-1' + i + 'T00:00:00Z', author: { handle: 'p2p.7rnx.net' }, record: { text: 'post ' + i + ' <b>', createdAt: '2026-09-1' + i + 'T00:00:00Z' } } })) }
102
103test('★ per DID, the last 3 posts as the AppView shows them (text kept short)', async (t) => {
104 const p = createProbes({ hostname: HOST, fetch: fakeFetch({ ...healthy, ['https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed']: posts3 }) })
105 const r = await p.probe({ dids: ours, emitted: 1 })
106 t.is(r.dids[0].appview.recent.length, 3)
107 t.alike(r.dids[0].appview.recent[0], { uri: posts3.feed[0].post.uri, text: 'post 1 <b>', createdAt: '2026-09-11T00:00:00Z' })
108 t.is(r.dids[0].appview.newestPost, posts3.feed[0].post.uri, 'newest is still the first')
109})
110
111test('★ each lens feed as bsky\'s getFeed returns it (top 10), or its error', async (t) => {
112 const FEED = 'at://did:plc:lc/app.bsky.feed.generator/loser-club'
113 const p = createProbes({ hostname: HOST, fetch: fakeFetch({ ...healthy, ['https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed']: posts3 }) })
114 const r = await p.probe({ dids: [], emitted: 1, feeds: [FEED] })
115 t.is(r.feeds[0].uri, FEED)
116 t.is(r.feeds[0].posts.length, 3)
117 t.is(r.feeds[0].posts[0].author, 'p2p.7rnx.net')
118 const broken = createProbes({ hostname: HOST, fetch: fakeFetch(healthy) })
119 const b = await broken.probe({ dids: [], emitted: 1, feeds: [FEED] })
120 t.ok(b.feeds[0].error, 'a feed bsky cannot load says so')
121 t.ok(b.problems.some((s) => /loser-club/.test(s) && /getFeed/.test(s)), 'and it is a named problem')
122})
test/public.test.js +53 -0
……@@ -0,0 +1,53 @@
1// The public read-only page at https://pds.7rnx.net/ — "what really shows up", from bsky's side (#80).
2import test from 'brittle'
3import { renderPublicPage } from '../lib/public.js'
4
5const NOW = 1e12
6const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
7function sample () {
8 return {
9 version: '0.5.0',
10 process: { uptimeS: 7300, rss: 280e6, heapUsed: 1, heapTotal: 1 },
11 roles: { membrane: { up: true }, fediverse: { up: true }, lens: { up: true } },
12 membrane: { dids: [{ did: WJ, handle: 'p2p.7rnx.net', rev: 'r', fronts: 2, live: 1 }], relay: { emitted: 40269 }, guard: {} },
13 fediverse: { users: [{ username: 'p2p', primary: true, followers: 1, delivered: 172 }], queue: { pending: 0, dead: 0, failing: [] }, inbound: { count: 1 }, registrations: 2 },
14 lens: { feeds: [{ id: 'loser-club', feedUri: 'at://did:plc:lc/app.bsky.feed.generator/loser-club', posts: 299, members: 2, unfronted: [], perMember: [] }] },
15 bsky: {
16 at: NOW - 60e3,
17 hostname: 'pds.7rnx.net',
18 relay: { status: 'active', seq: 40269 },
19 dids: [{ did: WJ, handle: 'p2p.7rnx.net', inSync: true, plcPds: 'https://pds.7rnx.net', appview: { resolves: true, postsCount: 201, recent: [{ uri: 'at://' + WJ + '/app.bsky.feed.post/3mvks7khp3u6w', text: 'blursed <script>x</script>', createdAt: '2026-09-15T14:05:29Z' }] } }],
20 feeds: [{ uri: 'at://did:plc:lc/app.bsky.feed.generator/loser-club', posts: [{ uri: 'at://' + WJ + '/app.bsky.feed.post/3mvks7khp3u6w', author: 'p2p.7rnx.net', text: 'blursed', createdAt: '2026-09-15T14:05:29Z' }] }],
21 problems: []
22 },
23 alerts: { lastCheckAt: NOW - 60e3, open: [{ id: 'relay-newer:x', severity: 'critical', title: 'relay holds a newer commit', detail: 'internal detail', fix: 'favela-gateway emit-sync did=x', since: NOW - 7200e3 }], recentlyResolved: [] }
24 }
25}
26
27test('★ a full HTML page with bsky\'s view: relay, accounts, their recent posts, the feed as bsky shows it', (t) => {
28 const html = renderPublicPage(sample(), { now: NOW })
29 t.ok(html.startsWith('<!doctype html>'))
30 for (const s of ['pds.7rnx.net', 'active', 'p2p.7rnx.net', 'in sync', '201 posts', 'loser-club', 'blursed', '1 follower']) t.ok(html.includes(s), s)
31 t.ok(html.includes('https://bsky.app/profile/' + WJ + '/post/3mvks7khp3u6w'), 'each post links to where bsky shows it')
32 t.ok(html.includes('https://ap.7rnx.net/timeline'), 'the fediverse side links to its own timeline')
33})
34
35test('⛔ public means no internals: alert titles yes, commands and details no; no process stats', (t) => {
36 const html = renderPublicPage(sample(), { now: NOW })
37 t.ok(html.includes('relay holds a newer commit'), 'the title of an open problem is public')
38 t.absent(html.includes('emit-sync'), 'the fix command is not')
39 t.absent(html.includes('internal detail'), 'nor the detail')
40 t.absent(/280 MB|rss/i.test(html), 'nor process memory')
41})
42
43test('⛔ post text from bsky is escaped', (t) => {
44 const html = renderPublicPage(sample(), { now: NOW })
45 t.absent(html.includes('<script>x</script>'))
46 t.ok(html.includes('&lt;script&gt;'))
47})
48
49test('before the first probe the page still renders and says bsky has not been checked yet', (t) => {
50 const m = sample(); m.bsky = null; m.alerts = null
51 const html = renderPublicPage(m, { now: NOW })
52 t.ok(/not been checked yet/.test(html))
53})