| … | … | @@ -23,6 +23,7 @@ import { createProbes } from './probes.js' |
| 23 | 23 | import { createLens } from './lens.js' |
| 24 | 24 | import { createWatcher } from './watch.js' |
| 25 | 25 | import { renderStatusPage } from './panel.js' |
| 26 | import { renderPublicPage } from './public.js' | |
| 26 | 27 | |
| 27 | 28 | const ROLES = { |
| 28 | 29 | membrane: async (cfg, opts) => startMembrane(cfg, opts), |
| … | … | @@ -122,7 +123,8 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null, |
| 122 | 123 | if (running.lens) out.lens = running.lens.metrics() |
| 123 | 124 | if (probes) { |
| 124 | 125 | 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 }) | |
| 126 | 128 | } |
| 127 | 129 | if (watcher) out.alerts = watcher.current() |
| 128 | 130 | return out |
| … | … | @@ -162,6 +164,18 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null, |
| 162 | 164 | // The status page on the admin surface: both bridges' health, alerts first (lib/panel.js). |
| 163 | 165 | adminPages.set('/admin/status', async () => renderStatusPage(await metrics({}))) |
| 164 | 166 | |
| 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 | ||
| 165 | 179 | // The verbs this gateway serves, for `favela-gateway help`. release-ready is the process owner's. |
| 166 | 180 | handlers.verbs = async () => ({ verbs: [...Object.keys(handlers), 'release-ready'].sort() }) |
| 167 | 181 |
| … | … | @@ -20,6 +20,12 @@ const PLC = 'https://plc.directory' |
| 20 | 20 | const APPVIEW = 'https://public.api.bsky.app' |
| 21 | 21 | const TIMEOUT_MS = 8000 |
| 22 | 22 | |
| 23 | // A post as the page shows it: its uri, a short text and when it was written. | |
| 24 | function 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 | ||
| 23 | 29 | export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 60 * 1000, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) { |
| 24 | 30 | if (!hostname) throw new Error('createProbes: hostname required') |
| 25 | 31 | const doFetch = fetchImpl || ((...a) => globalThis.fetch(...a)) |
| … | … | @@ -43,7 +49,7 @@ export function createProbes ({ hostname, fetch: fetchImpl = null, ttlMs = 5 * 6 |
| 43 | 49 | } |
| 44 | 50 | |
| 45 | 51 | 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: [] } } | |
| 47 | 53 | |
| 48 | 54 | const lc = await getJson(`${RELAY}/xrpc/com.atproto.sync.getLatestCommit?did=${encodeURIComponent(did)}`) |
| 49 | 55 | 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 |
| 75 | 81 | } else { |
| 76 | 82 | out.appview.resolves = true |
| 77 | 83 | 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 | |
| 80 | 89 | if (top) { out.appview.newestPost = top.uri; out.appview.newestIndexedAt = top.indexedAt || null } |
| 81 | 90 | } |
| 82 | 91 | return out |
| 83 | 92 | } |
| 84 | 93 | |
| 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 = [] } = {}) { | |
| 86 | 105 | const problems = [] |
| 87 | 106 | const relay = { status: null, seq: null, error: null } |
| 88 | 107 | 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 |
| 97 | 116 | } |
| 98 | 117 | const rows = [] |
| 99 | 118 | 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 } | |
| 101 | 122 | } |
| 102 | 123 | |
| 103 | 124 | // The cached read every caller uses. Concurrent callers share the probe in flight. |
| … | … | @@ -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 | ||
| 12 | const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 13 | ||
| 14 | const 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. | |
| 25 | const 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 | ||
| 30 | const STYLE = `body{font:15px/1.5 system-ui,sans-serif;margin:0 auto;padding:16px;max-width:820px;color:#111;background:#fff} | |
| 31 | h1{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 | ||
| 37 | const 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 | ||
| 44 | export 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 | ||
| 88 | export default renderPublicPage |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-gateway", |
| 3 | "version": "0.4.0", | |
| 3 | "version": "0.5.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "favela-gateway", |
| 9 | "version": "0.4.0", | |
| 9 | "version": "0.5.0", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "ap-bridge": "file:../ap-bridge", |
| 12 | 12 | "atproto-bridge": "file:../atproto-bridge", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-gateway", |
| 3 | "version": "0.4.0", | |
| 3 | "version": "0.5.0", | |
| 4 | 4 | "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).", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/gateway.js", |
| … | … | @@ -153,7 +153,8 @@ test('★ alerts: with the watcher on, `alerts refresh=true` checks now and `met |
| 153 | 153 | t.ok(a.lastCheckAt > 0) |
| 154 | 154 | const m = await g.handlers.metrics({}) |
| 155 | 155 | 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') | |
| 157 | 158 | }) |
| 158 | 159 | |
| 159 | 160 | test('★ 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 |
| 164 | 165 | t.is(p.title, 'Gateway status') |
| 165 | 166 | for (const s of ['Roles', 'Served DIDs', 'Fediverse', 'membrane', 'fediverse']) t.ok(p.body.includes(s), s) |
| 166 | 167 | }) |
| 168 | ||
| 169 | test('★ 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 | }) |
| … | … | @@ -96,3 +96,27 @@ test('concurrent callers share one probe in flight', async (t) => { |
| 96 | 96 | const hostCalls = calls.filter((u) => u.includes('getHostStatus')).length |
| 97 | 97 | t.is(hostCalls, 1, 'one getHostStatus for two simultaneous asks') |
| 98 | 98 | }) |
| 99 | ||
| 100 | // ── the public page's view: what bsky actually shows ───────────────────────────────────────────────── | |
| 101 | const 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 | ||
| 103 | test('★ 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 | ||
| 111 | test('★ 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 | }) |
| … | … | @@ -0,0 +1,53 @@ |
| 1 | // The public read-only page at https://pds.7rnx.net/ — "what really shows up", from bsky's side (#80). | |
| 2 | import test from 'brittle' | |
| 3 | import { renderPublicPage } from '../lib/public.js' | |
| 4 | ||
| 5 | const NOW = 1e12 | |
| 6 | const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo' | |
| 7 | function 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 | ||
| 27 | test('★ 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 | ||
| 35 | test('⛔ 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 | ||
| 43 | test('⛔ 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('<script>')) | |
| 47 | }) | |
| 48 | ||
| 49 | test('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 | }) |