OverviewFilesCommitsIssuesDocsReleasesPeers

favela-gateway commits / 2bf625f

0.2.0: metrics + direct CLI; refuse to start beside a running gateway
- favela-gateway <verb> [key=value …] calls the RUNNING gateway's socket (no pgh in between): metrics renders a readable summary (--json for raw), help lists the verbs it serves (new `verbs` verb), anything else prints the reply. Any argument means client — it never boots. - The bare command (pgh's) refuses when the gateway socket already answers: a hand-typed favela-gateway can no longer open a second writer on the same stores. - Carries atproto-bridge 0.9.54 (relay self-heal + membrane metrics) and ap-bridge 0.10.16 (fediverse metrics) through the vendored closure. Tests: 21/21 (cli 7, probes 5, gateway 9). Real entry: help/metrics answer, a second daemon refused, release-ready via the CLI -> 75, status afterwards -> 'not running … pgh app start favela-gateway'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2bf625f7805f98f451d241b75e0b2f7ae0aa873a
by plankatron <fleeky@gmail.com> · 2026-10-13T22:42:10.000Z

Changed files (7) +283 -9

README.md +13 -0
……@@ -17,4 +17,17 @@ Planned: `lens` (the bsky custom feed, computed keylessly from the fronted repos
1717- A role that fails to start is reported down; the others keep serving.
1818- `favela-gateway admin …` is ap-bridge's operator CLI.
1919
20## Operating it
21
22```
23favela-gateway metrics # roles, peers, bsky's view of us, fediverse delivery, problems
24favela-gateway metrics refresh=true # probe bsky now (otherwise cached 5 min)
25favela-gateway help # the verbs the running gateway serves
26favela-gateway probe-front did=did:plc:…
27```
28
29Any argument talks to the RUNNING gateway's socket and never boots one; the bare command (what pgh runs)
30refuses to start beside a running gateway. When bsky's relay marks us offline while we are caught up, the
31membrane's guard sends one requestCrawl (at most every 15 min) and `metrics` shows it.
32
2033`GATEWAY_ROLES` picks the roles (default `membrane,fediverse`).
bin/favela-gateway.js +29 -1
……@@ -21,7 +21,9 @@ import { loadConfig as loadMembraneConfig } from 'atproto-bridge/pds/lib/config.
2121import { createShutdown } from 'atproto-bridge/pds/lib/shutdown.js'
2222import { maintainSidecarRegistration } from 'atproto-bridge/pds/lib/sidecar.js'
2323import { loadConfig as loadApConfig, assertUsable } from 'ap-bridge/lib/config.js'
24import net from 'bare-net'
2425import { startGateway } from '../lib/gateway.js'
26import { runCli } from '../lib/cli.js'
2527
2628const log = (o) => console.log(JSON.stringify({ t: new Date().toISOString(), ...o }))
2729
……@@ -38,8 +40,34 @@ if (process.argv[2] === 'admin') {
3840 process.exit(await runAdminCli(process.argv.slice(3), { uds: path.join(dir, 'admin.sock') }))
3941}
4042
43// ⛔ ANY OTHER ARGUMENT MEANS CLIENT, and a client never boots. pgh's supervisor starts the daemon with no
44// arguments; everything an operator types (`favela-gateway metrics`, `… probe-front did=…`, `… help`) goes
45// to the RUNNING gateway's control socket instead. An audit agent once ran `favela-peer --help` and booted
46// a second daemon on a live store — that cannot happen here.
47const sockPath = process.env.GATEWAY_SOCK || path.join(os.homedir(), '.config', 'favela-gateway', 'svc.sock')
48if (process.argv.length > 2) {
49 process.exit(await runCli(process.argv.slice(2), { sock: sockPath }))
50}
51
52// ⛔ AND THE DAEMON REFUSES TO START BESIDE A RUNNING ONE. A bare `favela-gateway` typed by hand would
53// otherwise open the same stores and swarm keypairs as the live gateway — two writers on one identity.
54function socketIsLive (p) {
55 return new Promise((resolve) => {
56 if (!fs.existsSync(p)) return resolve(false)
57 const probe = net.connect(p)
58 const done = (v) => { try { probe.destroy() } catch {}; resolve(v) }
59 probe.on('connect', () => done(true))
60 probe.on('error', () => done(false))
61 setTimeout(() => done(false), 500)
62 })
63}
64if (await socketIsLive(sockPath)) {
65 console.error(`favela-gateway: a gateway is already running on ${sockPath} — not starting a second one. Try: favela-gateway metrics`)
66 process.exit(1)
67}
68
4169const roles = String(process.env.GATEWAY_ROLES || 'membrane,fediverse').split(',').map((s) => s.trim()).filter(Boolean)
42const sock = process.env.GATEWAY_SOCK || path.join(os.homedir(), '.config', 'favela-gateway', 'svc.sock')
70const sock = sockPath
4371const name = process.env.PGH_APP_NAME || 'favela-gateway'
4472const version = pkgVersion()
4573
lib/cli.js +140 -0
……@@ -0,0 +1,140 @@
1// favela-gateway CLI — `favela-gateway <verb> [key=value …]` against the RUNNING gateway's control socket.
2//
3// No pgh in between: this is the gateway's own operator surface (hyper-social #80). It only ever talks to
4// a running gateway; it never opens a store, binds a port or boots anything (see bin/favela-gateway.js).
5
6export const USAGE = `favela-gateway — the favela gateway (bsky membrane + fediverse bridge)
7
8 favela-gateway run the gateway (pgh does this; refuses if one is already running)
9 favela-gateway metrics [--json] health: roles, peers, bsky's view of us, fediverse delivery
10 favela-gateway metrics refresh=true probe bsky now instead of using the 5-minute cache
11 favela-gateway status roles and ports
12 favela-gateway help the verbs the running gateway serves
13 favela-gateway <verb> [key=value …] any verb, e.g. probe-front did=did:plc:…, invite, notifications
14 favela-gateway admin … the fediverse role's operator CLI (ap-bridge admin)`
15
16// key=value → typed params. Numbers, booleans, null and JSON objects/arrays are parsed; anything else is
17// a string. A bare word is refused rather than guessed at.
18export function parseParams (args) {
19 const out = {}
20 for (const a of args) {
21 const i = a.indexOf('=')
22 if (i <= 0) throw new Error(`expected key=value, got "${a}"`)
23 const k = a.slice(0, i)
24 const v = a.slice(i + 1)
25 let val = v
26 if (/^(true|false|null)$/.test(v) || /^-?\d+(\.\d+)?$/.test(v) || /^[[{]/.test(v)) {
27 try { val = JSON.parse(v) } catch { val = v }
28 }
29 out[k] = val
30 }
31 return out
32}
33
34async function defaultConnect (sock) {
35 const { Client } = await import('hyper-svc')
36 return Client.connectUds(sock)
37}
38
39const ago = (ts, now) => {
40 if (!ts) return 'never'
41 const s = Math.max(0, Math.round((now - ts) / 1000))
42 if (s < 90) return s + 's ago'
43 if (s < 90 * 60) return Math.round(s / 60) + 'm ago'
44 if (s < 36 * 3600) return Math.round(s / 3600) + 'h ago'
45 return Math.round(s / 86400) + 'd ago'
46}
47const mb = (n) => (n == null ? '?' : Math.round(n / 1e6) + ' MB')
48const dur = (s) => (s >= 3600 ? Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm' : Math.floor(s / 60) + 'm' + (s % 60) + 's')
49
50/** The `metrics` answer as an operator reads it: what is up, what bsky sees, what is wrong. */
51export function renderMetrics (m, { now = Date.now() } = {}) {
52 const L = []
53 L.push(`favela-gateway ${m.version || '?'} up ${dur(m.process.uptimeS)} rss ${mb(m.process.rss)} heap ${mb(m.process.heapUsed)}/${mb(m.process.heapTotal)}`)
54 L.push('')
55 L.push('roles')
56 for (const [name, r] of Object.entries(m.roles)) L.push(` ${name.padEnd(10)} ${r.up ? 'up' : 'DOWN ' + (r.error || '')}`)
57
58 if (m.membrane) {
59 const g = m.membrane.guard || {}
60 L.push('')
61 L.push(`bsky (host ${m.bsky ? m.bsky.hostname : '?'}, probed ${m.bsky ? ago(m.bsky.at, now) : 'never'})`)
62 if (m.bsky) {
63 const r = m.bsky.relay
64 L.push(` relay ${r.error ? 'UNREACHABLE ' + r.error : (r.status || '?')} relay seq ${r.seq == null ? '?' : r.seq} ours ${m.membrane.relay.emitted}`)
65 }
66 L.push(` guard ${g.configured ? 'last pass ' + ago(g.lastAt, now) + (g.last && g.last.relayStatus ? ' (saw ' + g.last.relayStatus + ')' : '') + ', heals ' + (g.heals || 0) + (g.lastHealAt ? ' (last ' + ago(g.lastHealAt, now) + ')' : '') : 'OFF (no crawl relay configured)'}`)
67 L.push('')
68 L.push('served DIDs')
69 const byDid = new Map(((m.bsky && m.bsky.dids) || []).map((d) => [d.did, d]))
70 for (const d of m.membrane.dids) {
71 const b = byDid.get(d.did) || {}
72 const sync = b.inSync === true ? 'in sync' : b.inSync === false ? 'relay ' + b.relayRev : 'relay ?'
73 const av = b.appview ? (b.appview.resolves ? `appview ok (${b.appview.postsCount == null ? '?' : b.appview.postsCount} posts, newest ${b.appview.newestIndexedAt ? b.appview.newestIndexedAt.slice(0, 10) : '?'})` : 'APPVIEW DOES NOT RESOLVE') : 'appview ?'
74 L.push(` ${(d.handle || b.handle || d.did).padEnd(24)} head ${d.rev || '?'} ${sync}`)
75 L.push(` ${''.padEnd(24)} peers ${d.live}/${d.fronts} live, last append ${ago(d.lastAppendAt, now)}${d.forkRejected ? ', ' + d.forkRejected + ' rival-line rejections' : ''}${d.denied ? ', ' + d.denied + ' denied' : ''}`)
76 L.push(` ${''.padEnd(24)} ${av}`)
77 }
78 }
79
80 if (m.fediverse) {
81 const f = m.fediverse
82 L.push('')
83 L.push('fediverse')
84 for (const u of f.users) L.push(` ${('@' + u.username).padEnd(24)} ${u.followers} follower${u.followers === 1 ? '' : 's'}, ${u.delivered} delivered${u.primary ? ' (primary)' : u.held ? '' : ', NOT HELD'}`)
85 L.push(` queue ${f.queue.pending} pending, ${f.queue.dead} dead${f.queue.oldestPendingAt ? ', oldest ' + ago(f.queue.oldestPendingAt, now) : ''}`)
86 for (const e of f.queue.failing) L.push(` ${e.host}: ${e.attempts} attempts — ${e.lastError || '?'}`)
87 L.push(` inbound ${f.inbound.count}, last ${ago(f.inbound.lastReceivedAt, now)}`)
88 L.push(` registered ${f.registrations}`)
89 }
90
91 const problems = (m.bsky && m.bsky.problems) || []
92 L.push('')
93 if (problems.length) {
94 L.push('PROBLEMS')
95 for (const p of problems) L.push(' - ' + p)
96 } else {
97 L.push('no problems reported')
98 }
99 return L.join('\n')
100}
101
102/** Returns the process exit code. */
103export async function runCli (argv, { sock, out = (s) => console.log(s), err = (s) => console.error(s), connect = defaultConnect } = {}) {
104 const [verb, ...rest] = argv
105 if (!verb || verb === '--help' || verb === '-h') { out(USAGE); return 0 }
106
107 const json = rest.includes('--json')
108 let params
109 try { params = parseParams(rest.filter((a) => a !== '--json')) } catch (e) { err(e.message); err(USAGE); return 2 }
110
111 let c
112 try { c = await connect(sock) } catch (e) {
113 if (e && (e.code === 'ENOENT' || e.code === 'ECONNREFUSED' || /ENOENT|ECONNREFUSED/.test(e.message))) {
114 err(`favela-gateway is not running (no control socket at ${sock}). Start it with: pgh app start favela-gateway`)
115 } else {
116 err('could not reach the gateway: ' + ((e && e.message) || e))
117 }
118 return 1
119 }
120
121 try {
122 if (verb === 'help') {
123 const r = await c.call('verbs', {})
124 out(USAGE)
125 out('\nverbs served by this gateway: ' + r.verbs.join(', '))
126 return 0
127 }
128 const r = await c.call(verb, params)
129 if (verb === 'metrics' && !json) out(renderMetrics(r))
130 else out(JSON.stringify(r, null, 2))
131 return r && r.ok === false ? 1 : 0
132 } catch (e) {
133 err((e && e.message) || String(e))
134 return 1
135 } finally {
136 try { await c.close() } catch {}
137 }
138}
139
140export default runCli
lib/gateway.js +3 -0
……@@ -123,6 +123,9 @@ export async function startGateway ({ roles, log = defaultLog, bootstrap = null,
123123 }
124124 }
125125
126 // The verbs this gateway serves, for `favela-gateway help`. release-ready is the process owner's.
127 handlers.verbs = async () => ({ verbs: [...Object.keys(handlers), 'release-ready'].sort() })
128
126129 return { roles: running, down, handlers, status, metrics, steps }
127130}
128131
package-lock.json +5 -7
……@@ -1,16 +1,17 @@
11{
22 "name": "favela-gateway",
3 "version": "0.1.0",
3 "version": "0.2.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "favela-gateway",
9 "version": "0.1.0",
9 "version": "0.2.0",
1010 "dependencies": {
1111 "ap-bridge": "file:../ap-bridge",
1212 "atproto-bridge": "file:../atproto-bridge",
1313 "bare-fs": "^4.8.1",
14 "bare-net": "^2.3.3",
1415 "bare-os": "^3.9.3",
1516 "bare-path": "^3.1.2",
1617 "bare-process": "^4.5.1",
……@@ -26,7 +27,7 @@
2627 }
2728 },
2829 "../ap-bridge": {
29 "version": "0.10.15",
30 "version": "0.10.16",
3031 "license": "MIT",
3132 "dependencies": {
3233 "atproto-did-peer": "file:../atproto-did-peer",
……@@ -61,7 +62,7 @@
6162 }
6263 },
6364 "../atproto-bridge": {
64 "version": "0.9.52",
65 "version": "0.9.54",
6566 "hasInstallScript": true,
6667 "dependencies": {
6768 "@ipld/car": "^5.4.0",
……@@ -318,7 +319,6 @@
318319 "version": "2.2.0",
319320 "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.2.0.tgz",
320321 "integrity": "sha512-iEqjj122eXdbDP5gXwk/NG/XVI5xoGjQA24UlGBPafUeMIGesxUZ5rxDvjNXPBHDyTkO+H3IsV5izZ8T5IGAXA==",
321 "dev": true,
322322 "license": "Apache-2.0",
323323 "engines": {
324324 "bare": ">=1.7.0"
……@@ -564,7 +564,6 @@
564564 "version": "2.3.3",
565565 "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.3.tgz",
566566 "integrity": "sha512-q1noXFJKn+eNi6vXYv2Y+5FTnn+o7qpli4m2lUZTdK8cBjVs2zGaykuzTUIa29v70eOsQI9OAIyYwN3ODHtFaQ==",
567 "dev": true,
568567 "license": "Apache-2.0",
569568 "dependencies": {
570569 "bare-events": "^2.2.2",
……@@ -729,7 +728,6 @@
729728 "version": "2.6.1",
730729 "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.6.1.tgz",
731730 "integrity": "sha512-8f8UFP7O27gIGaVWJPysQ2am8LSD6YCuVSbnTbOrQjUD0RkWEZexvi2Q66P99MLxSephlyjoU0mLxWmozRJMnA==",
732 "dev": true,
733731 "license": "Apache-2.0",
734732 "dependencies": {
735733 "bare-dns": "^2.0.4",
package.json +2 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-gateway",
3 "version": "0.1.0",
3 "version": "0.2.0",
44 "description": "The favela gateway: the bsky membrane (atproto-bridge) and the fediverse bridge (ap-bridge) in one process, keyless towards atproto. Later roles: the bsky lens and a blind seeder (hyper-social #80).",
55 "type": "module",
66 "main": "lib/gateway.js",
……@@ -14,6 +14,7 @@
1414 "ap-bridge": "file:../ap-bridge",
1515 "atproto-bridge": "file:../atproto-bridge",
1616 "bare-fs": "^4.8.1",
17 "bare-net": "^2.3.3",
1718 "bare-os": "^3.9.3",
1819 "bare-path": "^3.1.2",
1920 "bare-process": "^4.5.1",
test/cli.test.js +91 -0
……@@ -0,0 +1,91 @@
1// The gateway's own CLI: `favela-gateway <verb> [key=value …]` talks to the RUNNING gateway's control
2// socket — no pgh in between (operator request, 2026-10-13).
3//
4// ⛔ A CLIENT NEVER BOOTS. An earlier audit agent ran `favela-peer --help` and booted a second daemon on a
5// live store. Here any argument means "client"; only the bare command (what pgh's supervisor runs) is
6// the daemon — and that refuses when a gateway already answers (bin/favela-gateway.js).
7
8import test from 'brittle'
9import { runCli, parseParams, renderMetrics } from '../lib/cli.js'
10
11function capture () {
12 const lines = []
13 return { lines, write: (s) => lines.push(String(s)), text: () => lines.join('\n') }
14}
15
16// A fake connection answering verbs from a table, recording what was asked.
17function fakeConnect (answers, calls = []) {
18 return async () => ({
19 call: async (verb, params) => {
20 calls.push({ verb, params })
21 if (!(verb in answers)) throw new Error('unknown method: ' + verb)
22 const a = answers[verb]
23 return typeof a === 'function' ? a(params) : a
24 },
25 close: async () => {}
26 })
27}
28
29test('parseParams: key=value pairs, with numbers, booleans and JSON values typed', (t) => {
30 t.alike(parseParams(['did=did:plc:x', 'limit=5', 'force=true', 'raw={"a":1}']), { did: 'did:plc:x', limit: 5, force: true, raw: { a: 1 } })
31 t.alike(parseParams([]), {})
32 t.exception(() => parseParams(['nonsense']), /key=value/, 'a bare word is refused, not guessed at')
33})
34
35test('★ a verb is called on the socket and its reply printed', async (t) => {
36 const calls = []
37 const out = capture()
38 const code = await runCli(['probe-front', 'did=did:plc:x'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({ 'probe-front': { ok: true, did: 'did:plc:x' } }, calls) })
39 t.is(code, 0)
40 t.alike(calls, [{ verb: 'probe-front', params: { did: 'did:plc:x' } }])
41 t.ok(out.text().includes('"did": "did:plc:x"'), 'the reply, as readable JSON')
42})
43
44test('help lists the verbs the RUNNING gateway serves', async (t) => {
45 const out = capture()
46 await runCli(['help'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({ verbs: { verbs: ['status', 'metrics', 'invite'] } }) })
47 t.ok(out.text().includes('metrics') && out.text().includes('invite'))
48})
49
50test('⛔ a gateway that is not running says so, and how to start it — exit 1, nothing booted', async (t) => {
51 const out = capture()
52 const code = await runCli(['status'], { sock: '/x', out: out.write, err: out.write, connect: async () => { const e = new Error('connect ENOENT /x'); e.code = 'ENOENT'; throw e } })
53 t.is(code, 1)
54 t.ok(/not running/.test(out.text()) && /pgh app start favela-gateway/.test(out.text()))
55})
56
57test('a verb the gateway does not serve fails with the gateway\'s own error', async (t) => {
58 const out = capture()
59 const code = await runCli(['nope'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({}) })
60 t.is(code, 1)
61 t.ok(/unknown method: nope/.test(out.text()))
62})
63
64const sampleMetrics = {
65 version: '0.2.0',
66 process: { uptimeS: 3725, rss: 196e6, heapUsed: 60e6, heapTotal: 80e6 },
67 roles: { membrane: { up: true }, fediverse: { up: false, error: 'fediverse config: AP_HOST is required' } },
68 membrane: {
69 dids: [{ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', handle: 'p2p.7rnx.net', rev: '3mrhxnkbhv622', fronts: 2, live: 1, lastAppendAt: 1e12, forkRejected: 0, denied: 0 }],
70 relay: { emitted: 40267, seqBase: 35200 },
71 guard: { configured: true, lastAt: 1e12, last: { relayStatus: 'active' }, heals: 1, lastHealAt: 1e12 }
72 },
73 fediverse: null,
74 bsky: { at: 1e12, hostname: 'pds.7rnx.net', relay: { status: 'active', seq: 40267 }, dids: [{ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', relayRev: '3mrhxnkbhv622', inSync: true, plcPds: 'https://pds.7rnx.net', handle: 'p2p.7rnx.net', appview: { resolves: true, postsCount: 201, newestIndexedAt: '2026-09-15T14:05:39Z' } }], problems: ['did:plc:x: the relay is behind'] }
75}
76
77test('★ metrics renders as a readable summary: roles, bsky, peers, problems', (t) => {
78 const s = renderMetrics(sampleMetrics, { now: 1e12 + 120e3 })
79 t.ok(/fediverse +DOWN.*AP_HOST is required/.test(s), 'a down role, with why')
80 t.ok(/relay +active/.test(s) && s.includes('40267'), 'bsky relay status and seq')
81 t.ok(s.includes('p2p.7rnx.net') && s.includes('in sync'), 'per DID: handle and relay sync')
82 t.ok(/1\/2 live/.test(s), 'peers: live of cached fronts')
83 t.ok(/2m ago/.test(s), 'last append as an age, not a timestamp')
84 t.ok(/problems/i.test(s) && s.includes('the relay is behind'), 'the named problems, last')
85})
86
87test('metrics --json prints the raw answer', async (t) => {
88 const out = capture()
89 await runCli(['metrics', '--json'], { sock: '/x', out: out.write, err: out.write, connect: fakeConnect({ metrics: sampleMetrics }) })
90 t.is(JSON.parse(out.text()).version, '0.2.0')
91})