OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity commits / 87badca

the admission ceremony moves here, and learns to be asked to CARRY
There are two bridges and there must not be two ceremonies. ap-bridge asks this about NAMES; atproto-bridge asks it about carrying an identity. Both are the same question underneath — may this caller speak for this identity — and a second copy would be the one that forgot the nonce is single-use, or that the root must be pinned INSIDE verify rather than compared afterwards. This package exists for exactly that: "EXTRACTED SO THERE IS ONE COPY". `frontBytes` is a THIRD prefix, not a reuse of CLAIM. Asking to be carried and taking a name are different acts, and a proof that could serve as either would let a request for one be replayed as the other. The identifier is opaque here on purpose — this package does not know what network names it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87badca43c584894e98fd17ce47e943b84efe6d0
by plankatron <fleeky@gmail.com> · 2026-09-11T16:34:42.000Z

Changed files (6) +579 -4

index.js +1 -1
……@@ -14,4 +14,4 @@ export { readAttested, readProof, signAttested } from './lib/attestation.js'
1414
1515// The bytes a peer signs to claim a name on a bridge. Here so the claimant and the verifier
1616// cannot disagree about them — a mismatch is indistinguishable from a forged proof.
17export { claimBytes, releaseBytes, CLAIM, RELEASE } from './lib/bridge-claim.js'
17export { claimBytes, releaseBytes, frontBytes, CLAIM, RELEASE, FRONT } from './lib/bridge-claim.js'
lib/admission.js +132 -0
……@@ -0,0 +1,132 @@
1// admission.js — proving that a caller CONTROLS the identity it is speaking for.
2//
3// ⛔ SHARED, because there are two bridges and there must not be two ceremonies. ap-bridge asks it
4// about NAMES and atproto-bridge asks it about CARRYING an identity; both questions are the same
5// question underneath, and a second copy would be the one that forgot the nonce is single-use. It
6// lives here for the reason this package exists at all — its own header: "EXTRACTED SO THERE IS ONE
7// COPY".
8//
9// ⛔ WHY THIS IS A SEPARATE FILE. `registrations.js` holds the registry and says, in its own header,
10// that it deliberately does not do this: "it does not verify the chain proof: that is
11// favela-identity's `readAttested`… it belongs at the route where the challenge lives. Here a `root`
12// is required and its SHAPE checked; proving the caller controls it is the caller's job and must
13// happen before claim() is reached." No route had a challenge, so that sentence described a thing
14// that did not exist, and `readAttested` appeared in this package only inside that comment.
15//
16// What that cost: an identity root is PUBLIC. `claim()` accepted any 64-hex string, so anyone could
17// take a name under anyone's identity; and `release()` compared a bare root, so anyone could BURN
18// someone's actor — permanently, because the generation counter deliberately makes a reused name a
19// different actor and there is no way back.
20//
21// ⛔ A CHALLENGE IS REQUIRED, NOT DECORATION. `readAttested` is a pure function of the proof's bytes
22// — no disk, no network, NO CLOCK — which is exactly the property that lets it run inside an
23// autobase apply. A verifier with no clock cannot detect replay. Freshness therefore has to come
24// from something the bridge issues and remembers, and there is nowhere else to put it.
25//
26// ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured
27// and replayed to claim a DIFFERENT name under the signer's root — consent proven, control proven,
28// and the wrong name registered. Binding username (and feed, on a claim) is what stops that.
29//
30// ⚠ TOTAL. This runs on a request path fed by strangers, so every path returns { ok, error } and
31// nothing throws: `readAttested` is already guarded that way for the same reason.
32
33import crypto from 'bare-crypto'
34import b4a from 'b4a'
35import { readAttested } from './attestation.js'
36import { claimBytes, releaseBytes, frontBytes } from './bridge-claim.js'
37
38const HEX64 = /^[0-9a-f]{64}$/i
39// Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a
40// name the registry would reject.
41const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
42
43
44const DEFAULT_TTL = 120_000 // two minutes — a round trip, not a session
45
46export class Admission {
47 constructor ({ ttlMs = DEFAULT_TTL, now = Date.now } = {}) {
48 this._ttl = ttlMs
49 this._now = now
50 this._open = new Map() // nonce -> expiresAt
51 }
52
53 /**
54 * Mint a single-use challenge.
55 *
56 * ⛔ From a CSPRNG. `registrations.js`'s invite token used `Math.random()`, which on an invite-only
57 * bridge is the entire admission gate; a guessable nonce here would be the same mistake one layer
58 * down. bare-crypto is already a dependency.
59 */
60 challenge () {
61 this._sweep()
62 const nonce = b4a.toString(crypto.randomBytes(32), 'hex')
63 const expiresAt = this._now() + this._ttl
64 this._open.set(nonce, expiresAt)
65 return { nonce, expiresAt }
66 }
67
68 /** The exact bytes a claimant must sign. Public, because the caller has to produce them too. */
69 claimBytes (params) { return claimBytes(params) }
70
71 /** The exact bytes a releaser must sign. */
72 releaseBytes (params) { return releaseBytes(params) }
73
74 verifyClaim ({ nonce, username, feedKey, root, proof } = {}) {
75 if (badName(username)) return fail('bad username')
76 if (feedKey !== null && feedKey !== undefined && !HEX64.test(String(feedKey))) return fail('bad feed key')
77 return this._verify(nonce, root, proof, () => this.claimBytes({ nonce, username, feedKey }))
78 }
79
80 verifyRelease ({ nonce, username, root, proof } = {}) {
81 if (badName(username)) return fail('bad username')
82 return this._verify(nonce, root, proof, () => this.releaseBytes({ nonce, username }))
83 }
84
85 /**
86 * Asking a bridge to CARRY an identity, rather than to name one.
87 *
88 * ⚠ `id` is opaque here, and deliberately: this package does not know what network names it, and
89 * the bridge doing the verifying is the only thing that needs to. What is checked is the same
90 * question in both cases — does this caller control the identity it is speaking for.
91 */
92 verifyFront ({ nonce, id, root, proof } = {}) {
93 if (typeof id !== 'string' || !id || id.length > 256 || id.includes(':::')) return fail('bad identifier')
94 return this._verify(nonce, root, proof, () => frontBytes({ nonce, id }))
95 }
96
97 frontBytes (params) { return frontBytes(params) }
98
99 /**
100 * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a
101 * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate
102 * caller one extra round trip in the rare case they got it wrong, and costs an attacker everything.
103 */
104 _verify (nonce, root, proof, bytesOf) {
105 if (typeof nonce !== 'string' || !this._open.has(nonce)) return fail('unknown or expired challenge')
106 const expiresAt = this._open.get(nonce)
107 this._open.delete(nonce)
108 if (this._now() > expiresAt) return fail('unknown or expired challenge')
109 if (typeof root !== 'string' || !HEX64.test(root)) return fail('identity root required')
110
111 // ⛔ The root is pinned INSIDE verify rather than compared afterwards. attestation.js: "a
112 // comparison the caller has to remember to write is a comparison someone eventually forgets."
113 // A non-null return means exactly: this root attested this device, and that device signed these
114 // bytes. Neither half is trusted separately.
115 const seen = readAttested(proof, bytesOf(), root.toLowerCase())
116 if (!seen) return fail('proof does not attest that identity')
117 return { ok: true, root: seen.root, device: seen.device }
118 }
119
120 /** Expired challenges are not evidence of anything — drop them rather than grow a map for ever. */
121 _sweep () {
122 const now = this._now()
123 for (const [nonce, expiresAt] of this._open) if (now > expiresAt) this._open.delete(nonce)
124 }
125
126 get open () { return this._open.size }
127}
128
129const badName = (s) => typeof s !== 'string' || !NAME.test(s)
130const fail = (error) => ({ ok: false, error })
131
132export default { Admission }
lib/bridge-claim.js +15 -1
……@@ -19,6 +19,10 @@ import b4a from 'b4a'
1919
2020export const CLAIM = 'favela-bridge:v1:claim'
2121export const RELEASE = 'favela-bridge:v1:release'
22// ⛔ A THIRD PREFIX, not a reuse of CLAIM. Asking a bridge to CARRY an identity is a different act
23// from taking a NAME on one, and a proof that could serve as either would let a request for one be
24// replayed as a request for the other.
25export const FRONT = 'favela-bridge:v1:front'
2226
2327/**
2428 * What a claimant signs. The REQUEST is inside it, not just the nonce: a proof covering only the
……@@ -34,4 +38,14 @@ export function releaseBytes ({ nonce, username }) {
3438 return b4a.from([RELEASE, nonce, username].join(':'), 'utf8')
3539}
3640
37export default { claimBytes, releaseBytes, CLAIM, RELEASE }
41/**
42 * What a peer signs to ask a bridge to carry an identity it holds.
43 *
44 * ⚠ The identifier is opaque here on purpose — this package does not know what network it names, and
45 * the bridge that verifies this is the only thing that needs to.
46 */
47export function frontBytes ({ nonce, id }) {
48 return b4a.from([FRONT, nonce, id].join(':'), 'utf8')
49}
50
51export default { claimBytes, releaseBytes, frontBytes, CLAIM, RELEASE, FRONT }
lib/peer-client.js +92 -0
……@@ -0,0 +1,92 @@
1// The client: how anything reaches its own favela-peer, over the local unix socket.
2//
3// ⛔ SHARED, because there are two consumers and the second would otherwise copy it. favela-bridge
4// reads records to project onto bsky; ap-bridge reads the same records to project onto the
5// fediverse. A wire protocol written twice drifts — this monorepo already carries six copies of one
6// signing format, three of which eat the first character of a hash.
7//
8// ⚠ It lives beside attestation because both answer the same question from outside the daemon: who
9// is this identity, and what did it write. Neither needs storage, a key, or a swarm.
10//
11// ⛔ READ ONLY, AND PULL. The peer is never asked to push and never told a bridge exists — that is
12// the boundary its own test enforces (`favela-peer/test/boundary.test.js`, hyper-social #61 §10):
13// "the moment the daemon imports the projection the dependency has been inverted". So this reaches
14// in, takes records in favela's own vocabulary, and the peer stays ignorant of what happens next.
15//
16// It is also the shape every other bridge here already uses — ap-bridge's records.js: "replicate the
17// peer's cores over Hyperswarm, read records, project. No key involved." This does the same over a
18// socket instead of the swarm, because for a co-located bridge a DHT rendezvous buys nothing.
19//
20// ⚠ Its wire format is newline-delimited JSON, `{id, cmd, args}` → `{id, ok, value|error}`, NOT
21// hyper-svc. The two daemons in this path speak different protocols and that is not worth papering
22// over: favela-peer's is deliberately the smallest thing that works (its rpc.js says so).
23
24import net from 'bare-net'
25
26export class FavelaPeer {
27 constructor (sock) {
28 if (!sock) throw new Error('FavelaPeer: a socket path is required')
29 this.sock = sock
30 }
31
32 /** One request, one connection. There is no session to keep and no benefit to holding one open. */
33 call (cmd, args = {}) {
34 return new Promise((resolve, reject) => {
35 const c = net.connect(this.sock)
36 let buf = ''
37 let done = false
38 const finish = (fn, v) => { if (!done) { done = true; try { c.destroy() } catch {} ; fn(v) } }
39 c.on('error', (err) => finish(reject, err))
40 c.on('connect', () => c.write(JSON.stringify({ id: 1, cmd, args }) + '\n'))
41 c.on('data', (d) => {
42 buf += d
43 const i = buf.indexOf('\n')
44 if (i < 0) return
45 let r
46 try { r = JSON.parse(buf.slice(0, i)) } catch (err) { return finish(reject, err) }
47 if (r.ok) finish(resolve, r.value)
48 else finish(reject, new Error(r.error || 'favela-peer refused ' + cmd))
49 })
50 // ⚠ A peer that accepts the connection and never answers would hang the bridge for ever.
51 setTimeout(() => finish(reject, new Error('favela-peer timed out on ' + cmd)), 30000)
52 })
53 }
54
55 async whoami () { return this.call('whoami') }
56
57 /**
58 * Which networks this identity has turned on, from the peer's own feed.
59 *
60 * ⛔ READ EVERY CYCLE, never cached. A person who unticks bsky in settings has withdrawn consent
61 * to publish, and a bridge holding a stale copy would keep publishing until someone restarted it.
62 *
63 * ⛔ AND A FAILURE READS AS OFF. If the peer cannot be asked, the answer is not "carry on with
64 * what we had" — it is publish nothing. Unknown consent is not consent.
65 */
66 async reach () {
67 try {
68 const r = await this.call('reach')
69 return {
70 networks: Array.isArray(r && r.networks) ? r.networks : [],
71 credential: (r && r.credential) || null
72 }
73 } catch {
74 return { networks: [], credential: null, unknown: true }
75 }
76 }
77
78 /**
79 * This node's own posts, newest last.
80 *
81 * ⚠ OUR OWN ONLY. A bridge projects what its identity wrote; forwarding a followed feed's posts
82 * under our DID would be republishing someone else's words as ours, which is a different act with
83 * a different name and is not this.
84 */
85 async ownPosts () {
86 const me = await this.whoami()
87 const rows = await this.call('posts-by', { feed: me.feed })
88 return { feed: me.feed, rows: Array.isArray(rows) ? rows : [] }
89 }
90}
91
92export default { FavelaPeer }
package.json +4 -2
……@@ -9,7 +9,8 @@
99 ".": "./index.js",
1010 "./lib/attestation.js": "./lib/attestation.js",
1111 "./lib/peer-client.js": "./lib/peer-client.js",
12 "./lib/bridge-claim.js": "./lib/bridge-claim.js"
12 "./lib/bridge-claim.js": "./lib/bridge-claim.js",
13 "./lib/admission.js": "./lib/admission.js"
1314 },
1415 "scripts": {
1516 "test": "brittle-bare test/*.test.js"
……@@ -21,7 +22,8 @@
2122 "bare-fs": "^4.0.0",
2223 "bare-path": "^3.0.0",
2324 "hypercore-crypto": "^3.4.0",
24 "bare-net": "^1.3.0"
25 "bare-net": "^1.3.0",
26 "bare-crypto": "^1.5.0"
2527 },
2628 "devDependencies": {
2729 "brittle": "^3.5.0"
spec/onboarding.md +335 -0
……@@ -0,0 +1,335 @@
1# onboarding — minting a DID, and claiming a name on a bridge
2
3**Status:** design. Nothing built.
4**Date:** 2026-09-11
5**Decisions (user, 2026-09-11):**
61. **Bridges accept peer-initiated claims and validate them.** Getting a name on a bridge is
7 something a peer does for itself; the bridge's job is to verify, not to mediate.
82. **Bridge names are DID/AP names only.** favela's own P2P handles are the NameRegistry's and are
9 not touched here (§2).
103. **Desktop gets DID parity with the handset** — "that was the whole point of integrating hpp".
11**Related:** `identity.md` §5.1 (the designated credential), §6 (a bridge is a resource),
12`ap-bridge/DESIGN.md` §4 (addressing) and §11 (M2, the keyless bridge), hyper-social #69 §7, #70.
13
14---
15
16## 1. What this fixes
17
18Two text boxes that record strings and perform nothing.
19
20| surface | what it is today | what is missing |
21|---|---|---|
22| `favela-surface/lib/pages.js:1558` "Bluesky identity (DID)" | `<input name="credential">` → `set-reach` | nothing mints, nothing validates, nothing designates |
23| `favela-surface/lib/pages.js:1574` "Fediverse name" | `<input name="fediverse">` → `set-reach` | nothing claims the name; it is transcribed by hand |
24
25Both halves of the machinery exist and neither has a caller:
26
27- `favela-surface-android/lib/node.js:97` — `did-mint` / `did-submit`, wired into the worklet,
28 **zero callers in favela-surface**.
29- `ap-bridge/lib/router.js:40` — `POST /ap/register`, **zero callers anywhere in the fleet**.
30 `@fleeky@ap.7rnx.net` was registered by hand.
31
32★ This spec does not design new capability. It designs the two ceremonies that reach the capability
33already written, and the one piece of verification that was specified and never built (§5.1).
34
35---
36
37## 2. Two namespaces, and they do not meet
38
39**Decision (user, 2026-09-11).** A bridge name is a **DID/AP name**. A favela handle is the
40**NameRegistry's**. Neither derives from the other.
41
42This is already true in the code and worth stating so it stays true:
43
44- `ap-bridge/lib/registrations.js`'s `NAME` regex is *"deliberately narrower than a favela handle:
45 lowercase, no dots (they read as domains), no separators to escape"* — because the string becomes
46 a path segment in a public URI.
47- A bridge name lives on **somebody's domain** and has an arbiter: *"A BRIDGE IS AN OWNED RESOURCE,
48 NOT A COMMONS… the operator owns the namespace under it, and gets the arbiter the commons
49 deliberately cannot have."* The NameRegistry is the commons, and hyper-social #59 §3 / pear-git #43
50 record that a squatted name there is permanent precisely because it has no owner.
51
52⛔ **The UI must never bind the two.** Offering the favela handle as a *suggested* default in the
53claim form is fine and kind; treating them as the same identifier is not, because the bridge can
54burn and re-issue a name (§5.2) and the NameRegistry cannot. A person whose bridge name was released
55by an operator must not thereby appear to have lost their favela handle.
56
57⚠ And they fail differently, which the UI has to survive: a favela handle resolves P2P and works
58offline; a bridge name resolves only while that bridge is up. `identity.md` §6.2 already requires a
59network in `reach` with no configured bridge to be a **visible** no-op.
60
61---
62
63## 3. Flow A — the DID
64
65### 3.1 Three steps, three different costs
66
67They must stay three. Collapsing them is what `favela-surface-android/lib/node.js:88` warns against:
68*"MINTING IS NOT SUBMITTING."*
69
70| step | verb | reversible? | what it costs |
71|---|---|---|---|
72| mint / restore | `did-mint` | n/a | nothing — P2P-only, no handle, no PDS |
73| designate | `set-reach {credential}` | yes | nothing; it is which DID signs |
74| submit | `did-submit` | **no** | *"a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log"* |
75
76⇒ **Three visible states**: no DID / minted, not submitted / submitted. A minted-unsubmitted DID is
77a working identity, and the page has to say so or it reads as a half-finished ceremony with no way
78back — the same failure `mint-identity`'s own comment names for the favela vault.
79
80### 3.2 The recovery phrase is shown once, by a route that cannot repeat it
81
82`did-mint` returns the root mnemonic and *"nothing persists it in the config. Its own test asserts
83that."* So the phrase renders **inside the POST response** and no GET route can reproduce it —
84identical to `/welcome`'s rule, which exists because *"if any GET could, 'a stolen device yields a
85locked vault' stops being true."*
86
87⚠ `did-mint` takes `{mnemonic}` for **restore**. Restore and create are one verb and one form with
88two doors, because restore is the only path that survives losing every device (#69 §6).
89
90### 3.3 Designation is a separate confirmation, not a side effect of minting
91
92`identity.md` §5.1: at most one credential is `designated: 'atproto'`; **none** → the projection is
93withheld; **several without a designation** → *"refuse to project, and surface it. Silently picking
94one would publish under an identity the author did not choose."*
95
96⇒ Minting **offers** designation and does not perform it. Today `reach.credential` is a single
97string, so "at most one" is structural rather than enforced — which is fine, and is the shipped
98approximation of §6 (see §6.2 below).
99
100### 3.4 ⛔ Desktop parity needs a composition host that does not exist
101
102Three constraints collide, and none of them is wrong:
103
104| constraint | where |
105|---|---|
106| favela-peer may not name `atproto-` anywhere in `lib/` | `favela-peer/test/boundary.test.js` |
107| favela-surface may not depend on favela-peer | `favela-surface/test/boundary.test.js` |
108| *"the in-process client is built by whoever holds both halves (the worklet), never here"* | `favela-surface/index.js` |
109
110On the handset there IS a worklet holding both halves, so `ATPROTO_OPS` wraps `dispatch()` and
111neither package learns about the other. On desktop there is no such thing: favela-surface and
112favela-peer are two processes over a UDS, and **favela-peer owns the socket**.
113
114⛔ Checked, so it is not assumed: **`favela-node` is not this host.** It is the co-owned-community
115DID node (`atproto-room` + `favela-community`), depends on neither favela-peer nor hyper-proto-peer,
116and is `identity.md` §10.7's open question.
117
118⇒ **Parity means promoting the worklet's composition into a host that owns the socket on desktop
119too**, with favela-peer a library there exactly as it already is on the handset. `lib/rpc.js`
120already anticipates this: it exports `dispatch(node, cmd, a)` **alongside** `createServer(node,
121sock)`, and `favela-surface/index.js` names the split — *"`dispatch` is the interface and the socket
122is one ADAPTER over it."* The host takes the adapter; the interface is unchanged.
123
124⚠ **This moves the socket owner on desktop**, which is why it is called out rather than assumed. The
125alternative — a second socket for atproto ops — is rejected: a context is *"anything with
126`call(cmd, args) -> Promise`"* and the surface deliberately never learns which it got. Two sockets
127would teach it.
128
129⚠ **The page must still degrade.** A surface talking to a plain favela-peer gets `unknown command`
130for `did-mint`. It probes once and hides the mint rather than rendering a button that throws —
131#70 §7's rule, that a page must not decide something it does not own, cuts both ways.
132
133---
134
135## 4. Flow B — claiming a bridge name
136
137### 4.1 The channel is the swarm, and it already has a second customer
138
139`ap-bridge/lib/signer.js:51` — `RemoteSigner` is written and *"NOT YET WIRED TO A TRANSPORT… the
140transport choice (protomux-rpc over Hyperswarm, matching hpp, vs a local UDS) stays a separate
141decision."* That is DESIGN.md §11's **M2, the keyless bridge**.
142
143★ **Registration and signing are the same channel in opposite directions.** Building registration
144over HTTP would stand up the second channel and leave M2 still needing the first. So:
145**ProtomuxRPC over Hyperswarm**, the shape `hyper-proto-peer/lib/peer.js:341` uses for `apply-write`.
146
147Three consequences, all good:
148
149- favela-peer needs `protomux-rpc` added. It already has `protomux` and `hyperswarm`.
150- **No HTTP client in favela-peer.** Which deletes the `bare-https` never-reaps-an-idle-socket
151 hazard (24.3 GB / 47k sockets, two OOMs) before it is introduced.
152- The bridge is already a swarm member holding feeds (`feed-holder.js`), and its own header says
153 *"A bridge is a service peers CONNECT TO."* Registration over the swarm is that sentence executed.
154
155⛔ **Both sides join `{server: true, client: true}`.** `peer.js:315` measured why: *"Two firewalled
156peers must BOTH be client:true to punch simultaneously — symmetric connects in ~2s, asymmetric never
157opens."* A NAT'd handset claiming a name on a NAT'd bridge host is exactly that case.
158
159### 4.2 The ceremony
160
161```
162peer bridge
163 ├─ join(bridgeTopic, {server:true, client:true})
164 │ ├─ announce on bridgeTopic
165 ├──── register-policy ─────────────────────▶ │
166 │ ◀── {policy: 'open'|'invite'} ─────────────┤
167 ├──── register-challenge ───────────────────▶ │ mint nonce, hold single-use
168 │ ◀── {nonce, expiresAt} ────────────────────┤
169 ├─ bytes = canon(nonce, username, feedKey)
170 ├─ proof = signAttested(bytes, node.cred)
171 ├──── register-claim {username, root, feedKey, proof, nonce, invite?} ──▶
172 │ ├─ readAttested(proof, bytes, root) → null ⇒ 403
173 │ ├─ claim() → {ok, username, generation}
174 │ ◀── {username, generation, acct, actor} ───┤
175 └─ setReach({ fediverse: acct })
176```
177
178⛔ **The nonce is not optional and the reason is structural.** `readAttested` is *"a function of the
179proof's bytes — no disk, no network, no clock"*, which is the property that lets it run inside
180`apply`. A verifier with no clock cannot detect replay, so freshness has to come from the challenge.
181There is nowhere else to put it.
182
183⛔ **`username` and `feedKey` are inside the signed bytes**, not merely alongside them. A proof that
184covered only the nonce could be captured and replayed to claim a *different* name under the signer's
185root — consent proven, control proven, and the wrong name registered.
186
187⛔ **Authorization is the chain proof, not the swarm key.** hpp gates `apply-write` on
188`conn.remotePublicKey` against an allowlist because it is relaying for known callers. A bridge
189accepts claims from **anyone** (decision 1) and must therefore verify *which identity*, not *which
190socket*. The swarm key says which connection; the chain proof says who. Do not substitute one.
191
192### 4.3 The peer already holds every input
193
194| the bridge wants | the peer has |
195|---|---|
196| `root` (64-hex identity root) | `node.rootHex()` — `favela-peer/lib/node.js:573` |
197| `feedKey` | `node.key.toString('hex')` |
198| a signature over the challenge | `signAttested(bytes, node.cred)` — `favela-identity/lib/attestation.js` |
199
200⚠ `rootHex()` returns **null when the node cannot sign**. An unlinked device must be refused here
201with the same message `requireLinked` already gives, not allowed to send a claim that cannot be
202verified.
203
204### 4.4 The verbs, and the gate they sit behind
205
206Named generically, because `favela-peer/test/boundary.test.js` forbids `ap-bridge`,
207`activitystreams`, `app.bsky`, `did:plc` and `atproto-` in `lib/`. The daemon never learns what a
208fediverse *is*; it claims a name on a bridge and records the answer.
209
210```
211bridge-policy { url } → { policy }
212bridge-claim { url, name, invite? } → { acct, generation }
213bridge-release { url, name } → { released }
214bridge-status { url, name } → { held, generation, since }
215```
216
217⛔ **All four go in `MUTATIONS`** (`favela-peer/lib/rpc.js:89`), so `requireLinked` gates them. The
218existing comment on `set-reach` is the argument verbatim: *"turning on a network puts words under
219this identity somewhere that does not forget, so it requires an ATTESTED device — the same gate as
220posting. A browser cannot do it without one."* Claiming the name is the same act, one step earlier.
221
222### 4.5 Recording the result
223
224On success the peer writes `setReach({ fediverse: '<name>@<host>' })`.
225
226⚠ **That field is uncommitted today** (`favela-peer/lib/peer.js`, working tree), while
227favela-surface **0.0.61 shipped the UI that writes it**. Against published favela-peer 0.0.50 the
228field is silently dropped. It works on the handset only because the android bundle links
229`file:../favela-peer` from the working tree. **Commit it before either flow is built**, or the
230success path records nothing on desktop and says it succeeded.
231
232---
233
234## 5. The bridge side
235
236Four items. The first is a hole, not a gap.
237
238### 5.1 ⛔ Control is never verified — anyone can claim a name under anyone's root
239
240`registrations.js`'s `claim()` requires `root` and checks only its **shape** (`HEX64`). Its own
241header is explicit about the division of labour:
242
243> *"This module holds the REGISTRY. It does not verify the chain proof: that is `favela-identity`'s
244> `readAttested`… it belongs at the route where the challenge lives."*
245
246**No route has a challenge, and `readAttested` appears in ap-bridge only inside that comment.** So
247§8's own rule — *"an invite without control lets an invited user claim someone else's identity"* —
248is stated in the file and not implemented by it. An identity root is public; presenting one proves
249nothing.
250
251The fix is §4.2's `register-challenge` + `readAttested(proof, bytes, root)`, pinning the root
252**inside** verify via `expectedIdentity` rather than string-comparing afterwards — *"a comparison the
253caller has to remember to write is a comparison someone eventually forgets."*
254
255⚠ **The HTTP route keeps the same gate.** `POST /ap/register` must not remain a softer door than the
256swarm verb, or the verification is decorative.
257
258### 5.2 ⛔ Release is forgeable, and release is irreversible
259
260`release(username, {root})` compares a **bare string** the caller supplies. Anyone who knows your
261root — it is public — can release your name. And release is designed to be unrecoverable: the
262`generation` counter increments so *"a reused name cannot collide with its predecessor's audience"*,
263which is correct and means a forged release **permanently burns your actor identity**.
264
265Same proof, same challenge, same fix. This is the more urgent half of §5.1 because the damage does
266not need a second step.
267
268### 5.3 `randomToken()` is `Math.random()`
269
270```js
271for (let i = 0; i < 4; i++) s += Math.floor(Math.random() * 0xffffffff).toString(16)...
272```
273
274On an `invite` bridge that token **is** the admission gate. Four draws from a non-CSPRNG.
275`bare-crypto` is already a dependency.
276
277### 5.4 There is no way to ask "do I still hold this name?"
278
279`GET /ap/register` returns `{policy}` and nothing else. `registrations.list()` exists with no route.
280So `reach.fediverse` records `name@bridge` and can never be re-confirmed — and after an operator
281release it is silently, permanently wrong.
282
283⇒ `bridge-status` (§4.4), **proof-gated**. A public listing of every name with its identity root is
284a doxxing surface; the holder asking about their own name is not.
285
286### 5.5 The `invite` verb is written, uncommitted, and has no caller
287
288`index.js:490` + `lib/control.js` in the working tree put `invite` on the owner-gated UDS, which is
289right — *"minting the token that gates it is not something an HTTP caller should be able to
290assert."* Without it an invite-only bridge cannot issue invites, so the `invite` policy is
291unreachable in practice. It needs committing and a CLI caller.
292
293⚠ And per #69's carried note, an invite token is bare hex and therefore **indistinguishable from a
294namespace key** — joining the wrong one succeeds silently. Prefix it.
295
296### 5.6 ★ None of this needs a new dependency
297
298`ap-bridge/package.json` already carries `bare-crypto` (§5.3) and `favela-identity` (§5.1, §5.2).
299The verifier and the CSPRNG are both already installed and unused.
300
301---
302
303## 6. Open
304
3051. ⛔ **How a peer finds a bridge.** The ceremony in §4.2 assumes a `bridgeTopic` and does not say
306 what derives it. Two candidates, and they trade differently:
307 - **the bridge's swarm key**, pasted or configured — authenticated, but it is a third bare 64-hex
308 string beside namespace keys and invite tokens, which #69 already filed as a confusion that
309 fails silently. It would have to be prefixed.
310 - **a topic derived from the hostname** (`hash('favela-bridge:' + host)`) — the person types
311 `ap.7rnx.net` and nothing is exchanged, but the topic is squattable. A squatter cannot steal the
312 claim (root and feedKey are public and the proof is bound to the nonce) but **can lie about
313 success**, leaving `reach.fediverse` naming an account that does not exist.
314 ⇒ Whichever is chosen, the honest confirmation is resolving the actor over HTTPS afterwards —
315 which the peer cannot do (§4.1, deliberately). So the check belongs to the surface or the person.
316 **Not decided.**
3172. **`reach.fediverse` as a flat field, or `identity.md` §6's `resources: [{name:'bridge:fediverse'}]`.**
318 §6 is the target; the registry is *"design. Nothing built."* The flat field is the shipped
319 approximation and moving is a migration, not a rename. **Not decided** — §4.5 only requires that
320 the existing field be committed.
3213. **Which bridge a `reach` network resolves to.** §6.2 requires the record to name a *network* and
322 configuration to resolve it to an instance. With one bridge this is invisible; with two it is the
323 whole question, and `bridge-claim {url}` above quietly assumes the person picks.
324
325---
326
327## 7. What this does not claim
328
329- It does not make a submitted DID revocable. PLC is append-only; `identity.md` §4.2.
330- It does not give a released bridge name back. The burn is deliberate (§5.2).
331- It does not implement §6's bridge-as-resource model, and leaves no half-built seam toward it.
332- It does not address `ap-bridge/DESIGN.md` §4's subdomain-per-actor addressing, which the shipped
333 path-based `/ap/users/<name>` layout diverges from. The `generation` machinery exists **because**
334 names share one host; on a subdomain-per-actor bridge the collision story is different and this
335 spec would need re-reading.