// admin.js — the operator's read-out of this bridge, over loopback.
//
// ⛔ WHAT IT DOES NOT DO: mint anything. This bridge is keyless for atproto and holds only its own AP
// signing key; an admin screen that created identities would make it a key holder for other people and
// reverse the whole peer/bridge split. Everything here is a flow that ALREADY EXISTS — issue an invite,
// see who is registered, release a name an operator owns the namespace for. No new authority.
//
// ⛔ LOOPBACK, ON ITS OWN LISTENER, AND OFF UNLESS CONFIGURED. The public listener serves the fediverse;
// this serves the operator. They must not be the same port, because the public one is proxied. There is
// NO authentication in front of this — reach it over SSH — which is exactly what favela-surface says of
// its own surface: "exposing it must be a decision, never a default."
//
// ⚠ Pure of transport, like router.js: every handler returns `{ status, headers, body }` so the whole
// surface is testable with no port bound.
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
const html = (status, body) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8' }, body })
const redirect = (to) => ({ status: 303, headers: { location: to }, body: '' })
const STYLE = `body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;max-width:1000px;color:#111}
h1{font-size:20px}h2{font-size:15px;margin-top:28px;border-bottom:1px solid #ddd;padding-bottom:4px}
table{border-collapse:collapse;width:100%;margin:8px 0}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid #eee;vertical-align:top}
th{font-weight:600;color:#555;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
code{font:12px ui-monospace,monospace;background:#f5f5f5;padding:1px 4px;border-radius:3px;word-break:break-all}
.who{color:#666;font-size:13px}.warn{color:#a00}.none{color:#999;font-style:italic}
button{font:inherit;padding:4px 10px}form{display:inline}`
const page = (title, body) =>
`<!doctype html><meta charset="utf-8"><title>${esc(title)}</title><style>${STYLE}</style>` +
`<h1>${esc(title)}</h1>${body}`
const when = (ms) => {
if (!ms) return '—'
const d = new Date(Number(ms))
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().replace('T', ' ').slice(0, 19) + 'Z'
}
// Links to the added pages, by path. Their titles come from rendering them, which is not free, so the
// link text is the path's last segment unless the page map carries a `label`.
function navFor (pages) {
if (!pages || !pages.size) return ''
const label = (p) => { const s = p.split('/').filter(Boolean).pop() || p; return s === 'status' ? 'Gateway status' : s }
return '<p class="who"><a href="/admin">overview</a> · ' + [...pages.keys()].map((p) => '<a href="' + esc(p) + '">' + esc(label(p)) + '</a>').join(' · ') + '</p>'
}
/** The registered names, the burned ones, and what the fediverse has sent us. */
export function renderOverview ({ nav = '', host, registrations = [], burned = [], inbound = [], cap = null, policy = null, primary = null, primaryDid = null }) {
const names = registrations.length
? '<table><tr><th>name</th><th>identity root</th><th>feed</th><th>gen</th><th>registered</th><th></th></tr>' +
registrations.map((r) =>
'<tr><td><code>' + esc(r.username) + '@' + esc(host) + '</code></td>' +
'<td><code>' + esc(String(r.root || '').slice(0, 16)) + '…</code></td>' +
'<td><code>' + esc(String(r.feedKey || '—').slice(0, 16)) + '…</code></td>' +
'<td>' + esc(r.generation) + '</td><td>' + esc(when(r.registeredAt)) + '</td>' +
'<td><form method="post" action="/admin/release"><input type="hidden" name="username" value="' +
esc(r.username) + '"><button>release</button></form></td></tr>').join('') +
'</table>'
: '<p class="none">Nobody has registered a name on this bridge.</p>'
// ⛔ SHOWN, NOT HIDDEN. A burned name is not a free name — the generation counter makes a reused one
// a DIFFERENT actor so it can never inherit the previous holder's audience. An operator seeing only
// the live list would read a burned name as simply absent and wonder why re-registering behaves oddly.
const dead = burned.length
? '<table><tr><th>name</th><th>gen</th><th>released</th><th>by</th></tr>' +
burned.map((b) => '<tr><td><code>' + esc(b.username) + '</code></td><td>' + esc(b.generation) +
'</td><td>' + esc(when(b.releasedAt)) + '</td><td>' + esc(b.by || '—') + '</td></tr>').join('') +
'</table>'
: '<p class="none">No names have been released.</p>'
const feed = inbound.length
? '<table><tr><th>type</th><th>from</th><th>at</th></tr>' +
inbound.slice(0, 50).map((i) => '<tr><td>' + esc(i.type || '?') + '</td><td><code>' +
esc(i.actor || i.actorUri || '—') + '</code></td><td>' + esc(when(i.receivedAt)) + '</td></tr>').join('') +
'</table>'
: '<p class="none">Nothing has arrived from the fediverse yet.</p>'
// ⛔ THE CAP IS SAID OUT LOUD, because for this network an eviction is not a cache miss. atproto
// inbound can be rebuilt from Constellation, a backlink index over the whole network; ActivityPub is
// push-only and no such index exists, so what is evicted here may be the only copy there ever was.
const capNote = cap
? '<p class="who">Keeping the newest <strong>' + esc(cap) + '</strong>. This is a buffer for a peer ' +
'that is offline, not the system\'s memory — the peer holds the durable copy. ' +
'<span class="warn">The fediverse has no backlink index, so an eviction here cannot be backfilled.</span></p>'
: ''
// ⛔ THE BRIDGE'S OWN ACTOR IS NOT A REGISTERED NAME, and leaving it off made the operator's MAIN
// actor invisible on the operator's page. It comes from config (AP_USERNAME), not from
// registrations.json, so a page rendering only the registry shows every name except the one the
// bridge was originally stood up to serve. Asked for by name after exactly that confusion.
const own = primary
? '<h2>This bridge\'s own actor</h2><table><tr><th>handle</th><th>did</th><th>where it comes from</th></tr>' +
'<tr><td><code>' + esc(primary) + '@' + esc(host) + '</code></td>' +
'<td><code>' + esc(primaryDid || '—') + '</code></td>' +
'<td class="who">configuration, not the name registry — it cannot be released here</td></tr></table>'
: ''
return page('bridge · ' + host, nav +
'<p class="who">Registration policy: <strong>' + esc(policy || 'off') + '</strong> · ' +
'<a href="/admin/profile">operators</a></p>' + own +
'<h2>Registered names</h2>' + names +
'<h2>Invites</h2><form method="post" action="/admin/invite"><button>issue an invite</button></form>' +
'<p class="who">Only meaningful on an invite-only bridge. An invite is single-use and is consumed ' +
'before the claim can fail on anything else.</p>' +
'<h2>Released names</h2>' + dead +
'<h2>Inbound from the fediverse</h2>' + capNote + feed +
'<p><a href="/admin/timeline">the full timeline →</a></p>')
}
export function renderInvite (token, host) {
return page('invite issued',
'<p>Give this to one person. It is single-use.</p><p><code>' + esc(token) + '</code></p>' +
'<p class="who">They enter it when claiming a name on <code>' + esc(host) + '</code>.</p>' +
'<p><a href="/admin">back</a></p>')
}
/**
* What the fediverse has sent this bridge, a page at a time.
*
* ⛔ THIS IS THE INBOUND SIDE. Outbound posts live in the peer's own feed, which this surface has no
* handle on — the bridge PROJECTS them from a feed it holds rather than storing a timeline of them. So
* a page claiming to be "the bridge's posts" would either be empty or quietly be this.
*
* ⚠ AND EVERY ROW HERE IS SOMETHING A STRANGER SENT. It is signature-verified at the inbox, but the
* CONTENT is theirs, so it is escaped like any other untrusted text and rendered as a flat list rather
* than as threads — inbound-store.js is explicit that it "does not index, thread, or render".
*/
export function renderTimeline ({ items = [], offset = 0, limit = 50, total = 0, cap = null } = {}) {
const rows = items.length
? '<table><tr><th>#</th><th>type</th><th>from</th><th>content</th><th>at</th></tr>' +
items.map((i, n) => {
const body = String(i.content || i.summary || '').replace(/<[^>]*>/g, ' ').trim()
return '<tr><td class="who">' + (offset + n + 1) + '</td>' +
'<td>' + esc(i.type || '?') + '</td>' +
'<td><code>' + esc(i.actor || i.actorUri || '—') + '</code></td>' +
'<td>' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '</td>' +
'<td class="who">' + esc(when(i.receivedAt)) + '</td></tr>'
}).join('') + '</table>'
: '<p class="none">Nothing on this page.</p>'
const next = offset + items.length
// ⛔ OFFERED ONLY WHEN THERE IS MORE. A "load 50 more" that returns an empty page is the control
// lying about the data behind it.
const more = next < total
? '<p><a href="/admin/timeline?offset=' + next + '">load ' + Math.min(limit, total - next) + ' more →</a></p>'
: '<p class="who">That is everything this bridge is holding.</p>'
const back = offset > 0
? '<p><a href="/admin/timeline?offset=' + Math.max(0, offset - limit) + '">← previous</a></p>'
: ''
return page('inbound timeline',
'<p class="who"><a href="/admin">← the bridge</a></p>' +
'<p>Showing <strong>' + (items.length ? (offset + 1) + '–' + next : '0') + '</strong> of ' +
'<strong>' + total + '</strong> held' +
(cap ? ' <span class="who">(cap ' + esc(cap) + ')</span>' : '') + '.</p>' +
rows + more + back +
// ⚠ Said here as well as on the overview, because this is the page where somebody notices the
// oldest row and wonders where the rest went.
'<p class="who">Newest first, and bounded: the oldest is evicted once the cap is reached. ' +
'<span class="warn">The fediverse has no backlink index, so an evicted item cannot be backfilled.</span></p>')
}
/**
* The whole admin surface as one function.
*
* ⚠ `release` is the OPERATOR authority, which is a different act from a holder releasing their own
* name: the names live on this operator's domain, and a bridge with no arbiter has permanent squatters.
* It still BURNS the name — see registrations.js — so it is not a way to hand a name to someone else.
*/
export async function adminRoutes (req, { registrations, inbound, config, pages = null }) {
const path = req.path || '/'
// ⛔ PAGES ANOTHER ROLE OF THE PROCESS ADDS (the favela gateway's status page, hyper-social #80): served
// behind the same gate as everything here, rendered by their owner, and checked BEFORE the registration
// guard — a gateway's health matters on a bridge with registration off too. A page that throws answers
// 500 with its reason rather than taking the admin surface down with it.
if (req.method === 'GET' && pages && pages.has(path)) {
try {
const p = await pages.get(path)(req)
return html(200, page(p.title, navFor(pages) + p.body))
} catch (err) {
return html(500, page('error', '<p class="warn">' + esc((err && err.message) || String(err)) + '</p>'))
}
}
if (!registrations) return html(404, page('not found', '<p>Registration is not configured on this bridge.</p>'))
if (req.method === 'GET' && (path === '/admin' || path === '/admin/' || path === '/')) {
return html(200, renderOverview({
nav: navFor(pages),
host: config.host,
policy: config.registration || null,
cap: config.inboundCap,
primary: config.username || null,
primaryDid: config.did || null,
registrations: await registrations.list(),
burned: typeof registrations.listBurned === 'function' ? await registrations.listBurned() : [],
inbound: inbound ? await inbound.list({ limit: 50 }) : []
}))
}
if (req.method === 'GET' && path === '/admin/timeline') {
const limit = 50
const offset = Math.max(0, Number(req.query && req.query.offset) || 0)
if (!inbound) return html(200, renderTimeline({ items: [], total: 0, offset, limit }))
return html(200, renderTimeline({
items: await inbound.list({ limit, offset }),
total: typeof inbound.count === 'function' ? await inbound.count() : 0,
offset,
limit,
cap: config.inboundCap
}))
}
if (req.method === 'POST' && path === '/admin/invite') {
const token = await registrations.invite()
return html(200, renderInvite(typeof token === 'string' ? token : (token && token.invite) || String(token), config.host))
}
if (req.method === 'POST' && path === '/admin/release') {
const username = String((req.form && req.form.username) || '').trim()
if (!username) return redirect('/admin')
await registrations.release(username, { operator: true })
return redirect('/admin')
}
return html(404, page('not found', '<p><a href="/admin">admin</a></p>'))
}
export default { adminRoutes, renderOverview, renderInvite }