// control.js — the UDS control surface and the OTA respawn contract.
//
// WHY THIS EXISTS. ap-bridge had NO control surface at all: no UDS, no sidecar registration, no
// `release-ready`. Every deploy was therefore a manual stop / install / start, while atproto-bridge
// and favela-web update themselves. That gap is also why ap-bridge is the app most likely to be
// running something other than what was published — INSTALLED != RUNNING, with no mechanism to close
// the gap.
//
// THE OTA CONTRACT (same as atproto-bridge 0.9.39 and favela-web):
// 1. pgh-sidecar does the FULL install — tree fetch, dep closure, materialize, symlink swap.
// 2. It then calls `release-ready` on this socket.
// 3. We respond ok FIRST, then shut down cleanly and exit 75.
// 4. The supervisor sees 75 and respawns against the new tree.
//
// ⚠ DELIBERATELY NOT pgh-runtime. It fetches ROOT trees WITHOUT dep closures, so an app pulling its
// own update produces a broken install — the MODULE_NOT_FOUND respawn loop that took the membrane
// down on 2026-07-31. The sidecar already does it correctly; duplicating half of it is how you get
// half of it. Our only job is to exit.
//
// ⚠ Respond BEFORE exiting. Exiting first drops the connection, and the sidecar cannot distinguish
// "app accepted and is respawning" from "app crashed".
//
// FAILS SOFT. Serving ActivityPub matters more than being remotely manageable: if hyper-svc is
// missing or the socket cannot bind, the bridge logs it and keeps running. Registration is likewise
// best-effort — a sidecar that is down (or absent entirely, e.g. a standalone `bare index.js` run)
// must not stop the actor federating.
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import process from 'bare-process'
export function sidecarSockPath () {
return process.env.PGH_SIDECAR_SOCK || path.join(os.homedir(), '.pgh', 'svc.sock')
}
// The sidecar socket may appear AFTER us — pgh boots apps in dependency order but the sidecar's own
// cold start indexes a large store, so it can lose the race. Poll rather than give up.
export async function waitForSidecarSock ({ timeoutMs = 120000, intervalMs = 1000, shouldStop = () => false } = {}) {
const sock = sidecarSockPath()
const deadline = Date.now() + timeoutMs
while (!fs.existsSync(sock)) {
if (shouldStop()) return false
if (Date.now() >= deadline) return false
await new Promise((r) => setTimeout(r, intervalMs))
}
return true
}
export async function registerWithSidecar ({ udsPath, name, runtime = 'bare', version = null }) {
const sidecarSock = sidecarSockPath()
if (!fs.existsSync(sidecarSock)) return { registered: false, reason: 'sidecar not running' }
try {
const { Client } = await import('hyper-svc')
const c = await Client.connectUds(sidecarSock)
await c.call('app-register', { name, udsPath, pid: process.pid, runtime, version })
await c.close()
return { registered: true, name }
} catch (err) {
return { registered: false, reason: err.message }
}
}
// Register once the sidecar is up, then re-register on an interval: the sidecar keeps its runtime
// registry IN MEMORY, so a sidecar restart silently drops us and every `pgh` command against this
// app starts failing with "not registered". Returns stop().
export function maintainSidecarRegistration ({ udsPath, name, runtime = 'bare', version = null, intervalMs = 30000 } = {}) {
let timer = null
let stopped = false
;(async () => {
const ready = await waitForSidecarSock({ shouldStop: () => stopped })
if (stopped) return
if (ready) await registerWithSidecar({ udsPath, name, runtime, version })
timer = setInterval(() => { registerWithSidecar({ udsPath, name, runtime, version }) }, intervalMs)
if (timer.unref) timer.unref()
})()
return () => { stopped = true; if (timer) clearInterval(timer) }
}
/**
* The control verbs as a plain map, separate from the socket that serves them.
*
* ⛔ The favela gateway (hyper-social #80) serves these on ITS socket beside the membrane's verbs, and
* must not bind ap-bridge's svc.sock or register its name: a release of ap-bridge pushed to a path the
* gateway served would restart the wrong process. startControl below registers this same map.
*
* `release-ready` is not here: whoever owns the process owns its exit.
*/
export function bridgeHandlers ({ name = 'ap-bridge', version = null, status = null, onReproject = null, onNotifications = null, onInvite = null } = {}) {
const handlers = {
status: async () => (status ? status() : { ok: true, name, version })
}
// Read what the fediverse sent us. An inbound store nothing can query is indistinguishable from
// the 202-and-discard it replaced, so the read verb ships with the write path, not after it.
if (onNotifications) {
handlers.notifications = async (params = {}) => onNotifications({
limit: Math.min(Number(params.limit) || 50, 200)
})
}
// ⛔ MINTING AN INVITE IS AN OPERATOR ACT, so it lives on the owner-gated unix socket and nowhere
// else. `registrations.js` has had `invite()`, single-use hashes and an `invite` policy since it
// was written, and NOTHING has ever called it — so a bridge could be switched to invite-only and
// then no invite could be created for it. The token type existed; the door did not.
//
// ⚠ The token is returned ONCE and stored nowhere: the log holds only its hash, exactly as the
// page that displays one already says. A control surface that could list tokens would defeat
// that, so this mints and forgets.
if (onInvite) handlers.invite = async () => onInvite()
// reproject — the operator verb for "the projection improved, tell the receivers".
//
// Needed because the publisher's drain only runs on onHead(), so an improvement reaches
// already-delivered posts only when some unrelated FUTURE post advances the head. #26 recorded
// that trap twice ("event-driven quietly meant never happens"); an event-driven system needs a
// reconciliation it can be ASKED to run.
if (onReproject) {
handlers.reproject = async (params = {}) => onReproject({
rkey: params.rkey || null,
force: !!params.force,
// Default ON: never re-send a post whose media cannot be served. Overridable, deliberately,
// because an operator staring at a specific case may know better than the probe.
verify: params.verify !== false,
limit: Number(params.limit) || 50
})
}
return handlers
}
// Stand up the control socket. Returns { svc, udsPath } or null when unavailable — the caller keeps
// serving either way.
//
// onRelease(version) is invoked AFTER the response is queued, via setImmediate, so the caller owns
// the shutdown sequence and exit code.
export async function startControl ({ udsPath, name, version = null, status = null, onRelease = null, onReproject = null, onNotifications = null, onInvite = null, log = console.log } = {}) {
try {
const { Service } = await import('hyper-svc')
// A stale socket from a killed process blocks bind. We only reach here after the supervisor
// decided no live instance owns this app, so an existing file is orphaned.
try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {}
const svc = new Service({ name, uds: udsPath })
for (const [verb, fn] of Object.entries(bridgeHandlers({ name, version, status, onReproject, onNotifications, onInvite }))) {
svc.handle(verb, async (ctx, params) => fn(params))
}
if (onRelease && !svc.hasHandler?.('release-ready')) {
svc.handle('release-ready', async (ctx, params = {}) => {
const ver = (params && params.version) || '?'
log({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: ver })
setImmediate(() => { onRelease(ver) })
return { ok: true, willRespawn: true }
})
}
await svc.listen()
maintainSidecarRegistration({ udsPath, name, version })
log({ msg: 'control surface up — OTA respawn armed', uds: udsPath })
return { svc, udsPath }
} catch (err) {
log({ msg: 'control surface unavailable (continuing to serve)', error: err && err.message })
return null
}
}