/**
* pgh peer client — call a pear-git-v3 daemon's /api/rpc over HTTP with
* envelope-signed requests (Phase 3 Tier 2).
*
* Loads the local pgh identity (hyperident device + attestation chain,
* or raw fallback), signs each call as a canonical anproto envelope,
* and POSTs to the daemon. The server verifies via Phase 2b + 2d and
* dispatches like any other envelope.
*
* Currently HTTP-only. A swarm-RPC transport (for NAT'd remote peers)
* is a future enhancement — would require a `verified-dispatch` handler
* on the daemon side so envelope auth is transport-agnostic. For LAN
* and any host with reachable HTTP, this client is sufficient.
*
* Usage:
*
* const client = await PghPeerClient.connect({
* url: 'http://tabertron.local:4040',
* identity: await loadOrCreateIdentity('/path/to/hyperident')
* })
*
* const result = await client.call('list-my-devices', {})
* await client.close()
*/
import http from '#node:http'
import https from '#node:https'
import { homedir } from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import sodium from 'sodium-native'
import { loadOrCreateIdentity } from './identity.js'
const DEFAULT_HYPERIDENT_HOME = join(homedir(), '.hyperident')
// Inlined anproto signing to avoid a file: dep on anproto (which would
// inflate the L1 bootstrap tarball). Wire-compatible with the canonical
// anproto implementation used by pear-git-v3/lib/auth/envelope.js.
async function anHash (data) {
const buf = typeof data === 'string' ? b4a.from(data, 'utf8') : data
const out = b4a.alloc(32)
sodium.crypto_hash_sha256(out, buf)
return b4a.toString(out, 'base64')
}
async function anSign (hash, secretKey, publicKey) {
const ts = Date.now()
const message = b4a.from(String(ts) + hash, 'utf8')
const sig = crypto.sign(message, secretKey) // detached, 64 bytes
const combined = b4a.concat([sig, message])
return b4a.toString(publicKey, 'base64') + b4a.toString(combined, 'base64')
}
export class PghPeerClient {
/**
* @param {object} opts
* @param {string} opts.url — daemon base URL (http[s]://host:port)
* @param {object} [opts.identity] — pre-loaded identity (from loadOrCreateIdentity);
* if omitted, loads from DEFAULT_HYPERIDENT_HOME
* @param {number} [opts.timeout] — per-request timeout ms (default 30s)
*/
static async connect (opts = {}) {
if (!opts.url) throw new Error('PghPeerClient.connect requires opts.url')
const identity = opts.identity || await loadOrCreateIdentity(DEFAULT_HYPERIDENT_HOME)
if (!identity.publicKey || !identity.secretKey) {
throw new Error('PghPeerClient: identity has no device keypair — run `pgh init` first')
}
return new PghPeerClient({
url: opts.url.replace(/\/+$/, ''),
identity,
timeout: opts.timeout || 30000
})
}
constructor ({ url, identity, timeout }) {
this.url = url
this.identity = identity
this.timeout = timeout
}
async _signEnvelope (method, params) {
const nonce = b4a.toString(crypto.randomBytes(8), 'hex')
const payload = JSON.stringify({ method, params: params || {}, nonce })
const hash = await anHash(payload)
const sig = await anSign(hash, this.identity.secretKey, this.identity.publicKey)
const envelope = { payload, sig }
if (this.identity.chain) envelope.chain = this.identity.chain
return envelope
}
/**
* Call a remote handler. Signs an envelope with the local identity,
* POSTs /api/rpc, throws on non-200 or handler errors.
*
* @param {string} method
* @param {object} [params]
* @returns {Promise<any>}
*/
async call (method, params = {}) {
const envelope = await this._signEnvelope(method, params)
const res = await postJson(this.url + '/api/rpc', envelope, this.timeout)
if (res.status !== 200) {
const msg = (res.body && res.body.error) || ('http ' + res.status)
const err = new Error(msg)
err.code = (res.body && res.body.code) || 'EHTTP'
err.status = res.status
throw err
}
if (!res.body || res.body.ok !== true) {
const msg = (res.body && res.body.error) || 'server did not return ok'
const err = new Error(msg)
err.code = (res.body && res.body.code) || 'ENOTOK'
throw err
}
return res.body.result
}
async close () {
// Nothing persistent to release — POSTs are per-call.
}
}
function postJson (url, body, timeoutMs) {
return new Promise((resolve, reject) => {
const json = JSON.stringify(body || {})
const u = new URL(url)
const isHttps = u.protocol === 'https:'
const lib = isHttps ? https : http
const req = lib.request({
method: 'POST',
hostname: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: u.pathname,
headers: {
'Content-Type': 'application/json',
'Content-Length': b4a.byteLength(json),
Accept: 'application/json'
}
}, (res) => {
const parts = []
res.on('data', (c) => parts.push(c))
res.on('end', () => {
const text = b4a.concat(parts).toString('utf8')
let parsed
try { parsed = JSON.parse(text) } catch { parsed = text }
resolve({ status: res.statusCode, body: parsed })
})
res.on('error', reject)
})
const timer = setTimeout(() => {
req.destroy(new Error('peer-client: request timed out after ' + timeoutMs + 'ms'))
}, timeoutMs)
req.on('close', () => clearTimeout(timer))
req.on('error', reject)
req.write(json)
req.end()
})
}