OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / lib / service.js

320 lines · javascript ref:
/**
 * Service — reusable per-app daemon library.
 *
 * One instance per app process. Owns:
 *   - Generic RPC handler registry (svc.handle)
 *   - Module mounts (svc.mount) with optional swarm replication
 *   - UDS, HTTP, and Hyperswarm transport instances (whichever the consumer enables)
 *
 * The consumer's corestore is passed in (or one is implied via swarm.corestore).
 * The Service does NOT create or own storage — that's the app's job.
 *
 * Local-only RPC. Peers exchange data via swarm replication, never commands.
 *
 * Usage:
 *
 *   const svc = new Service({
 *     name: 'pear-git',
 *     corestore: store,
 *     uds: '/tmp/pear-git.sock',
 *     http: { port: 4040 },
 *     swarm: { topic: '...32 bytes hex...' }
 *   })
 *
 *   svc.mount('orgs', new HyperOrgs(store, { namespace: 'orgs' }))
 *
 *   svc.handle('list-orgs', async (ctx, params) => {
 *     return svc.modules.orgs.list()
 *   })
 *
 *   await svc.listen()
 */

import process from '#node:process'
import ReadyResource from 'ready-resource'
import { UdsServer } from './transports/uds.js'
import { HttpServer } from './transports/http.js'
import { SwarmTransport } from './transports/swarm.js'

class NoSuchMethodError extends Error {
  constructor (method) {
    super(`unknown method: ${method}`)
    this.code = 'ENOMETHOD'
  }
}

class AnonymousDeniedError extends Error {
  constructor (method) {
    super(`anonymous access denied: ${method}`)
    this.code = 'EDENIED'
    this.status = 403
  }
}

/**
 * Default "is this caller anonymous?" predicate.
 *
 * Deliberately conservative — anything that is not clearly an authenticated
 * caller counts as anonymous. Consumers whose ctx carries richer identity
 * (pear-git-v3 has four distinct shapes) should pass their own via
 * opts.isAnonymous rather than widen this one.
 */
function defaultIsAnonymous (ctx) {
  if (!ctx) return true
  if (ctx.transport === 'uds') return false // local operator holds the socket
  return !ctx.identity
}

export class Service extends ReadyResource {
  /**
   * @param {object} opts
   * @param {string} opts.name - service name (used for default UDS path, logging)
   * @param {object} [opts.corestore] - required if swarm transport is enabled
   * @param {string} [opts.uds] - UDS socket path. Pass null/undefined to disable.
   * @param {object} [opts.http] - HTTP transport options { port, host }. Disable by omitting.
   * @param {object} [opts.swarm] - Swarm options { topic, swarm? }. Disable by omitting.
   */
  constructor (opts = {}) {
    super()
    if (!opts.name) throw new Error('Service requires opts.name')

    this._name = opts.name
    this._corestore = opts.corestore || null
    this._handlers = new Map()
    this._mounts = new Map()

    // Anonymous-dispatch policy (pear-git org #12).
    //
    // Every registered handler used to be callable by anyone, over HTTP and
    // over swarm-rpc, with no allowlist and no auth gate — so each new
    // svc.handle() was silently world-callable, and a daemon behind a public
    // reverse proxy exposed its entire RPC surface to the internet.
    //
    // Default stays 'allow' so the existing consumers (pear-git-v3, hypernote,
    // hyper-social-web, pgh-sidecar) are untouched until they opt in. Under
    // 'deny', a handler must declare `{ anonymous: true }` at registration to
    // be reachable by an unauthenticated caller.
    //
    // Opt-in at REGISTRATION rather than via a separate allowlist table: with
    // ~100 handlers a table drifts out of step with the handler set, whereas a
    // flag cannot. And the drift direction is safe — a handler nobody marked
    // is DENIED, which surfaces as a visible 403 rather than a silent hole.
    this._anonymousPolicy = opts.anonymousPolicy === 'deny' ? 'deny' : 'allow'
    this._isAnonymous = typeof opts.isAnonymous === 'function'
      ? opts.isAnonymous
      : defaultIsAnonymous
    // Called with ({ method, ctx }) whenever a dispatch is refused, so the
    // consumer can record denials alongside its own audit trail.
    this._onDeny = typeof opts.onDeny === 'function' ? opts.onDeny : null
    this._anonymousAllowed = new Set()

    this._udsOpt = opts.uds || null
    this._httpOpt = opts.http || null
    this._swarmOpt = opts.swarm || null

    this._uds = null
    this._http = null
    this._swarm = null

    if (this._swarmOpt && !this._corestore) {
      throw new Error('Service: swarm transport requires opts.corestore')
    }

    // Instantiate transports eagerly so apps can register HTTP routes,
    // grab the UDS path, etc., BEFORE calling listen().
    const dispatch = (method, params, ctx) => this.dispatch(method, params, ctx)

    if (this._udsOpt) {
      this._uds = new UdsServer({ path: this._udsOpt, dispatch })
    }
    if (this._httpOpt) {
      this._http = new HttpServer({
        port: this._httpOpt.port,
        host: this._httpOpt.host,
        prefix: this._httpOpt.prefix,
        ctxFactory: this._httpOpt.ctxFactory,
        dispatch
      })
    }
    if (this._swarmOpt) {
      this._swarm = new SwarmTransport({
        topic: this._swarmOpt.topic,
        corestore: this._corestore,
        mounts: () => this._listMounts(),
        dispatch: this._swarmOpt.rpc !== false ? dispatch : null,
        swarm: this._swarmOpt.swarm,
        server: this._swarmOpt.server,
        client: this._swarmOpt.client
      })
    }
  }

  // ── Properties ──────────────────────────────────────────────

