OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 97a51f2

sign-in: hpp hands back a wrapper, not the SwarmDevice
Every boot logged "favela-web sign-in FAILED — an hpp node with delegate() required", so the phone's UI read anonymously: no liked/reposted state, no writes, and nothing on screen saying so. bootMultiDevice().nodes[i] is not a SwarmDevice. It is the wrapper bootSwarmDevice returns — { device, sb, did, self, blobs, control, ... } (device-node.js:595) — and delegate() lives on the SwarmDevice inside it (swarm-device.js:699). The wrapper does carry .did, which is why this read as correct: it looks like a node until the guard rejects it, at boot, on a handset. The suite passed throughout because every fixture was a hand-written object with a delegate() on it — it proved establishSession against an imagined caller, never against the shape hpp actually returns. So the test added here asserts the real wrapper shape, and fails without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97a51f298dc09d6c0dd021669290dd6794c06454
by plankatron <fleeky@gmail.com> · 2026-08-11T20:23:28.000Z

Changed files (3) +50 -4

lib/node.js +7 -3
……@@ -209,10 +209,14 @@ async function boot () {
209209 // the kind of quiet degradation this port exists to remove, so it is logged either way.
210210 //
211211 // Non-fatal: a phone that cannot sign in still reads (anonymously). Reporting beats guessing.
212 const node = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
213 if (node) {
212 const entry = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
213 if (entry) {
214214 try {
215 const { loadOrCreateSubKey, establishSession } = await import('./session.js')
215 const { loadOrCreateSubKey, establishSession, sessionNodeOf } = await import('./session.js')
216 // hpp hands back a WRAPPER, not the SwarmDevice — see sessionNodeOf. Passing `entry` straight
217 // through is what made every boot sign-in fail with "an hpp node with delegate() required".
218 const node = sessionNodeOf(entry)
219 if (!node) throw new Error('hpp entry exposes no delegate() — cannot sign in')
216220 const key = loadOrCreateSubKey(path.join(dirs.root, 'ui-subkey.json'), fs, path.dirname)
217221 if (key.created) log('minted a UI sub-key for this device (delegated by our own DID)')
218222 const s = await establishSession({
lib/session.js +19 -0
……@@ -105,6 +105,25 @@ export function tokenFromSetCookie (setCookie) {
105105 return m ? m[1] : null
106106}
107107
108/**
109 * Pick the SwarmDevice out of whatever hpp handed us.
110 *
111 * ⛔ THE SHAPE IS NOT WHAT IT LOOKS LIKE. `bootMultiDevice().nodes[i]` is NOT a SwarmDevice — it is the
112 * WRAPPER `bootSwarmDevice` returns: `{ device, sb, did, self, blobs, control, membrane, overlay, … }`
113 * (hyper-proto-peer/lib/device-node.js:595). `delegate()` lives on the SwarmDevice INSIDE it
114 * (swarm-device.js:699). The wrapper DOES carry `.did`, which is exactly why this survived review and
115 * every test: it reads like a node right up until the delegate() guard rejects it — on a handset, at
116 * boot, with the UI silently downgraded to anonymous reads.
117 *
118 * Accept either shape, and refuse rather than guess when neither has delegate().
119 */
120export function sessionNodeOf (entry) {
121 if (!entry) return null
122 if (typeof entry.delegate === 'function') return entry // already a SwarmDevice
123 if (entry.device && typeof entry.device.delegate === 'function') return entry.device // hpp's wrapper
124 return null
125}
126
108127/**
109128 * Establish a session against the in-process favela-web.
110129 *
test/session.test.mjs +24 -1
……@@ -19,7 +19,7 @@ import pathm from 'bare-path'
1919import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
2020import { signAssertion } from 'atproto-identity/lib/auth.js'
2121import { makeAtprotoWebVerify, verifyChallengeSig } from 'atproto-identity/lib/web-auth.js'
22import { loadOrCreateSubKey, signChallenge, tokenFromSetCookie, establishSession, mintSubKey } from '../lib/session.js'
22import { loadOrCreateSubKey, signChallenge, tokenFromSetCookie, establishSession, mintSubKey, sessionNodeOf } from '../lib/session.js'
2323
2424const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
2525const toHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('')
……@@ -136,4 +136,27 @@ for (const [args, why] of [
136136 assert(e !== null, 'refuses without ' + why)
137137}
138138
139// ── 7. the SHAPE hpp actually hands us ──────────────────────────────────────────────────────────────
140// THE BUG THIS EXISTS FOR (found on a handset, 2026-08-11): every fixture above is a hand-written object
141// with a delegate() on it, so the suite only ever proved establishSession against a node shaped the way
142// the test author imagined. hpp does not hand us that. `bootSwarmDevice` returns a WRAPPER —
143// { device, sb, did, self, blobs, control, … } (hyper-proto-peer/lib/device-node.js:595) — and delegate()
144// lives on the SwarmDevice INSIDE it (swarm-device.js:699). The wrapper carries `.did`, so it passes a
145// casual "looks like a node" reading and then trips the delegate() guard at runtime, on the phone, with
146// the UI silently downgraded to anonymous. A mock that cannot reproduce the caller's shape cannot catch
147// a caller-shape bug, so this asserts the REAL shape.
148console.log('hpp wrapper shape')
149const wrapper = { did: DID, device: fakeNode, sb: {}, blobs: {}, control: null } // as device-node.js:595 returns
150assert(typeof wrapper.delegate !== 'function', 'the wrapper itself has NO delegate — this is the bug')
151assert(sessionNodeOf(wrapper) === fakeNode, 'unwraps the SwarmDevice out of the hpp wrapper')
152assert(sessionNodeOf(fakeNode) === fakeNode, 'passes a bare SwarmDevice through unchanged')
153assert(sessionNodeOf(null) === null, 'null in, null out')
154assert(sessionNodeOf({ did: DID, sb: {} }) === null, 'an entry with no delegate anywhere is refused, not guessed at')
155
156// and end-to-end: the wrapper, unwrapped, signs in for real
157const wrapped = await establishSession({
158 favelaUrl: 'http://127.0.0.1:4041', node: sessionNodeOf(wrapper), privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch
159})
160assert(wrapped.token && wrapped.did === DID, 'a session is established from the shape hpp really returns')
161
139162console.log('\nall session tests passed')