OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / hyper-proxy.js

1229 lines · javascript ref:
/**
 * Hyper Proxy — Local HTTP server bridging WebView to Hyperdrives
 *
 * URL mapping:
 *   localhost:PORT/hyper/KEY/path → fetches from Hyperdrive
 *   localhost:PORT/app/APP_ID/path → fetches from installed app's drive
 *
 * Injects <base> tags for relative link resolution in HTML.
 */

const http = require('bare-http1')
const crypto = require('bare-crypto')
const { PAGE_CONTEXT_SHIM, PAGE_CONTEXT_SHIM_HASH, pageContextMeta } = require('./page-context-bridge.cjs')
const { escapeStyleText } = require('./html-raw-text.cjs')

const USER_FRIENDLY_ERRORS = {
  'Invalid drive key': 'This link appears to be broken or incomplete',
  'Invalid drive key format': 'The address you entered is not valid',
  'File not found': 'The page you\'re looking for doesn\'t exist on this site',
  'Timeout': 'Taking longer than expected. The site may be offline or unreachable.',
  'Drive not found': 'This site is currently unavailable. The owner may have taken it offline.',
  'Failed to open drive': 'Could not connect to this site. It may be offline.',
  'Failed to open app drive': 'Could not load this app. It may be corrupted or unavailable.',
  'Failed to open catalog drive': 'Could not load the app store. The catalog may be unavailable.',
  'Hybrid fetch failed': 'Unable to load content. Check your connection and try again.',
  'No catalog.json found': 'This app store is empty or not properly configured.',
  'Invalid origin': 'Security error: Access denied',
  'Buffer exceeded': 'The response was too large to process',
  'Operation too large': 'This action is too large to complete',
}

function getUserFriendlyError(technicalError) {
  for (const [key, message] of Object.entries(USER_FRIENDLY_ERRORS)) {
    if (technicalError.includes(key)) {
      return message
    }
  }
  return 'Something went wrong. Please try again.'
}

function isLoopbackOrigin (origin) {
  if (typeof origin !== 'string') return false
  try {
    const parsed = new URL(origin)
    return parsed.protocol === 'http:' &&
      (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost')
  } catch {
    return false
  }
}

function normalizeOrigin (origin) {
  if (typeof origin !== 'string' || origin.length === 0) return null
  const parsed = new URL(origin)
  // Bare's URL implementation exposes protocol + host but not `origin`.
  // Building it explicitly keeps injected <base> URLs and token bindings
  // valid in the packaged runtime as well as in Node-based tests.
  return `${parsed.protocol}//${parsed.host}`
}

function originForPort (port) {
  return `http://127.0.0.1:${port}`
}

function normalizeDriveKeyHex (keyHex) {
  if (!isValidDriveKey(keyHex)) {
    throw new Error('Invalid drive key format')
  }
  return keyHex.toLowerCase()
}

function normalizeUrlSuffix (path) {
  const value = typeof path === 'string' && path.length > 0 ? path : '/'
  return value.startsWith('/') ? value : '/' + value
}

const CONTENT_TYPES = {
  html: 'text/html; charset=utf-8',
  htm: 'text/html; charset=utf-8',
  css: 'text/css; charset=utf-8',
  js: 'application/javascript; charset=utf-8',
  mjs: 'application/javascript; charset=utf-8',
  json: 'application/json; charset=utf-8',
  png: 'image/png',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  gif: 'image/gif',
  svg: 'image/svg+xml',
  ico: 'image/x-icon',
  webp: 'image/webp',
  woff: 'font/woff',
  woff2: 'font/woff2',
  ttf: 'font/ttf',
  mp4: 'video/mp4',
  webm: 'video/webm',
  mp3: 'audio/mpeg',
  pdf: 'application/pdf',
  txt: 'text/plain; charset=utf-8',
  md: 'text/markdown; charset=utf-8'
}

function guessType (path) {
  // Extract extension safely
  const lastDot = path.lastIndexOf('.')
  const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
  if (lastDot <= lastSlash) return 'application/octet-stream'
  const ext = path.slice(lastDot + 1).toLowerCase()
  return CONTENT_TYPES[ext] || 'application/octet-stream'
}

// Validate drive key format (64 hex characters)
function isValidDriveKey (keyHex) {
  return typeof keyHex === 'string' && /^[0-9a-f]{64}$/i.test(keyHex)
}

// Escape HTML entities to prevent XSS
/**
 * Compute the base64 SHA-256 hash of the body of an inline-script
 * shim string of the form `<script>BODY</script>` (the exact form
 * PEAR_SWARM_V1_SHIM and PEAR_ANONGPT_SHIM use). The browser computes
 * the CSP `'sha256-…'` hash over the literal text between the opening
 * `>` of `<script>` and the closing `<` of `</script>`, so we strip
 * the tags before hashing. Returns '' if the input doesn't look like
 * an inline-script block (e.g. empty shim or someone passed a
 * `<script src=…>` tag).
 */
function sha256ScriptBody (shimHtml) {
  if (!shimHtml || typeof shimHtml !== 'string') return ''
  const m = shimHtml.match(/^\s*<script\b[^>]*>([\s\S]*?)<\/script>\s*$/i)
  if (!m) return ''
  return crypto.createHash('sha256').update(m[1], 'utf8').digest('base64')
}

const HYPER_LINK_BRIDGE_SHIM = `<script>
(() => {
  if (window.__pearBrowserHyperLinkBridge) return
  window.__pearBrowserHyperLinkBridge = true
  const handleHyperLink = (event) => {
    const anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null
    if (!anchor) return
    const href = String(anchor.getAttribute('href') || anchor.href || '').trim()
    if (!/^hyper:\\/\\//i.test(href)) return
    event.preventDefault()
    event.stopPropagation()
    window.parent.postMessage({
      type: 'pearbrowser:navigate',
      url: href,
      openInNewTab: Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1 || anchor.target === '_blank')
    }, '*')
  }
  document.addEventListener('click', handleHyperLink, true)
  document.addEventListener('auxclick', handleHyperLink, true)
})()
</script>`
const HYPER_LINK_BRIDGE_SHIM_HASH = sha256ScriptBody(HYPER_LINK_BRIDGE_SHIM)

/**
 * Modify a page's Content-Security-Policy meta tag to authorize the
 * inline shim scripts we inject. We add `'sha256-…'` tokens to the
 * `script-src` directive — narrowly authorizing the exact bytes we
 * insert, without weakening the page's protection against XSS (no
 * `'unsafe-inline'`).
 *
 * Three cases:
 *   1. CSP has explicit `script-src` → append hashes to it.
 *   2. CSP has `default-src` but no `script-src` → add a fresh
 *      `script-src 'self' '<hashes>'` so hashes apply (CSP3 doesn't
 *      let `'sha256-…'` tokens inherit from `default-src`).
 *   3. CSP has neither → append `script-src 'self' '<hashes>'`.
 *   4. No CSP meta tag in the document → no-op (page never set one).
 *
 * Idempotent: re-running with the same hash string yields the same
 * policy.
 */
function injectCspShimHashes (html, hashesB64) {
  if (!hashesB64 || hashesB64.length === 0) return html
  const hashTokens = hashesB64.map((h) => `'sha256-${h}'`).join(' ')
  // Match the meta CSP tag in either attribute order; allow single or
  // double quotes around the content attribute value.
  const re = /<meta\s+[^>]*?http-equiv\s*=\s*["']Content-Security-Policy["'][^>]*?content\s*=\s*(["'])([\s\S]*?)\1[^>]*>/i
  return html.replace(re, (full, q, policy) => {
    let newPolicy = policy
    if (/script-src\b/i.test(newPolicy)) {
      newPolicy = newPolicy.replace(/script-src([^;]*)/i, (m, rest) => `script-src${rest} ${hashTokens}`)
    } else if (/default-src\b/i.test(newPolicy)) {
      newPolicy = newPolicy.replace(/(default-src[^;]*)(;|$)/i, (m, ds, end) => `${ds}; script-src 'self' ${hashTokens}${end}`)
    } else {
      newPolicy = newPolicy + (newPolicy.endsWith(';') ? ' ' : '; ') + `script-src 'self' ${hashTokens}`
    }
    return full.replace(`${q}${policy}${q}`, `${q}${newPolicy}${q}`)
  })
}

