| … | … | @@ -37,8 +37,16 @@ const when = (ms) => { |
| 37 | 37 | return Number.isNaN(d.getTime()) ? '—' : d.toISOString().replace('T', ' ').slice(0, 19) + 'Z' |
| 38 | 38 | } |
| 39 | 39 | |
| 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`. | |
| 42 | function 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 | ||
| 40 | 48 | /** The registered names, the burned ones, and what the fediverse has sent us. */ |
| 41 | export function renderOverview ({ host, registrations = [], burned = [], inbound = [], cap = null, policy = null, primary = null, primaryDid = null }) { | |
| 49 | export function renderOverview ({ nav = '', host, registrations = [], burned = [], inbound = [], cap = null, policy = null, primary = null, primaryDid = null }) { | |
| 42 | 50 | const names = registrations.length |
| 43 | 51 | ? '<table><tr><th>name</th><th>identity root</th><th>feed</th><th>gen</th><th>registered</th><th></th></tr>' + |
| 44 | 52 | registrations.map((r) => |
| … | … | @@ -88,7 +96,7 @@ export function renderOverview ({ host, registrations = [], burned = [], inbound |
| 88 | 96 | '<td class="who">configuration, not the name registry — it cannot be released here</td></tr></table>' |
| 89 | 97 | : '' |
| 90 | 98 | |
| 91 | return page('bridge · ' + host, | |
| 99 | return page('bridge · ' + host, nav + | |
| 92 | 100 | '<p class="who">Registration policy: <strong>' + esc(policy || 'off') + '</strong> · ' + |
| 93 | 101 | '<a href="/admin/profile">operators</a></p>' + own + |
| 94 | 102 | '<h2>Registered names</h2>' + names + |
| … | … | @@ -161,12 +169,27 @@ export function renderTimeline ({ items = [], offset = 0, limit = 50, total = 0, |
| 161 | 169 | * name: the names live on this operator's domain, and a bridge with no arbiter has permanent squatters. |
| 162 | 170 | * It still BURNS the name — see registrations.js — so it is not a way to hand a name to someone else. |
| 163 | 171 | */ |
| 164 | export async function adminRoutes (req, { registrations, inbound, config }) { | |
| 172 | export async function adminRoutes (req, { registrations, inbound, config, pages = null }) { | |
| 165 | 173 | 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 | ||
| 166 | 188 | if (!registrations) return html(404, page('not found', '<p>Registration is not configured on this bridge.</p>')) |
| 167 | 189 | |
| 168 | 190 | if (req.method === 'GET' && (path === '/admin' || path === '/admin/' || path === '/')) { |
| 169 | 191 | return html(200, renderOverview({ |
| 192 | nav: navFor(pages), | |
| 170 | 193 | host: config.host, |
| 171 | 194 | policy: config.registration || null, |
| 172 | 195 | cap: config.inboundCap, |
| … | … | @@ -60,8 +60,9 @@ const defaultLog = (o) => console.log(JSON.stringify({ t: new Date().toISOString |
| 60 | 60 | * @param {Function} [opts.log] |
| 61 | 61 | * @param {Array} [opts.bootstrap] DHT bootstrap; tests pass [] to stay off the public DHT |
| 62 | 62 | * @param {string} [opts.version] for the `status` verb |
| 63 | * @param {Map} [opts.adminPages] path -> async (req) => ({ title, body }), served on the admin surface | |
| 63 | 64 | */ |
| 64 | export async function startApBridge (config, { log = defaultLog, bootstrap = null, version = null } = {}) { | |
| 65 | export async function startApBridge (config, { log = defaultLog, bootstrap = null, version = null, adminPages = null } = {}) { | |
| 65 | 66 | const privateKey = fs.readFileSync(config.privateKeyPath, 'utf8') |
| 66 | 67 | const publicKeyPem = fs.readFileSync(config.publicKeyPath, 'utf8') |
| 67 | 68 | |
| … | … | @@ -547,7 +548,9 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul |
| 547 | 548 | log({ msg: 'admin surface UNCLAIMED — claim it with this bootstrap token, then it closes for good', bootstrap: bootstrapToken }) |
| 548 | 549 | } |
| 549 | 550 | |
| 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 } | |
| 551 | 554 | const adminServer = http.createServer((req, res) => { |
| 552 | 555 | parseForm(req).then((form) => serveAdmin( |
| 553 | 556 | { |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.16", | |
| 3 | "version": "0.10.17", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "ap-bridge", |
| 9 | "version": "0.10.16", | |
| 9 | "version": "0.10.17", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.16", | |
| 3 | "version": "0.10.17", | |
| 4 | 4 | "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -80,3 +80,26 @@ test('★★ nothing here mints an identity', async (t) => { |
| 80 | 80 | const missing = await adminRoutes(post('/admin/mint', {}), { registrations, inbound: null, config }) |
| 81 | 81 | t.is(missing.status, 404, 'and no such route exists') |
| 82 | 82 | }) |
| 83 | ||
| 84 | // ── pages another role of the process adds (the favela gateway's status page, hyper-social #80) ────── | |
| 85 | test('★ 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 | ||
| 93 | test('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 | ||
| 100 | test('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 | }) |