OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / router.js

1493 lines · javascript ref:
/**
 * SSR router. Registers a single 'request' listener on svc.http.server.
 *
 * Skips /rpc/* — owned by hyper-svc.
 *
 * Routes:
 *   GET  /                              → 303 /repos
 *   GET  /repos                         → renderReposIndex (landing page)
 *   GET  /status                        → renderStatus (daemon status)
 *   GET  /orgs                          → renderOrgsIndex
 *   GET  /orgs/:name                    → renderOrgShow
 *   POST /orgs/create                   → svc.dispatch('create-org', ...)
 *   POST /orgs/:name/member             → svc.dispatch('add-org-member', ...)
 *   POST /orgs/:name/resource           → svc.dispatch('add-org-resource', ...)
 *   GET  /deploy                        → renderDeploy
 *   GET  /releases                      → 303 /deploy (back-compat)
 *   GET  /files/raw?path=/attachments/<oid>  → content-addressed attachment bytes (V2 overlay)
 *
 *   Issues are per-repo only (/repos/:name/issues*). There is no global
 *   /issues listing — it was removed in the UI restructure.
 *
 *   GET  /api/auth/challenge            → mint a fresh challenge
 *   POST /api/auth/verify               → verify signature, mint session, set cookie
 *   POST /api/auth/logout               → revoke session, clear cookie
 *   GET  /api/auth/me                   → return current identity
 *   GET  /api/search/repos?q=...        → JSON autocomplete for repo names/desc
 *   GET  /api/search/code?q=...         → JSON autocomplete via git grep
 *   GET  /api/search/users?q=...        → JSON autocomplete by username/identity/userId
 *
 *   GET  /static/<path>                 → vendored browser JS (whitelisted)
 *
 * The router builds a `ctx` for every request: { transport: 'http',
 * identity: <session record or null> }, then routes mutating requests
 * through `svc.dispatch()` so the RPC handlers' requireWriter checks fire.
 */

import fs from '#node:fs'
import { pipeline } from '#node:stream/promises'
import { join } from '#node:path'
// Bare cannot resolve the bare specifier 'fs'/'path' — only the '#node:*' import
// map does (package.json). The release-download route used to `await import('fs')`
// inline, which threw MODULE_NOT_FOUND on every request and 500'd the whole
// route; use these module-scope bindings instead. Never reintroduce a dynamic
// import of a Node builtin here.
import path from '#node:path'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { spoolAttachment } from '../handlers/attachments.js'
import { pipeFile } from './pipe-file.js'

import { renderStatus } from './pages/status.js'
import { renderOrgsIndex, renderOrgShow, renderOrgRepos, renderOrgMembers, renderOrgSettings, renderOrgError, renderOrgInviteResult } from './pages/orgs.js'
import { renderIssuesIndex, renderIssueShow, renderIssueError } from './pages/issues.js'
import { renderDeploy } from './pages/deploy.js'
import { renderReposIndex, renderRepoPage } from './pages/repos.js'
import {
  renderRepoOverview, renderRepoTree, renderRepoBlob,
  renderRepoCommits, renderRepoCommit
} from './pages/repo-browser.js'
import { renderProfile, renderUserProfile, renderUsers } from './pages/profile.js'
import { renderDevices, renderDeviceInviteResult } from './pages/devices.js'
import { renderPeersIndex } from './pages/peers.js'
import { renderRepoSearch } from './pages/search.js'
import { renderGlobalSearch } from './pages/global-search.js'
import { renderAdmin } from './pages/admin.js'
import { renderDoc, renderDocsIndex } from './pages/docs.js'
import { renderRepoPeers, renderRepoInviteResult } from './pages/repo-peers.js'
import { renderRepoReleases } from './pages/repo-releases.js'
import { renderRepoDocs } from './pages/repo-docs.js'
import {
  sendHtml, redirect, redirectWithFlash, notFound, serverError, readForm,
  collectBody, errorStatus, renderError
} from './helpers.js'
import { extractIdentity, extractToken, COOKIE_NAME, requireAdmin, requireWriter } from '../auth/check.js'
import { serveStatic } from './static.js'
import { acceptsGzip } from './compress.js'
import { handleBootstrap } from '../handlers/bootstrap.js'
import { EnvelopeError } from '../auth/envelope.js'
import { verifyEnvelopeToCtx } from '../auth/envelope-ctx.js'
import { autoImportSSHKeys } from '../auth/ssh-identity.js'

const SESSION_COOKIE_MAXAGE = 24 * 60 * 60 // seconds — must match SessionStore TTL