function escapeHtml (str) {
  if (typeof str !== 'string') return ''
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/`/g, '&#96;')
}

class HyperProxy {
  constructor (getDrive, onError, relayClient, opts = {}) {
    this._getDrive = getDrive // async (keyHex) => Hyperdrive
    this._onError = onError || (() => {})
    this._relay = relayClient || null // RelayClient for fast-path
    this._httpBridge = null // HttpBridge for direct WebView API
    this._server = null
    this._port = 0
    this._perDriveOrigins = !!opts.perDriveOrigins
    this._driveOrigins = new Map() // driveKeyHex -> { server, port, ready, lastUsedAt }
    this._stats = { relayHits: 0, p2pHits: 0, total: 0 }
    // P2P-first relay race (privacy): wait this long for P2P before contacting
    // the relay. _relayFirst=true is the kill-switch back to the parallel race.
    this._relayGraceMs = 500
    this._relayFirst = false
    this._inFlight = new Map() // key -> Promise

    // LRU content cache
    this._cache = new Map() // Simple LRU implementation
    this._cacheMaxSize = 50 * 1024 * 1024 // 50MB
    this._cacheCurrentSize = 0
    this._cacheStats = { hits: 0, misses: 0 }
    this._apiTokens = new Map() // token -> { driveKeyHex, origin, issuedAt }
    this._apiTokenTtlMs = 10 * 60 * 1000 // 10 minutes
    // Separate least-privilege secret for the browser chrome's explicit
    // current-page capture. It is never accepted by /api/* and is stable only
    // for the lifetime of this drive's browser origin.
    this._pageContextTokens = new Map()
    /**
     * String injected into the <head> of every served text/html response,
     * exposing window.pear.swarm.v1 to pages. Set by setPearSwarmShim();
     * empty by default so older PearBrowsers gracefully omit it.
     */
    this._pearSwarmShim = ''
    /**
     * String injected into the <head> of every served text/html response,
     * exposing window.pear.sync + window.pear.identity to pages. Set by
     * setPearSyncShim(); empty by default. Without it, multi-writer apps
     * (peerit, p2pbuilders, …) fall back to single-user localStorage mode.
     */
    this._pearSyncShim = ''
    this._pearSyncShimHash = ''
    /**
     * Page-side anonGPT shim, plus the gate key it's allowed to be
     * injected for. The shim is only emitted into HTML responses when
     * the loaded drive's hex key equals this._anongptDriveKey AND the
     * drive's manifest.json declares the required privacy claims.
     * See setAnongptShim() / setAnongptDriveKey() and the manifest
     * cache below. Everything else gets only the swarm.v1 shim.
     */
    this._anongptShim = ''
    this._anongptDriveKey = ''
    // Cache of validated manifest results per drive key:
    //   Map<driveKeyHex, { ok: boolean, reason?: string, checkedAt: number }>
    // TTL is small because a publisher pushing a new release should
    // see the gate re-check; we cache for 60s to avoid hammering the
    // drive on every HTML response.
    this._anongptManifestCache = new Map()
    this._anongptManifestTtlMs = 60 * 1000
    /**
     * Content Shield (docs/BROWSER_PARITY_PLAN.md Phases 1–3). When set, every
     * /hyper/* and /app/* request is checked before any P2P/relay fetch and
     * HTML responses receive cosmetic CSS, optional strict-mode CSP, scriptlets,
     * and plugin styles/scripts. Null keeps the proxy byte-identical to the
     * pre-shield behavior.
     */
    this._contentShield = null
    /**
     * Privacy ladder settings for clearnet (/clearnet/*) handling.
     * Updated live from user-data via setPrivacySettings().
     */
    this._privacySettings = null
    this._clearnetHandler = null
  }

  setHttpBridge (bridge) {
    this._httpBridge = bridge
  }

  setContentShield (shield) {
    this._contentShield = shield || null
  }

  setPrivacySettings (settings) {
    this._privacySettings = settings || null
  }

  /**
   * Optional injection of clearnet request handler (from clearnet-proxy.cjs).
   * Defaults to requiring the module on first /clearnet/ hit.
   */
  setClearnetHandler (handler) {
    this._clearnetHandler = typeof handler === 'function' ? handler : null
  }

  pageContextToken (driveKeyHex) {
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    let token = this._pageContextTokens.get(keyHex)
    if (!token) {
      token = crypto.randomBytes(32).toString('hex')
      this._pageContextTokens.set(keyHex, token)
    }
    return token
  }

  /**
   * Provide the page-side window.pear.swarm.v1 shim (a string of HTML/JS
   * that gets concatenated into every served HTML response's <head>).
   * Called once at boot from index.js with PEAR_SWARM_V1_SHIM. Setting
   * this to the empty string disables the surface; pages will no longer
   * see window.pear.swarm.
   */
  setPearSwarmShim (shimHtml) {
    this._pearSwarmShim = String(shimHtml || '')
    this._pearSwarmShimHash = this._pearSwarmShim ? sha256ScriptBody(this._pearSwarmShim) : ''
  }

  /**
   * Provide the page-side window.pear.sync + window.pear.identity shim.
   * Called once at boot from index.js with PEAR_SYNC_SHIM. Empty string
   * disables the surface (pages see no data bridge → dev-mode fallback).
   */
  setPearSyncShim (shimHtml) {
    this._pearSyncShim = String(shimHtml || '')
    this._pearSyncShimHash = this._pearSyncShim ? sha256ScriptBody(this._pearSyncShim) : ''
  }

  /**
   * Set the page-side window.pear.anongpt shim string. Empty string
   * disables the surface entirely (any drive — including the anonGPT
   * drive — gets no anongpt API).
   */
  setAnongptShim (shimHtml) {
    this._anongptShim = String(shimHtml || '')
    this._anongptShimHash = this._anongptShim ? sha256ScriptBody(this._anongptShim) : ''
  }

  /**
   * Set the single Hyperdrive hex key allowed to receive the anonGPT
   * shim. See backend/constants.js ANONGPT_DRIVE_KEY. Any other drive
   * will not see window.pear.anongpt regardless of what its
   * manifest.json claims.
   */
  setAnongptDriveKey (driveKeyHex) {
    this._anongptDriveKey = typeof driveKeyHex === 'string' ? driveKeyHex.toLowerCase() : ''
  }

  /**
   * Returns true if the loaded drive is allowed to host the anongpt
   * shim AND its manifest.json declares the four required privacy
   * claims per anongpt/docs/spec/02-pearbrowser-dev-bridge.md §4. False
   * otherwise — including when the gate key isn't set, the drive
   * doesn't match, the manifest is missing, malformed, or doesn't
   * declare the claims. Cached per-drive for _anongptManifestTtlMs.
   *
   * The validation is intentionally strict: a missing field is treated
   * as a failed check, not as "default to allow". Privacy contract is
   * fail-closed by default.
   */
  async _shouldInjectAnongptShim (driveKeyHex) {
    if (!this._anongptShim) return false
    if (!this._anongptDriveKey) return false
    if (typeof driveKeyHex !== 'string') return false
    if (driveKeyHex.toLowerCase() !== this._anongptDriveKey) return false

    const cached = this._anongptManifestCache.get(driveKeyHex.toLowerCase())
    if (cached && (Date.now() - cached.checkedAt) < this._anongptManifestTtlMs) {
      return cached.ok
    }

    const result = await this._validateAnongptManifest(driveKeyHex)
    this._anongptManifestCache.set(driveKeyHex.toLowerCase(), {
      ok: result.ok,
      reason: result.ok ? null : result.reason,
      checkedAt: Date.now()
    })
    if (!result.ok) {
      console.warn('[anongpt-gate] manifest gate FAILED for', driveKeyHex.slice(0, 12) + '…', '—', result.reason)
    }
    return result.ok
  }

  /**
   * Read manifest.json from the drive and validate the four claims
   * required by the privacy contract. The "pear.anongpt.infer claim"
   * can be declared in either of two ways, both seen in practice:
   *   (a) manifest.permissions includes 'pear.anongpt.infer'  ← anonGPT
   *       publisher convention (treats the API as a permission scope)
   *   (b) manifest.pear.anongpt.infer is truthy                ← nested
   *       object form mentioned in the dev-bridge spec
   * Either form is accepted; both express the same intent. The four
   * privacy claims are checked exactly as the spec requires (no
   * heuristics — a missing field is a failed check, not a default).
   *
   *   - permission declared (one of the two forms above)
   *   - privacy.storesPrompts === false
   *   - privacy.remoteHttpInference === 'forbidden'
   *   - privacy.requiresLocalRuntime === true
   *
   * Returns { ok: true } on success, { ok: false, reason } otherwise.
   */
  async _validateAnongptManifest (driveKeyHex) {
    let manifest
    try {
      const fetched = await this._fetchP2P(driveKeyHex, '/manifest.json')
      if (!fetched || !fetched.content) {
        return { ok: false, reason: 'manifest.json not reachable' }
      }
      manifest = JSON.parse(fetched.content.toString('utf-8'))
    } catch (err) {
      return { ok: false, reason: 'manifest.json parse error: ' + (err && err.message) }
    }
    if (!manifest || typeof manifest !== 'object') {
      return { ok: false, reason: 'manifest.json not an object' }
    }
    const declaredViaPermissions = Array.isArray(manifest.permissions) &&
      manifest.permissions.includes('pear.anongpt.infer')
    const declaredViaNested = manifest.pear && manifest.pear.anongpt && manifest.pear.anongpt.infer
    if (!declaredViaPermissions && !declaredViaNested) {
      return {
        ok: false,
        reason: 'manifest.json does not declare pear.anongpt.infer (expected either ' +
          '`permissions: ["pear.anongpt.infer", ...]` or `pear.anongpt.infer: true`)'
      }
    }
    const privacy = manifest.privacy
    if (!privacy || typeof privacy !== 'object') {
      return { ok: false, reason: 'manifest.json missing `privacy` block' }
    }
    if (privacy.storesPrompts !== false) {
      return { ok: false, reason: 'privacy.storesPrompts must be false (got ' + JSON.stringify(privacy.storesPrompts) + ')' }
    }
    if (privacy.remoteHttpInference !== 'forbidden') {
      return { ok: false, reason: 'privacy.remoteHttpInference must be "forbidden" (got ' + JSON.stringify(privacy.remoteHttpInference) + ')' }
    }
    if (privacy.requiresLocalRuntime !== true) {
      return { ok: false, reason: 'privacy.requiresLocalRuntime must be true (got ' + JSON.stringify(privacy.requiresLocalRuntime) + ')' }
    }
    return { ok: true }
  }

  /**
   * Inject `<base>` + per-page `pear-api-token` meta + swarm.v1 shim
   * (always) + anongpt shim (gated) into an HTML response body. Used by
   * both the cache HIT and cache MISS paths so a reloaded page still
   * gets a fresh token. The token is per-request, never cached.
   *
   * @param {Buffer|string} content   raw upstream HTML
   * @param {string}        driveKeyHex
   * @param {string}        reqPath   request URL path (used to choose
   *                                  `/app/` vs `/hyper/` for <base>)
   * @returns {Buffer}                response body with the head block injected
   */
  async _injectHtmlHead (content, driveKeyHex, reqPath, documentOrigin = null) {
    const html = (Buffer.isBuffer(content) ? content : Buffer.from(content)).toString('utf-8')
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    const prefix = reqPath.startsWith('/app/') ? '/app/' : '/hyper/'
    // Host MUST match the document origin the page is navigated to (CMD_NAVIGATE
    // loads it from http://127.0.0.1:<port>). `localhost` and `127.0.0.1` are
    // DIFFERENT origins to the browser, so a `localhost` <base> makes every
    // relative resource (styles.css, js/app.js) resolve cross-origin to the
    // 127.0.0.1 document — and any page with a `script-src 'self'` / `style-src
    // 'self'` CSP then refuses its OWN files: index.html renders but nothing
    // else loads (the "splash but never boots" bug). Keep this in lockstep with
    // the host used in index.js CMD_NAVIGATE.
    const origin = normalizeOrigin(documentOrigin || originForPort(this._port))
    const baseHref = `${origin}${prefix}${keyHex}/`
    const apiToken = this.issueApiToken(keyHex, { origin })
    const contextToken = this.pageContextToken(keyHex)
    const includeAnongpt = await this._shouldInjectAnongptShim(keyHex)
    if (includeAnongpt) {
      console.log('[anongpt-gate] injecting shim for', keyHex.slice(0, 12) + '…')
    }
    // Cosmetic element hiding, scriptlets, plugin styles/scripts, and optional
    // strict third-party CSP ride the same injection path as the browser shims.
    // Style blocks need no CSP script hash; scriptlets/plugin scripts are
    // hash-authorized exactly like the swarm/sync/anongpt shims.
    const shieldOpts = { documentKey: keyHex }
    const shieldCss = this._contentShield
      ? this._contentShield.cosmeticCssFor(keyHex, shieldOpts)
      : ''
    const pluginCss = this._contentShield && typeof this._contentShield.pluginStylesFor === 'function'
      ? this._contentShield.pluginStylesFor(keyHex, shieldOpts)
      : ''
    const scriptlets = this._contentShield && typeof this._contentShield.scriptletsFor === 'function'
      ? this._contentShield.scriptletsFor(keyHex, shieldOpts)
      : []
    const pluginScripts = this._contentShield && typeof this._contentShield.pluginScriptsFor === 'function'
      ? this._contentShield.pluginScriptsFor(keyHex, shieldOpts)
      : []

    const scriptletTags = []
    const scriptletHashes = []
    for (const entry of scriptlets) {
      const tag = `<script data-pear-scriptlet="${escapeHtml(entry.name || 'scriptlet')}">${entry.body}</script>`
      const hash = sha256ScriptBody(tag)
      if (hash) {
        scriptletTags.push(tag)
        scriptletHashes.push(hash)
      }
    }
    const pluginScriptTags = []
    const pluginScriptHashes = []
    for (const entry of pluginScripts) {
      const tag = `<script data-pear-plugin="${escapeHtml(entry.pluginId || 'plugin')}">${entry.body}</script>`
      const hash = sha256ScriptBody(tag)
      if (hash) {
        pluginScriptTags.push(tag)
        pluginScriptHashes.push(hash)
      }
    }

    const strictMode = this._contentShield &&
      typeof this._contentShield.isStrict === 'function' &&
      this._contentShield.isStrict(keyHex)
    // Collect every script hash we will inject so strict CSP (and page CSP
    // rewriting) authorizes them. Order matches headInjection below.
    const hashesToAuthorize = []
    if (HYPER_LINK_BRIDGE_SHIM_HASH) hashesToAuthorize.push(HYPER_LINK_BRIDGE_SHIM_HASH)
    if (PAGE_CONTEXT_SHIM_HASH) hashesToAuthorize.push(PAGE_CONTEXT_SHIM_HASH)
    if (this._pearSwarmShim && this._pearSwarmShimHash) hashesToAuthorize.push(this._pearSwarmShimHash)
    if (this._pearSyncShim && this._pearSyncShimHash) hashesToAuthorize.push(this._pearSyncShimHash)
    if (includeAnongpt && this._anongptShimHash) hashesToAuthorize.push(this._anongptShimHash)
    for (const h of scriptletHashes) hashesToAuthorize.push(h)
    for (const h of pluginScriptHashes) hashesToAuthorize.push(h)

    const strictMeta = strictMode
      ? `<meta http-equiv="Content-Security-Policy" content="${this._contentShield.strictCspContent(hashesToAuthorize)}" data-pear-shield-strict="1">`
      : ''

    const headInjection =
      `<base href="${baseHref}">` +
      `<meta name="pear-api-token" content="${apiToken}">` +
      pageContextMeta(contextToken) +
      strictMeta +
      HYPER_LINK_BRIDGE_SHIM +
      PAGE_CONTEXT_SHIM +
      (this._pearSwarmShim || '') +
      (this._pearSyncShim || '') +
      (includeAnongpt ? this._anongptShim : '') +
      scriptletTags.join('') +
      pluginScriptTags.join('') +
      (shieldCss ? `<style data-pear-shield>${escapeStyleText(shieldCss)}</style>` : '') +
      (pluginCss ? `<style data-pear-plugin-style>${escapeStyleText(pluginCss)}</style>` : '')
    let injected = html.includes('<head>')
      ? html.replace('<head>', `<head>${headInjection}`)
      : html.replace(/<html>/i, `<html><head>${headInjection}</head>`)

    // Page may carry a strict CSP that forbids inline scripts (anonGPT
    // ships `script-src 'self'` with no 'unsafe-inline' — its own
    // scripts are hash-whitelisted in its page). Without help our
    // shims would be in the HTML but never execute, and the page's
    // feature detection for window.pear.* would report "private
    // runtime unavailable" — exactly the failure mode the spec demands
    // for missing runtimes. To make our injection visible to the page
    // we add the shim hashes to the page's CSP `script-src` directive.
    // We do NOT add 'unsafe-inline' (that would weaken the page's
    // protection against XSS); we add only the exact hashes of the
    // exact scripts we (the authorized runtime) inject.
    if (hashesToAuthorize.length > 0) {
      injected = injectCspShimHashes(injected, hashesToAuthorize)
    }

    return Buffer.from(injected)
  }

  get port () { return this._port }

  get perDriveOrigins () { return this._perDriveOrigins }

  async localOriginForDrive (driveKeyHex) {
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    if (!this._perDriveOrigins) return originForPort(this._port)
    try {
      const entry = await this._ensureDriveOrigin(keyHex)
      return originForPort(entry.port)
    } catch (err) {
      console.warn('[origin-isolation] per-drive origin failed for', keyHex.slice(0, 12) + '…', '-', err && err.message)
      return originForPort(this._port)
    }
  }

  async localUrlForDrive (driveKeyHex, mode, path = '/') {
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    const prefix = mode === 'app' ? '/app/' : '/hyper/'
    const origin = await this.localOriginForDrive(keyHex)
    return `${origin}${prefix}${keyHex}${normalizeUrlSuffix(path)}`
  }

  async _ensureDriveOrigin (driveKeyHex) {
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    let entry = this._driveOrigins.get(keyHex)
    if (entry) {
      entry.lastUsedAt = Date.now()
      return entry.ready
    }

    entry = {
      server: null,
      port: 0,
      ready: null,
      lastUsedAt: Date.now()
    }
    entry.server = http.createServer((req, res) => this._handle(req, res, {
      boundDriveKeyHex: keyHex,
      port: entry.port
    }))
    entry.ready = new Promise((resolve, reject) => {
      const onError = (err) => {
        this._driveOrigins.delete(keyHex)
        reject(err)
      }
      entry.server.once('error', onError)
      entry.server.listen(0, '127.0.0.1', () => {
        entry.server.removeListener('error', onError)
        entry.port = entry.server.address().port
        resolve(entry)
      })
    })
    this._driveOrigins.set(keyHex, entry)
    return entry.ready
  }

  async releaseDriveOrigin (driveKeyHex) {
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    this._pageContextTokens.delete(keyHex)
    const entry = this._driveOrigins.get(keyHex)
    if (!entry) return false
    this._driveOrigins.delete(keyHex)
    try { await entry.ready } catch {}
    if (!entry.server) return true
    await new Promise(resolve => entry.server.close(() => resolve()))
    return true
  }

  async start () {
    this._server = http.createServer((req, res) => this._handle(req, res))

    return new Promise((resolve, reject) => {
      this._server.on('error', reject)
      this._server.listen(0, '127.0.0.1', () => {
        this._port = this._server.address().port
        resolve(this._port)
      })
    })
  }

  async stop () {
    const closers = []
    if (this._server) {
      const server = this._server
      this._server = null
      closers.push(new Promise(resolve => server.close(() => resolve())))
    }
    for (const entry of this._driveOrigins.values()) {
      if (!entry.server) continue
      closers.push(new Promise(resolve => entry.server.close(() => resolve())))
    }
    this._driveOrigins.clear()
    this._pageContextTokens.clear()
    await Promise.all(closers)
  }

  async _handle (req, res, context = {}) {
    const serverPort = context.port || this._port
    const documentOrigin = originForPort(serverPort)
    // Validate origin - only allow strict loopback origins
    const origin = req.headers.origin
    if (origin && !isLoopbackOrigin(origin)) {
      res.statusCode = 403
      res.setHeader('Content-Type', 'text/plain')
      return res.end('Invalid origin: only localhost is allowed')
    }

    // Set CORS headers for valid origins
    res.setHeader('Access-Control-Allow-Origin', origin || documentOrigin)
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Pear-Token')

    // CORS preflight handler
    if (req.method === 'OPTIONS') {
      if (origin && !isLoopbackOrigin(origin)) {
        res.statusCode = 403
        return res.end('Invalid origin')
      }
      res.setHeader('Access-Control-Allow-Origin', origin || documentOrigin)
      res.statusCode = 204
      return res.end()
    }

    const url = new URL(req.url, documentOrigin)
    const path = url.pathname

    // HTTP Bridge — direct API for WebView apps (bypasses RN relay)
    if (this._httpBridge && path.startsWith('/api/')) {
      const handled = await this._httpBridge.handle(req, res, url)
      if (handled) return
    }

    // Health check
    if (path === '/health') {
      res.statusCode = 200
      res.setHeader('Content-Type', 'application/json')
      return res.end(JSON.stringify({ ok: true }))
    }

    // Clearnet proxy (Phase 4) — browser-owned https/http fetch with shield
    if (path.startsWith('/clearnet/')) {
      const handle = this._clearnetHandler || require('./clearnet-proxy.cjs').handleClearnetRequest
      return handle(req, res, url, {
        contentShield: this._contentShield,
        privacy: this._privacySettings,
        proxyOrigin: documentOrigin,
        port: serverPort
      })
    }

    try {
      let driveKeyHex, filePath

      if (path.startsWith('/hyper/')) {
        // Direct hyper:// browsing: /hyper/KEY/path
        const rest = path.slice('/hyper/'.length)
        const slash = rest.indexOf('/')
        driveKeyHex = slash === -1 ? rest : rest.slice(0, slash)
        filePath = slash === -1 ? '/' : rest.slice(slash)
      } else if (path.startsWith('/app/')) {
        // Installed app: /app/DRIVE_KEY/path
        const rest = path.slice('/app/'.length)
        const slash = rest.indexOf('/')
        driveKeyHex = slash === -1 ? rest : rest.slice(0, slash)
        filePath = slash === -1 ? '/' : rest.slice(slash)
      } else {
        res.statusCode = 404
        return res.end('Not found')
      }

      // SECURITY: Validate drive key format to prevent path traversal
      if (!isValidDriveKey(driveKeyHex)) {
        res.statusCode = 400
        return res.end('Invalid drive key format')
      }
      driveKeyHex = driveKeyHex.toLowerCase()

      if (context.boundDriveKeyHex && driveKeyHex !== context.boundDriveKeyHex) {
        res.statusCode = 403
        return res.end('Forbidden for this origin')
      }

      // SECURITY: Validate file path to prevent directory traversal
      if (filePath.includes('..') || filePath.includes('\x00')) {
        res.statusCode = 400
        return res.end('Invalid file path')
      }

      // Content Shield: decide before any cache/P2P/relay work so a blocked
      // subresource never costs swarm bandwidth or leaks to a relay.
      // Per-drive allowlist (documentKey) exempts the whole drive.
      if (this._contentShield) {
        const verdict = this._contentShield.shouldBlockUrl(
          `hyper://${driveKeyHex}${filePath}`,
          { documentKey: driveKeyHex }
        )
        if (verdict.blocked) {
          res.statusCode = 403
          res.setHeader('Content-Type', 'text/plain')
          res.setHeader('X-Pear-Shield', 'blocked')
          return res.end('Blocked by PearBrowser Shield')
        }
        if (verdict.allowlisted) {
          res.setHeader('X-Pear-Shield', 'allowlisted')
        }
      }

      this._stats.total++

      // Check if this is a directory request
      if (filePath.endsWith('/') || filePath === '') {
        const drive = await this._getDrive(driveKeyHex)
        if (drive) {
          // Brief wait for the drive's manifest to sync from the swarm
          // before checking whether index.html exists. Without this,
          // freshly-opened drives (just joined the swarm seconds ago)
          // returned null from drive.entry() before any blocks had
          // arrived — and we fell back to the empty directory listing.
          // 8s is plenty for a hot relay; first-time peer discovery
          // sometimes needs longer, but the directory-listing fallback
          // still kicks in if it does.
          try {
            await Promise.race([
              drive.update({ wait: true }),
              new Promise((_, reject) => setTimeout(() => reject(new Error('update-timeout')), 8000))
            ])
          } catch {
            // Timeout is acceptable — drive.entry will still try below.
          }
          const indexExists = await drive.entry(filePath + 'index.html').catch(() => null)
          if (!indexExists) {
            // No index, show directory listing
            return this._serveDirectoryListing(res, drive, driveKeyHex, filePath)
          }
          // Has index, serve it (filePath stays as directory path)
        }
      }

      // Check cache first
      const cacheKey = this._getCacheKey(driveKeyHex, filePath)
      const cached = this._getFromCache(cacheKey)
      if (cached) {
        res.setHeader('Content-Type', cached.contentType)
        res.setHeader('X-Cache', 'HIT')
        // HTML responses must STILL get the per-page api-token + shim
        // injection even when served from cache. The cache stores raw
        // upstream content; the token is per-request and must not be
        // cached (each page load gets a fresh token). Caching the post-
        // injection HTML would leak tokens across requests, so we
        // re-inject on every HIT instead.
        if (cached.contentType.includes('text/html')) {
          const injected = await this._injectHtmlHead(cached.content, driveKeyHex, path, documentOrigin)
          res.statusCode = 200
          return res.end(injected)
        }
        res.statusCode = 200
        return res.end(cached.content)
      }
      this._cacheStats.misses++

      // HYBRID FETCH: race relay (fast) vs P2P (reliable)
      const result = await this._hybridFetch(driveKeyHex, filePath)

      if (!result) {
        res.statusCode = 404
        return res.end('File not found')
      }

      // Cache successful result
      this._setCache(cacheKey, result.content, result.contentType)

      const contentType = result.contentType
      const content = result.content
      res.setHeader('X-Cache', 'MISS')

      res.setHeader('Content-Type', contentType)
      res.setHeader('X-Source', result.source)

      // Inject <base> tag + per-page api-token meta + window.pear.swarm.v1
      // shim for HTML responses. Pages get the shim "for free" — no
      // <script src> required from the author. Token is also exposed in a
      // meta tag so the shim can read it without holding it in JS at
      // construction time. The anonGPT shim is conditionally added when
      // the gate passes. Logic lives in _injectHtmlHead() so cache HIT
      // and cache MISS use the identical injection path.
      if (contentType.includes('text/html')) {
        const injected = await this._injectHtmlHead(content, driveKeyHex, path, documentOrigin)
        res.statusCode = 200
        return res.end(injected)
      }

      // Range request support for streaming (video, audio, large files)
      res.setHeader('Accept-Ranges', 'bytes')
      const rangeHeader = req.headers.range

      if (rangeHeader) {
        const total = content.length
        const match = rangeHeader.match(/bytes=(\d*)-(\d*)/)
        if (match && (match[1] || match[2])) {
          // Clamp the requested range to the actual content bounds. Without
          // this, an open-ended or oversized range (e.g. `bytes=0-999999`
          // against a smaller file) sets a Content-Length larger than the
          // bytes we actually send, and browsers stall the media stream.
          let start = match[1] ? parseInt(match[1], 10) : 0
          let end = match[2] ? parseInt(match[2], 10) : total - 1
          if (Number.isNaN(start)) start = 0
          if (Number.isNaN(end) || end > total - 1) end = total - 1

          // Unsatisfiable range → 416 per RFC 7233.
          if (start > end || start >= total) {
            res.statusCode = 416
            res.setHeader('Content-Range', `bytes */${total}`)
            return res.end()
          }

          const chunkSize = end - start + 1
          res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`)
          res.setHeader('Content-Length', chunkSize)
          res.statusCode = 206
          return res.end(content.slice(start, end + 1))
        }
      }

      res.setHeader('Content-Length', content.length)
      res.statusCode = 200
      res.end(content)
    } catch (err) {
      // Log detailed error internally
      this._onError(path, err.message)
      // Return user-friendly error to client
      const userMessage = getUserFriendlyError(err.message)
      res.statusCode = 502
      res.setHeader('Content-Type', 'text/html')
      res.end(`<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Cannot Load Page</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0a0a0a;
      color: #e0e0e0;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 40px;
    }
    .container { text-align: center; max-width: 400px; }
    .icon { font-size: 48px; margin-bottom: 16px; }
    h1 { color: #ff9500; font-size: 20px; margin-bottom: 12px; }
    p { color: #999; line-height: 1.6; margin-bottom: 24px; }
    .error-code { 
      display: inline-block;
      background: #1a1a1a;
      padding: 8px 16px;
      border-radius: 6px;
      font-family: monospace;
      font-size: 12px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">🔌</div>
    <h1>Cannot Load Page</h1>
    <p>${userMessage}</p>
    <div class="error-code">${err.code || '502'}</div>
  </div>
</body>
</html>`)
    }
  }

  /**
   * Hybrid fetch — race relay HTTP (fast) vs P2P Hyperdrive (reliable).
   * Deduplicates concurrent requests for the same file.
   * Returns { content, contentType, source } or null.
   */
  async _hybridFetch (keyHex, filePath) {
    const cacheKey = `${keyHex}:${filePath}`

    // Return existing promise if already fetching
    if (this._inFlight.has(cacheKey)) {
      return this._inFlight.get(cacheKey)
    }

    // Create the fetch promise
    const promise = this._doHybridFetch(keyHex, filePath)
    this._inFlight.set(cacheKey, promise)

    // Clean up when done
    promise.finally(() => {
      this._inFlight.delete(cacheKey)
    })

    return promise
  }

  /**
   * Internal hybrid fetch implementation — race relay HTTP (fast) vs P2P Hyperdrive (reliable).
   * Returns { content, contentType, source } or null.
   */
  async _doHybridFetch (keyHex, filePath) {
    // Resolve directory paths
    let resolvedPath = filePath
    if (filePath.endsWith('/') || filePath === '') {
      resolvedPath = (filePath || '/') + 'index.html'
    }

    // P2P-FIRST (privacy): try P2P, only contact the relay if P2P misses or is
    // slower than _relayGraceMs, so the relay never sees a fetch P2P could have
    // served. _relayFirst restores the old parallel race.
    const { p2pFirstFetch } = require('./p2p-first-fetch.js')
    const { result } = await p2pFirstFetch({
      fetchP2P: () => this._fetchP2P(keyHex, resolvedPath),
      fetchRelay: this._relay ? () => this._relay.fetch(keyHex, resolvedPath) : null,
      graceMs: this._relayGraceMs,
      relayFirst: this._relayFirst,
    })

    if (result) {
      if (result.source === 'relay') this._stats.relayHits++
      else this._stats.p2pHits++
    } else {
      this._onError(keyHex + resolvedPath, 'Hybrid fetch failed: all sources unavailable')
    }

    return result
  }

  /**
   * Fetch from P2P (Hyperdrive)
   * Uses { wait: true } for non-blocking wait — Hypercore handles
   * the waiting internally instead of us polling every 300ms.
   * Inspired by Vinjari's fetch.js approach.
   */
  async _fetchP2P (keyHex, filePath) {
    const drive = await this._getDrive(keyHex)
    if (!drive) return null

    // Use Hyperdrive's built-in wait: true to wait for the specific
    // block we need, with a 15s timeout. No polling.
    let content = await Promise.race([
      drive.get(filePath, { wait: true }),
      new Promise(resolve => setTimeout(() => resolve(null), 15000))
    ])

    // Stale-session self-heal: if the fetch missed, the author may have
    // RE-PUBLISHED the drive (advanced it to a new version) since this
    // session was opened. A cached session stays pinned to the version it
    // first saw, so a newer file (e.g. js/app.js after a re-publish) isn't in
    // the file index this session reads — index.html still loads from the old
    // version but its scripts 404, and the page hangs. Re-advance once and, ONLY
    // if the version actually moved, drop this drive's stale per-file cache and
    // retry — so a re-publish self-heals without making a genuinely-missing file
    // pay a second 15s wait.
    if (!content && typeof drive.update === 'function') {
      const before = drive.version
      try {
        await Promise.race([
          drive.update({ wait: true }),
          new Promise((_, reject) => setTimeout(() => reject(new Error('update-timeout')), 8000))
        ])
      } catch {}
      if (drive.version !== before) {
        this.invalidateCache(keyHex)
        content = await Promise.race([
          drive.get(filePath, { wait: true }),
          new Promise(resolve => setTimeout(() => resolve(null), 15000))
        ])
      }
    }

    if (!content) return null

    return { content, contentType: guessType(filePath) }
  }

  _getCacheKey (driveKeyHex, filePath) {
    return `${driveKeyHex}:${filePath}`
  }

  _getFromCache (key) {
    const entry = this._cache.get(key)
    if (!entry) return null

    // Check TTL (5 minutes)
    if (Date.now() - entry.timestamp > 5 * 60 * 1000) {
      this._cache.delete(key)
      this._cacheCurrentSize -= entry.size
      return null
    }

    // Update access order (LRU)
    entry.lastAccess = Date.now()
    this._cacheStats.hits++
    return entry
  }

  _setCache (key, content, contentType) {
    const size = content.length

    // Don't cache files > 5MB
    if (size > 5 * 1024 * 1024) return

    // Evict oldest entries if needed
    while (this._cacheCurrentSize + size > this._cacheMaxSize && this._cache.size > 0) {
      let oldest = null
      let oldestTime = Infinity
      for (const [k, v] of this._cache) {
        if (v.lastAccess < oldestTime) {
          oldestTime = v.lastAccess
          oldest = k
        }
      }
      if (oldest) {
        const entry = this._cache.get(oldest)
        this._cacheCurrentSize -= entry.size
        this._cache.delete(oldest)
      }
    }

    this._cache.set(key, {
      content,
      contentType,
      size,
      timestamp: Date.now(),
      lastAccess: Date.now()
    })
    this._cacheCurrentSize += size
  }

  /**
   * Invalidate cache entries for a specific drive key
   * @param {string} driveKeyHex - The drive key to invalidate
   */
  invalidateCache (driveKeyHex) {
    for (const key of this._cache.keys()) {
      if (key.startsWith(`${driveKeyHex}:`)) {
        const entry = this._cache.get(key)
        this._cacheCurrentSize -= entry.size
        this._cache.delete(key)
      }
    }
  }

  /**
   * Get cache statistics
   */
  getCacheStats () {
    return {
      ...this._cacheStats,
      size: this._cacheCurrentSize,
      maxSize: this._cacheMaxSize,
      entries: this._cache.size
    }
  }

  /**
   * Clear the entire cache
   */
  clearCache () {
    this._cache.clear()
    this._cacheCurrentSize = 0
    this._cacheStats.hits = 0
    this._cacheStats.misses = 0
  }

  issueApiToken (driveKeyHex, opts = {}) {
    const keyHex = normalizeDriveKeyHex(driveKeyHex)
    const origin = opts && opts.origin ? normalizeOrigin(opts.origin) : null
    if (origin && !isLoopbackOrigin(origin)) throw new Error('Invalid token origin')
    this._cleanupExpiredApiTokens()
    const token = crypto.randomBytes(32).toString('hex')
    this._apiTokens.set(token, { driveKeyHex: keyHex, origin, kind: 'drive', issuedAt: Date.now() })
    return token
  }

  validateApiToken (token) {
    if (typeof token !== 'string' || token.length < 32) return null
    this._cleanupExpiredApiTokens()
    const entry = this._apiTokens.get(token)
    if (!entry) return null
    return {
      driveKeyHex: entry.driveKeyHex,
      origin: entry.origin || null,
      kind: entry.kind || 'drive',
      issuedAt: entry.issuedAt
    }
  }

  _cleanupExpiredApiTokens () {
    const now = Date.now()
    for (const [token, entry] of this._apiTokens) {
      if (now - entry.issuedAt > this._apiTokenTtlMs) {
        this._apiTokens.delete(token)
      }
    }
  }

  async _serveDirectoryListing (res, drive, keyHex, dirPath) {
    const entries = []
    const MAX_ENTRIES = 1000 // Prevent memory exhaustion
    const TIMEOUT_MS = 5000
    const startTime = Date.now()

    // Normalize dirPath for listing (ensure it ends with / for prefix matching)
    const normalizedDirPath = dirPath.endsWith('/') ? dirPath : dirPath + '/'

    try {
      for await (const entry of drive.list(normalizedDirPath)) {
        // Check timeout
        if (Date.now() - startTime > TIMEOUT_MS) {
          break
        }
        entries.push(entry.key)
        if (entries.length >= MAX_ENTRIES) {
          entries.push('... (truncated)')
          break
        }
      }
    } catch (err) {
      this._onError('directory-listing', err.message)
    }

    // Escape all entries to prevent XSS
    const items = entries.map(e => {
      const name = e.startsWith(dirPath) ? e.slice(dirPath.length) : e
      const escapedName = escapeHtml(name)
      const escapedE = escapeHtml(e)
      return `<li><a href="/hyper/${escapeHtml(keyHex)}${escapedE}">${escapedName}</a></li>`
    }).join('\n')

    res.statusCode = 200
    res.setHeader('Content-Type', 'text/html; charset=utf-8')
    res.end(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>hyper://${escapeHtml(keyHex.slice(0, 8))}...${escapeHtml(dirPath)}</title>
<style>body{font-family:-apple-system,sans-serif;padding:20px;background:#0a0a0a;color:#e0e0e0}
h1{color:#ff9500;font-size:1.1em;word-break:break-all}ul{list-style:none;padding:0}
li{padding:8px 0;border-bottom:1px solid #333}a{color:#4dabf7;text-decoration:none}</style>
</head><body><h1>hyper://${escapeHtml(keyHex.slice(0, 8))}...${escapeHtml(dirPath)}</h1>
<ul>${items || '<li style="color:#666">Empty directory</li>'}</ul></body></html>`)
  }
}

module.exports = { 
  HyperProxy, 
  getUserFriendlyError,
  USER_FRIENDLY_ERRORS,
  escapeStyleText
}