OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / http-bridge.js

855 lines · javascript ref:
/**
 * HTTP Bridge — Direct localhost API for WebView apps
 *
 * Provides REST endpoints on the worklet's HTTP server so WebView
 * apps can call P2P APIs directly via fetch() instead of going
 * through the React Native postMessage relay.
 *
 * This eliminates the three-hop latency:
 *   Before: WebView → postMessage → RN → RPC → Worklet
 *   After:  WebView → fetch(localhost:PORT) → Worklet
 *
 * All endpoints are under /api/* on the same port as the hyper proxy.
 */

// hypercore-crypto + b4a work under BOTH Node (tests) and Bare (the app);
// bare-crypto would throw `require.addon is not a function` under Node.
const hypercoreCrypto = require('hypercore-crypto')
const b4a = require('b4a')

class HttpBridge {
  constructor (pearBridge, swarm, getDriveFn, opts = {}) {
    this._bridge = pearBridge
    this._swarm = swarm
    this._getDrive = getDriveFn || null // async (keyHex) => Hyperdrive
    this._allowedOrigins = opts.allowedOrigins || ['http://localhost', 'http://127.0.0.1']
    this._validateToken = opts.validateToken || (() => null)
    this._identity = opts.identity || null
    this._profile = opts.profile || null
    this._contacts = opts.contacts || null
    this._requestLogin = opts.requestLogin || null  // async (args) => attestation
    this._swarmBridge = opts.swarmBridge || null   // SwarmBridge instance — see backend/swarm-bridge.js
    this._anongptBuyer = opts.anongptBuyer || null // AnongptBuyer — see backend/anongpt-buyer.js
    this._anongptDriveKey = (opts.anongptDriveKey || '').toLowerCase()
    this._aiService = opts.aiService || null
    this._aiManifestCache = new Map()
    this._aiManifestTtlMs = opts.aiManifestTtlMs || 30000
    this._aiRequestOwners = new Map()
    this._rateLimiter = new Map() // Simple rate limiting per IP
    this._sseTickets = new Map()
    this._sseTicketTtlMs = opts.sseTicketTtlMs || 30000
    this._maxSseTickets = opts.maxSseTickets || 4096
  }

  // Simple rate limit check
  _checkRateLimit (ip) {
    const now = Date.now()
    const windowMs = 60000 // 1 minute
    const maxRequests = 100 // 100 requests per minute

    let entry = this._rateLimiter.get(ip)
    if (!entry || now - entry.resetAt > windowMs) {
      entry = { count: 0, resetAt: now + windowMs }
      this._rateLimiter.set(ip, entry)
    }
    entry.count++
    return entry.count <= maxRequests
  }

  // Validate appId format (alphanumeric, hyphen, underscore only)
  _isValidAppId (appId) {
    return typeof appId === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(appId)
  }

  // Get client IP from request
  _getClientIp (req) {
    return req.socket?.remoteAddress || '127.0.0.1'
  }