export function registerRoutes (svc) {
  if (!svc.http || !svc.http.server) {
    throw new Error('registerRoutes requires svc.http to be enabled')
  }

  // Prefix support — strip the configured prefix from incoming URLs so
  // all route matching works with bare paths. SSR pages use svc._prefix
  // to generate correct links. Set by startPearGit from opts.prefix.
  const prefix = svc._prefix || ''

  svc.http.server.on('request', async (req, res) => {
    // Capture the peer address NOW, synchronously, before anything awaits.
    // bare-http1 nulls req._socket when the request message ENDs
    // (lib/server-connection.js: `this._req._socket = null` in the END case),
    // and hyper-svc's /rpc/ path only builds its ctx AFTER `await
    // collectBody(req)` — by which point req.socket is already gone and
    // origin.remoteAddress lands as null. That is the one field you actually
    // want when reconstructing who called an RPC.
    //
    // This listener runs during the synchronous emit('request'), before the
    // parser reaches END, so the address is still there. httpCtxFactory in
    // pear-git.js falls back to this stamp. SSR routes were never affected —
    // they build ctx before consuming the body.
    if (req._peerAddress === undefined) {
      req._peerAddress = (req.socket && req.socket.remoteAddress) || null
    }

    // Strip prefix from URL if configured (e.g. /git/repos → /repos)
    if (prefix && req.url.startsWith(prefix)) {
      req.url = req.url.slice(prefix.length) || '/'
    }

    // Stamp gzip capability onto res once, here. sendHtml has 64 call
    // sites across the page modules; threading req through all of them to
    // read one header would be a far bigger change than it's worth. An
    // unstamped res (anything calling sendHtml outside this listener)
    // simply sends uncompressed.
    res.acceptsGzip = acceptsGzip(req)

    // hyper-svc handles /rpc/* — leave it alone
    if (req.url.startsWith('/rpc/')) return

    // Git smart HTTP routes — handled by registerGitRoutes, skip here
    if (req.url.match(/\.git\//)) return

    const url = req.url.split('?')[0]
    const query = parseQuery(req.url)

    // Build a per-request ctx with identity from session cookie/header.
    // F.7.5: also capture origin metadata (Host header, TCP peer, UA) so
    // SSR routes that dispatch RPC handlers (notably /api/auth/verify)
    // can stamp it onto Identity records — same shape as httpCtxFactory.
    const session = extractIdentity(req, svc.sessions)
    const token = extractToken(req)
    const ctx = {
      transport: 'http',
      prefix,
      identity: session
        ? { publicKey: session.publicKey, _token: token }
        : null,
      origin: {
        host: (req.headers && req.headers.host) || null,
        userAgent: (req.headers && req.headers['user-agent']) || null,
        remoteAddress: (req.socket && req.socket.remoteAddress) || null
      }
    }

    // Prefix-aware redirect helpers — all outgoing redirects must include
    // the prefix so reverse-proxied deployments land on the right path.
    const pfxRedirect = (res, loc) => redirect(res, prefix + loc)
    const pfxRedirectWithFlash = (res, loc, t, m) => redirectWithFlash(res, prefix + loc, t, m)

    try {
      // ── bootstrap artifacts (first-install for fresh machines) ──
      // install.sh + pgh-bootstrap-<arch>.tar.gz served from
      // svc._bootstrapDir. Fixed port 4040 is the canonical source
      // once 7rnx.net/pgh → this daemon. Fresh machine runs:
      //   curl -sSfL <host>/bootstrap/install.sh
      //     | PGH_DOWNLOAD_BASE=<host>/bootstrap sh
      if (url.startsWith('/bootstrap/') || url === '/bootstrap') {
        const handled = await handleBootstrap(req, res, url, svc._bootstrapDir)
        if (handled) return
      }

      // ── static assets ───────────────────────────────────────
      // `url` already has the ?v=<version> cache-buster stripped (it's
      // req.url.split('?')[0]), so the token never reaches path resolution.
      // req is passed through so serveStatic can answer If-None-Match.
      if (req.method === 'GET' && url.startsWith('/static/')) {
        const handled = await serveStatic(url.slice('/static/'.length), res, req)
        if (handled) return
        return notFound(res, 'static asset not found')
      }

      // ── signed-envelope RPC (Phase 2a) ──────────────────────
      // Canonical per-call-signed RPC. Replaces the session-cookie
      // dance for every mutating call. Wire format:
      //   spec/flows/auth-envelope.md
      if (req.method === 'POST' && url === '/api/rpc') {
        const raw = await collectBody(req)
        let envelope
        try {
          envelope = JSON.parse(b4a.toString(raw, 'utf8'))
        } catch {
          return sendJsonError(res, 400, 'envelope body must be JSON', 'NO_ENVELOPE')
        }

        // Verify envelope + build authenticated ctx. This pipeline is
        // transport-agnostic (shared with the swarm verified-dispatch RPC)
        // and OWNS the replay cache, so a nonce burned here can't be replayed
        // over the swarm. origin is HTTP-specific and built at this call site.
        let envCtx, verified, user, becameOwner
        try {
          const out = await verifyEnvelopeToCtx(svc, envelope, {
            transport: 'http',
            origin: ctx.origin
          })
          envCtx = out.ctx
          verified = out.verified
          user = out.user
          becameOwner = out.becameOwner
        } catch (err) {
          if (err instanceof EnvelopeError) {
            const status = err.status || 401
            return sendJsonError(res, status, err.message, err.code)
          }
          return sendJsonError(res, 500, err.message || 'envelope verify crashed', 'VERIFY_CRASH')
        }

        // HTTP-only owner convenience: post-TOFU SSH auto-import so the daemon
        // owner's `git push` via punch:// / SSH works without a separate
        // key-linking dance. Stays at the HTTP call site (swarm callers are
        // remote peers managing devices, not the local owner). Fire-and-forget.
        if (becameOwner && svc._autoImportSSH) {
          autoImportSSHKeys(svc, { allowRoot: svc._sshAllowRoot }).then(r => {
            if (r.imported > 0) {
              // eslint-disable-next-line no-console
              console.log('[pear-git-v3] post-TOFU auto-imported ' + r.imported +
                ' SSH key' + (r.imported === 1 ? '' : 's'))
            }
          }).catch(err => {
            // eslint-disable-next-line no-console
            console.error('[pear-git-v3] post-TOFU SSH auto-import failed:', err.message)
          })
        }

        try {
          const result = await svc.dispatch(
            verified.decoded.method,
            verified.decoded.params || {},
            envCtx
          )

          // Mint a session cookie from the verified envelope so subsequent
          // SSR form POSTs (which can't carry envelope headers easily)
          // authenticate via cookie. The cookie is effectively a
          // short-lived cache of "recently verified envelope owner";
          // the envelope + IdentityRegistry is the source of truth.
          //
          // TODO(Phase 2a complete migration): delete this once every
          // form POST handler accepts envelope headers directly. Until
          // then, this keeps browser form submits working transparently.
          try {
            const token = svc.sessions.create(verified.rootPubkey)
            res.setHeader('Set-Cookie',
              COOKIE_NAME + '=' + encodeURIComponent(token) +
              '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + SESSION_COOKIE_MAXAGE)
          } catch {
            // If cookie minting fails, the envelope call itself still
            // succeeds — we just forgo the session-cache optimization.
          }

          return sendJson(res, {
            ok: true,
            result,
            identity: {
              publicKeyHex: verified.rootPubkey,
              userId: user.userId,
              newUser: becameOwner || false,
              becameOwner
            }
          })
        } catch (err) {
          const status = errorStatus(err) || 500
          return sendJsonError(res, status, err.message || String(err), err.code)
        }
      }

      // ── auth API ────────────────────────────────────────────
      // Phase 2a: auth-challenge / auth-verify / auth-logout removed.
      // Identity is established via /api/rpc signed envelopes (see
      // spec/flows/auth-envelope.md). /api/rpc mints a session cookie
      // from the verified envelope so SSR form POSTs authenticate via
      // cookie during the transition — see ctxFactory in pear-git.js.
      // auth-verify-ssh is also gone: SSH git push uses system sshd
      // (see docs/ssh-access.md), not an HTTP handler.
      //
      // /api/auth/me is retained as a convenience wrapper around
      // whoami for pages / external tools that just want the current
      // identity without wrapping an envelope.
      if (req.method === 'GET' && url === '/api/auth/me') {
        const result = await svc.dispatch('whoami', {}, ctx)
        return sendJson(res, result)
      }

      // Sign-out: clear the session-cache cookie. Does NOT clear the
      // browser's envelope keypair — that's done client-side by
      // window.pgv3SignOut() in pgv3-bootstrap.js.
      if (req.method === 'POST' && url === '/api/auth/logout') {
        res.setHeader('Set-Cookie',
          COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0')
        return sendJson(res, { ok: true })
      }

      // ── search JSON API ─────────────────────────────────────
      if (req.method === 'GET' && url === '/api/search/repos') {
        const result = await svc.dispatch('search-all-repos', {
          query: query.q || '',
          limit: query.limit
        }, ctx)
        return sendJson(res, result)
      }

      // One page of /repos cards as an HTML fragment, for the "view more"
      // control. Server-rendered rather than JSON-plus-a-client-renderer so
      // the card markup has exactly one definition and a later page cannot
      // drift from the first. Reads the same list /repos already renders for
      // this same ctx — no new auth surface. `limit=0` means "everything left".
      if (req.method === 'GET' && url === '/api/repos/page') {
        const offset = Math.max(0, parseInt(query.offset, 10) || 0)
        const rawLimit = parseInt(query.limit, 10)
        // Cap a supplied limit so a hand-made URL cannot ask for an unbounded
        // render; 0 (the explicit "all remaining" choice) stays unbounded.
        const limit = rawLimit === 0 ? Infinity : Math.min(Math.max(rawLimit || 25, 1), 200)
        const page = await renderRepoPage(svc, ctx, { offset, limit })
        return sendJson(res, page)
      }

      if (req.method === 'GET' && url === '/api/search/code') {
        const result = await svc.dispatch('search-code-all', {
          query: query.q || '',
          limit: query.limit,
          maxRepos: query.maxRepos
        }, ctx)
        return sendJson(res, result)
      }

      if (req.method === 'GET' && url === '/api/search/users') {
        const result = await svc.dispatch('search-users', {
          query: query.q || '',
          limit: query.limit
        }, ctx)
        return sendJson(res, result)
      }

      // ── landing: / → /repos ─────────────────────────────────
      if (req.method === 'GET' && url === '/') {
        return pfxRedirect(res, '/repos')
      }

      // ── /status — daemon status cards (was /) ──────────────
      if (req.method === 'GET' && url === '/status') {
        return sendHtml(res, await renderStatus(svc, ctx))
      }

      // ── orgs ─────────────────────────────────────────────────
      if (req.method === 'GET' && url === '/orgs') {
        return sendHtml(res, await renderOrgsIndex(svc, ctx))
      }

      if (req.method === 'POST' && url === '/orgs/create') {
        const form = await readForm(req)
        try {
          await svc.dispatch('create-org', {
            name: form.name,
            type: form.type || 'group',
            displayName: form.displayName || form.name,
            description: form.description || ''
          }, ctx)
          return pfxRedirectWithFlash(res, '/orgs/' + encodeURIComponent(form.name),
            'success', `Org "${form.name}" created`)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(form.name, err, ctx), errorStatus(err))
        }
      }

      const orgShow = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])$/)
      if (req.method === 'GET' && orgShow) {
        const html = await renderOrgShow(svc, orgShow[1], ctx)
        if (!html) return notFound(res, `org "${orgShow[1]}" not found`)
        return sendHtml(res, html)
      }

      // Tab pages: GET /orgs/:name/{repos,members,settings}. Each renders
      // the same org-tabs chrome (active tab differs) and isolates one
      // section of the old cram-page. POST handlers below stay the same.
      const orgReposTab = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/repos$/)
      if (req.method === 'GET' && orgReposTab) {
        const html = await renderOrgRepos(svc, orgReposTab[1], ctx)
        if (!html) return notFound(res, `org "${orgReposTab[1]}" not found`)
        return sendHtml(res, html)
      }
      const orgMembersTab = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/members$/)
      if (req.method === 'GET' && orgMembersTab) {
        const html = await renderOrgMembers(svc, orgMembersTab[1], ctx)
        if (!html) return notFound(res, `org "${orgMembersTab[1]}" not found`)
        return sendHtml(res, html)
      }
      const orgSettingsTab = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/settings$/)
      if (req.method === 'GET' && orgSettingsTab) {
        const html = await renderOrgSettings(svc, orgSettingsTab[1], ctx)
        if (!html) return notFound(res, `org "${orgSettingsTab[1]}" not found`)
        return sendHtml(res, html)
      }

      const orgMember = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/member$/)
      if (req.method === 'POST' && orgMember) {
        const form = await readForm(req)
        try {
          await svc.dispatch('add-org-member', {
            org: orgMember[1],
            userId: form.userId,
            role: form.role || 'member'
          }, ctx)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(orgMember[1], err, ctx), errorStatus(err))
        }
        return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgMember[1]))
      }

      const orgResource = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/resource$/)
      if (req.method === 'POST' && orgResource) {
        const form = await readForm(req)
        try {
          await svc.dispatch('add-org-resource', {
            org: orgResource[1],
            resource: form.resource
          }, ctx)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(orgResource[1], err, ctx), errorStatus(err))
        }
        return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgResource[1]))
      }

      // POST /orgs/:name/invite → mint a P2P blind-pairing invite; show the code.
      const orgInvite = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/invite$/)
      if (req.method === 'POST' && orgInvite) {
        const form = await readForm(req)
        const role = form.role || 'reader'
        try {
          const { invitation } = await svc.dispatch('create-org-invite', { name: orgInvite[1], role }, ctx)
          return sendHtml(res, renderOrgInviteResult(orgInvite[1], invitation, role, ctx))
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(orgInvite[1], err, ctx), errorStatus(err))
        }
      }

      // POST /orgs/join → pair into an org via an invite code (daemon joins over swarm).
      if (req.method === 'POST' && url === '/orgs/join') {
        const form = await readForm(req)
        try {
          const joined = await svc.dispatch('join-org', { invitation: form.invitation }, ctx)
          return pfxRedirectWithFlash(res, '/orgs/' + encodeURIComponent(joined.name),
            'Joined ' + joined.name + ' (role: ' + joined.role + ')')
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError('join', err, ctx), errorStatus(err))
        }
      }

      // ── org member/resource removal + settings (P.6) ─────────
      const orgMemberRemove = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/member-remove$/)
      if (req.method === 'POST' && orgMemberRemove) {
        const form = await readForm(req)
        try {
          await svc.dispatch('remove-org-member', {
            org: orgMemberRemove[1],
            userId: form.userId
          }, ctx)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(orgMemberRemove[1], err, ctx), errorStatus(err))
        }
        return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgMemberRemove[1]))
      }

      const orgResourceRemove = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/resource-remove$/)
      if (req.method === 'POST' && orgResourceRemove) {
        const form = await readForm(req)
        try {
          await svc.dispatch('remove-org-resource', {
            org: orgResourceRemove[1],
            resource: form.resource
          }, ctx)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(orgResourceRemove[1], err, ctx), errorStatus(err))
        }
        return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgResourceRemove[1]))
      }

      const orgSettings = url.match(/^\/orgs\/([a-z0-9][a-z0-9-]*[a-z0-9])\/settings$/)
      if (req.method === 'POST' && orgSettings) {
        const form = await readForm(req)
        try {
          await svc.dispatch('update-org', {
            name: orgSettings[1],
            displayName: form.displayName,
            description: form.description,
            about: form.about,
            visibility: form.visibility
          }, ctx)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderOrgError(orgSettings[1], err, ctx), errorStatus(err))
        }
        return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgSettings[1]))
      }

      // ── global /issues routes removed in P.2 ─────────────────
      // Issues are always scoped per-repo or per-org. Any direct hit to
      // /issues gets redirected to /repos so users land somewhere useful
      // instead of seeing an error. /issues/:id is unreachable without
      // a scope — legacy links that survived return 404.
      if (req.method === 'GET' && url === '/issues') {
        return pfxRedirect(res, '/repos')
      }

      // Legacy POST stubs reject scope-less issue mutations with a
      // helpful error. All actual issue work is now routed under
      // /repos/:name/issues or /orgs/:name/issues.
      if (req.method === 'POST' && (url === '/issues/create' || url.match(/^\/issues\/\d+\/(labels|comment|close|reopen)$/))) {
        const err = { message: 'Global /issues endpoints were removed in P.2 — use /repos/:name/issues or /orgs/:name/issues instead.' }
        return sendHtml(res, renderError('issues moved', err, '/repos', 'back to repos', ctx), 404)
      }

      if (req.method === 'GET' && url.match(/^\/issues\/\d+$/)) {
        const err = { message: 'Global /issues/:id view was removed in P.2 — use /repos/:name/issues/:id or /orgs/:name/issues/:id instead.' }
        return sendHtml(res, renderError('issues moved', err, '/repos', 'back to repos', ctx), 404)
      }

      // ── release artifact download (P.5.1) ───────────────────
      // Serves files straight from the release dir. Public reads — whoever
      // has the serverKey can fetch any release via P2P anyway, so gating
      // HTTP reads wouldn't add security, just friction. Path-traversal
      // guarded by the repo / version / artifact regex components.
      // HEAD as well as GET: Android's DownloadManager preflights a download
      // with HEAD, and every route here used to be gated on GET alone, so HEAD
      // fell through to the 404 handler — which advertises a Content-Length for
      // a body it then (correctly) omits, leaving the client waiting. Measured
      // from a handset against the production forge: 25 s, zero bytes, no
      // download. See test/release-download.test.js.
      const releaseDl = url.match(/^\/releases\/([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)\/(.+)$/i)
      if ((req.method === 'GET' || req.method === 'HEAD') && releaseDl) {
        if (!svc._releases) return notFound(res, 'release manager not available')
        const repoName = decodeURIComponent(releaseDl[1])
        const version = decodeURIComponent(releaseDl[2])
        const artifactPath = decodeURIComponent(releaseDl[3])

        // READ GATE — release artifacts are repo content on a route OUTSIDE
        // /repos/, so the browser guard below does not cover them. A private
        // repo's builds must not be downloadable when its source is not.
        if (svc.repoAccess) {
          let d = null
          try {
            d = await svc.repoAccess.async(repoName, {
              transport: 'http',
              identity: ctx && ctx.identity ? ctx.identity : null
            })
          } catch { d = null }
          if (d && d.wouldDeny) {
            if (!d.enforced) {
              // eslint-disable-next-line no-console
              console.log('[read-gate] OBSERVE would-deny release:' + repoName + ' (' + d.reason + ')')
            } else {
              // eslint-disable-next-line no-console
              console.log('[read-gate] deny release:' + repoName + ' (' + d.reason + ')')
              return notFound(res, 'release not found')
            }
          }
        }
        // Extra traversal guard (defense in depth on top of the regex)
        if (artifactPath.includes('..') || artifactPath.startsWith('/')) {
          return notFound(res, 'invalid artifact path')
        }
        try {
          const full = path.join(svc._releases._versionDir(repoName, version), artifactPath)
          // Verify resolved path stays inside the version dir
          const resolved = path.resolve(full)
          const verRoot = path.resolve(svc._releases._versionDir(repoName, version))
          if (!resolved.startsWith(verRoot + path.sep) && resolved !== verRoot) {
            return notFound(res, 'invalid artifact path')
          }
          // Existence is checked BEFORE writeHead. createReadStream reports a
          // missing file ASYNCHRONOUSLY, so the `catch (ENOENT)` below never
          // sees it: the 200 has already been written and the client gets a
          // truncated body / CONNECTION_LOST instead of a 404. Same sync-vs-async
          // trap that made the unguarded .pipe() fatal. bootstrap.js already
          // stats first for this reason.
          let st = null
          try { st = fs.statSync(resolved) } catch { st = null }
          if (!st || !st.isFile()) return notFound(res, 'release artifact not found')
          // Headers go through setHeader, NEVER writeHead's object form. The
          // duplicate-Content-Length hazard is real but it comes from
          // bare-http1 itself: server-response.js `_write()` sets its own
          // Content-Length whenever a body fits in one finishing write, and
          // `writeHead` preserves header case while `setHeader` lowercases —
          // so the two land as SEPARATE keys and the header is emitted twice.
          // That only bites SMALL artifacts, which is exactly what the
          // favela-android OTA fetcher reads (tree.json). Routing everything
          // through setHeader means bare-http1's internal call overwrites the
          // same key instead of appending a second one.
          const total = st.size
          res.setHeader('Content-Type', mimeFor(artifactPath))
          res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(path.basename(artifactPath)) + '"')
          // Advertised so clients resume instead of restarting. A 148 MB APK
          // over mobile that drops at 90% used to begin again from zero.
          res.setHeader('Accept-Ranges', 'bytes')

          let start = 0
          let end = total - 1
          let partial = false
          const rangeHeader = req.headers.range
          if (rangeHeader) {
            // Single ranges only. A multi-range request needs a multipart body;
            // ignoring it and sending the whole entity is a legal response, and
            // no client of ours asks for one.
            const m = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim())
            if (m && !(m[1] === '' && m[2] === '')) {
              if (m[1] === '') {
                start = Math.max(0, total - Number(m[2]))       // suffix: last N bytes
              } else {
                start = Number(m[1])
                if (m[2] !== '') end = Number(m[2])
              }
              if (end > total - 1) end = total - 1
              if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
                res.setHeader('Content-Range', 'bytes */' + total)
                res.writeHead(416)
                res.flushHeaders()
                return res.end()
              }
              partial = true
              res.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + total)
            }
          }
          res.setHeader('Content-Length', String(end - start + 1))
          res.writeHead(partial ? 206 : 200)

          // HEAD: flushHeaders() FIRST, because `_final()` sets
          // Content-Length: 0 when it finds the headers unsent — which would
          // tell the client the artifact is empty. Flushing also means HEAD
          // never opens the file at all.
          if (req.method === 'HEAD') {
            res.flushHeaders()
            return res.end()
          }
          // pipeFile, not a bare .pipe(): the catch below only covers the
          // synchronous setup, so a stream error on a later tick would escape
          // and kill the daemon (see lib/web/pipe-file.js).
          const body = partial
            ? fs.createReadStream(resolved, { start, end })
            : fs.createReadStream(resolved)
          return pipeFile(body, res, 'release artifact ' + artifactPath)
        } catch (err) {
          if (err.code === 'ENOENT') return notFound(res, 'release artifact not found')
          throw err
        }
      }

      // ── docs ─────────────────────────────────────────────────
      // '/docs' used to 404: only the '/docs/<slug>' form was routed, so
      // the nav entry and the link from /repos both dead-ended, and
      // '/docs/' rendered "Invalid doc name". Both bare forms now serve
      // the index.
      if (req.method === 'GET' && (url === '/docs' || url === '/docs/')) {
        return sendHtml(res, await renderDocsIndex(ctx))
      }
      if (req.method === 'GET' && url.startsWith('/docs/')) {
        const slug = url.slice('/docs/'.length)
        return sendHtml(res, await renderDoc(ctx, slug))
      }

      // ── files (content-addressed attachments over the V2 overlay) ──────────
      // hyper-overlay 0.3.0 retired the v1 path-addressed overlay (the browse UI and
      // the put/delete routes); the forge now serves ONLY content-addressed
      // /attachments/<oid>. Attachments replicate cross-daemon over the V2 overlay
      // (hyper-svc auto-attaches it to every swarm connection).
      if (req.method === 'GET' && url === '/files/raw') {
        const path = query.path
        if (!path) return notFound(res, '/files/raw requires ?path=...')
        const m = /^\/attachments\/([0-9a-f]{64})$/i.exec(path)
        if (!m) return notFound(res, `file not found: ${path}`)
        const oid = m[1]
        // FAST LOCAL PATH: the attachment spool IS the canonical byte store, so serve
        // it directly whenever this daemon holds the bytes — no overlay-index lookup,
        // no peer round-trip. This also covers attachments predating overlayV2
        // registration (the v1-era spool files): fetchFile alone can't resolve those —
        // it treats them as remote and waits the full multi-source idle timeout (~30s)
        // then 404s (regression when v1 overlay.get was removed). Only fall to
        // overlayV2.fetchFile for a genuine CROSS-DAEMON pull (pfp uploaded on peer A,
        // rendered on peer B), which returns { destPath, size } — bytes on disk.
        let destPath = null
        let size = null
        const localSpool = join(svc._attachmentSpoolDir, oid)
        if (fs.existsSync(localSpool)) {
          destPath = localSpool
        } else if (svc.modules.overlayV2) {
          const got = await svc.modules.overlayV2.fetchFile(oid).catch(() => null)
          if (got && got.destPath) { destPath = got.destPath; size = got.size }
        }
        if (!destPath) return notFound(res, `file not found: ${path}`)
        if (size == null) { try { size = fs.statSync(destPath).size } catch {} }
        const headers = {
          'Content-Type': 'application/octet-stream',
          'Content-Disposition': 'inline; filename="' + encodeURIComponent(path.split('/').pop() || 'file') + '"'
        }
        // Content-Length is deliberately NOT set on a piped body. bare-http1's
        // writeHead stores header names with their ORIGINAL case
        // (`{...this._headers, ...headers}`) while setHeader lowercases them,
        // so a 'Content-Length' set here and the one the response adds for
        // itself become two distinct keys and BOTH get written — which its own
        // parser then rejects as `INVALID_HEADER: Duplicate header`. Let the
        // response derive it. Same reasoning as lib/web/static.js and
        // lib/handlers/bootstrap.js, which already avoid this.
        res.writeHead(200, headers)
        // pipeFile: an attachment can be rewritten by a concurrent overlay
        // fetch, or the browser can abort mid-body — either destroys the pipe
        // and, unguarded, takes the whole forge down.
        return pipeFile(fs.createReadStream(destPath), res, 'attachment ' + oid)
      }

      // ── binary upload ──────────────────────────────────────
      if (req.method === 'POST' && url === '/api/upload') {
        // Writer (or local UDS owner) required. Disk-spools the streamed body (bounds
        // RAM at ~1 chunk) then delegates to the shared spoolAttachment() — the same
        // code path as the upload-attachment RPC (#130): hash → spool/<oid> →
        // registerFile in the V2 overlay (canonical bytes on disk, replicated by hash).
        // #16: this used to check only that SOME identity was present, then call
        // spoolAttachment directly — around the gate rather than through it. But
        // /api/rpc mints a session for ANY verified envelope, including a
        // first-login TOFU user with read-only perms, so "authenticated" was a
        // very low bar: any peer that could sign an envelope could write
        // arbitrary content-addressed blobs into the overlay spool, which then
        // replicate to peers by hash. requireWriter is the same check the
        // upload-attachment RPC applies (handlers/attachments.js:53) to the same
        // spoolAttachment call — one policy, both doors. UDS and ownerMatched
        // still bypass, inside requireWriter.
        // ORDER MATTERS. The body consumer is attached SYNCHRONOUSLY, before
        // the first await. requireWriter is async, and on an empty body the
        // request stream reaches EOF during that gap — a pipeline() attached
        // afterwards waits for an end that already happened and never
        // resolves (a 30s hang that aborts the whole test file, exit 134).
        // Attaching first is also the order this route had before the gate
        // was added.
        //
        // The cost is that an unauthorized caller's bytes briefly hit a temp
        // file. That is not what this issue is about: the temp file is
        // unlinked in `finally` and never reaches the attachment spool, which
        // is the content-addressed store that actually replicates to peers by
        // hash. Nothing unauthorized becomes durable or reachable.
        const tmpPath = join(svc._attachmentSpoolDir, '.tmp-' + crypto.randomBytes(8).toString('hex'))
        const bodyWritten = pipeline(req, fs.createWriteStream(tmpPath))

        try {
          await requireWriter(ctx, svc.identityRegistry)
        } catch (err) {
          // Drain the body before answering so the connection isn't left
          // wedged mid-request, then drop the bytes.
          try { await bodyWritten } catch {}
          try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath) } catch {}
          return sendJsonError(res, err && err.status === 403 ? 403 : 401,
            (err && err.message) || 'writer permission required')
        }

        try {
          await bodyWritten
          const result = await spoolAttachment(svc, tmpPath, { move: true })
          return sendJson(res, result)
        } catch (err) {
          if (err.status === 400) return sendJsonError(res, 400, 'empty body')
          return sendJsonError(res, 500, 'attachment upload failed: ' + err.message)
        } finally {
          try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath) } catch {}
        }
      }

      // ── deploy + repos ──────────────────────────────────────
      if (req.method === 'GET' && url === '/releases') {
        return pfxRedirect(res, '/deploy')
      }

      if (req.method === 'GET' && url === '/deploy') {
        return sendHtml(res, await renderDeploy(svc, ctx))
      }

      // ── admin ──────────────────────────────────────────────────
      if (req.method === 'GET' && url === '/admin') {
        return sendHtml(res, await renderAdmin(svc, ctx, query.tab))
      }
      // /admin/trust/* routes were retired with trust-map. Future:
      // wire /admin/users/:userId/grant + /admin/users/:userId/revoke
      // backed by grant-permission + revoke-identity RPC handlers.

      // ── global search ─────────────────────────────────────────
      if (req.method === 'GET' && url === '/search') {
        return sendHtml(res, await renderGlobalSearch(svc, ctx, query.q, query.type))
      }

      // ── profile / users ──────────────────────────────────────
      // ── devices ─────────────────────────────────────────────
      if (req.method === 'GET' && url === '/devices') {
        return sendHtml(res, await renderDevices(svc, ctx))
      }
      if (req.method === 'POST' && url === '/devices/invite') {
        const form = await readForm(req)
        try {
          const r = await svc.dispatch('device-invite', { mirror: !!form.mirror }, ctx)
          return sendHtml(res, renderDeviceInviteResult(r.inviteHex, !!form.mirror, ctx))
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('device invite', err, prefix + '/devices', 'back to devices', ctx), errorStatus(err))
        }
      }
      if (req.method === 'POST' && url === '/devices/revoke') {
        const form = await readForm(req)
        try {
          await svc.dispatch('revoke-device', { devicePubkey: form.devicePubkey }, ctx)
          return pfxRedirectWithFlash(res, '/devices', 'Device revoked')
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('device revoke', err, prefix + '/devices', 'back to devices', ctx), errorStatus(err))
        }
      }
      if (req.method === 'POST' && url === '/devices/label') {
        const form = await readForm(req)
        try {
          await svc.dispatch('label-device', { devicePubkey: form.devicePubkey, label: form.label }, ctx)
          return pfxRedirectWithFlash(res, '/devices', 'Device renamed')
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('device label', err, prefix + '/devices', 'back to devices', ctx), errorStatus(err))
        }
      }

      if (req.method === 'GET' && url === '/profile') {
        return sendHtml(res, await renderProfile(svc, ctx))
      }
      if (req.method === 'POST' && url === '/profile/update') {
        const form = await readForm(req)
        try {
          const result = await svc.dispatch('update-profile', {
            name: form.name !== undefined ? form.name : undefined,
            profilePicture: form.profilePicture ? form.profilePicture : null
          }, ctx)
          return sendJson(res, { ok: true, user: result.user })
        } catch (err) {
          if (err.status === 401 || err.status === 403) {
            return sendJsonError(res, err.status, err.message)
          }
          return sendJsonError(res, 500, err.message)
        }
      }
      if (req.method === 'GET' && url === '/users') {
        return sendHtml(res, await renderUsers(svc, ctx))
      }
      if (req.method === 'GET' && url.startsWith('/profile/')) {
        const pubkey = decodeURIComponent(url.slice('/profile/'.length))
        return sendHtml(res, await renderUserProfile(svc, ctx, pubkey))
      }

      if (req.method === 'GET' && url === '/repos') {
        return sendHtml(res, await renderReposIndex(svc, ctx))
      }

      // POST /repos/:name/invite → mint a P2P blind-pairing invite; show the code.
      const repoInvite = url.match(/^\/repos\/([^/]+)\/invite$/)
      if (req.method === 'POST' && repoInvite) {
        const repoName = decodeURIComponent(repoInvite[1])
        const form = await readForm(req)
        const role = form.role || 'reader'
        try {
          const { invitation } = await svc.dispatch('create-repo-invite', { name: repoName, role }, ctx)
          return sendHtml(res, renderRepoInviteResult(repoName, invitation, role, ctx))
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('repo invite', err, prefix + '/repos', 'back to repos', ctx), errorStatus(err))
        }
      }

      // POST /repos/:name/writer-revoke → revoke a repo writer's push authority.
      const repoWriterRevoke = url.match(/^\/repos\/([^/]+)\/writer-revoke$/)
      if (req.method === 'POST' && repoWriterRevoke) {
        const repoName = decodeURIComponent(repoWriterRevoke[1])
        const form = await readForm(req)
        try {
          await svc.dispatch('repo-revoke-writer', { name: repoName, userId: form.userId }, ctx)
          return pfxRedirectWithFlash(res, '/repos/' + encodeURIComponent(repoName) + '/peers',
            'Revoked push access for ' + String(form.userId).slice(0, 16) + '…')
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('revoke writer', err, prefix + '/repos/' + encodeURIComponent(repoName) + '/peers', 'back to peers', ctx), errorStatus(err))
        }
      }

      // POST /repos/join → pair into a repo tracker via an invite code (over swarm).
      if (req.method === 'POST' && url === '/repos/join') {
        const form = await readForm(req)
        try {
          const joined = await svc.dispatch('join-repo', { invitation: form.invitation }, ctx)
          return pfxRedirectWithFlash(res, '/repos/' + encodeURIComponent(joined.name) + '/peers',
            'Joined repo ' + joined.name + ' (role: ' + joined.role + ')')
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('repo join', err, prefix + '/repos', 'back to repos', ctx), errorStatus(err))
        }
      }

      // ── peers ───────────────────────────────────────────────
      if (req.method === 'GET' && url === '/peers') {
        return sendHtml(res, await renderPeersIndex(svc, ctx))
      }
      if (req.method === 'POST' && url === '/peers/remote-add') {
        const form = await readForm(req)
        try {
          // U37: adding a sync remote means the daemon will actively
          // pull from that server key on demand — a non-trivial trust
          // decision. Gated on owner. UDS bypasses.
          await requireAdmin(ctx, svc.identityRegistry)
          if (!form.name || !form.serverKey) throw Object.assign(
            new Error('remote-add requires { name, serverKey }'),
            { status: 400 }
          )
          if (svc._sync && typeof svc._sync.addRemote === 'function') {
            svc._sync.addRemote(form.name, form.serverKey)
          }
          return pfxRedirectWithFlash(res, '/peers', 'success', `Remote "${form.name}" added`)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('peers · add remote failed', err, '/peers', 'back to peers', ctx), errorStatus(err))
        }
      }
      if (req.method === 'POST' && url === '/peers/remote-remove') {
        const form = await readForm(req)
        try {
          await requireAdmin(ctx, svc.identityRegistry)
          if (svc._sync && typeof svc._sync.removeRemote === 'function' && form.name) {
            svc._sync.removeRemote(form.name)
          }
          return pfxRedirectWithFlash(res, '/peers', 'success', `Remote "${form.name}" removed`)
        } catch (err) {
          if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
          return sendHtml(res, renderError('peers · remove remote failed', err, '/peers', 'back to peers', ctx), errorStatus(err))
        }
      }

      // ── repo browser ───────────────────────────────────────
      if (req.method === 'GET') {
        // READ GATE — one guard for the whole /repos/:name/* family.
        //
        // There are ~14 GET routes below (overview, tree, blob, raw, commits,
        // commit, search, issues, peers, releases, docs …) and no shared
        // repo-resolution helper, so gating each one individually would mean
        // fourteen chances to forget — and every route added later would
        // silently default to readable. Checking once here, before dispatch,
        // is the choke point.
        //
        // Anonymous browsing of PUBLIC repos is unaffected: repo-access
        // answers from the marker alone, with no identity and no lookup.
        const repoScoped = url.match(/^\/repos\/([^/]+)(?:\/|$)/)
        if (repoScoped && svc.repoAccess) {
          const repoName = decodeURIComponent(repoScoped[1])
          let decision
          try {
            decision = await svc.repoAccess.async(repoName, {
              transport: 'http',
              identity: ctx && ctx.identity ? ctx.identity : null
            })
          } catch {
            decision = null // a broken gate must not take the web UI down
          }
          if (decision && decision.wouldDeny) {
            if (!decision.enforced) {
              // eslint-disable-next-line no-console
              console.log('[read-gate] OBSERVE would-deny web:' + url + ' (' + decision.reason + ')')
            } else {
              // eslint-disable-next-line no-console
              console.log('[read-gate] deny web:' + url + ' (' + decision.reason + ')')
              // 404, not 403 — a private repo must not confirm its existence,
              // matching the classic-git transports.
              return sendHtml(
                res,
                renderError('not found', { message: 'Repository not found' }, '/repos', 'back to repos', ctx),
                404
              )
            }
          }
        }

        // /repos/:name
        const repoMatch = url.match(/^\/repos\/([^/]+)$/)
        if (repoMatch) {
          return sendHtml(res, await renderRepoOverview(svc, ctx, decodeURIComponent(repoMatch[1])))
        }

        // /repos/:name/tree or /repos/:name/tree/path/to/dir
        const treeMatch = url.match(/^\/repos\/([^/]+)\/tree(?:\/(.*))?$/)
        if (treeMatch) {
          const name = decodeURIComponent(treeMatch[1])
          const path = treeMatch[2] ? decodeURIComponent(treeMatch[2]) : ''
          return sendHtml(res, await renderRepoTree(svc, ctx, name, path, query.ref))
        }

        // /repos/:name/blob/path/to/file
        const blobMatch = url.match(/^\/repos\/([^/]+)\/blob\/(.+)$/)
        if (blobMatch) {
          return sendHtml(res, await renderRepoBlob(svc, ctx, decodeURIComponent(blobMatch[1]), decodeURIComponent(blobMatch[2]), query.ref))
        }

        // /repos/:name/raw/path/to/file — raw blob bytes (images, svg, downloads)
        const rawMatch = url.match(/^\/repos\/([^/]+)\/raw\/(.+)$/)
        if (rawMatch) {
          const name = decodeURIComponent(rawMatch[1])
          const filePath = decodeURIComponent(rawMatch[2])
          const sync = svc._sync
          if (!sync) return notFound(res, 'sync service not running')
          let data
          try {
            data = await sync.readFileRaw(name, filePath, query.ref)
          } catch (err) {
            return notFound(res, 'file not found: ' + filePath)
          }
          const mime = mimeFor(filePath)
          const headers = {
            'Content-Type': mime,
            'X-Content-Type-Options': 'nosniff',
            'Content-Disposition': 'inline; filename="' + encodeURIComponent(filePath.split('/').pop() || 'file') + '"'
          }
          // SVG can carry inline scripts; neutralise them on direct navigation.
          // (<img> embeds never execute SVG scripts, so the file-view is fine.)
          if (mime === 'image/svg+xml') headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'"
          res.writeHead(200, headers)
          return res.end(data)
        }

        // /repos/:name/commits
        const commitsMatch = url.match(/^\/repos\/([^/]+)\/commits$/)
        if (commitsMatch) {
          return sendHtml(res, await renderRepoCommits(svc, ctx, decodeURIComponent(commitsMatch[1]), query.ref))
        }

        // /repos/:name/commit/:hash
        const commitMatch = url.match(/^\/repos\/([^/]+)\/commit\/([a-f0-9]+)$/)
        if (commitMatch) {
          return sendHtml(res, await renderRepoCommit(svc, ctx, decodeURIComponent(commitMatch[1]), commitMatch[2]))
        }

        // /repos/:name/search?q=...
        const searchMatch = url.match(/^\/repos\/([^/]+)\/search$/)
        if (searchMatch) {
          return sendHtml(res, await renderRepoSearch(svc, ctx, decodeURIComponent(searchMatch[1]), query.q))
        }

        // /repos/:name/issues — per-repo issue list
        const repoIssuesMatch = url.match(/^\/repos\/([^/]+)\/issues$/)
        if (repoIssuesMatch) {
          const repoName = decodeURIComponent(repoIssuesMatch[1])
          const labels = parseLabelInput(query.labels)
          return sendHtml(res, await renderIssuesIndex(svc, query.status || 'open', ctx, { repo: repoName }, labels))
        }

        // /repos/:name/issues/:id — per-repo issue detail
        const repoIssueMatch = url.match(/^\/repos\/([^/]+)\/issues\/(\d+)$/)
        if (repoIssueMatch) {
          const repoName = decodeURIComponent(repoIssueMatch[1])
          const html = await renderIssueShow(svc, parseInt(repoIssueMatch[2]), ctx, { repo: repoName })
          return html ? sendHtml(res, html) : notFound(res, 'Issue not found')
        }

        // /orgs/:name/issues — per-org issue list (P.2)
        const orgIssuesMatch = url.match(/^\/orgs\/([^/]+)\/issues$/)
        if (orgIssuesMatch) {
          const orgName = decodeURIComponent(orgIssuesMatch[1])
          const labels = parseLabelInput(query.labels)
          return sendHtml(res, await renderIssuesIndex(svc, query.status || 'open', ctx, { org: orgName }, labels))
        }

        // /orgs/:name/issues/:id — per-org issue detail (P.2)
        const orgIssueMatch = url.match(/^\/orgs\/([^/]+)\/issues\/(\d+)$/)
        if (orgIssueMatch) {
          const orgName = decodeURIComponent(orgIssueMatch[1])
          const html = await renderIssueShow(svc, parseInt(orgIssueMatch[2]), ctx, { org: orgName })
          return html ? sendHtml(res, html) : notFound(res, 'Issue not found')
        }

        // /repos/:name/peers — per-repo peer/replication info (P.3)
        const repoPeersMatch = url.match(/^\/repos\/([^/]+)\/peers$/)
        if (repoPeersMatch) {
          const repoName = decodeURIComponent(repoPeersMatch[1])
          return sendHtml(res, await renderRepoPeers(svc, ctx, repoName))
        }

        // /repos/:name/releases — placeholder for P.5 deploy integration
        const repoReleasesMatch = url.match(/^\/repos\/([^/]+)\/releases$/)
        if (repoReleasesMatch) {
          const repoName = decodeURIComponent(repoReleasesMatch[1])
          return sendHtml(res, await renderRepoReleases(svc, ctx, repoName, { showAll: query.all === '1' }))
        }

        // /repos/:name/docs — markdown docs index (U38)
        const repoDocsMatch = url.match(/^\/repos\/([^/]+)\/docs$/)
        if (repoDocsMatch) {
          const repoName = decodeURIComponent(repoDocsMatch[1])
          const html = await renderRepoDocs(svc, repoName, ctx)
          if (!html) return notFound(res, `repo "${repoName}" not found`)
          return sendHtml(res, html)
        }
      }

      // ── per-repo issue mutations ───────────────────────────
      if (req.method === 'POST') {
        const repoIssueCreate = url.match(/^\/repos\/([^/]+)\/issues\/create$/)
        if (repoIssueCreate) {
          const repoName = decodeURIComponent(repoIssueCreate[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('create-issue', {
              repo: repoName,
              title: form.title,
              body: form.body,
              images: form.images ? form.images.split(',').filter(Boolean) : undefined,
              labels: parseLabelInput(form.labels)
            }, ctx)
            return pfxRedirectWithFlash(res, '/repos/' + encodeURIComponent(repoName) + '/issues',
              'success', `Issue "${form.title}" created`)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ repo: repoName }, err, ctx), errorStatus(err))
          }
        }

        const repoIssueLabels = url.match(/^\/repos\/([^/]+)\/issues\/(\d+)\/labels$/)
        if (repoIssueLabels) {
          const repoName = decodeURIComponent(repoIssueLabels[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('update-issue', {
              repo: repoName,
              id: parseInt(repoIssueLabels[2], 10),
              labels: parseLabelInput(form.labels)
            }, ctx)
            return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/issues/' + repoIssueLabels[2])
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ repo: repoName }, err, ctx), errorStatus(err))
          }
        }

        const repoIssueComment = url.match(/^\/repos\/([^/]+)\/issues\/(\d+)\/comment$/)
        if (repoIssueComment) {
          const repoName = decodeURIComponent(repoIssueComment[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('comment-issue', {
              repo: repoName,
              id: parseInt(repoIssueComment[2]),
              body: form.body
            }, ctx)
            return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/issues/' + repoIssueComment[2])
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ repo: repoName }, err, ctx), errorStatus(err))
          }
        }

        const repoIssueClose = url.match(/^\/repos\/([^/]+)\/issues\/(\d+)\/(close|reopen)$/)
        if (repoIssueClose) {
          const repoName = decodeURIComponent(repoIssueClose[1])
          try {
            await svc.dispatch('update-issue', {
              repo: repoName,
              id: parseInt(repoIssueClose[2]),
              status: repoIssueClose[3] === 'close' ? 'closed' : 'open'
            }, ctx)
            return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/issues/' + repoIssueClose[2])
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ repo: repoName }, err, ctx), errorStatus(err))
          }
        }

        // ── admin: user management (P.7) ──────────────────────────
        if (url === '/admin/user-grant') {
          const form = await readForm(req)
          const permsRaw = (form.permissions || '').trim()
          const permissions = permsRaw ? permsRaw.split(',').map(s => s.trim()).filter(Boolean) : []
          try {
            await svc.dispatch('grant-permission', {
              userId: form.userId,
              permissions
            }, ctx)
            return pfxRedirect(res, '/admin')
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderError('admin · grant failed', err, '/admin', 'back to admin', ctx), errorStatus(err))
          }
        }

        if (url === '/admin/user-deactivate') {
          const form = await readForm(req)
          try {
            await svc.dispatch('deactivate-user', { userId: form.userId }, ctx)
            return pfxRedirect(res, '/admin')
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderError('admin · deactivate failed', err, '/admin', 'back to admin', ctx), errorStatus(err))
          }
        }

        if (url === '/admin/user-reactivate') {
          const form = await readForm(req)
          try {
            await svc.dispatch('reactivate-user', { userId: form.userId }, ctx)
            return pfxRedirect(res, '/admin')
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderError('admin · reactivate failed', err, '/admin', 'back to admin', ctx), errorStatus(err))
          }
        }

        // ── deploy trigger (P.5) ─────────────────────────────────
        // Uses the pre-existing rescan-releases handler from services/deploy.js
        if (url === '/deploy/rescan') {
          try {
            await svc.dispatch('rescan-releases', {}, ctx)
            return pfxRedirect(res, '/deploy')
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderError('deploy · rescan failed', err, '/deploy', 'back to deploy', ctx), errorStatus(err))
          }
        }

        // ── per-org issue mutations (P.2) — mirror the per-repo set ─
        const orgIssueCreate = url.match(/^\/orgs\/([^/]+)\/issues\/create$/)
        if (orgIssueCreate) {
          const orgName = decodeURIComponent(orgIssueCreate[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('create-issue', {
              org: orgName,
              title: form.title,
              body: form.body,
              images: form.images ? form.images.split(',').filter(Boolean) : undefined,
              labels: parseLabelInput(form.labels)
            }, ctx)
            return pfxRedirectWithFlash(res, '/orgs/' + encodeURIComponent(orgName) + '/issues',
              'success', `Issue "${form.title}" created`)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ org: orgName }, err, ctx), errorStatus(err))
          }
        }

        const orgIssueLabels = url.match(/^\/orgs\/([^/]+)\/issues\/(\d+)\/labels$/)
        if (orgIssueLabels) {
          const orgName = decodeURIComponent(orgIssueLabels[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('update-issue', {
              org: orgName,
              id: parseInt(orgIssueLabels[2], 10),
              labels: parseLabelInput(form.labels)
            }, ctx)
            return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgName) + '/issues/' + orgIssueLabels[2])
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ org: orgName }, err, ctx), errorStatus(err))
          }
        }

        const orgIssueComment = url.match(/^\/orgs\/([^/]+)\/issues\/(\d+)\/comment$/)
        if (orgIssueComment) {
          const orgName = decodeURIComponent(orgIssueComment[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('comment-issue', {
              org: orgName,
              id: parseInt(orgIssueComment[2]),
              body: form.body
            }, ctx)
            return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgName) + '/issues/' + orgIssueComment[2])
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ org: orgName }, err, ctx), errorStatus(err))
          }
        }

        // (placeholder — release mutations below)
        const releasePromote = url.match(/^\/repos\/([^/]+)\/releases\/promote$/)
        if (releasePromote) {
          const repoName = decodeURIComponent(releasePromote[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('promote-release', {
              repo: repoName,
              version: form.version,
              toChannel: form.toChannel
            }, ctx)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            throw err
          }
          return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/releases')
        }

        const releaseDelete = url.match(/^\/repos\/([^/]+)\/releases\/delete$/)
        if (releaseDelete) {
          const repoName = decodeURIComponent(releaseDelete[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('delete-release', {
              repo: repoName,
              version: form.version
            }, ctx)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            throw err
          }
          return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/releases')
        }

        const releaseUnpin = url.match(/^\/repos\/([^/]+)\/releases\/unpin$/)
        if (releaseUnpin) {
          const repoName = decodeURIComponent(releaseUnpin[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('unpin-release-channel', {
              repo: repoName,
              channel: form.channel
            }, ctx)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            throw err
          }
          return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/releases')
        }

        const releaseBuild = url.match(/^\/repos\/([^/]+)\/releases\/build$/)
        if (releaseBuild) {
          const repoName = decodeURIComponent(releaseBuild[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('build-release', {
              repo: repoName,
              ref: form.ref,
              isTag: !!form.isTag,
              channel: form.channel || undefined
            }, ctx)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            throw err
          }
          return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/releases')
        }

        const releaseGC = url.match(/^\/repos\/([^/]+)\/releases\/gc$/)
        if (releaseGC) {
          const repoName = decodeURIComponent(releaseGC[1])
          const form = await readForm(req)
          try {
            await svc.dispatch('gc-releases', {
              repo: repoName,
              dryRun: !!form.dryRun
            }, ctx)
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            throw err
          }
          return pfxRedirect(res, '/repos/' + encodeURIComponent(repoName) + '/releases')
        }

        const orgIssueClose = url.match(/^\/orgs\/([^/]+)\/issues\/(\d+)\/(close|reopen)$/)
        if (orgIssueClose) {
          const orgName = decodeURIComponent(orgIssueClose[1])
          try {
            await svc.dispatch('update-issue', {
              org: orgName,
              id: parseInt(orgIssueClose[2]),
              status: orgIssueClose[3] === 'close' ? 'closed' : 'open'
            }, ctx)
            return pfxRedirect(res, '/orgs/' + encodeURIComponent(orgName) + '/issues/' + orgIssueClose[2])
          } catch (err) {
            if (err.status === 401 || err.status === 403) return authRedirect(res, prefix)
            return sendHtml(res, renderIssueError({ org: orgName }, err, ctx), errorStatus(err))
          }
        }
      }

      // ── 410 Gone for removed v1 endpoints (U23) ──────────────
      // Federation and token management were removed in v3. Return a
      // styled 410 so anyone hitting stale bookmarks or scripts gets a
      // clear explanation instead of a generic 404.
      if (url.startsWith('/api/tokens') || url.startsWith('/federation')) {
        const err = { message: 'This endpoint was removed in pear-git v3. Federation and token management are no longer available.' }
        return sendHtml(res, renderError('gone', err, '/repos', 'back to repos', ctx), 410)
      }

      // No SSR route matched. hyper-svc's listener already let this
      // through (since it's not /rpc/*), so we own the response.
      return sendHtml(res, renderError('not found', { message: 'The page you requested does not exist.' }, '/repos', 'back to repos', ctx), 404)
    } catch (err) {
      // eslint-disable-next-line no-console
      console.error('[pear-git-v3/web]', err)
      if (!res.headersSent) {
        return serverError(res, err)
      }
    }
  })
}

