OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / admin-surface.js

66 lines · javascript ref:
// The operator surface, assembled: sign-in, then the pages, with the gate between them.
//
// ⛔ THE GATE IS HERE AND NOWHERE ELSE, so there is one place to read to know what is protected. Every
// path that is not sign-in or a static asset requires a live session, and "live" is re-checked against
// the identity registry per request rather than trusted from the cookie.
//
// ⛔ STATIC IS SERVED FROM A FIXED LIST, not from a directory join. A path taken off the wire and
// joined to a root is a traversal bug; three files that never change do not need a filesystem walk.

import fs from 'bare-fs'
import { join } from 'bare-path'
import { authRoutes, currentAdmin } from './admin-auth.js'
import { adminRoutes } from './admin.js'

const TYPES = { '.js': 'text/javascript; charset=utf-8' }

// The browser half of the identity layer. Vendored, and the same files favela-surface and pear-git
// serve — noble is pure JS so signing works without crypto.subtle; the SHA-256 in the client does use
// it, which is why this surface must be reached over HTTPS or localhost.
// ⛔ THE TRANSITIVE SET, not just the entry. anproto-client.js imports noble-ed25519, noble-sha512 and
// noble-sha256, and two of THOSE import noble-crypto — a list holding only the entry point serves a
// module graph with a hole in it, and the browser reports a bare network error on an import rather than
// anything about signing in. assets.test.js walks the imports and asserts this set is closed.
const ASSETS = new Set([
  'anproto-client.js', 'noble-ed25519.js', 'noble-sha512.js', 'noble-sha256.js', 'noble-crypto.js'
])

export function staticAsset (name, root) {
  if (!ASSETS.has(name)) return null
  try {
    return { status: 200, headers: { 'content-type': TYPES['.js'] }, body: fs.readFileSync(join(root, 'static', name)) }
  } catch {
    return null
  }
}

export async function serveAdmin (req, ctx) {
  const path = req.path || '/'

  if (path.startsWith('/static/')) {
    const asset = staticAsset(path.slice('/static/'.length), ctx.root)
    return asset || { status: 404, headers: {}, body: 'not found' }
  }

  // Sign-in first: these are the only routes reachable without a session, by definition.
  const auth = await authRoutes(req, ctx)
  if (auth) return auth

  // ⛔ NO SESSION, NO SURFACE. A redirect rather than a 403 because the operator's next act is always
  // to sign in, and a bare 403 on a page they navigated to reads as "this is broken".
  const admin = await currentAdmin(req, ctx)
  if (!admin) {
    // ⚠ Carry where they were going. Sessions die with the process, so a deploy bounces an operator
    // out of whatever page they were reading; sending them to /admin afterwards loses their place for
    // no reason, and silent re-auth makes the whole round trip invisible when it works.
    const back = encodeURIComponent(req.path + (req.query && Object.keys(req.query).length
      ? '?' + new URLSearchParams(req.query).toString()
      : ''))
    return { status: 303, headers: { location: '/admin/login?next=' + back }, body: '' }
  }

  return adminRoutes(req, ctx)
}

export default { serveAdmin, staticAsset }