OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 6e59238

a peer can ask for a name over the swarm, through the same door
favela-peer has NO outbound HTTP — measured, not assumed: no bare-fetch, no bare-https, nothing in lib/. So the HTTP registration route, which is now proof gated and correct, is unreachable by the thing that most needs it: a handset asking for a name without anybody shelling into anything. Both sides are already swarm members, and feed-holder.js's own header says a bridge "is a service peers CONNECT TO". ⛔ ONE IMPLEMENTATION, TWO ADAPTERS. The claim/release rules move to admit.js and both router.js and register-rpc.js call it. A second copy of the flow is the failure favela-markdown/lib/reach.js names — written twice, the second written wrong — and here the second copy would be the one that forgot the proof. ⛔ Authorisation stays the chain proof, not the connection. hpp gates apply-write on conn.remotePublicKey because it relays for known callers; a bridge accepts requests from anyone, so what must be proved is WHICH IDENTITY. The swarm key says which socket, readAttested says who. On the FeedHolder's existing swarm rather than a second one, joined {server:true, client:true} — both flags load-bearing, since a NAT'd handset and a NAT'd bridge host only punch when both send outbound. The bridge key is logged at boot: it is what a peer needs to find this bridge, and a key written nowhere is the device-key mistake one layer over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6e5923826a34cab1319876387687e24d266f489c
by plankatron <fleeky@gmail.com> · 2026-09-11T16:15:05.000Z

Changed files (5) +332 -66