function sendJson (res, body) {
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
  res.end(JSON.stringify(body))
}

function sendJsonError (res, status, message, code) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
  const body = { error: message }
  if (code) body.code = code
  res.end(JSON.stringify(body))
}

/**
 * Redirect a mutating request that hit an auth check failure. The
 * browser-side bootstrap script auto-runs on every page load, so by the
 * time the user gets to /, they should have a fresh session and can
 * retry the action.
 */
function authRedirect (res, pfx) {
  res.writeHead(303, { Location: (pfx || '') + '/?auth=required' })
  res.end()
}

function parseQuery (url) {
  const idx = url.indexOf('?')
  if (idx === -1) return {}
  const params = new URLSearchParams(url.slice(idx + 1))
  const out = {}
  for (const [k, v] of params) out[k] = v
  return out
}

/**
 * Split a comma-separated label input into a trimmed, deduplicated
 * string array. Returns undefined for empty input so we don't clobber
 * existing labels in an update with an empty array unintentionally.
 */
/**
 * Best-effort Content-Type for release artifacts. We don't inspect bytes
 * or extract anything — the tarball / zip / whatever is opaque. This just
 * lets browsers handle downloads with a sensible type hint.
 */
function mimeFor (filename) {
  const f = filename.toLowerCase()
  if (f.endsWith('.tar.gz') || f.endsWith('.tgz')) return 'application/gzip'
  if (f.endsWith('.tar.xz')) return 'application/x-xz'
  if (f.endsWith('.tar.bz2')) return 'application/x-bzip2'
  if (f.endsWith('.tar')) return 'application/x-tar'
  if (f.endsWith('.zip')) return 'application/zip'
  if (f.endsWith('.gz')) return 'application/gzip'
  if (f.endsWith('.sha256') || f.endsWith('.sha512') || f.endsWith('.checksums') || f.endsWith('.txt')) return 'text/plain; charset=utf-8'
  if (f.endsWith('.json')) return 'application/json; charset=utf-8'
  if (f.endsWith('.yaml') || f.endsWith('.yml')) return 'text/yaml; charset=utf-8'
  if (f.endsWith('.js')) return 'text/javascript; charset=utf-8'
  if (f.endsWith('.html')) return 'text/html; charset=utf-8'
  if (f.endsWith('.md')) return 'text/markdown; charset=utf-8'
  if (f.endsWith('.csv')) return 'text/csv; charset=utf-8'
  if (f.endsWith('.tsv')) return 'text/tab-separated-values; charset=utf-8'
  if (f.endsWith('.svg')) return 'image/svg+xml'
  if (f.endsWith('.png')) return 'image/png'
  if (f.endsWith('.jpg') || f.endsWith('.jpeg')) return 'image/jpeg'
  if (f.endsWith('.gif')) return 'image/gif'
  if (f.endsWith('.webp')) return 'image/webp'
  if (f.endsWith('.avif')) return 'image/avif'
  if (f.endsWith('.bmp')) return 'image/bmp'
  if (f.endsWith('.ico')) return 'image/x-icon'
  if (f.endsWith('.deb')) return 'application/vnd.debian.binary-package'
  if (f.endsWith('.rpm')) return 'application/x-rpm'
  if (f.endsWith('.appimage')) return 'application/x-executable'
  // An APK is a zip, but it is matched by extension so the .zip case above
  // never catches it. Without this line it falls through to octet-stream and
  // Android Chrome only saves the file; with the correct type Chrome hands it
  // to the package installer. Content-Disposition: attachment is already set on
  // the release route, which is what APKs want.
  if (f.endsWith('.apk')) return 'application/vnd.android.package-archive'
  // Unknown — let browsers treat as a binary download
  return 'application/octet-stream'
}

function parseLabelInput (raw) {
  if (!raw) return undefined
  const trimmed = String(raw).split(',').map(s => s.trim()).filter(Boolean)
  if (trimmed.length === 0) return []
  return Array.from(new Set(trimmed))
}