  get name () { return this._name }
  get corestore () { return this._corestore }

  /** Read-only handle to the UDS transport (or null if disabled). */
  get uds () { return this._uds }

  /** Read-only handle to the HTTP transport (or null if disabled). */
  get http () { return this._http }

  /** Read-only handle to the swarm transport (or null if disabled). */
  get swarm () { return this._swarm }

  /**
   * Mounted modules, accessible by name. Use `svc.modules.orgs` after
   * `svc.mount('orgs', orgsInstance)`.
   */
  get modules () {
    // Lazily build a property-style accessor object on top of the map
    if (!this._modulesProxy) {
      const map = this._mounts
      this._modulesProxy = new Proxy({}, {
        get (_target, prop) {
          const m = map.get(prop)
          return m ? m.instance : undefined
        },
        has (_target, prop) {
          return map.has(prop)
        },
        ownKeys () {
          return Array.from(map.keys())
        },
        getOwnPropertyDescriptor (_target, prop) {
          if (map.has(prop)) {
            return { enumerable: true, configurable: true, value: map.get(prop).instance }
          }
        }
      })
    }
    return this._modulesProxy
  }

  /** Map iteration: list mount entries as { name, instance }. */
  _listMounts () {
    const out = []
    for (const [name, m] of this._mounts) out.push({ name, instance: m.instance })
    return out
  }

  // ── Registration ────────────────────────────────────────────

  /**
   * Register an RPC handler. The handler is invoked from any RPC transport
   * with `(ctx, params)` and should return a JSON-serializable result.
   *
   * @param {string} name - method name (e.g. "list-orgs")
   * @param {(ctx: object, params: any) => Promise<any>|any} fn
   */
  handle (name, fn, opts = {}) {
    if (typeof name !== 'string' || !name) throw new Error('handle requires a name')
    if (typeof fn !== 'function') throw new Error('handle requires a function')
    if (this._handlers.has(name)) throw new Error(`handler "${name}" already registered`)
    this._handlers.set(name, fn)
    // `anonymous: true` opts this method IN to unauthenticated dispatch when
    // the service runs anonymousPolicy: 'deny'. Absent = denied, which is the
    // safe default for a handler nobody has classified yet.
    if (opts && opts.anonymous === true) this._anonymousAllowed.add(name)
  }

  /** Is this method callable by an unauthenticated caller? */
  allowsAnonymous (name) { return this._anonymousAllowed.has(name) }

  /** Methods explicitly opted in to anonymous dispatch, sorted. */
  listAnonymous () { return Array.from(this._anonymousAllowed).sort() }

  /** Whether a handler is registered. */
  hasHandler (name) { return this._handlers.has(name) }

  /** List registered handler names. */
  listHandlers () { return Array.from(this._handlers.keys()).sort() }

  /**
   * Mount a module instance. Modules are accessible via `svc.modules.<name>`.
   * If the module exposes a `.base` (Autobase consumer) and the swarm
   * transport is enabled, the base is replicated on every swarm connection.
   *
   * @param {string} name
   * @param {object} instance
   */
  mount (name, instance) {
    if (typeof name !== 'string' || !name) throw new Error('mount requires a name')
    if (!instance) throw new Error('mount requires an instance')
    if (this._mounts.has(name)) throw new Error(`mount "${name}" already registered`)
    this._mounts.set(name, { instance })
  }

  /** Whether a module is mounted. */
  hasMount (name) { return this._mounts.has(name) }

  /** List mounted module names. */
  listMounts () { return Array.from(this._mounts.keys()).sort() }

  // ── Dispatch ────────────────────────────────────────────────

  /**
   * Dispatch a method call to its handler. Used by all RPC transports.
   * Throws NoSuchMethodError (code: 'ENOMETHOD') if the method isn't registered.
   */
  async dispatch (method, params, ctx) {
    const handler = this._handlers.get(method)
    if (!handler) throw new NoSuchMethodError(method)

    // Anonymous gate (#12). Runs for EVERY transport, because the surface was
    // open on all of them — HTTP via a public reverse proxy, and swarm-rpc to
    // any peer that knows the topic.
    //
    // Ordered after the ENOMETHOD check on purpose: an unknown method should
    // still say so, otherwise the gate turns every typo into a 403 and makes
    // the API undebuggable. It leaks only which method names exist, which the
    // handler list already exposes.
    if (this._anonymousPolicy === 'deny' && !this._anonymousAllowed.has(method)) {
      let anon = true
      try {
        anon = this._isAnonymous(ctx)
      } catch {
        anon = true // a predicate that throws must fail CLOSED
      }
      if (anon) {
        if (this._onDeny) {
          try { this._onDeny({ method, ctx, reason: 'anonymous' }) } catch {}
        }
        throw new AnonymousDeniedError(method)
      }
    }

    return handler(ctx || {}, params)
  }

  // ── Lifecycle ───────────────────────────────────────────────

  async _open () {
    if (this._uds) await this._uds.listen()
    if (this._http) await this._http.listen()
    if (this._swarm) await this._swarm.listen()
  }

  /**
   * Alias for `ready()` — feels more natural for a daemon ("start listening").
   */
  async listen () {
    await this.ready()
  }

  async _close () {
    const errors = []
    if (this._swarm) try { await this._swarm.close() } catch (e) { errors.push(e) }
    if (this._http) try { await this._http.close() } catch (e) { errors.push(e) }
    if (this._uds) try { await this._uds.close() } catch (e) { errors.push(e) }
    this._swarm = null
    this._http = null
    this._uds = null
    if (errors.length) {
      const msg = errors.map(e => e.message).join('; ')
      throw new Error('Service close errors: ' + msg)
    }
  }
}