/**
* 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))
}