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