| … | … | @@ -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 | ||
| 11 | steps: | |
| 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 |
| … | … | @@ -1,18 +1,23 @@ |
| 1 | 1 | #!/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`. | |
| 3 | 3 | // |
| 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. | |
| 7 | 9 | // |
| 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. | |
| 12 | 15 | // |
| 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. | |
| 16 | 21 | |
| 17 | 22 | import fs from 'bare-fs' |
| 18 | 23 | import os from 'bare-os' |
| … | … | @@ -21,18 +26,31 @@ import process from 'bare-process' |
| 21 | 26 | import { join } from 'bare-path' |
| 22 | 27 | import Hyperswarm from 'hyperswarm' |
| 23 | 28 | import { createServer } from 'favela-peer/lib/rpc.js' |
| 29 | import { startControl } from 'favela-peer/lib/control.js' | |
| 24 | 30 | import { boot, inProcessClient } from '../lib/host.js' |
| 31 | import { watchProjection } from '../lib/project.js' | |
| 25 | 32 | |
| 26 | 33 | const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela') |
| 27 | 34 | const 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. | |
| 38 | const svcSock = process.env.FAVELA_HOST_SVC_SOCK || join(storage, 'favela-host.svc.sock') | |
| 28 | 39 | const port = process.env.FAVELA_SURFACE_PORT ? Number(process.env.FAVELA_SURFACE_PORT) : null |
| 29 | 40 | const 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. | |
| 42 | const peerConfig = process.env.PEER_CONFIG || null | |
| 43 | const peerStorage = process.env.PEER_STORAGE || null | |
| 44 | ||
| 45 | function pkgVersion () { | |
| 46 | try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url).pathname, 'utf8')).version } catch { return null } | |
| 47 | } | |
| 30 | 48 | |
| 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. | |
| 34 | 51 | function socketIsLive (path) { |
| 35 | 52 | return new Promise((resolve) => { |
| 53 | if (!fs.existsSync(path)) return resolve(false) | |
| 36 | 54 | const probe = net.connect(path) |
| 37 | 55 | const done = (v) => { try { probe.destroy() } catch {}; resolve(v) } |
| 38 | 56 | probe.on('connect', () => done(true)) |
| … | … | @@ -45,33 +63,70 @@ const say = (msg, data = {}) => console.log(JSON.stringify({ msg, ...data })) |
| 45 | 63 | |
| 46 | 64 | async function main () { |
| 47 | 65 | 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') | |
| 50 | 73 | process.exit(1) |
| 51 | 74 | } |
| 52 | 75 | |
| 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. | |
| 56 | 78 | 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 | }) | |
| 58 | 86 | |
| 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. | |
| 61 | 88 | const client = inProcessClient(node) |
| 62 | 89 | 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() }) | |
| 63 | 91 | |
| 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) })) | |
| 65 | 98 | |
| 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 {} | |
| 67 | 110 | 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 {} | |
| 68 | 112 | try { await node.close() } catch {} |
| 69 | 113 | try { await swarm.destroy() } catch {} |
| 70 | 114 | try { fs.unlinkSync(sock) } catch {} |
| 71 | process.exit(0) | |
| 115 | clearTimeout(deadline) | |
| 116 | process.exit(code) | |
| 72 | 117 | } |
| 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 | }) | |
| 75 | 130 | } |
| 76 | 131 | |
| 77 | 132 | main().catch((err) => { console.error(err); process.exit(1) }) |
| … | … | @@ -10,5 +10,5 @@ |
| 10 | 10 | // offer the atproto verbs that neither favela-peer nor favela-surface may import. A shell adds its |
| 11 | 11 | // own platform concerns on top — bare-kit IPC and OTA on Android, a unix socket on a desktop. |
| 12 | 12 | |
| 13 | export { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from './lib/host.js' | |
| 13 | export { boot, serveSurface, inProcessClient, listenPreferring, signIn, signInOrServe, PORT_FILE } from './lib/host.js' | |
| 14 | 14 | export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath, bootAtproto } from './lib/atproto.js' |
| … | … | @@ -33,7 +33,11 @@ import { join, dirname } from 'bare-path' |
| 33 | 33 | * and wrote down the rule: "THE VAULT LIVES UNDER THE NODE'S STORAGE, not ~/.hyperident. A phone has |
| 34 | 34 | * no home directory... the daemon already knows where its own state goes, so it says." |
| 35 | 35 | */ |
| 36 | export 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). | |
| 40 | export const atprotoConfigPath = (node) => node.atprotoConfigFile || join(node.storage, 'atproto', 'config.json') | |
| 37 | 41 | |
| 38 | 42 | /** Where a minted identity's sealed recovery root goes. */ |
| 39 | 43 | export const recoveryVaultPath = (node, did) => |
| … | … | @@ -65,6 +69,45 @@ const writeJson = (p, v) => { |
| 65 | 69 | fs.renameSync(tmp, p) |
| 66 | 70 | } |
| 67 | 71 | |
| 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 | */ | |
| 83 | export 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. | |
| 103 | async 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 | ||
| 68 | 111 | export const ATPROTO_OPS = { |
| 69 | 112 | /** |
| 70 | 113 | * Create a new atproto identity, or restore one from a recovery phrase. |
| … | … | @@ -169,13 +212,14 @@ export const ATPROTO_OPS = { |
| 169 | 212 | return { ...r, log: lines } |
| 170 | 213 | }, |
| 171 | 214 | |
| 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 } | |
| 176 | 218 | const genesis = readJson(genesisPath(node, entry.did)) |
| 177 | 219 | return { |
| 178 | 220 | did: entry.did, |
| 221 | // Every identity this node holds, so a page can offer the choice rather than show only the first. | |
| 222 | dids, | |
| 179 | 223 | // ⛔ The handle and endpoint a bridge granted, if one has. Without them a published DID has |
| 180 | 224 | // `alsoKnownAs: []` and `services: {}` — nothing for Bluesky to fetch a repo from — so the page |
| 181 | 225 | // must be able to tell "published" from "reachable", which are not the same state at all. |
| … | … | @@ -208,8 +252,7 @@ export const ATPROTO_OPS = { |
| 208 | 252 | * mnemonic unsealed to avoid the prompt would make "a stolen device yields a locked vault" false. |
| 209 | 253 | */ |
| 210 | 254 | '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) }) | |
| 213 | 256 | if (!entry || !entry.did) throw new Error('this node has no identity for a bridge to carry') |
| 214 | 257 | if (!a.passphrase) throw new Error('this needs your recovery passphrase — it unlocks the rotation key, the only thing allowed to change this identity') |
| 215 | 258 | |
| … | … | @@ -228,7 +271,7 @@ export const ATPROTO_OPS = { |
| 228 | 271 | |
| 229 | 272 | // ⛔ THE IRREVERSIBLE HALF, and only now. Everything above can be undone by asking the bridge to |
| 230 | 273 | // 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 }) | |
| 232 | 275 | return { ...adopted, carriedBy: a.bridge, pending: r.status === 202 ? (r.body.reason || true) : false } |
| 233 | 276 | }, |
| 234 | 277 | |
| … | … | @@ -263,8 +306,7 @@ export const ATPROTO_OPS = { |
| 263 | 306 | if (!/^[a-z0-9][a-z0-9.-]{0,252}[a-z0-9]$/.test(handle)) throw new Error('that is not a usable handle') |
| 264 | 307 | if (!/^https:\/\/[^\s]+$/.test(pdsUrl)) throw new Error('a PDS endpoint must be an https URL') |
| 265 | 308 | |
| 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) }) | |
| 268 | 310 | if (!entry || !entry.did) throw new Error('this node has no identity to adopt a handle onto') |
| 269 | 311 | const did = entry.did |
| 270 | 312 | |
| … | … | @@ -329,9 +371,8 @@ export const ATPROTO_OPS = { |
| 329 | 371 | * test identity is forever. Its own separateness is the safeguard: nothing that mints can submit |
| 330 | 372 | * by accident. |
| 331 | 373 | */ |
| 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) }) | |
| 335 | 376 | if (!entry || !entry.did) throw new Error('did-submit: this node has no identity to publish') |
| 336 | 377 | |
| 337 | 378 | const p = genesisPath(node, entry.did) |
| … | … | @@ -375,15 +416,18 @@ export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath } |
| 375 | 416 | * ⚠ Returns null rather than throwing when there is no identity. A node with no DID is the normal |
| 376 | 417 | * state, not a failure, and a read-only node that boots beats a node that does not. |
| 377 | 418 | */ |
| 378 | export async function bootAtproto (node, { log = () => {} } = {}) { | |
| 379 | const configFile = atprotoConfigPath(node) | |
| 419 | export 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) | |
| 380 | 424 | if (!fs.existsSync(configFile)) return null |
| 381 | 425 | |
| 382 | const home = join(node.storage, 'atproto') | |
| 426 | const home = storage || join(node.storage, 'atproto') | |
| 383 | 427 | // ⛔ Set BEFORE loadConfig — it is what loadConfig reads. Our mint writes the config under the |
| 384 | 428 | // node's storage precisely because a handset has no home directory, so hpp must be pointed at it. |
| 385 | 429 | 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') | |
| 387 | 431 | |
| 388 | 432 | const { loadConfig } = await import('hyper-proto-peer/lib/config.js') |
| 389 | 433 | const cfg = loadConfig() |
| … | … | @@ -155,6 +155,28 @@ export async function signIn (node, storage) { |
| 155 | 155 | return { publicKey, claimed: true } |
| 156 | 156 | } |
| 157 | 157 | |
| 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 | */ | |
| 169 | export 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 | ||
| 158 | 180 | /** |
| 159 | 181 | * Bring a node up and serve it. |
| 160 | 182 | * |
| … | … | @@ -167,12 +189,15 @@ export async function signIn (node, storage) { |
| 167 | 189 | * `onStage` reports progress to whatever is embedding this — bare-kit IPC on a handset, a log line |
| 168 | 190 | * on a desktop. The host does not know which. |
| 169 | 191 | */ |
| 170 | export async function boot (storage, { swarm, onStage = () => {}, appUpdate = null, port = null, host = '127.0.0.1' } = {}) { | |
| 192 | export async function boot (storage, { swarm, onStage = () => {}, appUpdate = null, port = null, host = '127.0.0.1', atproto: atprotoOpts = {} } = {}) { | |
| 171 | 193 | if (!swarm) throw new Error('boot needs a swarm — the caller constructs it, see the note above') |
| 172 | 194 | fs.mkdirSync(storage, { recursive: true }) |
| 173 | 195 | onStage('storage', { storage }) |
| 174 | 196 | |
| 175 | 197 | 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 | |
| 176 | 201 | // ⛔ ready() FIRST: it builds the overlay, and the transport needs it — the bytes ride the same |
| 177 | 202 | // connections as the log, so a transport without the overlay replicates half the system. Same |
| 178 | 203 | // 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 |
| 189 | 214 | |
| 190 | 215 | // ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port could be raced by anything |
| 191 | 216 | // 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 }) | |
| 194 | 219 | |
| 195 | 220 | // Reuse the port bound last time so a viewer's URL survives a restart. |
| 196 | 221 | let wanted = port |
| … | … | @@ -213,11 +238,11 @@ export async function boot (storage, { swarm, onStage = () => {}, appUpdate = nu |
| 213 | 238 | // |
| 214 | 239 | // ⚠ Returned as a promise, so a caller that genuinely needs the handle can wait for it and a caller |
| 215 | 240 | // 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 }) | |
| 217 | 242 | .catch((e) => { onStage('atproto boot failed', { error: (e && e.message) || String(e) }); return null }) |
| 218 | 243 | |
| 219 | 244 | return { node, swarm, surface, who, atproto } |
| 220 | 245 | } |
| 221 | 246 | |
| 222 | 247 | export { ATPROTO_OPS } |
| 223 | export default { boot, serveSurface, inProcessClient, listenPreferring, signIn } | |
| 248 | export default { boot, serveSurface, inProcessClient, listenPreferring, signIn, signInOrServe } |
| … | … | @@ -185,4 +185,51 @@ export async function projectBsky (node, hpp, { send = false, limit = Infinity, |
| 185 | 185 | }) |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | export 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 | */ | |
| 205 | export 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 | ||
| 235 | export default { deviceClient, peerClient, pickNode, projectBsky, watchProjection } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "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.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "bin": { |
| … | … | @@ -14,6 +14,7 @@ |
| 14 | 14 | "anproto": "file:../anproto", |
| 15 | 15 | "atproto-identity": "file:../atproto-identity", |
| 16 | 16 | "atproto-repo": "file:../atproto-repo", |
| 17 | "b4a": "^1.6.0", | |
| 17 | 18 | "bare-fs": "^4.8.1", |
| 18 | 19 | "bare-http1": "^4.5.7", |
| 19 | 20 | "bare-net": "^2.3.3", |
| … | … | @@ -28,5 +29,32 @@ |
| 28 | 29 | }, |
| 29 | 30 | "devDependencies": { |
| 30 | 31 | "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 | } | |
| 31 | 59 | } |
| 32 | 60 | } |
| … | … | @@ -73,3 +73,29 @@ test('it points hpp at the config under node storage, not a home directory', asy |
| 73 | 73 | t.absent(fs.existsSync(join(process.env.HOME, '.config', 'hyper-proto-peer', 'config.json')), |
| 74 | 74 | 'and nothing was read from or written to $HOME') |
| 75 | 75 | }) |
| 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. | |
| 82 | test('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 | }) |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import fs from 'bare-fs' | |
| 12 | import os from 'bare-os' | |
| 13 | import process from 'bare-process' | |
| 14 | import { join } from 'bare-path' | |
| 15 | import { inProcessClient } from '../index.js' | |
| 16 | import { atprotoEntry, atprotoConfigPath } from '../lib/atproto.js' | |
| 17 | ||
| 18 | const PASS = 'a passphrase long enough' | |
| 19 | ||
| 20 | async 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 | ||
| 41 | test('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 | ||
| 48 | test('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 | }) |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import fs from 'bare-fs' | |
| 12 | import os from 'bare-os' | |
| 13 | import { join } from 'bare-path' | |
| 14 | import { FavelaNode } from 'favela-peer/lib/node.js' | |
| 15 | import { dispatch } from 'favela-peer/lib/rpc.js' | |
| 16 | import { an } from 'anproto' | |
| 17 | import { signIn, signInOrServe } from '../lib/host.js' | |
| 18 | ||
| 19 | const 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 } | |
| 20 | const stub = () => ({ async join () {}, async leave () {}, async destroy () {} }) | |
| 21 | ||
| 22 | async 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. | |
| 31 | async 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 | ||
| 37 | test('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 | ||
| 43 | test('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 | ||
| 51 | test('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 | }) |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import { EventEmitter } from 'bare-events' | |
| 12 | import { watchProjection } from '../lib/project.js' | |
| 13 | ||
| 14 | const tick = () => new Promise((resolve) => setTimeout(resolve, 20)) | |
| 15 | ||
| 16 | function fakeNode () { | |
| 17 | const base = new EventEmitter() | |
| 18 | return { node: { peer: { feed: { base } } }, base } | |
| 19 | } | |
| 20 | ||
| 21 | test('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 | ||
| 30 | test('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 | ||
| 42 | test('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 | ||
| 63 | test('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 | ||
| 75 | test('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 | }) |