  _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
    }
  }

  _requestOrigins (req) {
    const origins = []
    const rawOrigin = Array.isArray(req.headers.origin) ? req.headers.origin[0] : req.headers.origin
    if (typeof rawOrigin === 'string' && rawOrigin.length > 0) origins.push(rawOrigin)

    const rawHost = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host
    if (typeof rawHost === 'string' && rawHost.length > 0) origins.push(`http://${rawHost}`)

    return [...new Set(origins)]
  }

  _sameOrigin (a, b) {
    try {
      const left = new URL(a)
      const right = new URL(b)
      // Bare's URL implementation does not expose `.origin`.
      return left.protocol === right.protocol && left.host === right.host
    } catch {
      return false
    }
  }

  _checkTokenOrigin (req, res, expectedOrigin) {
    if (!expectedOrigin) return true
    const origins = this._requestOrigins(req)
    if (origins.length === 0) return true
    for (const origin of origins) {
      if (!this._sameOrigin(origin, expectedOrigin)) {
        this._jsonError(res, 'Token origin mismatch', 403)
        return false
      }
    }
    return true
  }

  // Namespace a page's appId by the drive it was served from, so two apps can't
  // touch each other's sync data. We HASH `driveKey:appId` to a fixed 64 hex
  // chars: a plain `driveKey:appId` concat is ≥65 chars and the Autobase bridge
  // caps appId at 64 (pear-bridge.js _validateAppId), so the un-hashed form made
  // EVERY sync call fail — which is why pages could only ever run in dev mode.
  // The hash is deterministic and per-drive, so all users of the same app (same
  // drive) share a namespace and can discover each other's outboxes, while
  // different apps stay isolated.
  _scopeAppId (driveKeyHex, appId) {
    return b4a.toString(hypercoreCrypto.data(b4a.from(`${driveKeyHex}:${appId}`)), 'hex')
  }

  async _hasAiPermission (driveKeyHex) {
    const keyHex = String(driveKeyHex || '').toLowerCase()
    if (!/^[0-9a-f]{64}$/.test(keyHex) || !this._getDrive) return false
    const cached = this._aiManifestCache.get(keyHex)
    if (cached && Date.now() - cached.checkedAt < this._aiManifestTtlMs) return cached.allowed

    let allowed = false
    try {
      const drive = await this._getDrive(keyHex)
      let timer = null
      const raw = drive && await Promise.race([
        drive.get('/manifest.json', { wait: true }),
        new Promise(resolve => { timer = setTimeout(() => resolve(null), 10000) })
      ]).finally(() => { if (timer) clearTimeout(timer) })
      const manifest = raw ? JSON.parse(raw.toString('utf8')) : null
      allowed = !!(manifest && (
        (Array.isArray(manifest.permissions) && manifest.permissions.includes('pear.ai.infer')) ||
        manifest.pear?.ai?.infer === true
      ))
    } catch {}

    this._aiManifestCache.set(keyHex, { allowed, checkedAt: Date.now() })
    return allowed
  }

  _requireToken (req, res) {
    const rawToken = req.headers['x-pear-token']
    const token = Array.isArray(rawToken) ? rawToken[0] : rawToken
    const entry = this._validateToken(token)
    if (!entry) {
      this._jsonError(res, 'Unauthorized', 401)
      return null
    }
    if (typeof entry === 'string') return { driveKeyHex: entry, token }
    if (entry && typeof entry.driveKeyHex === 'string') {
      const auth = {
        driveKeyHex: entry.driveKeyHex,
        token,
        origin: entry.origin || null,
        kind: entry.kind || 'drive'
      }
      if (!this._checkTokenOrigin(req, res, auth.origin)) return null
      return auth
    }
    this._jsonError(res, 'Unauthorized', 401)
    return null
  }

  _pruneSseTickets (now = Date.now()) {
    for (const [ticket, entry] of this._sseTickets) {
      if (entry.expiresAt <= now) this._sseTickets.delete(ticket)
    }
    while (this._sseTickets.size > this._maxSseTickets) {
      const oldest = this._sseTickets.keys().next().value
      if (!oldest) break
      this._sseTickets.delete(oldest)
    }
  }

  _mintSseTicket (auth, channelId) {
    const now = Date.now()
    this._pruneSseTickets(now)
    let ticket
    do {
      ticket = b4a.toString(hypercoreCrypto.randomBytes(32), 'hex')
    } while (this._sseTickets.has(ticket))
    this._sseTickets.set(ticket, {
      driveKeyHex: auth.driveKeyHex,
      origin: auth.origin || null,
      kind: auth.kind || 'drive',
      channelId,
      expiresAt: now + this._sseTicketTtlMs
    })
    return { ticket, expiresInMs: this._sseTicketTtlMs }
  }

  _consumeSseTicket (req, res, urlObj, channelId) {
    const ticket = urlObj.searchParams.get('ticket')
    if (!ticket) {
      this._jsonError(res, 'SSE ticket required', 401)
      return null
    }
    const entry = this._sseTickets.get(ticket)
    if (!entry) {
      this._jsonError(res, 'Invalid SSE ticket', 401)
      return null
    }
    this._sseTickets.delete(ticket)
    if (entry.expiresAt <= Date.now()) {
      this._jsonError(res, 'Expired SSE ticket', 401)
      return null
    }
    if (entry.channelId !== channelId) {
      this._jsonError(res, 'SSE ticket channel mismatch', 403)
      return null
    }
    if (!this._checkTokenOrigin(req, res, entry.origin || null)) return null
    return {
      driveKeyHex: entry.driveKeyHex,
      token: ticket,
      origin: entry.origin || null,
      kind: entry.kind || 'drive'
    }
  }

  /**
   * Handle an incoming HTTP request.
   * Returns true if handled, false if not an API route.
   */
  async handle (req, res, url) {
    const path = url.pathname

    if (!path.startsWith('/api/')) return false

    // Rate limiting
    const clientIp = this._getClientIp(req)
    if (!this._checkRateLimit(clientIp)) {
      res.statusCode = 429
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Rate limit exceeded' }))
      return true
    }

    // Origin validation
    const origin = req.headers.origin
    if (origin) {
      const isAllowed = this._isLoopbackOrigin(origin) &&
        this._allowedOrigins.some(allowed => origin === allowed || origin.startsWith(allowed + ':'))
      if (!isAllowed) {
        res.statusCode = 403
        res.setHeader('Content-Type', 'application/json')
        res.end(JSON.stringify({ error: 'Invalid origin' }))
        return true
      }
    }

    res.setHeader('Content-Type', 'application/json')

    try {
      // Parse JSON body for POST requests
      let body = null
      if (req.method === 'POST') {
        body = await this._readBody(req)
      }

      // --- Sync API ---

      if (req.method === 'POST' && path === '/api/sync/create') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._isValidAppId(body.appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, body.appId)
        const result = await this._bridge.createSyncGroup(scopedAppId)
        return this._json(res, { ...result, appId: body.appId })
      }

      if (req.method === 'POST' && path === '/api/sync/join') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._isValidAppId(body.appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        // Validate invite key format (64 hex chars)
        if (!body.inviteKey || !/^[0-9a-f]{64}$/i.test(body.inviteKey)) {
          return this._jsonError(res, 'Invalid invite key format', 400)
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, body.appId)
        const result = await this._bridge.joinSyncGroup(scopedAppId, body.inviteKey)
        return this._json(res, { ...result, appId: body.appId })
      }

      if (req.method === 'POST' && path === '/api/sync/append') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        // Validate appId
        if (!this._isValidAppId(body.appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        // Limit operation size
        const opSize = JSON.stringify(body.op || body).length
        if (opSize > 100000) { // 100KB max operation
          return this._jsonError(res, 'Operation too large', 413)
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, body.appId)
        const result = await this._bridge.append(scopedAppId, body.op || body)
        return this._json(res, result)
      }

      if (req.method === 'GET' && path === '/api/sync/get') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const appId = url.searchParams.get('appId')
        const key = url.searchParams.get('key')
        if (!this._isValidAppId(appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        // Validate key format
        if (!key || typeof key !== 'string' || key.length > 1024) {
          return this._jsonError(res, 'Invalid key', 400)
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, appId)
        const result = await this._bridge.get(scopedAppId, key)
        return this._json(res, result)
      }

      if (req.method === 'GET' && path === '/api/sync/list') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const appId = url.searchParams.get('appId')
        const prefix = url.searchParams.get('prefix') || ''
        let limit = parseInt(url.searchParams.get('limit') || '100')
        if (!this._isValidAppId(appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        // Enforce max limit
        if (isNaN(limit) || limit < 1) limit = 100
        if (limit > 1000) limit = 1000
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, appId)
        const result = await this._bridge.list(scopedAppId, prefix, { limit })
        return this._json(res, result)
      }

      // Phase 4 addition — range queries with explicit bounds + reverse
      if (req.method === 'GET' && path === '/api/sync/range') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const appId = url.searchParams.get('appId')
        if (!this._isValidAppId(appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        const opts = {
          gte: url.searchParams.get('gte') || undefined,
          gt: url.searchParams.get('gt') || undefined,
          lte: url.searchParams.get('lte') || undefined,
          lt: url.searchParams.get('lt') || undefined,
          reverse: url.searchParams.get('reverse') === '1' || url.searchParams.get('reverse') === 'true',
          limit: parseInt(url.searchParams.get('limit') || '100') || 100,
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, appId)
        const result = await this._bridge.range(scopedAppId, opts)
        return this._json(res, result)
      }

      // Phase 4 addition — count under a prefix (for UI counters)
      if (req.method === 'GET' && path === '/api/sync/count') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const appId = url.searchParams.get('appId')
        const prefix = url.searchParams.get('prefix') || ''
        if (!this._isValidAppId(appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, appId)
        const count = await this._bridge.count(scopedAppId, prefix)
        return this._json(res, { count })
      }

      if (req.method === 'GET' && path === '/api/sync/status') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const appId = url.searchParams.get('appId')
        if (!this._isValidAppId(appId)) {
          return this._jsonError(res, 'Invalid appId format', 400)
        }
        const scopedAppId = this._scopeAppId(auth.driveKeyHex, appId)
        const result = this._bridge.getSyncStatus(scopedAppId)
        return this._json(res, result ? { ...result, appId } : null)
      }

      // --- Identity ---

      if (req.method === 'GET' && path === '/api/identity') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        // PHASE A: return the per-app sub-key (stable per user+app), NOT
        // the raw swarm or root keypair. Two different apps see two
        // different pubkeys for the same user — privacy by default.
        let appPubkey = null
        if (this._identity) {
          try { appPubkey = this._identity.getAppKeypair(auth.driveKeyHex).publicKey.toString('hex') }
          catch { /* demo mode */ }
        }
        return this._json(res, {
          publicKey: appPubkey,           // per-app sub-key
          driveKey: auth.driveKeyHex,
          algorithm: 'ed25519',
        })
      }

      // Sign a payload with the per-app sub-key (ed25519). Safe to expose —
      // the root keypair stays sealed inside the worklet.
      if (req.method === 'POST' && path === '/api/identity/sign') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._identity) return this._jsonError(res, 'identity not available', 503)
        // NOTE: the POST body was already read once at the top of _handle (for
        // every POST). Reading it again here hangs forever — the request stream
        // is already consumed, so 'data'/'end' never fire. Use the parsed body.
        if (!body || typeof body.payload !== 'string') {
          return this._jsonError(res, '`payload` (string) required', 400)
        }
        try {
          const result = this._identity.signForApp(
            auth.driveKeyHex,
            body.payload,
            body.namespace || ''
          )
          return this._json(res, result)
        } catch (err) {
          return this._jsonError(res, err.message || 'sign failed', 500)
        }
      }

      // --- Login ceremony (Identity Plan Phase C) ---
      //
      // POST /api/login      { scopes, appName, reason } → attestation
      // GET  /api/login/status                           → current grant
      // POST /api/login/logout                           → revoke grant for this app

      if (req.method === 'POST' && path === '/api/login') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._requestLogin) return this._jsonError(res, 'login not available', 503)
        let body
        try { body = await this._readBody(req) } catch { return this._jsonError(res, 'Invalid JSON body', 400) }
        const scopes = Array.isArray(body.scopes) ? body.scopes.map(String) : []
        const appName = typeof body.appName === 'string' ? body.appName.slice(0, 128) : null
        const reason = typeof body.reason === 'string' ? body.reason.slice(0, 512) : null
        try {
          const attestation = await this._requestLogin({
            driveKeyHex: auth.driveKeyHex, scopes, appName, reason,
          })
          // Attach the visible profile fields the app is allowed to see.
          let profileFields = null
          if (this._profile) {
            try { profileFields = await this._profile.getVisibleProfile(auth.driveKeyHex) } catch {}
          }
          return this._json(res, { ...attestation, profile: profileFields })
        } catch (err) {
          return this._jsonError(res, err.message || 'Login failed', 403)
        }
      }

      if (req.method === 'GET' && path === '/api/login/status') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._profile || !this._identity) {
          return this._json(res, { loggedIn: false })
        }
        const grant = await this._profile.getGrant(auth.driveKeyHex)
        if (!grant) return this._json(res, { loggedIn: false })
        let appPubkey = null
        try { appPubkey = this._identity.getAppKeypair(auth.driveKeyHex).publicKey.toString('hex') } catch {}
        let profileFields = null
        try { profileFields = await this._profile.getVisibleProfile(auth.driveKeyHex) } catch {}
        return this._json(res, {
          loggedIn: true,
          appPubkey,
          scopes: grant.scopes,
          expiresAt: grant.expiresAt,
          profile: profileFields,
        })
      }

      if (req.method === 'POST' && path === '/api/login/logout') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (this._profile) {
          try { await this._profile.revokeGrant(auth.driveKeyHex) } catch {}
        }
        return this._json(res, { ok: true })
      }

      // --- Contacts (Identity Plan Phase D) ---
      //
      // All endpoints require a valid token AND an active grant with
      // the `contacts:read` scope.

      if (path.startsWith('/api/contacts')) {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._contacts) return this._jsonError(res, 'Contacts not available', 503)

        // Gate on grant+scope
        const grant = this._profile ? await this._profile.getGrant(auth.driveKeyHex) : null
        if (!grant || !grant.scopes.includes('contacts:read')) {
          return this._jsonError(res, 'contacts:read scope required — call pear.login first', 403)
        }

        if (req.method === 'GET' && path === '/api/contacts/list') {
          const limit = parseInt(url.searchParams.get('limit') || '1000')
          return this._json(res, await this._contacts.list({ limit }))
        }
        if (req.method === 'GET' && path === '/api/contacts/lookup') {
          const pk = url.searchParams.get('pubkey')
          if (!pk) return this._jsonError(res, 'pubkey required', 400)
          return this._json(res, await this._contacts.lookup(pk))
        }
        return this._jsonError(res, 'Not found', 404)
      }

      // --- Drive Operations (Vinjari-inspired) ---

      if (req.method === 'GET' && path === '/api/drive/info') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const key = url.searchParams.get('key') || auth.driveKeyHex
        if (key !== auth.driveKeyHex) {
          return this._jsonError(res, 'Forbidden for this drive', 403)
        }
        const drive = await this._getDrive(key)
        if (!drive) return this._jsonError(res, 'Drive not found', 404)
        return this._json(res, {
          key,
          version: drive.version,
          writable: drive.writable,
          peers: this._swarm ? this._swarm.connections.size : 0,
          discoveryKey: drive.discoveryKey ? drive.discoveryKey.toString('hex') : null
        })
      }

      if (req.method === 'GET' && path === '/api/drive/readdir') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const key = url.searchParams.get('key') || auth.driveKeyHex
        const dirPath = url.searchParams.get('path') || '/'
        if (key !== auth.driveKeyHex) {
          return this._jsonError(res, 'Forbidden for this drive', 403)
        }
        if (dirPath.includes('..') || dirPath.includes('\x00')) {
          return this._jsonError(res, 'Invalid path', 400)
        }
        const drive = await this._getDrive(key)
        if (!drive) return this._jsonError(res, 'Drive not found', 404)
        const entries = []
        try {
          for await (const entry of drive.list(dirPath)) {
            entries.push({ key: entry.key, size: entry.value?.blob?.byteLength || 0 })
          }
        } catch {}
        return this._json(res, { key, path: dirPath, entries })
      }

      // --- swarm.v1 (direct Hyperswarm access for hyper:// pages — see docs/SWARM-V1.md) ---
      //
      //   POST /api/swarm/join       — open a channel; returns channelId
      //   GET  /api/swarm/events     — SSE stream of peer/message events
      //   POST /api/swarm/send       — send to a peer
      //   POST /api/swarm/leave      — close the channel
      //
      // All four are gated by the per-app token + Origin check above.
      // Tier C topic joins additionally fire EVT_SWARM_REQUEST and wait
      // on the user's consent reply before resolving.

      if (path.startsWith('/api/swarm/')) {
        if (!this._swarmBridge) {
          return this._jsonError(res, 'swarm bridge not available', 503)
        }
        if (req.method === 'GET' && path === '/api/swarm/events') {
          const channelId = url.searchParams.get('channelId')
          if (!channelId) return this._jsonError(res, 'channelId required', 400)
          const auth = this._consumeSseTicket(req, res, url, channelId)
          if (!auth) return true
          // SSE response — long-lived, no JSON Content-Type.
          res.statusCode = 200
          res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
          res.setHeader('Cache-Control', 'no-cache, no-transform')
          res.setHeader('Connection', 'keep-alive')
          res.setHeader('X-Accel-Buffering', 'no')
          // Initial comment to flush headers immediately.
          res.write(': pear.swarm.v1 stream\n\n')

          const closeHandlers = []
          const stream = {
            send (eventObj) {
              try {
                res.write('data: ' + JSON.stringify(eventObj) + '\n\n')
              } catch {}
            },
            close () {
              try { res.end() } catch {}
            },
            onClose (fn) {
              closeHandlers.push(fn)
            }
          }
          const cleanup = () => closeHandlers.forEach((fn) => { try { fn() } catch {} })
          req.on('close', cleanup)
          req.on('error', cleanup)
          res.on('close', cleanup)

          this._swarmBridge.attachStream(channelId, stream)
          // attachStream sends the 'unknown channelId' error + closes itself
          // when the channel doesn't exist, so we don't need to do anything
          // else here. If it succeeded, the response stays open until the
          // page closes the EventSource (or the channel is leave()'d).
          return true
        }

        const auth = this._requireToken(req, res)
        if (!auth) return true

        if (req.method === 'POST' && path === '/api/swarm/ticket') {
          const channelId = body?.channelId
          if (typeof channelId !== 'string' || channelId.length === 0) {
            return this._jsonError(res, 'channelId required', 400)
          }
          if (channelId.length > 256) {
            return this._jsonError(res, 'channelId too long', 400)
          }
          return this._json(res, this._mintSseTicket(auth, channelId))
        }

        if (req.method === 'POST' && path === '/api/swarm/join') {
          try {
            const result = await this._swarmBridge.join({
              driveKeyHex: auth.driveKeyHex,
              appName: body?.appName || null,
              reason: body?.reason || null,
              topicHex: body?.topicHex || null,
              subtopic: body?.subtopic === undefined ? null : body.subtopic,
              protocol: body?.protocol || 'pear.swarm.v1',
              version: body?.version || 1,
              server: !!body?.server,
              client: body?.client !== false
            })
            return this._json(res, result)
          } catch (err) {
            return this._jsonError(res, err.message, 400)
          }
        }

        if (req.method === 'POST' && path === '/api/swarm/send') {
          try {
            this._swarmBridge.send(body?.channelId, body?.peerId, body?.data)
            return this._json(res, { ok: true })
          } catch (err) {
            return this._jsonError(res, err.message, 400)
          }
        }

        if (req.method === 'POST' && path === '/api/swarm/leave') {
          try {
            await this._swarmBridge.leave(body?.channelId)
            return this._json(res, { ok: true })
          } catch (err) {
            return this._jsonError(res, err.message, 400)
          }
        }

        return this._jsonError(res, 'Unknown swarm endpoint', 404)
      }

      // --- anonGPT private buyer (Phase 0 plumbing) ---
      //
      // window.pear.anongpt.infer() in the page → POST here. The route
      // gate is doubled: (1) the X-Pear-Token only validates for the
      // drive that was issued the token (so a non-anonGPT page can't
      // call this even by reading the token off the wire), and (2) we
      // re-verify driveKeyHex matches the configured anonGPT key
      // before invoking the buyer. Either gate failing yields 403 so
      // the page can't tell whether the API exists for other drives.
      // See backend/anongpt-buyer.js for the buyer side.

      if (req.method === 'POST' && path === '/api/anongpt/infer') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._anongptDriveKey || auth.driveKeyHex.toLowerCase() !== this._anongptDriveKey) {
          return this._jsonError(res, 'anonGPT API is restricted to the anonGPT drive', 403)
        }
        if (!this._anongptBuyer) {
          // No buyer wired at all (Phase 0 not initialized). Honest
          // fail-closed — the page's existing UI already handles this.
          return this._json(res, {
            ok: false,
            code: 'buyer-not-configured',
            message: 'PearBrowser was started without an anonGPT buyer module.'
          })
        }
        const result = await this._anongptBuyer.infer(body || {})
        return this._json(res, result)
      }

      // --- Browser-owned native AI (QVAC) ---

      if (req.method === 'GET' && path === '/api/ai/capabilities') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        const allowed = await this._hasAiPermission(auth.driveKeyHex)
        const capabilities = this._aiService
          ? this._aiService.capabilities()
          : { available: false, local: true, streaming: true, models: [], reason: 'runtime-not-configured' }
        return this._json(res, { ...capabilities, allowed })
      }

      if (req.method === 'POST' && path === '/api/ai/completions') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._aiService) return this._jsonError(res, 'Local AI runtime is unavailable', 503)
        if (!await this._hasAiPermission(auth.driveKeyHex)) {
          return this._jsonError(res, 'App manifest does not declare pear.ai.infer', 403)
        }

        let run
        try {
          run = this._aiService.complete({
            origin: `hyper://${auth.driveKeyHex}`,
            model: body?.model,
            messages: body?.messages,
            maxTokens: body?.maxTokens,
            temperature: body?.temperature,
            reasoningBudget: body?.reasoningBudget
          })
        } catch (err) {
          return this._jsonError(res, err.message || 'Invalid AI request', err.code === 'queue-full' ? 429 : 400)
        }

        res.statusCode = 200
        res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8')
        res.setHeader('Cache-Control', 'no-store')
        res.setHeader('X-Pear-AI-Request-Id', run.requestId)
        this._aiRequestOwners.set(run.requestId, auth.driveKeyHex.toLowerCase())
        let streamDone = false
        const cancelOnDisconnect = () => {
          if (!streamDone) this._aiService.cancel(run.requestId).catch(() => {})
        }
        if (typeof res.on === 'function') res.on('close', cancelOnDisconnect)

        try {
          for await (const event of run.events) {
            res.write(JSON.stringify(event) + '\n')
          }
          await run.final.catch(() => {})
        } finally {
          streamDone = true
          this._aiRequestOwners.delete(run.requestId)
          res.end()
        }
        return true
      }

      if (req.method === 'POST' && path === '/api/ai/cancel') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        if (!this._aiService) return this._jsonError(res, 'Local AI runtime is unavailable', 503)
        if (!await this._hasAiPermission(auth.driveKeyHex)) {
          return this._jsonError(res, 'App manifest does not declare pear.ai.infer', 403)
        }
        const requestId = typeof body?.requestId === 'string' ? body.requestId : ''
        if (!requestId) return this._jsonError(res, 'requestId required', 400)
        if (this._aiRequestOwners.get(requestId) !== auth.driveKeyHex.toLowerCase()) {
          return this._json(res, { ok: false })
        }
        return this._json(res, { ok: await this._aiService.cancel(requestId) })
      }

      // --- Status ---

      if (req.method === 'GET' && path === '/api/bridge/status') {
        const auth = this._requireToken(req, res)
        if (!auth) return true
        return this._json(res, {
          type: 'http-bridge',
          syncGroups: this._bridge._syncGroups ? this._bridge._syncGroups.size : 0,
          swarmConnected: !!this._swarm,
          peerCount: this._swarm ? this._swarm.connections.size : 0,
          driveKey: auth.driveKeyHex
        })
      }

      // Not found
      res.statusCode = 404
      res.end(JSON.stringify({ error: 'Unknown API endpoint: ' + path }))
      return true

    } catch (err) {
      res.statusCode = 500
      res.end(JSON.stringify({ error: err.message }))
      return true
    }
  }

  _json (res, data) {
    res.statusCode = 200
    res.end(JSON.stringify(data))
    return true
  }

  _jsonError (res, message, status = 400) {
    res.statusCode = status
    res.end(JSON.stringify({ error: message }))
    return true
  }

  _readBody (req) {
    return new Promise((resolve, reject) => {
      let data = ''
      let size = 0
      req.on('data', (chunk) => {
        size += chunk.length
        if (size > 1024 * 1024) { // 1MB max
          req.destroy()
          reject(new Error('Body too large'))
          return
        }
        data += chunk
      })
      req.on('end', () => {
        try {
          const parsed = data ? JSON.parse(data) : {}
          // SECURITY: Prevent prototype pollution
          if (parsed && typeof parsed === 'object') {
            delete parsed.__proto__
            delete parsed.constructor
          }
          resolve(parsed)
        } catch (err) {
          reject(new Error('Invalid JSON: ' + err.message))
        }
      })
      req.on('error', reject)
    })
  }
}

module.exports = { HttpBridge }