OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / admin.js

233 lines · javascript ref:
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

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 }