OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / e80b0dd

0.1.0: favela-host is THE favela app — one process, the same stores, autostarted
Decided 2026-10-13 (hyper-social #77): one desktop app instead of favela-peer, favela-surface, favela-bridge and hyper-proto-peer as four. With them separate a reboot brought back the bridge and the signer but not the node or its UI (both autostart:false), and the surface then asked for an invite when the real problem was that nothing was behind it (ashatron). The five changes from #77 §5, each tested red first: 1. Shared stores. bootAtproto takes hpp's own config + storage (PEER_CONFIG / PEER_STORAGE); the did-* ops read the same config. A second store for the same devices would be a second writer for the same identity. 2. signInOrServe: a node another browser already claimed still boots and serves; only the in-app session is skipped, and the reason is said. (signIn still throws — the handset relies on it.) 3. watchProjection: favela-bridge's --watch inside the app, EVENT-DRIVEN on the own feed's autobee 'update' (no polling), coalesced, never stopped by a failing pass. Sends, as the deployed watch did; consent stays in projectCycle. 4. pgh app: autostart, FAVELA_STORAGE=favela-peer's store, PEER_* = hpp's, surface on 4042, its own control socket (favela-host.svc.sock — not favela-peer's svc.sock, so a favela-peer release cannot restart it), release-ready -> exit 75, bounded shutdown that also closes hpp. Refuses to start beside a live favela-peer socket or hpp control socket. .pear-git/ci.yaml copied from favela-peer's; b4a declared. 5. did-* ops choose an identity (explicit did, then the designated credential, then the only one); a write with several and none chosen refuses instead of using dids[0]. Verified: suite 31/31 (11 new); declare-deps guard clean; the real entry refused to start beside tabertron's running hpp before opening anything; an isolated boot served the surface (303 to login), answered whoami on the socket and status on its control socket, and exited 75 on release-ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e80b0dded3864e631a83e2fe066a627e7cc117eb
by plankatron <fleeky@gmail.com> · 2026-10-13T17:02:07.000Z

Changed files (11) +569 -53

.pear-git/ci.yaml +99 -0
……@@ -0,0 +1,99 @@
1# pear-git CI — auto-build + publish on `git push pear master`.
2#
3# Copied from favela-peer's ci.yaml (install -> vendor -> guard -> tests -> publish), the only order where
4# every precondition holds. favela-host is THE favela app (hyper-social #77, 2026-10-13), so it ships the
5# way every other fleet app does: a push builds and publishes, hosts autoupdate, and the app respawns on
6# release-ready. Its siblings are all file: deps, vendored from the build machine's checkout.
7#
8# NO daemon-boot smoke: CI steps are synchronous with a hard timeout, and a step that boots a long-running
9# daemon orphans the child when the timeout fires.
10
11steps:
12 - name: install deps (root closure)
13 run: npm install --production
14 timeout: 300
15
16 # Vendor LAST among the npm operations. CI git-archives only this repo, so each file: dep is a
17 # dangling symlink until it is copied in from the build machine's working tree, and whatever is
18 # checked out there ships to the fleet. Refuse unless each sibling HEAD is reachable from a hosted
19 # branch.
20 #
21 # ⛔ The SOURCE comes from the file: spec, not the package name. Two siblings live off the flat
22 # layout: hyper-identity2 is `file:../packages/hyper-identity2` and hyperident is
23 # `file:../repos/hyperident`. Looking them up as hyper-fleeky/<name> (ap-bridge's rule) finds
24 # neither. The spec is resolved against the canonical checkout, because the export in /tmp has no
25 # meaningful `..`.
26 - name: "vendor file: siblings into the closure"
27 run: |
28 HF=/storage/projects/code/hypercore-kb/hyper-fleeky
29 [ -d "$HF" ] || HF="$HOME/hyper-fleeky"
30 SIBS=$(node -e 'const d=require("./package.json");const a={...d.dependencies,...d.devDependencies};console.log(Object.entries(a).filter(([,v])=>String(v).startsWith("file:../")).map(([k,v])=>k+"="+v.slice(8)).join(" "))')
31 echo "siblings (derived from package.json): $SIBS"
32 [ -n "$SIBS" ] || { echo "no file: siblings found — package.json unreadable?"; exit 1; }
33 rc=0
34 for pair in $SIBS; do
35 pkg=${pair%%=*}; rel=${pair#*=}
36 SRC="$HF/$rel"
37 if [ ! -d "$SRC" ]; then echo " $pkg: $SRC NOT FOUND on the build machine — refuse"; rc=1; continue; fi
38 GD="$HOME/git-repos/$(basename "$rel").git"
39 if [ ! -d "$GD" ]; then echo " $pkg: no hosted repo at $GD — refuse"; rc=1; continue; fi
40 LOCAL=$(git -C "$SRC" rev-parse HEAD 2>/dev/null)
41 REFS=$(git --git-dir="$GD" for-each-ref --contains "$LOCAL" --format='%(refname:short)' refs/heads 2>/dev/null | tr '\n' ' ')
42 if [ -z "$REFS" ]; then echo " $pkg: $(echo $LOCAL | cut -c1-7) NOT in hosted history — refuse (push it)"; rc=1; continue; fi
43 echo " $pkg ($rel): $(echo $LOCAL | cut -c1-7) ok (on: $REFS)"
44 rm -rf "node_modules/$pkg" && cp -rL "$SRC" "node_modules/$pkg"
45 done
46 exit $rc
47 timeout: 180
48
49 # Declare-your-deps guard: an undeclared runtime import resolves locally through hoisting and then
50 # crash-loops the --production closure with MODULE_NOT_FOUND. Checks the DECLARATION, not the
51 # disk. Runtime code is index.js, bin/ and lib/; `#node:` specifiers are package imports and are
52 # skipped by the pattern.
53 - name: every direct import is declared
54 run: |
55 rc=0
56 for spec in $(grep -hoE "from '[a-z0-9@][^']*'" index.js bin/*.js lib/*.js \
57 | sed -E "s/.*from '([^']*)'/\1/" | sort -u); do
58 case "$spec" in
59 @*) m=$(echo "$spec" | cut -d/ -f1,2) ;;
60 *) m=$(echo "$spec" | cut -d/ -f1) ;;
61 esac
62 if node -e "const d=require('./package.json');process.exit(({...d.dependencies,...d.devDependencies})['$m']?0:1)"; then echo " ok: $m"
63 else echo " UNDECLARED: $m (from '$spec') — resolves on disk but is not in package.json"; rc=1; fi
64 done
65 exit $rc
66 timeout: 60
67
68 # Tests AFTER vendoring, against the exact tree about to be published. npm must not touch this
69 # tree now (it re-resolves and deletes hand-vendored directories), so registry devDependencies go
70 # into a SEPARATE prefix, only what the tree lacks is copied in, the suite runs with the tree's own
71 # brittle (brittle-bare runs tests in-process), and exactly those additions are removed again.
72 - name: tests (offline, against the vendored closure)
73 run: |
74 B=$(mktemp -d)
75 SPECS=$(node -p 'Object.entries(require("./package.json").devDependencies||{}).filter(([,v])=>!String(v).startsWith("file:")).map(([k,v])=>k+"@"+v).join(" ")')
76 echo "test-only devDependencies: $SPECS"
77 npm install --prefix "$B" --no-audit --no-fund --no-save $SPECS >/dev/null 2>&1 || { echo "could not install: $SPECS"; exit 1; }
78 ADDED=""
79 for p in $(cd "$B/node_modules" && ls -A | grep -v -E '^\.'); do
80 case "$p" in
81 @*) for q in $(ls -A "$B/node_modules/$p"); do
82 if [ ! -e "node_modules/$p/$q" ]; then mkdir -p "node_modules/$p" && cp -r "$B/node_modules/$p/$q" "node_modules/$p/$q" && ADDED="$ADDED $p/$q"; fi
83 done ;;
84 *) if [ ! -e "node_modules/$p" ]; then cp -r "$B/node_modules/$p" "node_modules/$p" && ADDED="$ADDED $p"; fi ;;
85 esac
86 done
87 echo "overlaid for the test run only:$ADDED"
88 # The runner's path comes from brittle's own bin map: brittle 4 moved it from bin/bare.js
89 # (ap-bridge's 3.x) to brittle-bare.js at the package root.
90 RUNNER=node_modules/brittle/$(node -p 'require("./node_modules/brittle/package.json").bin["brittle-bare"]')
91 bare "$RUNNER" test/*.test.js; rc=$?
92 for p in $ADDED; do rm -rf "node_modules/$p"; done
93 rm -rf "$B"
94 exit $rc
95 timeout: 900
96
97 - name: pgh publish (content-addressed -> installable app)
98 run: pgh publish . --accept-conflict "automated CI publish; dep version-splits inherited from per-app lockfiles across the pgh ecosystem"
99 timeout: 300
bin/favela-host.js +82 -27
……@@ -1,18 +1,23 @@
11#!/usr/bin/env bare
2// favela-host — the desktop entry. `bare bin/favela-host.js`.
2// favela-host — THE favela app on a desktop, home node or server. `bare bin/favela-host.js`.
33//
4// One process holding a favela-peer node, favela-surface rendering it, and the atproto verbs. This is
5// the same composition the Android worklet runs, PLUS a unix socket — which the worklet cannot have
6// because Android SELinux refuses an unprivileged app a `sock_file`, and a desktop has no such limit.
4// One process holding a favela-peer node, favela-surface rendering it, the atproto signer, and the bsky
5// projection. It replaces running favela-peer, favela-surface, favela-bridge and hyper-proto-peer as four
6// apps (hyper-social #77, decided 2026-10-13): with them separate, a reboot brought back the bridge and
7// the signer but not the node or its UI, and the surface then asked for an invite when the real problem
8// was that nothing was behind it.
79//
8// ⛔ THE SOCKET IS SERVED FROM THE SAME COMPOSITION, not from `dispatch`. That is the whole point of
9// `createServer`'s `call` option: `favela-bridge` and the `favela` CLI reach this over the socket and
10// now see exactly the verb set the pages see. A host that served a smaller set over the socket would
11// be two different daemons wearing one name.
10// ⛔ THE SAME STORES THE FOUR APPS USE, never a copy. FAVELA_STORAGE is favela-peer's
11// (~/.config/favela-peer, so the socket and the `favela` CLI keep working), and PEER_CONFIG / PEER_STORAGE
12// are hyper-proto-peer's own config and data. A second store for the same devices would be a second
13// writer for the same identity — a fork. Without PEER_CONFIG (a fresh desktop, a handset) the atproto
14// half lives under the node's storage, as before.
1215//
13// ⚠ It does NOT replace favela-peer. A headless box that wants only the daemon still runs
14// `favela-peer`, and a surface pointed at a remote daemon still runs `favela-surface` against this
15// socket. This is the third arrangement, not a replacement for either.
16// ⛔ AND NEVER BESIDE THEM. It refuses to start while a favela-peer holds the socket or a
17// hyper-proto-peer holds hpp's control socket: two processes on one store is the same fork.
18//
19// ⛔ THE SOCKET IS SERVED FROM THE SAME COMPOSITION, not from `dispatch` — `favela-bridge` and the
20// `favela` CLI reach this over the socket and see exactly the verb set the pages see.
1621
1722import fs from 'bare-fs'
1823import os from 'bare-os'
……@@ -21,18 +26,31 @@ import process from 'bare-process'
2126import { join } from 'bare-path'
2227import Hyperswarm from 'hyperswarm'
2328import { createServer } from 'favela-peer/lib/rpc.js'
29import { startControl } from 'favela-peer/lib/control.js'
2430import { boot, inProcessClient } from '../lib/host.js'
31import { watchProjection } from '../lib/project.js'
2532
2633const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela')
2734const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock')
35// ⛔ ITS OWN control socket, not favela-peer's `svc.sock` in the same directory: the sidecar remembers
36// favela-peer's registration, and a favela-peer release pushed to a path this app now served would
37// restart the wrong process.
38const svcSock = process.env.FAVELA_HOST_SVC_SOCK || join(storage, 'favela-host.svc.sock')
2839const port = process.env.FAVELA_SURFACE_PORT ? Number(process.env.FAVELA_SURFACE_PORT) : null
2940const host = process.env.FAVELA_SURFACE_HOST || '127.0.0.1'
41// hpp's own config and data, when this machine has them. Empty strings count as unset.
42const peerConfig = process.env.PEER_CONFIG || null
43const peerStorage = process.env.PEER_STORAGE || null
44
45function pkgVersion () {
46 try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url).pathname, 'utf8')).version } catch { return null }
47}
3048
31// ⛔ Refuse to start if a daemon already holds the socket. Removing a live socket would leave the
32// running daemon unreachable while this one looked healthy — one process, two owners, no error.
33// Lifted from favela-peer/index.js, which has the same hazard for the same reason.
49// ⛔ Refuse to start if a daemon already holds a socket. Removing a live socket would leave the running
50// daemon unreachable while this one looked healthy — one process, two owners, no error.
3451function socketIsLive (path) {
3552 return new Promise((resolve) => {
53 if (!fs.existsSync(path)) return resolve(false)
3654 const probe = net.connect(path)
3755 const done = (v) => { try { probe.destroy() } catch {}; resolve(v) }
3856 probe.on('connect', () => done(true))
……@@ -45,33 +63,70 @@ const say = (msg, data = {}) => console.log(JSON.stringify({ msg, ...data }))
4563
4664async function main () {
4765 fs.mkdirSync(storage, { recursive: true })
48 if (fs.existsSync(sock) && await socketIsLive(sock)) {
49 console.error('favela-host: a daemon is already listening on ' + sock)
66 if (await socketIsLive(sock)) {
67 console.error('favela-host: a daemon is already listening on ' + sock + ' — stop favela-peer first (pgh app stop favela-peer)')
68 process.exit(1)
69 }
70 if (peerStorage && await socketIsLive(join(peerStorage, 'control.sock'))) {
71 console.error('favela-host: hyper-proto-peer is running on ' + peerStorage + ' — stop it first (pgh app stop hyper-proto-peer); ' +
72 'two processes on one store would be two writers for the same identity')
5073 process.exit(1)
5174 }
5275
53 // ⛔ CONSTRUCTED HERE, not inside the host. lib/host.js explains why: importing hyperswarm there
54 // duplicated it in the Android bundle, because this package and that app resolve it to different
55 // copies on disk. The desktop owns its swarm; the worklet owns its own.
76 // ⛔ CONSTRUCTED HERE, not inside the host: lib/host.js explains why (the Android bundle would carry
77 // two copies of hyperswarm otherwise). The desktop owns its swarm; the worklet owns its own.
5678 const swarm = new Hyperswarm()
57 const { node, surface } = await boot(storage, { swarm, onStage: say, port, host })
79 const { node, surface, atproto } = await boot(storage, {
80 swarm,
81 onStage: say,
82 port,
83 host,
84 atproto: peerConfig ? { configFile: peerConfig, storage: peerStorage } : {}
85 })
5886
59 // The SAME client the pages use — see the header. `inProcessClient` is cheap and stateless, so
60 // building a second one rather than reaching into `surface` keeps the dependency one-way.
87 // The SAME client the pages use — see the header.
6188 const client = inProcessClient(node)
6289 createServer(node, sock, { call: (cmd, args) => client.call(cmd, args), onError: () => {} })
90 say('favela-host up', { feed: node.key.toString('hex'), storage, sock, url: surface.url, atproto: peerConfig || 'node storage', version: pkgVersion() })
6391
64 say('favela-host up', { feed: node.key.toString('hex'), storage, sock, url: surface.url })
92 // favela-bridge's --watch, inside the app: event-driven on the node's own feed. Waits for the atproto
93 // half; a node with no identity projects nothing.
94 let projection = null
95 watchProjection(node, atproto, { log: (line) => say('bsky', { line }) })
96 .then((w) => { projection = w; say(w.watching ? 'bsky projection watching' : 'no atproto identity — nothing to project') })
97 .catch((e) => say('bsky projection did not start', { error: (e && e.message) || String(e) }))
6598
66 const shutdown = async () => {
99 // Idempotent and BOUNDED: a close that never settles must not keep an OTA from respawning (the
100 // supervisor respawns only once we exit). The first caller's exit code wins: 75 for release-ready,
101 // which the supervisor respawns immediately, 0 for a signal.
102 let control = null
103 let stopping = false
104 const shutdown = async (code = 0) => {
105 if (stopping) return
106 stopping = true
107 const deadline = setTimeout(() => { say('shutdown: deadline reached, exiting anyway', { code }); process.exit(code) }, 15000)
108 try { if (projection) projection.stop() } catch {}
109 try { if (control) await control.close() } catch {}
67110 try { surface.server.close() } catch {}
111 try { const hpp = await Promise.race([atproto, new Promise((resolve) => setTimeout(() => resolve(null), 2000))]); if (hpp) await hpp.close() } catch {}
68112 try { await node.close() } catch {}
69113 try { await swarm.destroy() } catch {}
70114 try { fs.unlinkSync(sock) } catch {}
71 process.exit(0)
115 clearTimeout(deadline)
116 process.exit(code)
72117 }
73 process.on('SIGINT', shutdown)
74 process.on('SIGTERM', shutdown)
118 process.on('SIGINT', () => shutdown(0))
119 process.on('SIGTERM', () => shutdown(0))
120
121 // OTA: registers this app's control socket with pgh-sidecar so a published release reaches THIS
122 // process. Fails soft — the app keeps serving if the sidecar is absent.
123 control = await startControl({
124 udsPath: svcSock,
125 name: process.env.PGH_APP_NAME || 'favela-host',
126 version: pkgVersion(),
127 status: () => ({ ok: true, name: 'favela-host', version: pkgVersion(), feed: node.key.toString('hex'), url: surface.url }),
128 onRelease: () => shutdown(75)
129 })
75130}
76131
77132main().catch((err) => { console.error(err); process.exit(1) })
index.js +1 -1
……@@ -10,5 +10,5 @@
1010// offer the atproto verbs that neither favela-peer nor favela-surface may import. A shell adds its
1111// own platform concerns on top — bare-kit IPC and OTA on Android, a unix socket on a desktop.
1212
13export { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from './lib/host.js'
13export { boot, serveSurface, inProcessClient, listenPreferring, signIn, signInOrServe, PORT_FILE } from './lib/host.js'
1414export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath, bootAtproto } from './lib/atproto.js'
lib/atproto.js +61 -17
……@@ -33,7 +33,11 @@ import { join, dirname } from 'bare-path'
3333 * and wrote down the rule: "THE VAULT LIVES UNDER THE NODE'S STORAGE, not ~/.hyperident. A phone has
3434 * no home directory... the daemon already knows where its own state goes, so it says."
3535 */
36export const atprotoConfigPath = (node) => join(node.storage, 'atproto', 'config.json')
36//
37// ⛔ UNLESS THE EMBEDDER NAMES hpp's OWN CONFIG (`node.atprotoConfigFile`). On a machine that already runs
38// hyper-proto-peer the identities live in hpp's config and data, and a second copy under the node's
39// storage would be a second writer for the same devices — a fork (hyper-social #77 §5, change 1).
40export const atprotoConfigPath = (node) => node.atprotoConfigFile || join(node.storage, 'atproto', 'config.json')
3741
3842/** Where a minted identity's sealed recovery root goes. */
3943export const recoveryVaultPath = (node, did) =>
……@@ -65,6 +69,45 @@ const writeJson = (p, v) => {
6569 fs.renameSync(tmp, p)
6670}
6771
72/**
73 * Which identity an op acts on.
74 *
75 * ⛔ NOT `dids[0]`. On a server the desktop app boots hpp's own config, which holds several identities
76 * (wj62 and loser-club), and did-carry / did-adopt / did-submit are permanent appends to a global log —
77 * "whichever is first" is not a choice anyone made (hyper-social #77 §5, change 5). pickNode's rule
78 * (lib/project.js): an explicit `did`, then the designated credential (identity.md §5.1), then the only
79 * one. With several and none chosen a WRITE refuses; a read answers for the first and lists them all.
80 *
81 * Returns { cfg, entry, dids } — `cfg` because did-adopt writes the entry back into it.
82 */
83export function atprotoEntry (node, a = {}, { write = false, designated = null } = {}) {
84 const cfg = readJson(atprotoConfigPath(node))
85 const all = cfg && Array.isArray(cfg.dids) ? cfg.dids.filter((d) => d && d.did) : []
86 const dids = all.map((d) => d.did)
87 if (a && a.did) {
88 const entry = all.find((d) => d.did === a.did)
89 if (!entry) throw new Error(a.did + ' is not an identity this node holds')
90 return { cfg, entry, dids }
91 }
92 if (designated) {
93 const entry = all.find((d) => d.did === designated)
94 if (entry) return { cfg, entry, dids }
95 }
96 if (all.length > 1 && write) {
97 throw new Error('this node holds several identities (' + dids.join(', ') + ') and none is designated — say which one')
98 }
99 return { cfg, entry: all[0] || null, dids }
100}
101
102// The designated credential, if this node has one — reach's, the same one the projector honours.
103async function designatedDid (node) {
104 try {
105 const { dispatch } = await import('favela-peer/lib/rpc.js')
106 const r = await dispatch(node, 'reach', {})
107 return (r && r.credential) || null
108 } catch { return null }
109}
110
68111export const ATPROTO_OPS = {
69112 /**
70113 * Create a new atproto identity, or restore one from a recovery phrase.
……@@ -169,13 +212,14 @@ export const ATPROTO_OPS = {
169212 return { ...r, log: lines }
170213 },
171214
172 'did-status': async (node) => {
173 const cfg = readJson(atprotoConfigPath(node))
174 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
175 if (!entry || !entry.did) return { did: null, sealed: false, submitted: false, publishable: false }
215 'did-status': async (node, a = {}) => {
216 const { entry, dids } = atprotoEntry(node, a, { designated: await designatedDid(node) })
217 if (!entry || !entry.did) return { did: null, dids, sealed: false, submitted: false, publishable: false }
176218 const genesis = readJson(genesisPath(node, entry.did))
177219 return {
178220 did: entry.did,
221 // Every identity this node holds, so a page can offer the choice rather than show only the first.
222 dids,
179223 // ⛔ The handle and endpoint a bridge granted, if one has. Without them a published DID has
180224 // `alsoKnownAs: []` and `services: {}` — nothing for Bluesky to fetch a repo from — so the page
181225 // must be able to tell "published" from "reachable", which are not the same state at all.
……@@ -208,8 +252,7 @@ export const ATPROTO_OPS = {
208252 * mnemonic unsealed to avoid the prompt would make "a stolen device yields a locked vault" false.
209253 */
210254 'did-carry': async (node, a) => {
211 const cfg = readJson(atprotoConfigPath(node))
212 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
255 const { entry } = atprotoEntry(node, a, { write: true, designated: await designatedDid(node) })
213256 if (!entry || !entry.did) throw new Error('this node has no identity for a bridge to carry')
214257 if (!a.passphrase) throw new Error('this needs your recovery passphrase — it unlocks the rotation key, the only thing allowed to change this identity')
215258
……@@ -228,7 +271,7 @@ export const ATPROTO_OPS = {
228271
229272 // ⛔ THE IRREVERSIBLE HALF, and only now. Everything above can be undone by asking the bridge to
230273 // drop us; from here the append is permanent and the previous state stays readable for ever.
231 const adopted = await ATPROTO_OPS['did-adopt'](node, { passphrase: a.passphrase, handle, pdsUrl, plcUrl: a.plcUrl })
274 const adopted = await ATPROTO_OPS['did-adopt'](node, { did: entry.did, passphrase: a.passphrase, handle, pdsUrl, plcUrl: a.plcUrl })
232275 return { ...adopted, carriedBy: a.bridge, pending: r.status === 202 ? (r.body.reason || true) : false }
233276 },
234277
……@@ -263,8 +306,7 @@ export const ATPROTO_OPS = {
263306 if (!/^[a-z0-9][a-z0-9.-]{0,252}[a-z0-9]$/.test(handle)) throw new Error('that is not a usable handle')
264307 if (!/^https:\/\/[^\s]+$/.test(pdsUrl)) throw new Error('a PDS endpoint must be an https URL')
265308
266 const cfg = readJson(atprotoConfigPath(node))
267 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
309 const { cfg, entry } = atprotoEntry(node, a, { write: true, designated: await designatedDid(node) })
268310 if (!entry || !entry.did) throw new Error('this node has no identity to adopt a handle onto')
269311 const did = entry.did
270312
……@@ -329,9 +371,8 @@ export const ATPROTO_OPS = {
329371 * test identity is forever. Its own separateness is the safeguard: nothing that mints can submit
330372 * by accident.
331373 */
332 'did-submit': async (node) => {
333 const cfg = readJson(atprotoConfigPath(node))
334 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
374 'did-submit': async (node, a = {}) => {
375 const { entry } = atprotoEntry(node, a, { write: true, designated: await designatedDid(node) })
335376 if (!entry || !entry.did) throw new Error('did-submit: this node has no identity to publish')
336377
337378 const p = genesisPath(node, entry.did)
……@@ -375,15 +416,18 @@ export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath }
375416 * ⚠ Returns null rather than throwing when there is no identity. A node with no DID is the normal
376417 * state, not a failure, and a read-only node that boots beats a node that does not.
377418 */
378export async function bootAtproto (node, { log = () => {} } = {}) {
379 const configFile = atprotoConfigPath(node)
419export async function bootAtproto (node, { log = () => {}, configFile = null, storage = null } = {}) {
420 // `configFile` + `storage`: hpp's EXISTING config and data, when this machine already has them (the
421 // desktop app on a server). Absent — the handset, a fresh desktop — both live under node storage.
422 if (configFile) node.atprotoConfigFile = configFile
423 configFile = atprotoConfigPath(node)
380424 if (!fs.existsSync(configFile)) return null
381425
382 const home = join(node.storage, 'atproto')
426 const home = storage || join(node.storage, 'atproto')
383427 // ⛔ Set BEFORE loadConfig — it is what loadConfig reads. Our mint writes the config under the
384428 // node's storage precisely because a handset has no home directory, so hpp must be pointed at it.
385429 process.env.PEER_CONFIG = configFile
386 process.env.ATPROTO_OVERLAY_ROOT = process.env.ATPROTO_OVERLAY_ROOT || join(home, 'overlay')
430 if (!storage) process.env.ATPROTO_OVERLAY_ROOT = process.env.ATPROTO_OVERLAY_ROOT || join(home, 'overlay')
387431
388432 const { loadConfig } = await import('hyper-proto-peer/lib/config.js')
389433 const cfg = loadConfig()
lib/host.js +30 -5
……@@ -155,6 +155,28 @@ export async function signIn (node, storage) {
155155 return { publicKey, claimed: true }
156156}
157157
158/**
159 * Sign this app in if it can — and serve either way.
160 *
161 * ⛔ THE DESKTOP CASE. On a handset the app IS the browser and `signIn` throwing is correct (the worklet
162 * relies on it). On a desktop or server the node has usually been claimed by a real browser through the
163 * surface already, and `boot()` passing that throw up meant the one-app host could not start on either
164 * server (hyper-social #77 §5, change 2). Serving the surface does not need this app's own session —
165 * browsers sign themselves in — so a claimed or revoked app key is REPORTED and boot continues.
166 *
167 * ⚠ Anything else still throws: a node whose browser registry cannot be read is not one to serve.
168 */
169export async function signInOrServe (node, storage) {
170 try {
171 const who = await signIn(node, storage)
172 return { ...who, signedIn: true }
173 } catch (err) {
174 const reason = (err && err.message) || String(err)
175 if (/already claimed|revoked/.test(reason)) return { publicKey: null, claimed: false, signedIn: false, reason }
176 throw err
177 }
178}
179
158180/**
159181 * Bring a node up and serve it.
160182 *
……@@ -167,12 +189,15 @@ export async function signIn (node, storage) {
167189 * `onStage` reports progress to whatever is embedding this — bare-kit IPC on a handset, a log line
168190 * on a desktop. The host does not know which.
169191 */
170export async function boot (storage, { swarm, onStage = () => {}, appUpdate = null, port = null, host = '127.0.0.1' } = {}) {
192export async function boot (storage, { swarm, onStage = () => {}, appUpdate = null, port = null, host = '127.0.0.1', atproto: atprotoOpts = {} } = {}) {
171193 if (!swarm) throw new Error('boot needs a swarm — the caller constructs it, see the note above')
172194 fs.mkdirSync(storage, { recursive: true })
173195 onStage('storage', { storage })
174196
175197 const node = new FavelaNode(storage, { transport: null, swarm })
198 // ⛔ BEFORE anything can call an atproto op: the settings page asks did-status at any time, and it must
199 // read the same config bootAtproto boots (hpp's own, when the embedder named it).
200 if (atprotoOpts.configFile) node.atprotoConfigFile = atprotoOpts.configFile
176201 // ⛔ ready() FIRST: it builds the overlay, and the transport needs it — the bytes ride the same
177202 // connections as the log, so a transport without the overlay replicates half the system. Same
178203 // order favela-peer's own index.js uses, for the same reason.
……@@ -189,8 +214,8 @@ export async function boot (storage, { swarm, onStage = () => {}, appUpdate = nu
189214
190215 // ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port could be raced by anything
191216 // else that found it; claiming through `dispatch` cannot.
192 const who = await signIn(node, storage)
193 onStage(who.claimed ? 'claimed this node' : 'signed in')
217 const who = await signInOrServe(node, storage)
218 onStage(who.signedIn ? (who.claimed ? 'claimed this node' : 'signed in') : 'serving without an app session', who.signedIn ? {} : { reason: who.reason })
194219
195220 // Reuse the port bound last time so a viewer's URL survives a restart.
196221 let wanted = port
……@@ -213,11 +238,11 @@ export async function boot (storage, { swarm, onStage = () => {}, appUpdate = nu
213238 //
214239 // ⚠ Returned as a promise, so a caller that genuinely needs the handle can wait for it and a caller
215240 // that just wants a booted node does not have to.
216 const atproto = bootAtproto(node, { log: (m, d) => onStage(m, d) })
241 const atproto = bootAtproto(node, { log: (m, d) => onStage(m, d), configFile: atprotoOpts.configFile || null, storage: atprotoOpts.storage || null })
217242 .catch((e) => { onStage('atproto boot failed', { error: (e && e.message) || String(e) }); return null })
218243
219244 return { node, swarm, surface, who, atproto }
220245}
221246
222247export { ATPROTO_OPS }
223export default { boot, serveSurface, inProcessClient, listenPreferring, signIn }
248export default { boot, serveSurface, inProcessClient, listenPreferring, signIn, signInOrServe }
lib/project.js +48 -1
……@@ -185,4 +185,51 @@ export async function projectBsky (node, hpp, { send = false, limit = Infinity,
185185 })
186186}
187187
188export default { deviceClient, peerClient, pickNode, projectBsky }
188/**
189 * Keep projecting for as long as the app runs — favela-bridge's `--watch`, inside the app.
190 *
191 * ⛔ WHY. Without this a desktop that ran favela-host instead of the four separate apps would silently
192 * stop posting to bsky: projection here was on demand and dry by default, and favela-bridge's watch
193 * daemon was the only thing that sent (hyper-social #77 §5, change 3).
194 *
195 * ⛔ EVENT-DRIVEN, NOT POLLED. The bridge polled every 15 s; this runs one pass at boot and one per
196 * change to the node's own feed (autobee emits 'update' when its view moves). Coalesced: never two
197 * passes at once, and any number of changes during a pass become exactly one more.
198 *
199 * ⚠ IT SENDS, as the deployed `--watch` did. Consent is not decided here: projectCycle reads the
200 * node's reach and withholds anything a network is not switched on for.
201 *
202 * `atproto` is boot()'s promise of the hpp handle; null (no identity) means nothing to project.
203 * `project` is injectable for tests. Returns { watching, stop }.
204 */
205export async function watchProjection (node, atproto, { log = () => {}, project = projectBsky } = {}) {
206 const hpp = await atproto
207 if (!hpp || !hpp.nodes || hpp.nodes.length === 0) return { watching: false, stop () {} }
208
209 let running = false
210 let again = false
211 let stopped = false
212 const pass = async () => {
213 if (stopped) return
214 if (running) { again = true; return }
215 running = true
216 try {
217 do {
218 again = false
219 // ⛔ One pass NEVER ends the watch — a sink respawning on OTA, a peer restarting, are ordinary.
220 try { await project(node, hpp, { send: true, log }) } catch (err) { log('projection pass failed: ' + ((err && err.message) || err)) }
221 } while (again && !stopped)
222 } finally { running = false }
223 }
224
225 const base = node.peer && node.peer.feed && node.peer.feed.base
226 const onUpdate = () => { pass() }
227 if (base && base.on) base.on('update', onUpdate)
228 pass()
229 return {
230 watching: true,
231 stop () { stopped = true; if (base && base.off) base.off('update', onUpdate) }
232 }
233}
234
235export default { deviceClient, peerClient, pickNode, projectBsky, watchProjection }
package.json +30 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-host",
3 "version": "0.0.1",
4 "description": "Holds both halves — a favela-peer node, favela-surface rendering it, and the atproto verbs neither package may import.",
3 "version": "0.1.0",
4 "description": "THE favela app on a desktop, home node or server: a favela-peer node, favela-surface rendering it, the atproto signer and the bsky projection, in one process.",
55 "type": "module",
66 "main": "index.js",
77 "bin": {
……@@ -14,6 +14,7 @@
1414 "anproto": "file:../anproto",
1515 "atproto-identity": "file:../atproto-identity",
1616 "atproto-repo": "file:../atproto-repo",
17 "b4a": "^1.6.0",
1718 "bare-fs": "^4.8.1",
1819 "bare-http1": "^4.5.7",
1920 "bare-net": "^2.3.3",
……@@ -28,5 +29,32 @@
2829 },
2930 "devDependencies": {
3031 "brittle": "^3.5.0"
32 },
33 "pgh": {
34 "bin": "favela-host",
35 "upgrade": "pgh://1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86/favela-host",
36 "entrypoint": "bin/favela-host.js",
37 "type": "daemon",
38 "runtime": "bare",
39 "autostart": true,
40 "depends": [
41 "pgh-sidecar"
42 ],
43 "defaults": {
44 "env": {
45 "FAVELA_STORAGE": "${HOME}/.config/favela-peer",
46 "FAVELA_SURFACE_PORT": "4042",
47 "PEER_CONFIG": "${HOME}/.config/hyper-proto-peer/config.json",
48 "PEER_STORAGE": "${HOME}/.config/hyper-proto-peer/data"
49 }
50 },
51 "service": {
52 "requiresNetwork": true
53 },
54 "stage": {
55 "ignore": [
56 "test/"
57 ]
58 }
3159 }
3260}
test/boot-atproto.test.js +26 -0
……@@ -73,3 +73,29 @@ test('it points hpp at the config under node storage, not a home directory', asy
7373 t.absent(fs.existsSync(join(process.env.HOME, '.config', 'hyper-proto-peer', 'config.json')),
7474 'and nothing was read from or written to $HOME')
7575})
76
77// ⛔ ONE APP ON A MACHINE THAT ALREADY RUNS hyper-proto-peer. The servers hold wj62 and loser-club in
78// hpp's own config and data (~/.config/hyper-proto-peer). A desktop app that booted them from
79// `<favela storage>/atproto` instead would open a SECOND store for the same devices — a second writer for
80// the same identity, which is a fork (hyper-social #77 §5, change 1). So the embedder can hand bootAtproto
81// hpp's existing config and storage, and every atproto op on the node then reads that same config.
82test('given hpp\'s own config and storage, it boots THOSE and writes nothing under node storage', async (t) => {
83 const { node } = fixture(t, 'shared')
84 const hppHome = join(os.tmpdir(), 'fh-hpp-' + Date.now() + '-' + Math.random().toString(16).slice(2))
85 const configFile = join(hppHome, 'config.json')
86 const storage = join(hppHome, 'data')
87 fs.mkdirSync(storage, { recursive: true })
88 node.atprotoConfigFile = configFile // what boot() sets from the embedder's options
89
90 const client = inProcessClient(node)
91 const r = await client.call('did-mint', { passphrase: PASS })
92 t.ok(fs.existsSync(configFile), 'the mint wrote hpp\'s config, not one under node storage')
93
94 const hpp = await bootAtproto(node, { configFile, storage })
95 t.teardown(async () => { try { await hpp.close() } catch {} })
96 t.is(hpp && hpp.nodes.length, 1, 'booted the identity from hpp\'s config')
97 t.ok(fs.readdirSync(storage).length > 0, 'hpp\'s data directory is the one in use')
98 t.absent(fs.existsSync(join(node.storage, 'atproto', 'config.json')), 'no second config under node storage')
99 t.absent(fs.existsSync(join(node.storage, 'atproto', 'statebase')), 'no second store under node storage')
100 t.is((await client.call('did-status')).did, r.did, 'did-status reads the same config')
101})
test/multi-did-ops.test.js +53 -0
……@@ -0,0 +1,53 @@
1// The atproto ops on a node that holds SEVERAL identities.
2//
3// ⛔ WHY. Every did-* op read `cfg.dids[0]`. The desktop app on a server boots hpp's own config, which
4// holds wj62 AND loser-club (hyper-social #77 §5, change 5), so "this node's identity" was whichever
5// happened to be first — and did-carry / did-adopt / did-submit are PERMANENT appends to a global log.
6// The rule is pickNode's (lib/project.js): an explicit `did` wins, then the designated credential
7// (identity.md §5.1), then the only one; with several and none chosen a WRITE refuses rather than
8// guessing. did-status only reads, so it answers for the chosen one and lists them all.
9
10import test from 'brittle'
11import fs from 'bare-fs'
12import os from 'bare-os'
13import process from 'bare-process'
14import { join } from 'bare-path'
15import { inProcessClient } from '../index.js'
16import { atprotoEntry, atprotoConfigPath } from '../lib/atproto.js'
17
18const PASS = 'a passphrase long enough'
19
20async function twoDids (t) {
21 const root = join(os.tmpdir(), 'fh-multi-' + Date.now() + '-' + Math.random().toString(16).slice(2))
22 const storage = join(root, 'storage')
23 fs.mkdirSync(storage, { recursive: true })
24 const realHome = process.env.HOME
25 process.env.HOME = join(root, 'home')
26 fs.mkdirSync(process.env.HOME, { recursive: true })
27 t.teardown(() => { process.env.HOME = realHome })
28 const node = { storage }
29 const client = inProcessClient(node)
30 // hpp's mint refuses a second identity in one config (by design), so the second entry is written the
31 // way a co-owner's config gets one — appended to `dids`. Only selection is under test here.
32 const a = await client.call('did-mint', { passphrase: PASS })
33 const cfgPath = atprotoConfigPath(node)
34 const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
35 const b = 'did:plc:secondidentityforselect'
36 cfg.dids.push({ ...cfg.dids[0], did: b })
37 fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
38 return { node, client, a: a.did, b }
39}
40
41test('did-status lists every identity and answers for the one asked about', async (t) => {
42 const { client, a, b } = await twoDids(t)
43 const st = await client.call('did-status')
44 t.alike(st.dids, [a, b], 'both identities listed')
45 t.is((await client.call('did-status', { did: b })).did, b, 'an explicit did is answered for')
46})
47
48test('a write with several identities and none chosen REFUSES instead of using the first', async (t) => {
49 const { node, b } = await twoDids(t)
50 t.exception(() => atprotoEntry(node, {}, { write: true }), /several identities/)
51 t.is(atprotoEntry(node, { did: b }, { write: true }).entry.did, b, 'an explicit did is used')
52 t.exception(() => atprotoEntry(node, { did: 'did:plc:notmine' }, { write: true }), /not an identity/)
53})
test/sign-in.test.js +56 -0
……@@ -0,0 +1,56 @@
1// The desktop app boots on a node someone already signed into.
2//
3// ⛔ WHY. `signIn` proves THIS APP's own browser key, claiming the node on first run — on a handset the
4// app IS the browser, and the worklet relies on it throwing when it cannot. But on a desktop or server
5// the node has usually been claimed already, by a real browser through favela-surface. `boot()` passed
6// that throw straight up, so the one-app desktop host could not start on either server (hyper-social
7// #77 §5, change 2). Serving the surface does not need the app's own session: browsers sign in
8// themselves. So boot signs in when it can and serves either way, saying which.
9
10import test from 'brittle'
11import fs from 'bare-fs'
12import os from 'bare-os'
13import { join } from 'bare-path'
14import { FavelaNode } from 'favela-peer/lib/node.js'
15import { dispatch } from 'favela-peer/lib/rpc.js'
16import { an } from 'anproto'
17import { signIn, signInOrServe } from '../lib/host.js'
18
19const tmp = (name) => { const d = join(os.tmpdir(), 'fh-signin-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2)); fs.mkdirSync(d, { recursive: true }); return d }
20const stub = () => ({ async join () {}, async leave () {}, async destroy () {} })
21
22async function node (t, name) {
23 const storage = tmp(name)
24 const n = new FavelaNode(storage, { transport: stub() })
25 await n.ready()
26 t.teardown(() => n.close())
27 return { n, storage }
28}
29
30// Another browser claims the node first, the way favela-surface's login page does.
31async function claimByAnotherBrowser (n) {
32 const kp = await an.gen()
33 const { challenge } = await dispatch(n, 'browser-challenge', {})
34 await dispatch(n, 'browser-claim', { publicKey: an.publicKey(kp), challenge, signature: await an.sign(await an.hash(challenge), kp) })
35}
36
37test('signIn still throws on a node another browser claimed (the handset relies on it)', async (t) => {
38 const { n, storage } = await node(t, 'throws')
39 await claimByAnotherBrowser(n)
40 await t.exception(() => signIn(n, storage), /already claimed/)
41})
42
43test('signInOrServe: on a node another browser claimed, boot continues — no app session, and it says why', async (t) => {
44 const { n, storage } = await node(t, 'serves')
45 await claimByAnotherBrowser(n)
46 const who = await signInOrServe(n, storage)
47 t.is(who.signedIn, false)
48 t.ok(/claimed/.test(who.reason), 'the reason is reported: ' + who.reason)
49})
50
51test('signInOrServe: on an unclaimed node it claims, exactly as signIn does', async (t) => {
52 const { n, storage } = await node(t, 'claims')
53 const who = await signInOrServe(n, storage)
54 t.is(who.signedIn, true)
55 t.is(who.claimed, true)
56})
test/watch-projection.test.js +83 -0
……@@ -0,0 +1,83 @@
1// The bsky projection, running for as long as the desktop app runs.
2//
3// ⛔ WHY. favela-host only projected on demand, as a dry run by default, so a desktop that ran it instead
4// of the four separate apps would silently stop posting to bsky — favela-bridge's `--watch` daemon was
5// the only thing sending (hyper-social #77 §5, change 3). This replaces that daemon inside the app.
6// ⛔ EVENT-DRIVEN, NOT POLLED: one pass at boot, then one per change to the node's own feed — the
7// standing rule is "rip out polling timers". Coalesced: never two passes at once, and a change that
8// lands during a pass gets exactly one more.
9
10import test from 'brittle'
11import { EventEmitter } from 'bare-events'
12import { watchProjection } from '../lib/project.js'
13
14const tick = () => new Promise((resolve) => setTimeout(resolve, 20))
15
16function fakeNode () {
17 const base = new EventEmitter()
18 return { node: { peer: { feed: { base } } }, base }
19}
20
21test('no identity, no projection', async (t) => {
22 const { node, base } = fakeNode()
23 let runs = 0
24 const w = await watchProjection(node, Promise.resolve(null), { project: async () => { runs++ } })
25 base.emit('update'); await tick()
26 t.is(runs, 0)
27 t.absent(w.watching)
28})
29
30test('one pass at boot, then one per change to the own feed, always sending', async (t) => {
31 const { node, base } = fakeNode()
32 const calls = []
33 const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async (n, hpp, opts) => { calls.push(opts.send) } })
34 t.teardown(() => w.stop())
35 await tick()
36 t.is(calls.length, 1, 'the boot pass')
37 base.emit('update'); await tick()
38 t.is(calls.length, 2, 'a change triggers a pass')
39 t.ok(calls.every((s) => s === true), 'it sends, like the deployed --watch did')
40})
41
42test('coalesced: changes during a pass produce exactly ONE more pass, never two at once', async (t) => {
43 const { node, base } = fakeNode()
44 let running = 0
45 let maxConcurrent = 0
46 let runs = 0
47 let release
48 const project = async () => {
49 running++; runs++; maxConcurrent = Math.max(maxConcurrent, running)
50 if (runs === 1) await new Promise((resolve) => { release = resolve })
51 running--
52 }
53 const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project })
54 t.teardown(() => w.stop())
55 await tick()
56 base.emit('update'); base.emit('update'); base.emit('update')
57 await tick()
58 release(); await tick(); await tick()
59 t.is(maxConcurrent, 1, 'never two passes at once')
60 t.is(runs, 2, 'the three changes during the first pass became one more pass')
61})
62
63test('a failing pass does not stop the watch', async (t) => {
64 const { node, base } = fakeNode()
65 let runs = 0
66 const said = []
67 const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++; if (runs === 1) throw new Error('sink respawning') }, log: (m) => said.push(m) })
68 t.teardown(() => w.stop())
69 await tick()
70 base.emit('update'); await tick()
71 t.is(runs, 2, 'the next change still projects')
72 t.ok(said.some((m) => /sink respawning/.test(m)), 'the failure was said')
73})
74
75test('stop() detaches from the feed', async (t) => {
76 const { node, base } = fakeNode()
77 let runs = 0
78 const w = await watchProjection(node, Promise.resolve({ nodes: [{}] }), { project: async () => { runs++ } })
79 await tick()
80 w.stop()
81 base.emit('update'); await tick()
82 t.is(runs, 1, 'only the boot pass')
83})