OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity commits / 6306219

favela-identity: users, credentials, and bridges
favela made the atproto DID the root of identity, which is why #47 (unrotatable DID), #48 (a destroyed vault) and #49 (secret custody) are all the same bug: the root key is also the network identifier. pear-git-v3 has never worked that way. Its IdentityRegistry resolves getIdentityByDid beside getIdentityBySSHKey and getIdentityByNoiseKey — a DID is a leaf, one credential among several on a durable User record, with add/remove/revoke/restore/merge and a link-token ceremony already written and replicating in production. favela runs its OWN instance of that registry, separate from pgv3's; interlinking is deferred (§9) and deliberately left without a half-built seam. This costs no fork: the constructor already takes (store, {prefix, bootstrapKey, keyPair, db, fastForwardTo}), so a separate instance is a separate store and key. favela-web already depends on hyper-identity and imports only AtprotoProvider — lib/auth.js:19 says it "doesn't need hyper-identity's full autobase IdentityRegistry". This turns that on. Two decisions reach outward: - §5 the canonical record names the USER, not the DID, resolved at projection time. A DID in the bytes would make rotation a record rewrite, which record-vocabulary §8 forbids outright. - §6 a bridge is a RESOURCE, so adding a network is a grant rather than a record migration — the same argument §5.1 makes for visibility being URIs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6306219a5fac2a2478f8dd8f40ffed56a5955e36
by plankatron <fleeky@gmail.com> · 2026-09-03T01:39:56.000Z

Changed files (4) +268 -0

.gitignore +2 -0
……@@ -0,0 +1,2 @@
1node_modules/
2*.log
README.md +27 -0
……@@ -0,0 +1,27 @@
1# favela-identity
2
3**An account is a User. A DID is a credential attached to it. A bridge is a resource it holds.**
4
5favela made the atproto DID the root of identity. That is why the DID cannot be rotated, why
6destroying a vault destroys the account, and why there is no way to add or subtract a credential.
7pear-git-v3 has never worked that way: its `IdentityRegistry` resolves a DID with
8`getIdentityByDid` sitting beside `getIdentityBySSHKey` and `getIdentityByNoiseKey` — a DID is a
9leaf there, one credential among several hanging off a durable user record.
10
11This package adopts that model for favela.
12
13## Separate registry, shared code
14
15favela runs its **own IdentityRegistry autobase**, not pear-git-v3's. A forge account and a favela
16account are different accounts, and interlinking them is deliberately left for later.
17
18That costs nothing to build: `IdentityRegistry` already takes `(store, { prefix, bootstrapKey,
19keyPair, db, fastForwardTo })`, so a separate instance is a separate corestore and a separate
20bootstrap key. **The code is reused; only the instance is new.**
21
22## Status
23
24Design. Nothing built. Start at `spec/identity.md`.
25
26Related: `favela-markdown` (the record — its author reference is decided here),
27`packages/hyper-identity` (the registry implementation), hyper-social #47, #48, #49.
package.json +7 -0
……@@ -0,0 +1,7 @@
1{
2 "name": "favela-identity",
3 "version": "0.0.0",
4 "description": "favela's account layer — a User is durable, a DID is one credential among several, and a bridge is a resource. Runs hyper-identity's IdentityRegistry as its own separate instance.",
5 "type": "module",
6 "license": "MIT"
7}
spec/identity.md +232 -0
……@@ -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
6pear-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
8here), `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
16favela made the atproto DID the root of identity. Three open issues are the same bug wearing
17different 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
23All three are "the root key is also the network identifier". They dissolve when the DID is a
24credential 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."*
28favela-web already depends on `hyper-identity` (`package.json:82`) and imports exactly one symbol
29from it, `AtprotoProvider`. The registry was consciously declined. This spec turns it on.
30
31---
32
33## 2. Verified vs assumed
34
35Read 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```
53User (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
64The account is what persists. Credentials come and go beneath it, and none of them is privileged
65over 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.
70We are choosing a data model that is already written and already replicating in production, not
71designing one.
72
73⚠ **`createUser`'s read-back is not a formality.** It exists because a peer that is `writable` but
74has an empty indexer set resolves `append()` and never linearizes, and the old code returned the
75in-memory op — a phantom account, indistinguishable from a real one, re-minted on every request.
76Any 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
84The DID stops being the account, so it can be **rotated, revoked, or absent**. A user with no
85atproto credential is a perfectly valid favela account that simply does not project to bsky. A
86user 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
97That asymmetry is worth designing around rather than discovering: rotation is cheap on the
98fediverse 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
106The canonical record names the **user**, not the DID. The DID is resolved **at projection time**,
107because `at://<did>/<collection>/<rkey>` is an atproto addressing concern and nothing else.
108
109If 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"*
111is the rule that already cost two of wj62's posts, unrecoverably. Naming the user keeps the DID out
112of the bytes, which is what makes §4 possible at all.
113
114### 5.1 The designated atproto credential
115
116A 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
125This 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
131pgv3's `resources[]` already models "a named thing an identity holds permissions over". In pgv3
132those are repos. Here they are **bridges**.
133
134```
135resources: [
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
142argument `record-vocabulary.md` §5.1 makes for `visibility` being URIs rather than protocol flags:
143knowledge of projections must not live inside the record.
144
145### 6.1 A bridge is not a User
146
147A bridge holds its own credentials — ap-bridge carries an RSA keypair for HTTP Signatures, the
148atproto path uses the shared `#atproto` signing key. Those are the **bridge's** secrets, not a
149person'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
154network → 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
164know **where it actually went** — which inboxes, which collections, when. That is per-bridge
165delivery state. It belongs beside the bridge resource, it is **local and derived**, and it is not
166replicated.
167
168---
169
170## 7. Two authority concepts, again
171
172pgv3 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
174only the first. An org member with write permission still could not push, because nothing had made
175its daemon a writer on the shared tracker.
176
177favela's signer election is the same shape. Granting a user `write` on `bridge:bsky` does **not**
178make its device a writer on the room's autobase. Both grants are needed, and they are separate
179ceremonies. 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
198Two 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
209designed before either registry is in use would be designed against guesses.
210
211---
212
213## 10. Open
214
2151. **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.
2172. **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.
2193. **Scoping the ~120 call sites** — mechanical rename or genuine model change, per site.
2204. **Whether the peer or favela-web owns the registry instance.** Both need it; only one should
221 write it.
2225. **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.