index.js +21 -0
……@@ -443,6 +443,27 @@ async function main () {
443443 log({ msg: 'released', username })
444444 }
445445
446 // ⛔ THE SWARM DOOR onto the same registration flow. A peer has no outbound HTTP — favela-peer
447 // has none at all — and both sides are already swarm members, so this is how a handset asks for a
448 // name without anybody shelling into anything. The rules are `admit.js`; this is transport.
449 //
450 // ⚠ On the FeedHolder's swarm, not a second one: it already runs one to hold registered peers'
451 // feeds, and two swarms in a process is two NAT mappings and two sets of connections for no gain.
452 if (registrations && holder && holder.swarm) {
453 try {
454 const { serveRegistration } = await import('./lib/register-rpc.js')
455 const reg = serveRegistration({ swarm: holder.swarm, registrations, admission, config, onRegistered: ctx.onRegistered, onReleased: ctx.onReleased, log })
456 await reg.ready()
457 // ⛔ PRINTED, because it is what a peer needs in order to find this bridge at all. A key that
458 // exists and is written nowhere is the favela-android device-key mistake one layer over.
459 log({ msg: 'registration open over the swarm', bridgeKey: reg.key })
460 } catch (err) {
461 // Non-fatal: the HTTP door still works, and a bridge that federates is worth more than one
462 // that refuses to boot because a second door would not open.
463 log({ msg: 'swarm registration unavailable', error: (err && err.message) || String(err) })
464 }
465 }
466
446467 // ⛔ AND ON BOOT, or a restart silently unserves everyone who registered. The registry is
447468 // durable; what was held is not, so it has to be re-held from what the registry remembers.
448469 if (registrations) {
lib/admit.js +88 -0
……@@ -0,0 +1,88 @@
1// admit.js — claiming and releasing a name, once, for every transport that offers it.
2//
3// ⛔ ONE IMPLEMENTATION, TWO ADAPTERS. This lived inside the HTTP route, which was fine while HTTP
4// was the only way in. It is not: favela-peer has NO outbound HTTP at all (measured — no bare-fetch,
5// no bare-https, nothing), so a peer asking a bridge for a name has to do it over the swarm, which
6// both sides already speak. Copying the flow into a second transport would be the "written three
7// times and the third one written wrong" failure `favela-markdown/lib/reach.js` names.
8//
9// So the rules live here — proof first, then policy, then hold the feed — and `router.js` and
10// `register-rpc.js` are two doors onto the same room.
11//
12// ⚠ Returns `{ status, body }` rather than throwing. The status codes are HTTP's because they are
13// already the contract a peer reads (`taken` → pick another, `invite required` → go ask the
14// operator, 403 → you did not prove control) and inventing a second vocabulary for the swarm would
15// mean two things to keep in step.
16
17export async function admitClaim ({ registrations, admission, config, onRegistered }, params = {}) {
18 if (!registrations) return { status: 404, body: { error: 'not found' } }
19 if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } }
20
21 // ⛔ CONTROL, BEFORE CONSENT — and before the request's shape is even examined. hyper-social #65
22 // §8: admission needs both and neither substitutes for the other. An identity root is PUBLIC, so
23 // presenting one proves nothing; this is the half that was only ever asserted in a comment.
24 const ok = admission.verifyClaim({
25 nonce: params.nonce,
26 username: params.username,
27 feedKey: params.feedKey || null,
28 root: params.root,
29 proof: params.proof
30 })
31 if (!ok.ok) return { status: 403, body: { error: ok.error } }
32
33 const r = await registrations.claim({
34 username: params.username,
35 root: params.root,
36 feedKey: params.feedKey || null,
37 invite: params.invite || null
38 })
39 if (!r.ok) {
40 // ⛔ Distinguished, because a peer acts differently on each: `taken` means pick another name,
41 // `invite required` means go and ask the operator, and a bad request is the peer's own bug.
42 const status = r.error === 'taken' ? 409 : r.error === 'invite required' ? 403 : 400
43 return { status, body: { error: r.error } }
44 }
45
46 // ⛔ HOLD THE FEED NOW, so a registration is a served actor rather than a row in a file. The caller
47 // supplies this: neither this file nor the router knows about swarms or corestores.
48 if (onRegistered) {
49 try {
50 await onRegistered({ username: r.username, root: params.root, feedKey: params.feedKey || null })
51 } catch (err) {
52 // ⚠ The name IS claimed at this point. Reporting success would promise an actor that does not
53 // serve; releasing it would lose a name the peer was just told it had. So: claimed, and honest
54 // that it is not live yet.
55 return { status: 202, body: { username: r.username, generation: r.generation, pending: String(err.message || err) } }
56 }
57 }
58
59 const host = config && config.host
60 return {
61 status: 200,
62 body: {
63 username: r.username,
64 generation: r.generation,
65 acct: host ? r.username + '@' + host : null,
66 actor: `https://${host}/ap/users/${r.username}/actor`,
67 inbox: `https://${host}/ap/users/${r.username}/inbox`
68 }
69 }
70}
71
72export async function admitRelease ({ registrations, admission, onReleased }, params = {}) {
73 if (!registrations) return { status: 404, body: { error: 'not found' } }
74 if (!admission) return { status: 503, body: { error: 'registration is not configured on this bridge' } }
75
76 // ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a
77 // reused name a different actor, so there is no way back — and this used to compare a bare `root`,
78 // which is public. Anyone who could read a root could destroy the actor behind it.
79 const ok = admission.verifyRelease({ nonce: params.nonce, username: params.username, root: params.root, proof: params.proof })
80 if (!ok.ok) return { status: 403, body: { error: ok.error } }
81
82 const rel = await registrations.release(params.username, { root: params.root })
83 if (!rel.ok) return { status: rel.error === 'not registered' ? 404 : 403, body: { error: rel.error } }
84 if (onReleased) { try { await onReleased(params.username) } catch {} }
85 return { status: 200, body: { released: rel.username } }
86}
87
88export default { admitClaim, admitRelease }
lib/register-rpc.js +101 -0
……@@ -0,0 +1,101 @@
1// register-rpc.js — asking this bridge for a name over the SWARM.
2//
3// ⛔ WHY NOT HTTP. favela-peer has no outbound HTTP at all — measured, not assumed: no bare-fetch, no
4// bare-https, nothing in `lib/`. Giving it one to register a name would add a client, and with it the
5// bare-https defect where an idle socket is never reaped (24.3 GB and two OOMs). Both sides already
6// speak the swarm, and this bridge is already a member of it — `feed-holder.js` runs one to hold
7// registered peers' feeds, and its own header says a bridge "is a service peers CONNECT TO".
8//
9// ⛔ THE SAME ROOM, A SECOND DOOR. Every rule lives in `admit.js`; this file is transport. A copy of
10// the claim flow here would be the failure `favela-markdown/lib/reach.js` names — written twice, and
11// the second one eventually written wrong.
12//
13// ⛔ AUTHORISATION IS THE CHAIN PROOF, NOT THE CONNECTION. hyper-proto-peer gates `apply-write` on
14// `conn.remotePublicKey` against an allowlist because it relays for known callers. A bridge accepts
15// requests from ANYONE — that is what an open bridge is — so what must be proved is WHICH IDENTITY,
16// not which socket. The swarm key says which connection; `readAttested` says who. Do not substitute
17// one for the other.
18
19import ProtomuxRPC from 'protomux-rpc'
20import c from 'compact-encoding'
21import b4a from 'b4a'
22
23export const PROTOCOL = 'favela/bridge-register/1'
24
25/**
26 * The verbs, as plain async functions. Exported separately from the transport so they can be tested
27 * without a DHT — the wiring below is thin enough to read, and the rules are not in it.
28 */
29export function registerHandlers (deps) {
30 return {
31 /** What a peer asks BEFORE trying, so a refusal is never a surprise. */
32 policy: async () => ({
33 policy: deps.registrations ? deps.registrations.policy : null,
34 host: (deps.config && deps.config.host) || null,
35 proof: !!deps.admission
36 }),
37
38 /** ⛔ Single-use, and required: readAttested has no clock, so replay is caught here or nowhere. */
39 challenge: async () => {
40 if (!deps.admission) throw new Error('registration is not configured on this bridge')
41 return deps.admission.challenge()
42 },
43
44 claim: async (params = {}) => {
45 const { admitClaim } = await import('./admit.js')
46 return admitClaim(deps, params)
47 },
48
49 release: async (params = {}) => {
50 const { admitRelease } = await import('./admit.js')
51 return admitRelease(deps, params)
52 }
53 }
54}
55
56/**
57 * Serve those verbs to anyone who connects on this bridge's own key.
58 *
59 * ⛔ THE TOPIC IS THE BRIDGE'S SWARM KEY, joined `{ server: true, client: true }`. Both flags are
60 * load-bearing and hyper-proto-peer measured why: a member that joins `client: false` never sends
61 * outbound holepunch packets, so its NAT mapping never opens and a peer punching from the other side
62 * is dropped. A NAT'd handset registering with a NAT'd bridge host is exactly that case — symmetric
63 * connects in ~2s, asymmetric never open at all.
64 *
65 * ⚠ The key is what a peer needs in order to find this bridge, so it is RETURNED and should be
66 * published somewhere a person can read it.
67 */
68export function serveRegistration ({ swarm, log = () => {}, ...deps }) {
69 if (!swarm) throw new Error('serveRegistration: a swarm is required')
70 const handlers = registerHandlers(deps)
71
72 const onconnection = (conn) => {
73 let rpc
74 try {
75 rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
76 } catch { return }
77 for (const [name, fn] of Object.entries(handlers)) {
78 rpc.respond(name, async (params) => {
79 try {
80 return await fn(params || {})
81 } catch (err) {
82 // ⚠ Answered, never thrown across the wire as a dead channel: a peer that gets no reply
83 // cannot tell a refusal from a bridge that is down, and those need different actions.
84 return { status: 500, body: { error: (err && err.message) || String(err) } }
85 }
86 })
87 }
88 }
89
90 swarm.on('connection', onconnection)
91 const topic = swarm.keyPair.publicKey
92 const disc = swarm.join(topic, { server: true, client: true })
93
94 return {
95 key: b4a.toString(topic, 'hex'),
96 ready: () => disc.flushed(),
97 close: () => { try { swarm.off('connection', onconnection) } catch {} }
98 }
99}
100
101export default { serveRegistration, registerHandlers, PROTOCOL }
lib/router.js +7 -66
……@@ -59,21 +59,11 @@ export async function handleRequest (req, ctx) {
5959 if (method === 'DELETE') {
6060 let del
6161 try { del = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
62 // ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a
63 // reused name a different actor, so there is no way back — and this used to compare a bare
64 // `root`, which is public. Anyone who could read a root could destroy the actor behind it.
65 // ⛔ NOT OPTIONAL. A conditional gate is a gate that is absent wherever the condition is, and
66 // "the tests passed" would then mean "the tests ran without it".
67 if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' })
68 {
69 const ok = ctx.admission.verifyRelease({ nonce: del.nonce, username: del.username, root: del.root, proof: del.proof })
70 if (!ok.ok) return json(403, { error: ok.error })
71 }
72 const rel = await ctx.registrations.release(del.username, { root: del.root })
73 if (!rel.ok) return json(rel.error === 'not registered' ? 404 : 403, { error: rel.error })
74 if (ctx.onReleased) { try { await ctx.onReleased(del.username) } catch {} }
75 return json(200, { released: rel.username })
62 const { admitRelease } = await import('./admit.js')
63 const r = await admitRelease(ctx, del)
64 return json(r.status, r.body)
7665 }
66
7767 /**
7868 * ⛔ PUSH A PROFILE REFRESH. A remote server caches an actor and re-fetches on its own schedule
7969 * — roughly daily — so a changed name, avatar or bio is invisible until then. An Update is the
……@@ -93,58 +83,9 @@ export async function handleRequest (req, ctx) {
9383
9484 let req_
9585 try { req_ = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
96
97 // ⛔ CONTROL, before consent. hyper-social #65 §8: admission needs both and neither substitutes
98 // for the other. The policy half (open/invite) was built; this half was only ever asserted in a
99 // comment, so an identity root — which is PUBLIC — was accepted as proof of holding it.
100 if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' })
101 {
102 const ok = ctx.admission.verifyClaim({
103 nonce: req_.nonce,
104 username: req_.username,
105 feedKey: req_.feedKey || null,
106 root: req_.root,
107 proof: req_.proof
108 })
109 if (!ok.ok) return json(403, { error: ok.error })
110 }
111
112 const r = await ctx.registrations.claim({
113 username: req_.username,
114 root: req_.root,
115 feedKey: req_.feedKey || null,
116 invite: req_.invite || null
117 })
118 if (!r.ok) {
119 // ⛔ Distinguished, because a peer acts differently on each: `taken` means pick another name,
120 // `invite required` means go and ask the operator, and a bad request is the peer's own bug.
121 const status = r.error === 'taken' ? 409 : r.error === 'invite required' ? 403 : 400
122 return json(status, { error: r.error })
123 }
124
125 // ⚠ The feed key is recorded but NOT yet used: this bridge still reads a co-located peer over a
126 // socket, so holding a remote feed is the next piece. Taking it now means a peer registers once
127 // rather than twice.
128 // ⛔ HOLD IT NOW, so a registration is a served actor rather than a row in a file. index.js
129 // supplies this: the router knows nothing about swarms or corestores, and should not.
130 if (ctx.onRegistered) {
131 try {
132 await ctx.onRegistered({ username: r.username, root: req_.root, feedKey: req_.feedKey || null })
133 } catch (err) {
134 // ⚠ The name IS claimed at this point. Reporting success would promise an actor that does
135 // not serve; releasing it would lose a name the peer was told it had. So: claimed, and
136 // honest that it is not live yet.
137 return json(202, { username: r.username, generation: r.generation, pending: String(err.message || err) })
138 }
139 }
140
141 const host = ctx.config && ctx.config.host
142 return json(200, {
143 username: r.username,
144 generation: r.generation,
145 actor: `https://${host}/ap/users/${r.username}/actor`,
146 inbox: `https://${host}/ap/users/${r.username}/inbox`
147 })
86 const { admitClaim } = await import('./admit.js')
87 const r = await admitClaim(ctx, req_)
88 return json(r.status, r.body)
14889 }
14990
15091 /**
test/register-rpc.test.js +115 -0
……@@ -0,0 +1,115 @@
1// The swarm door onto registration — the same room as the HTTP one.
2//
3// ⛔ WHY IT EXISTS. favela-peer has no outbound HTTP (measured: no bare-fetch, no bare-https, nothing
4// in lib/), so a peer asking a bridge for a name cannot use the HTTP route. Both already speak the
5// swarm. What must NOT happen is a second copy of the rules, so these assert that this door reaches
6// the same `admit.js` and refuses exactly what the other door refuses.
7
8import test from 'brittle'
9import b4a from 'b4a'
10import IdentityKey from 'keet-identity-key'
11import crypto from 'hypercore-crypto'
12import { signAttested } from 'favela-identity/lib/attestation.js'
13import { MemoryRegistrations } from '../lib/registrations.js'
14import { Admission } from '../lib/admission.js'
15import { registerHandlers } from '../lib/register-rpc.js'
16
17const FEED = 'f'.repeat(64)
18
19async function identity () {
20 const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
21 const kp = crypto.keyPair()
22 const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey)
23 return { rootHex: b4a.toString(idk.identityKeyPair.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
24}
25
26const deps = (policy = 'open', extra = {}) => ({
27 registrations: new MemoryRegistrations({ policy }),
28 admission: new Admission(),
29 config: { host: 'ap.7rnx.net' },
30 ...extra
31})
32
33test('★ a peer asks the policy before trying, rather than guessing from a refusal', async (t) => {
34 const h = registerHandlers(deps('invite'))
35 const p = await h.policy()
36 t.is(p.policy, 'invite')
37 t.is(p.host, 'ap.7rnx.net')
38 t.ok(p.proof, 'and that this bridge requires a proof')
39})
40
41test('★★ the whole ceremony over the swarm door: challenge, sign, claim', async (t) => {
42 const d = deps('open')
43 const h = registerHandlers(d)
44 const me = await identity()
45
46 const { nonce } = await h.challenge()
47 const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
48 const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
49
50 t.is(r.status, 200)
51 t.is(r.body.acct, 'alice@ap.7rnx.net', 'the name a person actually types')
52 t.is(r.body.actor, 'https://ap.7rnx.net/ap/users/alice/actor')
53 t.is((await d.registrations.get('alice')).root, me.rootHex)
54})
55
56test('⛔ it refuses exactly what the HTTP door refuses', async (t) => {
57 const d = deps('open')
58 const h = registerHandlers(d)
59 const me = await identity()
60 const other = await identity()
61
62 t.is((await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED })).status, 403, 'no proof')
63
64 const { nonce } = await h.challenge()
65 const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), other.cred)
66 const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
67 t.is(r.status, 403, 'a proof from somebody else')
68 t.is(r.body.error, 'proof does not attest that identity')
69
70 t.is(await d.registrations.get('alice'), null, 'and nothing was registered')
71})
72
73test('⛔ the feed is held at claim time, so a name is a served actor not a row in a file', async (t) => {
74 const held = []
75 const d = deps('open', { onRegistered: async (u) => { held.push(u) } })
76 const h = registerHandlers(d)
77 const me = await identity()
78
79 const { nonce } = await h.challenge()
80 const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
81 t.is((await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })).status, 200)
82 t.is(held.length, 1)
83 t.is(held[0].feedKey, FEED, 'the bridge was told which feed to hold')
84})
85
86test('⚠ a name claimed but not servable says so, rather than promising an actor that is dead', async (t) => {
87 const d = deps('open', { onRegistered: async () => { throw new Error('swarm unreachable') } })
88 const h = registerHandlers(d)
89 const me = await identity()
90
91 const { nonce } = await h.challenge()
92 const proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
93 const r = await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
94
95 t.is(r.status, 202, 'claimed, and honest that it is not live')
96 t.ok(/swarm unreachable/.test(r.body.pending), r.body.pending)
97 t.ok(await d.registrations.get('alice'), 'the name IS held — releasing it would lose what the peer was told it had')
98})
99
100test('★ releasing over the same door needs the same proof', async (t) => {
101 const d = deps('open')
102 const h = registerHandlers(d)
103 const me = await identity()
104
105 let { nonce } = await h.challenge()
106 let proof = signAttested(d.admission.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
107 await h.claim({ username: 'alice', root: me.rootHex, feedKey: FEED, nonce, proof })
108
109 t.is((await h.release({ username: 'alice', root: me.rootHex })).status, 403, 'unproven cannot burn it')
110 t.ok(await d.registrations.get('alice'), 'still held')
111
112 ;({ nonce } = await h.challenge())
113 proof = signAttested(d.admission.releaseBytes({ nonce, username: 'alice' }), me.cred)
114 t.is((await h.release({ username: 'alice', root: me.rootHex, nonce, proof })).status, 200, 'the holder can')
115})