| … | … | @@ -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):** |
| 6 | 1. **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. |
| 8 | 2. **Bridge names are DID/AP names only.** favela's own P2P handles are the NameRegistry's and are |
| 9 | not touched here (§2). |
| 10 | 3. **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 | |
| 18 | Two 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 | |
| 25 | Both 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 |
| 33 | already 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 | |
| 42 | This 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 |
| 53 | claim form is fine and kind; treating them as the same identifier is not, because the bridge can |
| 54 | burn and re-issue a name (§5.2) and the NameRegistry cannot. A person whose bridge name was released |
| 55 | by 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 |
| 58 | offline; a bridge name resolves only while that bridge is up. `identity.md` §6.2 already requires a |
| 59 | network 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 | |
| 67 | They 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 |
| 77 | a working identity, and the page has to say so or it reads as a half-finished ceremony with no way |
| 78 | back — 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 |
| 83 | that."* So the phrase renders **inside the POST response** and no GET route can reproduce it — |
| 84 | identical to `/welcome`'s rule, which exists because *"if any GET could, 'a stolen device yields a |
| 85 | locked vault' stops being true."* |
| 86 | |
| 87 | ⚠ `did-mint` takes `{mnemonic}` for **restore**. Restore and create are one verb and one form with |
| 88 | two 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 |
| 93 | withheld; **several without a designation** → *"refuse to project, and surface it. Silently picking |
| 94 | one 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 |
| 97 | string, so "at most one" is structural rather than enforced — which is fine, and is the shipped |
| 98 | approximation of §6 (see §6.2 below). |
| 99 | |
| 100 | ### 3.4 ⛔ Desktop parity needs a composition host that does not exist |
| 101 | |
| 102 | Three 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 | |
| 110 | On the handset there IS a worklet holding both halves, so `ATPROTO_OPS` wraps `dispatch()` and |
| 111 | neither package learns about the other. On desktop there is no such thing: favela-surface and |
| 112 | favela-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 |
| 115 | DID node (`atproto-room` + `favela-community`), depends on neither favela-peer nor hyper-proto-peer, |
| 116 | and 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 |
| 119 | too**, with favela-peer a library there exactly as it already is on the handset. `lib/rpc.js` |
| 120 | already anticipates this: it exports `dispatch(node, cmd, a)` **alongside** `createServer(node, |
| 121 | sock)`, and `favela-surface/index.js` names the split — *"`dispatch` is the interface and the socket |
| 122 | is 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 |
| 125 | alternative — 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 |
| 127 | would teach it. |
| 128 | |
| 129 | ⚠ **The page must still degrade.** A surface talking to a plain favela-peer gets `unknown command` |
| 130 | for `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 |
| 140 | transport choice (protomux-rpc over Hyperswarm, matching hpp, vs a local UDS) stays a separate |
| 141 | decision."* That is DESIGN.md §11's **M2, the keyless bridge**. |
| 142 | |
| 143 | ★ **Registration and signing are the same channel in opposite directions.** Building registration |
| 144 | over 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 | |
| 147 | Three 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 |
| 156 | peers must BOTH be client:true to punch simultaneously — symmetric connects in ~2s, asymmetric never |
| 157 | opens."* 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 | ``` |
| 162 | peer 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 |
| 179 | proof'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. |
| 181 | There is nowhere else to put it. |
| 182 | |
| 183 | ⛔ **`username` and `feedKey` are inside the signed bytes**, not merely alongside them. A proof that |
| 184 | covered only the nonce could be captured and replayed to claim a *different* name under the signer's |
| 185 | root — 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 |
| 189 | accepts claims from **anyone** (decision 1) and must therefore verify *which identity*, not *which |
| 190 | socket*. 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 |
| 201 | with the same message `requireLinked` already gives, not allowed to send a claim that cannot be |
| 202 | verified. |
| 203 | |
| 204 | ### 4.4 The verbs, and the gate they sit behind |
| 205 | |
| 206 | Named 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 |
| 208 | fediverse *is*; it claims a name on a bridge and records the answer. |
| 209 | |
| 210 | ``` |
| 211 | bridge-policy { url } → { policy } |
| 212 | bridge-claim { url, name, invite? } → { acct, generation } |
| 213 | bridge-release { url, name } → { released } |
| 214 | bridge-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 |
| 218 | existing comment on `set-reach` is the argument verbatim: *"turning on a network puts words under |
| 219 | this identity somewhere that does not forget, so it requires an ATTESTED device — the same gate as |
| 220 | posting. 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 | |
| 224 | On success the peer writes `setReach({ fediverse: '<name>@<host>' })`. |
| 225 | |
| 226 | ⚠ **That field is uncommitted today** (`favela-peer/lib/peer.js`, working tree), while |
| 227 | favela-surface **0.0.61 shipped the UI that writes it**. Against published favela-peer 0.0.50 the |
| 228 | field 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 |
| 230 | success path records nothing on desktop and says it succeeded. |
| 231 | |
| 232 | --- |
| 233 | |
| 234 | ## 5. The bridge side |
| 235 | |
| 236 | Four 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 |
| 241 | header 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"* — |
| 248 | is stated in the file and not implemented by it. An identity root is public; presenting one proves |
| 249 | nothing. |
| 250 | |
| 251 | The 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 |
| 253 | caller 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 |
| 256 | swarm 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 |
| 261 | root — 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"*, |
| 263 | which is correct and means a forged release **permanently burns your actor identity**. |
| 264 | |
| 265 | Same proof, same challenge, same fix. This is the more urgent half of §5.1 because the damage does |
| 266 | not need a second step. |
| 267 | |
| 268 | ### 5.3 `randomToken()` is `Math.random()` |
| 269 | |
| 270 | ```js |
| 271 | for (let i = 0; i < 4; i++) s += Math.floor(Math.random() * 0xffffffff).toString(16)... |
| 272 | ``` |
| 273 | |
| 274 | On 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. |
| 280 | So `reach.fediverse` records `name@bridge` and can never be re-confirmed — and after an operator |
| 281 | release it is silently, permanently wrong. |
| 282 | |
| 283 | ⇒ `bridge-status` (§4.4), **proof-gated**. A public listing of every name with its identity root is |
| 284 | a 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 |
| 289 | right — *"minting the token that gates it is not something an HTTP caller should be able to |
| 290 | assert."* Without it an invite-only bridge cannot issue invites, so the `invite` policy is |
| 291 | unreachable 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 |
| 294 | namespace 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). |
| 299 | The verifier and the CSPRNG are both already installed and unused. |
| 300 | |
| 301 | --- |
| 302 | |
| 303 | ## 6. Open |
| 304 | |
| 305 | 1. ⛔ **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.** |
| 317 | 2. **`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. |
| 321 | 3. **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. |