| … | … | @@ -0,0 +1,232 @@ |
| 1 | # favela-identity — users, credentials, and bridges |
| 2 | |
| 3 | **Status:** design. Nothing built. |
| 4 | **Date:** 2026-09-02 |
| 5 | **Decision (user, 2026-09-02):** favela runs its **own** IdentityRegistry autobase, separate from |
| 6 | pear-git-v3's. Interlinking the two is explicitly deferred (§9). |
| 7 | **Related:** `favela-markdown/spec/library.md` (the record; its author reference is decided in §5 |
| 8 | here), `packages/hyper-identity` (the implementation), hyper-social #47, #48, #49. |
| 9 | |
| 10 | --- |
| 11 | |
| 12 | ## 1. The decision |
| 13 | |
| 14 | **An account is a `User`. A DID is one credential attached to it. A bridge is a resource it holds.** |
| 15 | |
| 16 | favela made the atproto DID the root of identity. Three open issues are the same bug wearing |
| 17 | different clothes: |
| 18 | |
| 19 | - **#47** — an unrotatable DID. |
| 20 | - **#48** — a vault destroyed, rotation authority recovered from a backup. |
| 21 | - **#49** — secret custody in a swarm, designed from scratch. |
| 22 | |
| 23 | All three are "the root key is also the network identifier". They dissolve when the DID is a |
| 24 | credential you can add and subtract rather than the thing everything hangs from. |
| 25 | |
| 26 | **This is not a new build.** `favela-web/lib/auth.js:19` says, in a comment: |
| 27 | *"keyless VIEW; it doesn't need hyper-identity's full autobase IdentityRegistry."* |
| 28 | favela-web already depends on `hyper-identity` (`package.json:82`) and imports exactly one symbol |
| 29 | from it, `AtprotoProvider`. The registry was consciously declined. This spec turns it on. |
| 30 | |
| 31 | --- |
| 32 | |
| 33 | ## 2. Verified vs assumed |
| 34 | |
| 35 | Read in source on 2026-09-02. Nothing outside this table is verified. |
| 36 | |
| 37 | | claim | where | |
| 38 | |---|---| |
| 39 | | `IdentityRegistry(store, { prefix, bootstrapKey, keyPair, db, fastForwardTo })` — a separate instance is a separate store + key, no fork | `identity-registry.js:24-63` | |
| 40 | | A `User` is `{ userId, username, name, email, profilePicture, globalPermissions, resources[], identities[], createdAt, updatedAt }` | `identity-registry.js:134-160` | |
| 41 | | A DID already resolves as a credential, beside SSH and noise keys | `getIdentityByDid:338`, `getIdentityBySSHKey:373`, `getIdentityByNoiseKey:390`, `getIdentityByAtprotoHandle:356` | |
| 42 | | Adding and subtracting credentials already exists | `addIdentityToUser:454`, `removeIdentityFromUser:466`, `revokeIdentity:584`, `restoreIdentity:607`, `mergeAccounts:570` | |
| 43 | | A link ceremony exists — hashed, expiring, single-use | `CREATE_LINK_TOKEN` / `CONSUME_LINK_TOKEN`, `:955-1012` | |
| 44 | | `createUser` reads back from the view and **throws** rather than report success for a write that did not land | `identity-registry.js:168-185` | |
| 45 | | favela-web already depends on hyper-identity and declined the registry | `favela-web/package.json:82`, `lib/auth.js:9,19` | |
| 46 | | pgv3 keeps git-push ACL and autobase writership as **independent** concepts | `pear-git-v3` skill, org #71 | |
| 47 | |
| 48 | --- |
| 49 | |
| 50 | ## 3. The model |
| 51 | |
| 52 | ``` |
| 53 | User (durable — the account) |
| 54 | ├─ identities[] credentials, each independently addable and revocable |
| 55 | │ ├─ device pubkey (hyperident / keet device chain) |
| 56 | │ ├─ did:plc:… (atproto — see §4) |
| 57 | │ ├─ atproto handle |
| 58 | │ ├─ ssh fingerprint |
| 59 | │ └─ noise key |
| 60 | ├─ globalPermissions[] |
| 61 | └─ resources[] { name, permissions } — repos in pgv3; BRIDGES here (§6) |
| 62 | ``` |
| 63 | |
| 64 | The account is what persists. Credentials come and go beneath it, and none of them is privileged |
| 65 | over the others — which is the entire difference from today. |
| 66 | |
| 67 | **Operationally**, "adding and subtracting identities" is already the registry's op set: |
| 68 | `ADD_IDENTITY`, `REMOVE_IDENTITY`, `LINK_IDENTITY`, `REVOKE_IDENTITY`, `RESTORE_IDENTITY`, |
| 69 | `MERGE_ACCOUNTS`, plus the link-token ceremony for attaching a credential from another device. |
| 70 | We are choosing a data model that is already written and already replicating in production, not |
| 71 | designing one. |
| 72 | |
| 73 | ⚠ **`createUser`'s read-back is not a formality.** It exists because a peer that is `writable` but |
| 74 | has an empty indexer set resolves `append()` and never linearizes, and the old code returned the |
| 75 | in-memory op — a phantom account, indistinguishable from a real one, re-minted on every request. |
| 76 | Any new write path here copies that pattern: never report success for a write that will not commit. |
| 77 | |
| 78 | --- |
| 79 | |
| 80 | ## 4. The DID becomes a leaf |
| 81 | |
| 82 | ### 4.1 What this buys |
| 83 | |
| 84 | The DID stops being the account, so it can be **rotated, revoked, or absent**. A user with no |
| 85 | atproto credential is a perfectly valid favela account that simply does not project to bsky. A |
| 86 | user with a fresh DID is the same account with a new credential. |
| 87 | |
| 88 | ### 4.2 ⚠ What rotation does NOT buy — state it plainly |
| 89 | |
| 90 | - **The PLC operation log is append-only and permanent.** Rotating does not erase; the old DID and |
| 91 | its history remain resolvable forever. This layer makes a DID *replaceable*, never *deletable*. |
| 92 | - **`at://` URIs contain the DID.** Every already-signed atproto record is addressed under the old |
| 93 | DID, so historical atproto addressing is **frozen at rotation**. Rotation is forward-looking. |
| 94 | - **ActivityPub URIs survive**, because `objectIdFor({host, rkey})` derives from the **rkey only** |
| 95 | (`record-vocabulary.md` §2, verified) — the 22 already-federated objects keep resolving. |
| 96 | |
| 97 | That asymmetry is worth designing around rather than discovering: rotation is cheap on the |
| 98 | fediverse side and expensive on the atproto side. |
| 99 | |
| 100 | --- |
| 101 | |
| 102 | ## 5. How a record names its author |
| 103 | |
| 104 | **This is the decision that reaches into `favela-markdown`.** |
| 105 | |
| 106 | The canonical record names the **user**, not the DID. The DID is resolved **at projection time**, |
| 107 | because `at://<did>/<collection>/<rkey>` is an atproto addressing concern and nothing else. |
| 108 | |
| 109 | If the canonical record embedded a DID, rotation would require rewriting every record — and |
| 110 | `record-vocabulary.md` §8 forbids that absolutely: *"Never re-encode a record you did not author"* |
| 111 | is the rule that already cost two of wj62's posts, unrecoverably. Naming the user keeps the DID out |
| 112 | of the bytes, which is what makes §4 possible at all. |
| 113 | |
| 114 | ### 5.1 The designated atproto credential |
| 115 | |
| 116 | A user may hold zero, one, or several DIDs, but the signer needs exactly one at sign time. |
| 117 | |
| 118 | **Rule:** a user has at most one credential marked `designated: 'atproto'`. |
| 119 | - **None** → no `app.bsky.feed.post` projection is written. This is fail-closed and matches |
| 120 | `library.md` §6: the projection is withheld, the canonical record is unaffected, and the post |
| 121 | still replicates to every favela peer. |
| 122 | - **Several without a designation** → refuse to project, and surface it. Silently picking one would |
| 123 | publish under an identity the author did not choose. |
| 124 | |
| 125 | This is the same shape as `visibility` gating the bsky projection, arriving from the identity side. |
| 126 | |
| 127 | --- |
| 128 | |
| 129 | ## 6. The bridge layer — a bridge is a resource |
| 130 | |
| 131 | pgv3's `resources[]` already models "a named thing an identity holds permissions over". In pgv3 |
| 132 | those are repos. Here they are **bridges**. |
| 133 | |
| 134 | ``` |
| 135 | resources: [ |
| 136 | { name: 'bridge:bsky', permissions: ['write'] }, |
| 137 | { name: 'bridge:fediverse', permissions: ['write'] } |
| 138 | ] |
| 139 | ``` |
| 140 | |
| 141 | **Adding a network is `addResource` plus a grant. It is never a record migration** — the same |
| 142 | argument `record-vocabulary.md` §5.1 makes for `visibility` being URIs rather than protocol flags: |
| 143 | knowledge of projections must not live inside the record. |
| 144 | |
| 145 | ### 6.1 A bridge is not a User |
| 146 | |
| 147 | A bridge holds its own credentials — ap-bridge carries an RSA keypair for HTTP Signatures, the |
| 148 | atproto path uses the shared `#atproto` signing key. Those are the **bridge's** secrets, not a |
| 149 | person's. So a bridge is a resource with a credential set, never a row in `identities[]`. |
| 150 | |
| 151 | ### 6.2 `reach` resolves through the bridge registry |
| 152 | |
| 153 | `reach` in a record names a **network** (`design.md` §5.1). The bridge registry resolves |
| 154 | network → a configured bridge instance → its credentials and endpoint. Two consequences: |
| 155 | |
| 156 | - The record never names a bridge instance, so moving `ap.7rnx.net` or running a second AP bridge |
| 157 | changes configuration, not records. |
| 158 | - A network in `reach` with no configured bridge is a **visible** no-op, reported like any other |
| 159 | projection loss (`library.md` §4) rather than silently dropped. |
| 160 | |
| 161 | ### 6.3 Retraction bookkeeping lives here |
| 162 | |
| 163 | `design.md` §6 identifies the one thing that cannot live in the record: to retract a post you must |
| 164 | know **where it actually went** — which inboxes, which collections, when. That is per-bridge |
| 165 | delivery state. It belongs beside the bridge resource, it is **local and derived**, and it is not |
| 166 | replicated. |
| 167 | |
| 168 | --- |
| 169 | |
| 170 | ## 7. Two authority concepts, again |
| 171 | |
| 172 | pgv3 learned this at a cost (org #71): the **ACL** (`globalPermissions` / `resources[]`) and |
| 173 | **autobase writership** (`addWriter` of a peer's local key) are independent, and membership grants |
| 174 | only the first. An org member with write permission still could not push, because nothing had made |
| 175 | its daemon a writer on the shared tracker. |
| 176 | |
| 177 | favela's signer election is the same shape. Granting a user `write` on `bridge:bsky` does **not** |
| 178 | make its device a writer on the room's autobase. Both grants are needed, and they are separate |
| 179 | ceremonies. Design them as two from the start rather than discovering it in production. |
| 180 | |
| 181 | --- |
| 182 | |
| 183 | ## 8. Migration |
| 184 | |
| 185 | - **~120 DID-keyed call sites** across `favela-web/lib/` (measured by grep, not audited). This is |
| 186 | the bulk of the work and it is not yet scoped. |
| 187 | - **Existing records are not rewritten.** ⛔ The DID inside already-signed records stays exactly as |
| 188 | it is. A record with no user reference is a legacy record, read as belonging to the user that |
| 189 | currently designates that DID — resolved forward, never rewritten. Same attrition rule as |
| 190 | `record-vocabulary.md` §8. |
| 191 | - **Bootstrap.** The first favela user is created from the device already holding the DID, and that |
| 192 | DID is attached as its designated atproto credential. Nobody has to re-pair, and no vault moves. |
| 193 | |
| 194 | --- |
| 195 | |
| 196 | ## 9. Interlinking with pear-git-v3 — deferred, not foreclosed |
| 197 | |
| 198 | Two registries, two autobases, two account spaces. What keeps the door open: |
| 199 | |
| 200 | - `IdentityRegistry` takes a **`prefix`**, so two registries can share a view namespace without |
| 201 | colliding. |
| 202 | - Both resolve credentials by the same keys (`getIdentityByPublicKey`, `getIdentityByDid`), so |
| 203 | **the same device pubkey can appear in both** — which is already the natural link: one device, |
| 204 | two accounts, provable as the same holder. |
| 205 | - The link-token ceremony (`CREATE_LINK_TOKEN` / `CONSUME_LINK_TOKEN`) is the obvious mechanism to |
| 206 | extend across registries when the time comes. |
| 207 | |
| 208 | ⛔ **Do not build any of this now.** Nothing in §1–§8 depends on it, and a cross-registry linkage |
| 209 | designed before either registry is in use would be designed against guesses. |
| 210 | |
| 211 | --- |
| 212 | |
| 213 | ## 10. Open |
| 214 | |
| 215 | 1. **Where favela's registry autobase lives** — its own corestore, or a namespace in the peer's |
| 216 | existing one. Affects backup and the blast radius of a corrupt store. |
| 217 | 2. **Who admits the first writer**, and how a second device joins. `DeviceLinker` blind-pairing is |
| 218 | the candidate and is what #49 describes from scratch; it is not yet chosen here. |
| 219 | 3. **Scoping the ~120 call sites** — mechanical rename or genuine model change, per site. |
| 220 | 4. **Whether the peer or favela-web owns the registry instance.** Both need it; only one should |
| 221 | write it. |
| 222 | 5. **Bridge credential custody.** §6.1 says a bridge holds its own secrets; where they live, and |
| 223 | what happens on device loss, is #49's question wearing a different hat. |
| 224 | |
| 225 | --- |
| 226 | |
| 227 | ## 11. What this does not claim |
| 228 | |
| 229 | - It does not make a DID deletable. PLC is append-only; §4.2. |
| 230 | - It does not preserve historical `at://` addressing across a rotation. Nothing can. |
| 231 | - It does not reduce the migration cost; §8 names it and does not estimate it. |
| 232 | - It does not interlink with pear-git-v3, and deliberately leaves no half-built seam toward it. |