OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 8cbf853

HandleRegistry — P2P name->identity registry
Standalone Autobase-backed registry mapping handles to hyperident identities (name -> { identityRoot, devicePubkey, metadata }). Identity-layer generalization of pgh's app-name NameRegistry: same proven mechanism (TOFU first-claim-wins via Autobase linearization, writer-set anti-spam gate, tombstone-on-release for reclaim), value genericized to identity data. Backs hyperident-shell name resolution (replacing the local names-file stand-in). Class validated single-process (claim/resolve/TOFU/update/ release/reclaim). Swarm replication + shell wiring next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8cbf8536c4f35c3b7defb1f44fc8e0d3e3a54dde
by plankatron <fleeky@gmail.com> · 2026-07-13T21:31:00.000Z

Changed files (1) +227 -0

lib/handle-registry.js +227 -0
……@@ -0,0 +1,227 @@
1/**
2 * HandleRegistry — Autobase-backed P2P registry mapping human handles to
3 * hyperident identities. `name -> { identityRoot, devicePubkey, metadata }`.
4 *
5 * This is the identity-layer generalization of pgh's app-name NameRegistry:
6 * same proven mechanism (Autobase linearization gives TOFU first-claim-wins for
7 * free; the writer set is the anti-spam gate; releases tombstone for reclaim),
8 * but the value is pluggable identity data instead of a pgh publishing payload.
9 * Standalone (its own autobase / swarm topic) so it can be shared across apps
10 * without depending on pgh.
11 *
12 * Ops (JSON-encoded appends):
13 * { type: 'claim-handle', entry } — claim a handle
14 * { type: 'update-handle', name, updates, signedBy } — owner: rotate device/metadata
15 * { type: 'release-handle', name, signedBy } — owner: tombstone (reclaimable)
16 * { type: 'add-writer', publicKey } — grant claim authority
17 * { type: 'remove-writer', publicKey } — revoke
18 *
19 * Claim entry:
20 * name — flat handle string, unique
21 * identity: {
22 * identityRoot — hyperident root pubkey (survives device rotation; the owner key)
23 * devicePubkey — the device pubkey to reach the identity at (e.g. a shell address)
24 * }
25 * metadata? — free-form (e.g. { kind: 'shell' })
26 * claimedAt — ms epoch
27 *
28 * Hyperbee view keys:
29 * handle:<name> → entry
30 * by-identity:<identityRoot>:<name> → { name }
31 * by-device:<devicePubkey>:<name> → { name }
32 * tombstone:<name> → { releasedAt, releasedBy }
33 *
34 * AuthZ note: update/release check op.signedBy === claim.identity.identityRoot,
35 * an UNVERIFIED caller-supplied field (same TOFU model as pgh NameRegistry). The
36 * real gate is Autobase write access. Cryptographic writer->identity binding is a
37 * follow-up (see plan Phase 2, decision ii).
38 */
39
40import Autobase from 'autobase'
41import Hyperbee from 'hyperbee'
42import b4a from 'b4a'
43
44export const OP_CLAIM_HANDLE = 'claim-handle'
45export const OP_UPDATE_HANDLE = 'update-handle'
46export const OP_RELEASE_HANDLE = 'release-handle'
47export const OP_ADD_WRITER = 'add-writer'
48export const OP_REMOVE_WRITER = 'remove-writer'
49
50export class HandleRegistry {
51 /**
52 * @param {Corestore} corestore
53 * @param {object} [opts]
54 * @param {string} [opts.namespace='handle-registry']
55 * @param {Buffer|string} [opts.bootstrap] — autobase key (hex/Buffer). Omit to
56 * create a NEW registry (this node becomes the first writer).
57 */
58 constructor (corestore, opts = {}) {
59 this._store = corestore.namespace(opts.namespace || 'handle-registry')
60 this._bootstrap = opts.bootstrap
61 ? (b4a.isBuffer(opts.bootstrap) ? opts.bootstrap : b4a.from(opts.bootstrap, 'hex'))
62 : null
63 this._base = null
64 this._view = null
65 this._ready = false
66 }
67
68 async ready () {
69 if (this._ready) return
70
71 const apply = async (nodes, view, base) => {
72 for (const node of nodes) {
73 let op
74 try { op = JSON.parse(b4a.toString(node.value)) } catch { continue }
75
76 if (op.type === OP_CLAIM_HANDLE) {
77 const e = op.entry
78 if (!e || !e.name || !e.identity) continue
79 if (!e.identity.identityRoot && !e.identity.devicePubkey) continue
80
81 const key = 'handle:' + e.name
82 const existing = await view.get(key)
83 if (existing) {
84 const tomb = await view.get('tombstone:' + e.name)
85 if (!tomb) continue // actively owned — reject (first-claim-wins)
86 await view.del('tombstone:' + e.name) // tombstoned → allow reclaim
87 }
88
89 const identityRoot = e.identity.identityRoot || e.identity.devicePubkey
90 const devicePubkey = e.identity.devicePubkey || e.identity.identityRoot
91 const entry = {
92 name: e.name,
93 identity: { identityRoot, devicePubkey },
94 metadata: e.metadata || {},
95 claimedAt: e.claimedAt || Date.now()
96 }
97 await view.put(key, entry)
98 await view.put('by-identity:' + identityRoot + ':' + e.name, { name: e.name })
99 await view.put('by-device:' + devicePubkey + ':' + e.name, { name: e.name })
100 }
101
102 if (op.type === OP_UPDATE_HANDLE) {
103 if (!op.name || !op.signedBy) continue
104 const existing = await view.get('handle:' + op.name)
105 if (!existing) continue
106 if (existing.value.identity.identityRoot !== op.signedBy) continue
107 const updated = { ...existing.value, identity: { ...existing.value.identity } }
108 if (op.updates && typeof op.updates === 'object') {
109 if (op.updates.devicePubkey) updated.identity.devicePubkey = op.updates.devicePubkey
110 if (op.updates.metadata) updated.metadata = { ...updated.metadata, ...op.updates.metadata }
111 updated.updatedAt = Date.now()
112 }
113 await view.put('handle:' + op.name, updated)
114 }
115
116 if (op.type === OP_RELEASE_HANDLE) {
117 if (!op.name || !op.signedBy) continue
118 const existing = await view.get('handle:' + op.name)
119 if (!existing) continue
120 if (existing.value.identity.identityRoot !== op.signedBy) continue
121 await view.put('tombstone:' + op.name, { releasedAt: Date.now(), releasedBy: op.signedBy })
122 }
123
124 if (op.type === OP_ADD_WRITER && op.publicKey) {
125 try { await base.addWriter(b4a.from(op.publicKey, 'hex')) } catch (err) {
126 console.error('[handle-registry] addWriter failed:', err.message)
127 }
128 }
129
130 if (op.type === OP_REMOVE_WRITER && op.publicKey) {
131 try { await base.removeWriter(b4a.from(op.publicKey, 'hex')) } catch (err) {
132 console.error('[handle-registry] removeWriter failed:', err.message)
133 }
134 }
135 }
136 }
137
138 this._base = new Autobase(this._store, this._bootstrap, {
139 open: (store) => new Hyperbee(store.get('view'), { keyEncoding: 'utf-8', valueEncoding: 'json' }),
140 apply,
141 valueEncoding: 'binary'
142 })
143
144 await this._base.ready()
145 this._view = this._base.view
146 this._ready = true
147 }
148
149 async close () {
150 if (this._base) { try { await this._base.close() } catch {} this._base = null }
151 this._view = null
152 this._ready = false
153 }
154
155 get keyHex () { return this._base ? b4a.toString(this._base.key, 'hex') : null }
156 get localKeyHex () { return this._base ? b4a.toString(this._base.local.key, 'hex') : null }
157 get writable () { return !!(this._base && this._base.writable) }
158 get discoveryKey () { return this._base ? this._base.discoveryKey : null }
159
160 // ── Writes ───────────────────────────────────────────────────
161
162 async claim (entry) {
163 if (!this._ready) await this.ready()
164 if (!entry || !entry.name || !entry.identity) {
165 throw new Error('claim requires { name, identity: { identityRoot?, devicePubkey? } }')
166 }
167 const op = { type: OP_CLAIM_HANDLE, entry: { ...entry, claimedAt: entry.claimedAt || Date.now() } }
168 await this._base.append(b4a.from(JSON.stringify(op)))
169 await this.update()
170 }
171
172 async updateHandle (name, updates, signedBy) {
173 if (!this._ready) await this.ready()
174 if (!name || !signedBy) throw new Error('updateHandle requires { name, signedBy }')
175 await this._base.append(b4a.from(JSON.stringify({ type: OP_UPDATE_HANDLE, name, updates: updates || {}, signedBy })))
176 await this.update()
177 }
178
179 async release (name, signedBy) {
180 if (!this._ready) await this.ready()
181 if (!name || !signedBy) throw new Error('release requires { name, signedBy }')
182 await this._base.append(b4a.from(JSON.stringify({ type: OP_RELEASE_HANDLE, name, signedBy })))
183 await this.update()
184 }
185
186 async addWriter (publicKey) {
187 if (!this._ready) await this.ready()
188 const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
189 await this._base.append(b4a.from(JSON.stringify({ type: OP_ADD_WRITER, publicKey: hex })))
190 await this.update()
191 }
192
193 async removeWriter (publicKey) {
194 if (!this._ready) await this.ready()
195 const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
196 await this._base.append(b4a.from(JSON.stringify({ type: OP_REMOVE_WRITER, publicKey: hex })))
197 await this.update()
198 }
199
200 // ── Reads ────────────────────────────────────────────────────
201
202 async resolve (name) {
203 if (!this._ready) await this.ready()
204 const entry = await this._view.get('handle:' + name)
205 if (!entry) return null
206 if (await this._view.get('tombstone:' + name)) return null
207 return entry.value
208 }
209
210 async list (opts = {}) {
211 if (!this._ready) await this.ready()
212 const out = []
213 for await (const entry of this._view.createReadStream({ gt: 'handle:', lt: 'handle:\xff' })) {
214 if (!opts.includeReleased && await this._view.get('tombstone:' + entry.value.name)) continue
215 if (opts.identity && entry.value.identity.identityRoot !== opts.identity) continue
216 out.push(entry.value)
217 }
218 return out
219 }
220
221 async update () {
222 if (!this._ready) await this.ready()
223 return this._base.update()
224 }
225
226 replicate (stream) { return this._base.replicate(stream) }
227}