OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / ef95895

0.10.17: admin pages another role adds (the gateway's status page)
startApBridge({ adminPages }) — a Map path -> async (req) => ({ title, body }) the favela gateway fills after its roles start. Served behind the same session gate, inside the admin chrome, checked before the registration guard (gateway health matters with registration off too); linked from the overview; a page that throws answers 500 with its reason instead of breaking /admin. Tests first: 3 in admin.test.js (red); suite 370/370. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ef958956561ad0d761433300417afa846821185f
by plankatron <fleeky@gmail.com> · 2026-10-14T00:51:54.000Z

Changed files (5) +57 -8

lib/admin.js +26 -3
……@@ -37,8 +37,16 @@ const when = (ms) => {
3737 return Number.isNaN(d.getTime()) ? '—' : d.toISOString().replace('T', ' ').slice(0, 19) + 'Z'
3838}
3939
40// Links to the added pages, by path. Their titles come from rendering them, which is not free, so the
41// link text is the path's last segment unless the page map carries a `label`.
42function navFor (pages) {
43 if (!pages || !pages.size) return ''
44 const label = (p) => { const s = p.split('/').filter(Boolean).pop() || p; return s === 'status' ? 'Gateway status' : s }
45 return '<p class="who"><a href="/admin">overview</a> · ' + [...pages.keys()].map((p) => '<a href="' + esc(p) + '">' + esc(label(p)) + '</a>').join(' · ') + '</p>'
46}
47
4048/** The registered names, the burned ones, and what the fediverse has sent us. */
41export function renderOverview ({ host, registrations = [], burned = [], inbound = [], cap = null, policy = null, primary = null, primaryDid = null }) {
49export function renderOverview ({ nav = '', host, registrations = [], burned = [], inbound = [], cap = null, policy = null, primary = null, primaryDid = null }) {
4250 const names = registrations.length
4351 ? '<table><tr><th>name</th><th>identity root</th><th>feed</th><th>gen</th><th>registered</th><th></th></tr>' +
4452 registrations.map((r) =>
……@@ -88,7 +96,7 @@ export function renderOverview ({ host, registrations = [], burned = [], inbound
8896 '<td class="who">configuration, not the name registry — it cannot be released here</td></tr></table>'
8997 : ''
9098
91 return page('bridge · ' + host,
99 return page('bridge · ' + host, nav +
92100 '<p class="who">Registration policy: <strong>' + esc(policy || 'off') + '</strong> · ' +
93101 '<a href="/admin/profile">operators</a></p>' + own +
94102 '<h2>Registered names</h2>' + names +
……@@ -161,12 +169,27 @@ export function renderTimeline ({ items = [], offset = 0, limit = 50, total = 0,
161169 * name: the names live on this operator's domain, and a bridge with no arbiter has permanent squatters.
162170 * It still BURNS the name — see registrations.js — so it is not a way to hand a name to someone else.
163171 */
164export async function adminRoutes (req, { registrations, inbound, config }) {
172export async function adminRoutes (req, { registrations, inbound, config, pages = null }) {
165173 const path = req.path || '/'
174
175 // ⛔ PAGES ANOTHER ROLE OF THE PROCESS ADDS (the favela gateway's status page, hyper-social #80): served
176 // behind the same gate as everything here, rendered by their owner, and checked BEFORE the registration
177 // guard — a gateway's health matters on a bridge with registration off too. A page that throws answers
178 // 500 with its reason rather than taking the admin surface down with it.
179 if (req.method === 'GET' && pages && pages.has(path)) {
180 try {
181 const p = await pages.get(path)(req)
182 return html(200, page(p.title, navFor(pages) + p.body))
183 } catch (err) {
184 return html(500, page('error', '<p class="warn">' + esc((err && err.message) || String(err)) + '</p>'))
185 }
186 }
187
166188 if (!registrations) return html(404, page('not found', '<p>Registration is not configured on this bridge.</p>'))
167189
168190 if (req.method === 'GET' && (path === '/admin' || path === '/admin/' || path === '/')) {
169191 return html(200, renderOverview({
192 nav: navFor(pages),
170193 host: config.host,
171194 policy: config.registration || null,
172195 cap: config.inboundCap,
lib/bridge.js +5 -2
……@@ -60,8 +60,9 @@ const defaultLog = (o) => console.log(JSON.stringify({ t: new Date().toISOString
6060 * @param {Function} [opts.log]
6161 * @param {Array} [opts.bootstrap] DHT bootstrap; tests pass [] to stay off the public DHT
6262 * @param {string} [opts.version] for the `status` verb
63 * @param {Map} [opts.adminPages] path -> async (req) => ({ title, body }), served on the admin surface
6364 */
64export async function startApBridge (config, { log = defaultLog, bootstrap = null, version = null } = {}) {
65export async function startApBridge (config, { log = defaultLog, bootstrap = null, version = null, adminPages = null } = {}) {
6566 const privateKey = fs.readFileSync(config.privateKeyPath, 'utf8')
6667 const publicKeyPem = fs.readFileSync(config.publicKeyPath, 'utf8')
6768
……@@ -547,7 +548,9 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
547548 log({ msg: 'admin surface UNCLAIMED — claim it with this bootstrap token, then it closes for good', bootstrap: bootstrapToken })
548549 }
549550
550 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, log, root: new URL('..', import.meta.url).pathname }
551 // `pages`: admin pages another role of the process adds (the favela gateway's status page). A Map the
552 // caller may fill after start; served behind the same login (lib/admin.js).
553 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, log, pages: adminPages, root: new URL('..', import.meta.url).pathname }
551554 const adminServer = http.createServer((req, res) => {
552555 parseForm(req).then((form) => serveAdmin(
553556 {
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.16",
3 "version": "0.10.17",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "ap-bridge",
9 "version": "0.10.16",
9 "version": "0.10.17",
1010 "license": "MIT",
1111 "dependencies": {
1212 "atproto-did-peer": "file:../atproto-did-peer",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.16",
3 "version": "0.10.17",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/admin.test.js +23 -0
……@@ -80,3 +80,26 @@ test('★★ nothing here mints an identity', async (t) => {
8080 const missing = await adminRoutes(post('/admin/mint', {}), { registrations, inbound: null, config })
8181 t.is(missing.status, 404, 'and no such route exists')
8282})
83
84// ── pages another role of the process adds (the favela gateway's status page, hyper-social #80) ──────
85test('★ an added page is served behind the same gate, even when registration is off', async (t) => {
86 const pages = new Map([['/admin/status', async () => ({ title: 'Gateway status', body: '<p id="x">all roles up</p>' })]])
87 const res = await adminRoutes(get('/admin/status'), { registrations: null, inbound: null, config, pages })
88 t.is(res.status, 200)
89 t.ok(res.body.includes('<p id="x">all roles up</p>'), 'the page body, as rendered by its owner')
90 t.ok(res.body.includes('<title>Gateway status</title>'), 'inside the admin chrome')
91})
92
93test('the overview links every added page', async (t) => {
94 const registrations = new MemoryRegistrations({ policy: 'open' })
95 const pages = new Map([['/admin/status', async () => ({ title: 'Gateway status', body: '' })]])
96 const res = await adminRoutes(get('/admin'), { registrations, inbound: null, config, pages })
97 t.ok(res.body.includes('href="/admin/status"') && res.body.includes('Gateway status'))
98})
99
100test('an added page that throws answers 500 with the reason, not a dead admin', async (t) => {
101 const pages = new Map([['/admin/status', async () => { throw new Error('metrics unavailable') }]])
102 const res = await adminRoutes(get('/admin/status'), { registrations: null, inbound: null, config, pages })
103 t.is(res.status, 500)
104 t.ok(res.body.includes('metrics unavailable'))
105})