OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity-core commits / d4eee47

core: take the registry too — the engine was one method all along
BaseIdentityRegistry, moved from hyper-identity unchanged apart from the seam. Every user, identity, username, link-token and writer operation; 19 appends, 15 updates, every read. None of it was ever autobase-specific — autobase and autobee spell `append`, `update`, `view`, `writable`, `ready`, `close` and the rest identically, which is why this is a subclass and not a fork. The engine is exactly one abstract method: async _createBase({ apply, rootKey }) // and _adaptHost() for the spelling ⛔ Two things dropped on the way in. The `engine` OPTION is gone: once the choice is a package, an option is a second way to say the same thing and the wrong one is a lie that constructs the other engine — a false green the parity harness caught once already. It is now `static engine`, declared by the binding. ⚠ And `Buffer.from(this._bootstrapKey, 'hex')` became `b4a.from`. `Buffer` is not a global under Bare, and that line is on the bootstrap path — a latent crash for any Bare consumer that passed a bootstrapKey, which is exactly what favela-peer will do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d4eee477d7c06bd0ab0eb2e61fe93651badcd609
by plankatron <fleeky@gmail.com> · 2026-09-06T21:03:10.000Z

Changed files (3) +1150 -3

index.js +1 -0
……@@ -8,5 +8,6 @@
88// every consumer transitively, which is the whole reason this split exists: `hyper-identity2` runs
99// autobee and must not carry autobase beside it.
1010
11export { BaseIdentityRegistry } from './lib/registry.js'
1112export { createApplyFunction } from './lib/apply.js'
1213export { OpTypes, encodeOp, decodeOp, validateOp } from './lib/operations.js'
lib/registry.js +1144 -0
……@@ -0,0 +1,1144 @@
1import crypto from 'hypercore-crypto'
2import b4a from 'b4a'
3import { createApplyFunction } from './apply.js'
4import { encodeOp, OpTypes } from './operations.js'
5
6const RESERVED_USERNAMES = [
7 'api', 'admin', 'web', 'sync', 'static', 'assets', 'auth',
8 'health', 'status', 'login', 'logout', 'register', 'settings'
9]
10
11const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/
12
13/**
14 * IdentityRegistry — Autobase-backed user and identity management
15 *
16 * Provides CRUD operations for users, identities, and permissions
17 * using Autobase for multi-writer consensus.
18 *
19 * Generic: uses 'resources' instead of application-specific terms.
20 */
21export class BaseIdentityRegistry {
22 // Which engine this binding runs on. Subclasses set it; the base has none, because having one
23 // would mean the engine-free half had an opinion about engines.
24 static engine = null
25 get engine () { return this.constructor.engine }
26
27 constructor (store, opts = {}) {
28 this._store = store
29 this._prefix = opts.prefix || ''
30 this._base = null
31 this._view = null
32 this._ready = false
33
34 // Separate Hyperbee for direct key lookups (identity records, pubkey index)
35 // This is NOT the Autobase view — it's a direct-write store for identity metadata
36 this.db = opts.db || null
37
38 // Mirror support: when bootstrapKey is passed, join the primary's
39 // Autobase instead of creating a fresh one.
40 this._bootstrapKey = opts.bootstrapKey || null
41
42 // keyPair: when provided, the Autobase writer's local core uses
43 // this Ed25519 keypair instead of a corestore-derived one. This
44 // lets the caller pin the autobase ROOT to something stable
45 // (e.g. the hyperident device keypair) so the root is deterministic
46 // across machines and doesn't need to be shipped as a separate
47 // bootstrap key via bundles. The primary's autobase root ends up
48 // equal to keyPair.publicKey; mirrors pass that same publicKey as
49 // bootstrapKey to join.
50 this._keyPair = opts.keyPair || null
51
52 // fastForwardTo: the ORIGIN's `_system` core key (hex or buffer), obtained
53 // via `base.getSystemKey()` on the origin and shipped in the deployment
54 // bundle. A joining peer fast-forwards straight to that system core instead
55 // of deriving its own.
56 //
57 // This is what keet does on every join: the invitation carries
58 // `fastForwardTo: base.getSystemKey()` (keet-core/index.js:1991,1999) and
59 // the joiner passes `{ key }` into Autobase (tasks/pairing/keys.js:46 →
60 // index.js:1643). Without it a joiner must derive its own system state, and
61 // a peer that ends up with an empty indexer set derives a DIFFERENT
62 // `_system` core (its manifest is a function of the indexer set,
63 // autobase/lib/store.js:541-578) — after which the origin's signed
64 // checkpoints are for a core it does not hold, and it can never converge.
65 this._fastForwardTo = opts.fastForwardTo || null
66 }
67
68 /**
69 * Initialize Autobase with Hyperbee view
70 */
71 async ready () {
72 if (this._ready) return
73
74 const apply = createApplyFunction({ prefix: this._prefix, adaptHost: this._adaptHost() })
75
76 const rootKey = this._bootstrapKey
77 ? (typeof this._bootstrapKey === 'string'
78 ? b4a.from(this._bootstrapKey, 'hex')
79 : this._bootstrapKey)
80 : null
81
82 this._base = await this._createBase({ apply, rootKey })
83
84 await this._base.ready()
85 this._view = this._base.view
86 this._ready = true
87 }
88
89 /**
90 * Build the multi-writer log. THE ONLY ENGINE-SPECIFIC METHOD IN THIS CLASS.
91 *
92 * Everything else here — 19 appends, 15 updates, every read, every op — is spelled identically by
93 * autobase and autobee, which is why the split is a subclass and not a fork.
94 *
95 * @abstract
96 * @param {{ apply: Function, rootKey: Buffer|null }} opts
97 */
98 async _createBase (opts) {
99 throw new Error('BaseIdentityRegistry: subclass must implement _createBase — this class has no engine')
100 }
101
102 /**
103 * How this engine spells the core's writer-set vocabulary. See hyper-identity-core's README.
104 * Null means "speaks `{ isIndexer }` natively" — which is autobee, not autobase.
105 */
106 _adaptHost () { return null }
107
108 /**
109 * Get the multi-writer log (for adding writers, etc.)
110 */
111 get base () { return this._base }
112
113 /**
114 * Get Hyperbee view
115 */
116 get view () { return this._view }
117
118 // ==================== USER OPERATIONS ====================
119
120 /**
121 * Create a new user
122 */
123 async createUser (opts = {}) {
124 const userId = opts.userId || crypto.randomBytes(16).toString('hex')
125 // Timestamp-preserving create: when a caller (e.g. the identity-publish
126 // reconciler re-emitting a record it already holds) passes explicit
127 // createdAt/updatedAt, forward them so the replayed op does NOT bump the
128 // record's clock. The apply honours op.createdAt/op.updatedAt and falls
129 // back to its own Date.now() when absent, so the default path (no opts)
130 // is unchanged. Preserving timestamps is load-bearing for keyed LWW
131 // convergence — a re-announce must never out-clock a peer's newer edit.
132 const now = Date.now()
133 const op = {
134 type: OpTypes.CREATE_USER,
135 userId,
136 username: opts.username || null,
137 name: opts.name || 'Anonymous',
138 email: opts.email || null,
139 profilePicture: opts.profilePicture || null,
140 globalPermissions: opts.globalPermissions || ['read'],
141 resources: opts.resources || [{ name: '*', permissions: ['read'] }],
142 identities: opts.identities || [],
143 createdAt: opts.createdAt || now,
144 updatedAt: opts.updatedAt || opts.createdAt || now
145 }
146 await this._base.append(encodeOp(op))
147 await this._base.update()
148 // Read back from the view — apply may have modified the record (e.g. the
149 // first user is promoted to admin).
150 //
151 // The read-back is ALSO the proof that the write landed. `append()` resolves
152 // on a peer that can never linearize (admitted and `writable`, but with an
153 // empty indexer set nothing is ever confirmed), and the record simply never
154 // appears. The previous `return user || op` handed back the in-memory op,
155 // whose userId was minted locally a few lines above — indistinguishable from
156 // a persisted user. That turned a broken peer into SILENT DATA LOSS: every
157 // request re-ran TOFU, produced a fresh phantom userId, and the profile page
158 // read "Not authenticated yet" forever with no error logged anywhere.
159 //
160 // Never report success for a write that will not commit — the rule every
161 // durable autobase app follows (autobase skill, admission-patterns.md).
162 const user = await this.getUser(userId)
163 if (!user) {
164 const err = new Error('createUser: the write did not land — refusing to report success ' +
165 'for a user that was not persisted (userId=' + userId + ')')
166 err.code = 'EWRITE_NOT_PERSISTED'
167 throw err
168 }
169 return user
170 }
171
172 /**
173 * Get user by userId
174 */
175 async getUser (userId) {
176 const entry = await this._view.get(`${this._prefix}user:${userId}`)
177 return entry ? entry.value : null
178 }
179
180 /**
181 * Get user by identity ID
182 */
183 async getUserByIdentity (identityId) {
184 const link = await this._view.get(`${this._prefix}user-identity:${identityId}`)
185 if (!link) return null
186 return this.getUser(link.value.userId)
187 }
188
189 /**
190 * Get user by username (indexed lookup)
191 */
192 async getUserByUsername (username) {
193 if (!username) return null
194 const entry = await this._view.get(`${this._prefix}username:${username}`)
195 if (!entry) return null
196 return this.getUser(entry.value.userId)
197 }
198
199 /**
200 * Update user
201 */
202 async updateUser (userId, updates) {
203 const op = {
204 type: OpTypes.UPDATE_USER,
205 userId,
206 ...updates
207 }
208 await this._base.append(encodeOp(op))
209 await this._base.update()
210 }
211
212 /**
213 * Delete user
214 */
215 async deleteUser (userId) {
216 await this._base.append(encodeOp({ type: OpTypes.DELETE_USER, userId }))
217 await this._base.update()
218 }
219
220 /**
221 * List all users
222 */
223 async listUsers () {
224 const users = []
225 for await (const entry of this._view.createReadStream({
226 gte: `${this._prefix}user:`,
227 lt: `${this._prefix}user:\xff`
228 })) {
229 if (!entry.value.mergedInto) users.push(entry.value)
230 }
231 return users
232 }
233
234 // ==================== IDENTITY OPERATIONS ====================
235
236 /**
237 * Register an identity record.
238 *
239 * Writes through the Autobase apply (`REGISTER_IDENTITY` op) so the
240 * full record + reverse indexes (pubkey, sshkey, noisekey) replicate
241 * to mirror peers via the autobase view. Keeps a parallel write to
242 * the local Hyperbee `db` for backward-compat with daemons that
243 * still read from there during the migration window.
244 *
245 * Pre-fix this method only wrote to `db` (local-only) — see
246 * pear-git-v3/test/identity-record-replication.test.js for the
247 * proof that putting it in the view fixes cross-daemon visibility.
248 *
249 * @param {Object} identity - { identityId, method, publicKey, credentials, ... }
250 */
251 async registerIdentity (identity) {
252 if (!identity || !identity.identityId || !identity.method) {
253 throw new Error('registerIdentity requires { identityId, method, ... }')
254 }
255 // 1. Append op to autobase — view picks up record + reverse indexes
256 // via apply, replicates to all peers.
257 await this._base.append(encodeOp({
258 type: OpTypes.REGISTER_IDENTITY,
259 identity
260 }))
261
262 // 2. Mirror to local db too, so legacy code paths reading directly
263 // from db keep working until everything reads from the view.
264 // (This write is local-only; no harm if it diverges — view is
265 // the source of truth.)
266 if (this.db) {
267 try {
268 await this.db.put(`identity:${identity.identityId}`, identity)
269 if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
270 await this.db.put(`sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
271 } else if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
272 await this.db.put(`noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
273 }
274 if (identity.publicKey) {
275 await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
276 }
277 if (identity.method === 'atproto' && identity.credentials) {
278 if (identity.credentials.did) {
279 await this.db.put(`atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
280 }
281 if (identity.credentials.handle) {
282 await this.db.put(`atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
283 }
284 }
285 } catch {
286 // db is best-effort cache; failure here doesn't undo the autobase append
287 }
288 }
289 }
290
291 /**
292 * Get identity by ID. Try the autobase view first (replicated, source
293 * of truth post-fix); fall back to the local db (legacy/cache).
294 */
295 async getIdentity (identityId) {
296 if (this._view) {
297 const entry = await this._view.get(`${this._prefix}identity:${identityId}`).catch(() => null)
298 if (entry && entry.value) return entry.value
299 }
300 if (!this.db) return null
301 const entry = await this.db.get(`identity:${identityId}`).catch(() => null)
302 return entry ? entry.value : null
303 }
304
305 /**
306 * Get identity by public key. View first, then db fallback.
307 */
308 async getIdentityByPublicKey (publicKey) {
309 if (this._view) {
310 const idx = await this._view.get(`${this._prefix}pubkey:${publicKey}`).catch(() => null)
311 if (idx && idx.value && idx.value.identityId) {
312 const id = await this.getIdentity(idx.value.identityId)
313 if (id) return id
314 }
315 }
316 if (!this.db) return null
317 const entry = await this.db.get(`pubkey:${publicKey}`).catch(() => null)
318 if (!entry) return null
319 return this.getIdentity(entry.value.identityId)
320 }
321
322 /**
323 * Get identity by atproto did:plc. View first, then db fallback.
324 * (The `atproto` provider registers identityId = did, so this also
325 * covers direct getIdentity(did); the index makes it explicit + future-proof.)
326 */
327 async getIdentityByDid (did) {
328 if (this._view) {
329 const idx = await this._view.get(`${this._prefix}atproto-did:${did}`).catch(() => null)
330 if (idx && idx.value && idx.value.identityId) {
331 const id = await this.getIdentity(idx.value.identityId)
332 if (id) return id
333 }
334 }
335 if (!this.db) return null
336 const entry = await this.db.get(`atproto-did:${did}`).catch(() => null)
337 if (!entry) return null
338 return this.getIdentity(entry.value.identityId)
339 }
340
341 /**
342 * Get identity by atproto handle (e.g. alice.7rnx.net). View first, then db.
343 * This is the multi-user-handle lookup: handle -> the bound identity.
344 */
345 async getIdentityByAtprotoHandle (handle) {
346 if (this._view) {
347 const idx = await this._view.get(`${this._prefix}atproto-handle:${handle}`).catch(() => null)
348 if (idx && idx.value && idx.value.identityId) {
349 const id = await this.getIdentity(idx.value.identityId)
350 if (id) return id
351 }
352 }
353 if (!this.db) return null
354 const entry = await this.db.get(`atproto-handle:${handle}`).catch(() => null)
355 if (!entry) return null
356 return this.getIdentity(entry.value.identityId)
357 }
358
359 /**
360 * Get identity by SSH key fingerprint. View first, then db fallback.
361 */
362 async getIdentityBySSHKey (fingerprint) {
363 if (this._view) {
364 const idx = await this._view.get(`${this._prefix}sshkey:${fingerprint}`).catch(() => null)
365 if (idx && idx.value && idx.value.identityId) {
366 const id = await this.getIdentity(idx.value.identityId)
367 if (id) return id
368 }
369 }
370 if (!this.db) return null
371 const entry = await this.db.get(`sshkey:${fingerprint}`).catch(() => null)
372 if (!entry) return null
373 return this.getIdentity(entry.value.identityId)
374 }
375
376 /**
377 * Get identity by Noise protocol public key. View first, then db fallback.
378 */
379 async getIdentityByNoiseKey (noiseKey) {
380 if (this._view) {
381 const idx = await this._view.get(`${this._prefix}noisekey:${noiseKey}`).catch(() => null)
382 if (idx && idx.value && idx.value.identityId) {
383 const id = await this.getIdentity(idx.value.identityId)
384 if (id) return id
385 }
386 }
387 if (!this.db) return null
388 const entry = await this.db.get(`noisekey:${noiseKey}`).catch(() => null)
389 if (!entry) return null
390 return this.getIdentity(entry.value.identityId)
391 }
392
393 /**
394 * Link identity to user via Autobase
395 *
396 * SECURITY: This method moves an identity to a user account. Callers MUST
397 * verify ownership before calling (e.g., via challenge-response signature).
398 * The registry itself cannot verify ownership because it doesn't have the
399 * request context. Auth providers are responsible for proving key ownership
400 * before calling this.
401 *
402 * @param {string} identityId - Identity to link
403 * @param {string} userId - User to link to
404 * @param {Object} [opts] - Options
405 * @param {boolean} [opts.verified] - Set to true to confirm caller has verified ownership
406 */
407 async linkIdentityToUser (identityId, userId, opts = {}) {
408 // Guard: require explicit verification flag to prevent accidental misuse
409 if (!opts.verified) {
410 throw new Error('linkIdentityToUser requires opts.verified=true — caller must verify key ownership first')
411 }
412
413 // Check if identity is already linked to a different user
414 if (this.db) {
415 const identity = await this.getIdentity(identityId)
416 if (identity && identity.userId && identity.userId !== userId) {
417 throw new Error(`Identity already linked to user ${identity.userId}`)
418 }
419 }
420
421 // Update direct-write db first (source of truth for identity records)
422 if (this.db) {
423 const identity = await this.getIdentity(identityId)
424 if (identity) {
425 identity.userId = userId
426 identity.updatedAt = Date.now()
427 await this.db.put(`identity:${identityId}`, identity)
428 }
429 }
430
431 // Then append to Autobase for multi-writer consensus
432 await this._base.append(encodeOp({
433 type: OpTypes.LINK_IDENTITY,
434 identityId,
435 userId
436 }))
437 await this._base.update()
438 }
439
440 /**
441 * Add identity to user
442 */
443 async addIdentityToUser (userId, identityId) {
444 await this._base.append(encodeOp({
445 type: OpTypes.ADD_IDENTITY,
446 userId,
447 identityId
448 }))
449 await this._base.update()
450 }
451
452 /**
453 * Remove identity from user
454 */
455 async removeIdentityFromUser (userId, identityId) {
456 await this._base.append(encodeOp({
457 type: OpTypes.REMOVE_IDENTITY,
458 userId,
459 identityId
460 }))
461 await this._base.update()
462 }
463
464 /**
465 * List user's identities
466 */
467 async listUserIdentities (userId) {
468 const user = await this.getUser(userId)
469 if (!user) return []
470
471 const identities = []
472 for (const id of user.identities) {
473 const identity = await this.getIdentity(id)
474 if (identity) identities.push(identity)
475 }
476 return identities
477 }
478
479 // ==================== PERMISSIONS ====================
480
481 /**
482 * Upgrade permissions for user on resource
483 * @param {string} userId
484 * @param {string} resourceName - Resource name or '*' for global
485 * @param {string[]} permissions - ['read', 'write', 'admin']
486 */
487 async upgradePermissions (userId, resourceName, permissions) {
488 await this._base.append(encodeOp({
489 type: OpTypes.UPGRADE_PERMISSIONS,
490 userId,
491 resourceName,
492 permissions
493 }))
494 await this._base.update()
495 }
496
497 /**
498 * Check if any admin user exists
499 */
500 async hasAnyAdmin () {
501 return this._hasAnyAdmin()
502 }
503
504 async _hasAnyAdmin () {
505 for await (const entry of this._view.createReadStream({
506 gte: `${this._prefix}user:`,
507 lt: `${this._prefix}user:\xff`
508 })) {
509 const user = entry.value
510 if (user.mergedInto) continue
511 if (user.globalPermissions && user.globalPermissions.includes('admin')) return true
512 }
513 return false
514 }
515
516 // ==================== DEACTIVATION ====================
517
518 /**
519 * Deactivate user — blocks auth while preserving data
520 * @param {string} userId
521 * @param {{ reason?: string }} opts
522 */
523 async deactivateUser (userId, opts = {}) {
524 await this._base.append(encodeOp({
525 type: OpTypes.DEACTIVATE_USER,
526 userId,
527 reason: opts.reason || null
528 }))
529 await this._base.update()
530 }
531
532 /**
533 * Reactivate user — re-enable auth
534 * @param {string} userId
535 */
536 async reactivateUser (userId) {
537 await this._base.append(encodeOp({
538 type: OpTypes.REACTIVATE_USER,
539 userId
540 }))
541 await this._base.update()
542 }
543
544 /**
545 * Check if user is deactivated
546 * @param {string} userId
547 * @returns {Promise<boolean>}
548 */
549 async isDeactivated (userId) {
550 const user = await this.getUser(userId)
551 return !!(user && user.deactivatedAt)
552 }
553
554 // ==================== ACCOUNT MERGE ====================
555
556 /**
557 * Merge two accounts
558 */
559 async mergeAccounts (primaryUserId, secondaryUserId) {
560 await this._base.append(encodeOp({
561 type: OpTypes.MERGE_ACCOUNTS,
562 primaryUserId,
563 secondaryUserId
564 }))
565 await this._base.update()
566 }
567
568 // ==================== REVOCATION ====================
569
570 /**
571 * Revoke an identity
572 */
573 async revokeIdentity (identityId, opts = {}) {
574 await this._base.append(encodeOp({
575 type: OpTypes.REVOKE_IDENTITY,
576 identityId,
577 reason: opts.reason || null,
578 revokedAt: Date.now()
579 }))
580 await this._base.update()
581
582 // Also update direct-write store
583 if (this.db) {
584 const identity = await this.getIdentity(identityId)
585 if (identity) {
586 identity.revoked = true
587 identity.revokedAt = Date.now()
588 await this.db.put(`identity:${identityId}`, identity)
589 }
590 }
591 }
592
593 /**
594 * Restore a revoked identity
595 */
596 async restoreIdentity (identityId) {
597 await this._base.append(encodeOp({
598 type: OpTypes.RESTORE_IDENTITY,
599 identityId
600 }))
601 await this._base.update()
602
603 if (this.db) {
604 const identity = await this.getIdentity(identityId)
605 if (identity) {
606 delete identity.revoked
607 delete identity.revokedAt
608 await this.db.put(`identity:${identityId}`, identity)
609 }
610 }
611 }
612
613 /**
614 * List revoked identities
615 */
616 async listRevokedIdentities () {
617 if (!this.db) return []
618 const revoked = []
619 for await (const entry of this.db.createReadStream({
620 gte: 'identity:',
621 lt: 'identity:\xff'
622 })) {
623 if (entry.value.revoked) revoked.push(entry.value)
624 }
625 return revoked
626 }
627
628 // ==================== AUTO-REGISTRATION ====================
629
630 /**
631 * Auto-register: atomically create user + identity for first-time auth.
632 * First user gets admin, subsequent get read-only.
633 *
634 * @param {Object} identityData - { identityId, method, credentials, name? }
635 * @returns {{ user: Object, identity: Object }}
636 */
637 async autoRegister (identityData) {
638 // SECURITY: Always request read-only permissions. The apply function
639 // deterministically promotes the FIRST CREATE_USER to admin based on
640 // Autobase linearization order. This eliminates the TOCTOU race
641 // condition where two writers both see "no admin" and both get admin.
642 const user = await this.createUser({
643 name: identityData.name || 'Anonymous',
644 globalPermissions: ['read'],
645 resources: [{ name: '*', permissions: ['read'] }]
646 })
647
648 // Register identity in direct-write store
649 const identity = {
650 identityId: identityData.identityId,
651 method: identityData.method,
652 publicKey: identityData.credentials ? identityData.credentials.publicKey : identityData.identityId,
653 userId: user.userId,
654 createdAt: Date.now()
655 }
656 await this.registerIdentity(identity)
657
658 // Link identity to user (safe: autoRegister is called after auth provider verification)
659 await this.linkIdentityToUser(identity.identityId, user.userId, { verified: true })
660
661 return { user, identity }
662 }
663
664 /**
665 * Check if any users exist
666 */
667 async _hasAnyUsers () {
668 for await (const _ of this._view.createReadStream({ // eslint-disable-line no-unused-vars
669 gte: `${this._prefix}user:`,
670 lt: `${this._prefix}user:\xff`,
671 limit: 1
672 })) {
673 return true
674 }
675 return false
676 }
677
678 // ==================== USERNAME MANAGEMENT ====================
679
680 /**
681 * Validate username format
682 * @param {string} username
683 * @returns {{ valid: boolean, reason?: string }}
684 */
685 validateUsername (username) {
686 if (!username || typeof username !== 'string') {
687 return { valid: false, reason: 'Username is required' }
688 }
689
690 const lower = username.toLowerCase()
691 if (lower !== username) {
692 return { valid: false, reason: 'Username must be lowercase' }
693 }
694
695 if (username.length < 3 || username.length > 32) {
696 return { valid: false, reason: 'Username must be 3-32 characters' }
697 }
698
699 if (!USERNAME_REGEX.test(username)) {
700 return { valid: false, reason: 'Username must be lowercase alphanumeric with dashes, no leading/trailing dash' }
701 }
702
703 if (username.includes('--')) {
704 return { valid: false, reason: 'Username must not contain consecutive dashes' }
705 }
706
707 if (RESERVED_USERNAMES.includes(username)) {
708 return { valid: false, reason: 'Username is reserved' }
709 }
710
711 return { valid: true }
712 }
713
714 /**
715 * Set username for a user (unique, validated)
716 * @param {string} userId
717 * @param {string} username
718 * @returns {Promise<Object>} Updated user from view
719 */
720 async setUsername (userId, username) {
721 const validation = this.validateUsername(username)
722 if (!validation.valid) {
723 const err = new Error(validation.reason)
724 err.code = 'VALIDATION_ERROR'
725 err.status = 400
726 throw err
727 }
728
729 // Check uniqueness in view
730 const existing = await this._view.get(`${this._prefix}username:${username}`)
731 if (existing && existing.value.userId !== userId) {
732 const err = new Error('Username already taken')
733 err.code = 'CONFLICT'
734 err.status = 409
735 throw err
736 }
737
738 // Emit Autobase op for multi-writer consensus
739 await this._base.append(encodeOp({
740 type: OpTypes.SET_USERNAME,
741 userId,
742 username
743 }))
744 await this._base.update()
745
746 // Also update direct-write db if present
747 if (this.db) {
748 const user = await this.getUser(userId)
749 if (user) {
750 if (user.username) {
751 await this.db.del(`username:${user.username}`)
752 }
753 await this.db.put(`username:${username}`, { userId })
754 }
755 }
756
757 return this.getUser(userId)
758 }
759
760 /**
761 * Check if username is available
762 * @param {string} username
763 * @returns {Promise<boolean>}
764 */
765 async isUsernameAvailable (username) {
766 const validation = this.validateUsername(username)
767 if (!validation.valid) return false
768
769 const entry = await this._view.get(`${this._prefix}username:${username}`)
770 return !entry
771 }
772
773 /**
774 * List all identities from direct-write store (admin)
775 * @returns {Promise<Object[]>}
776 */
777 async listAllIdentities () {
778 if (!this.db) return []
779 const identities = []
780 for await (const entry of this.db.createReadStream({
781 gte: 'identity:', lt: 'identity:\xff'
782 })) {
783 identities.push(entry.value)
784 }
785 return identities
786 }
787
788 // ==================== PROFILE UPDATES ====================
789
790 /**
791 * Update user profile (name, picture, theme)
792 * @param {string} userId
793 * @param {Object} updates - { name?, profilePicture?, theme? }
794 * @returns {Promise<Object>} Updated user
795 */
796 async updateUserProfile (userId, updates) {
797 const filtered = {}
798 if (updates.name !== undefined) filtered.name = updates.name
799 if (updates.profilePicture !== undefined) filtered.profilePicture = updates.profilePicture
800 if (updates.theme !== undefined) filtered.theme = updates.theme
801
802 await this.updateUser(userId, filtered)
803 return this.getUser(userId)
804 }
805
806 // ==================== REVOCATION (ENHANCED) ====================
807
808 /**
809 * Check if identity is revoked (fast lookup via view index)
810 * @param {string} identityId
811 * @returns {Promise<boolean>}
812 */
813 async isRevoked (identityId) {
814 // Check Autobase view revocation index
815 const revoked = await this._view.get(`${this._prefix}revoked:${identityId}`)
816 if (revoked) return true
817
818 // Fallback: check direct-write identity record
819 if (this.db) {
820 const identity = await this.getIdentity(identityId)
821 if (identity && identity.revoked) return true
822 }
823 return false
824 }
825
826 // ==================== USER QUERIES ====================
827
828 /**
829 * Get user with all linked identity objects resolved
830 * @param {string} userId
831 * @returns {Promise<{ user: Object, identities: Object[] } | null>}
832 */
833 async getUserWithIdentities (userId) {
834 const user = await this.getUser(userId)
835 if (!user) return null
836
837 const identities = []
838 for (const id of (user.identities || [])) {
839 const identity = await this.getIdentity(id)
840 if (identity) identities.push(identity)
841 }
842
843 return { user, identities }
844 }
845
846 /**
847 * List users who have set a username (public directory)
848 * @returns {Promise<Object[]>} Users with username, name, createdAt
849 */
850 async listPublicUsers () {
851 const users = []
852 for await (const entry of this._view.createReadStream({
853 gte: `${this._prefix}user:`,
854 lt: `${this._prefix}user:\xff`
855 })) {
856 const user = entry.value
857 if (user.username && !user.mergedInto) {
858 users.push({
859 userId: user.userId,
860 username: user.username,
861 name: user.name,
862 createdAt: user.createdAt
863 })
864 }
865 }
866 return users
867 }
868
869 /**
870 * Delete user and cascade cleanup (identities, indexes)
871 * @param {string} userId
872 */
873 async deleteUserCascade (userId) {
874 const user = await this.getUser(userId)
875 if (!user) return
876
877 // Remove identity records and pubkey indexes from direct-write db
878 if (this.db) {
879 for (const identityId of (user.identities || [])) {
880 const identity = await this.getIdentity(identityId)
881 if (identity && identity.publicKey) {
882 await this.db.del(`pubkey:${identity.publicKey}`)
883 }
884 await this.db.del(`identity:${identityId}`)
885 }
886
887 // Remove username index
888 if (user.username) {
889 await this.db.del(`username:${user.username}`)
890 }
891 }
892
893 // Delete user via Autobase
894 await this.deleteUser(userId)
895 }
896
897 /**
898 * Import a keypair into an existing user account.
899 * Registers the new identity and links it to the user.
900 * Used for device linking — same person, new browser/device.
901 *
902 * @param {string} userId - Existing user to link to
903 * @param {string} publicKey - New public key (44-char base64)
904 * @param {string} method - Auth method (default: 'web-identity')
905 * @returns {Promise<Object>} The new identity record
906 */
907 async importIdentity (userId, publicKey, method = 'web-identity') {
908 // Check user exists
909 const user = await this.getUser(userId)
910 if (!user) throw new Error('User not found')
911
912 // Check identity doesn't already exist
913 const existing = await this.getIdentityByPublicKey(publicKey)
914 if (existing) {
915 if (existing.userId === userId) return existing // Already linked
916 throw new Error('This key is already linked to a different user')
917 }
918
919 const identity = {
920 identityId: publicKey,
921 method,
922 publicKey,
923 userId,
924 createdAt: Date.now()
925 }
926 await this.registerIdentity(identity)
927 await this.linkIdentityToUser(publicKey, userId, { verified: true })
928
929 return identity
930 }
931
932 // ==================== LINK TOKENS (F.7.2) ====================
933
934 /**
935 * Generate a short-lived link token bound to a user. The token itself
936 * is returned to the caller; only the SHA-256 hash is stored in the
937 * Autobase view, so reading the registry can't replay tokens.
938 *
939 * @param {string} userId - The target user the token will link to
940 * @param {object} [opts]
941 * @param {number} [opts.ttlMs] - Token lifetime; default 5 min
942 * @returns {Promise<{token, tokenHash, expiresAt}>}
943 */
944 async createLinkToken (userId, opts = {}) {
945 if (!userId) throw new Error('createLinkToken requires { userId }')
946 const ttl = typeof opts.ttlMs === 'number' ? opts.ttlMs : 5 * 60 * 1000
947 const token = crypto.randomBytes(16).toString('hex')
948 const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
949 const expiresAt = Date.now() + ttl
950
951 await this._base.append(encodeOp({
952 type: OpTypes.CREATE_LINK_TOKEN,
953 tokenHash,
954 userId,
955 expiresAt,
956 createdAt: Date.now()
957 }))
958 await this._base.update()
959
960 return { token, tokenHash, expiresAt }
961 }
962
963 /**
964 * Look up a link token row by its raw token (re-hashes for lookup).
965 * Returns the stored row or null.
966 */
967 async getLinkToken (token) {
968 if (!token) return null
969 const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
970 const entry = await this._view.get(`${this._prefix}link-token:${tokenHash}`)
971 return entry ? entry.value : null
972 }
973
974 /**
975 * Consume a link token: validates expiry + single-use, attaches a new
976 * identity to the token's target user, optionally deactivates a
977 * throwaway user the new identity used to belong to. The whole sequence
978 * runs in a single Autobase op (CONSUME_LINK_TOKEN) so apply-time
979 * checks are atomic.
980 *
981 * @param {string} token - Raw token (re-hashed internally)
982 * @param {string} newIdentityId - Identity to attach to target user
983 * @param {object} [opts]
984 * @param {string} [opts.deactivateUserId] - Throwaway user to deactivate
985 * @returns {Promise<{userId, newIdentityId}>}
986 */
987 async consumeLinkToken (token, newIdentityId, opts = {}) {
988 if (!token) throw new Error('consumeLinkToken requires { token }')
989 if (!newIdentityId) throw new Error('consumeLinkToken requires { newIdentityId }')
990
991 // Pre-flight: classify the failure reason for the caller. The apply
992 // function silently no-ops on the same conditions (defence in depth).
993 const stored = await this.getLinkToken(token)
994 if (!stored) throw new Error('link token not found')
995 if (stored.consumed) throw new Error('link token already consumed')
996 if (stored.expiresAt && stored.expiresAt < Date.now()) throw new Error('link token expired')
997
998 const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
999
1000 await this._base.append(encodeOp({
1001 type: OpTypes.CONSUME_LINK_TOKEN,
1002 tokenHash,
1003 newIdentityId,
1004 deactivateUserId: opts.deactivateUserId || null
1005 }))
1006 await this._base.update()
1007
1008 // Update direct-write identity record so reverse lookups stay coherent
1009 if (this.db) {
1010 try {
1011 const ident = await this.getIdentity(newIdentityId)
1012 if (ident) {
1013 ident.userId = stored.userId
1014 ident.updatedAt = Date.now()
1015 await this.db.put(`identity:${newIdentityId}`, ident)
1016 }
1017 } catch {}
1018 }
1019
1020 return { userId: stored.userId, newIdentityId }
1021 }
1022
1023 // ==================== FEDERATION & LIFECYCLE ====================
1024
1025 /**
1026 * Add an Autobase writer via the op pipeline. Appends an ADD_WRITER
1027 * op that (a) records the writer in the view for audit, and (b) is
1028 * picked up by identity-apply.js to call `base.addWriter` on the
1029 * Autobase host. Going through the op pipeline (instead of calling
1030 * `_base.addWriter` directly) ensures the writer registration
1031 * linearizes across replicas and is visible in the shared view.
1032 *
1033 * @param {string|Buffer} key - writer's local core key (hex or buffer)
1034 * @param {object} [opts]
1035 * @param {string} [opts.role='writer'] - 'writer' | 'indexer'
1036 * @param {string} [opts.addedBy] - who added this writer (audit)
1037 */
1038 async addWriter (key, opts = {}) {
1039 const writerKey = typeof key === 'string'
1040 ? key
1041 : b4a.toString(key, 'hex')
1042 if (!writerKey || writerKey.length !== 64) {
1043 throw new Error('addWriter requires a 32-byte (64-char hex) writer key')
1044 }
1045 const op = {
1046 type: OpTypes.ADD_WRITER,
1047 writerKey,
1048 role: opts.role || 'writer',
1049 addedBy: opts.addedBy || null
1050 }
1051 await this._base.append(encodeOp(op))
1052 }
1053
1054 /**
1055 * Announce the local-writer keys a linked device has on OTHER
1056 * autobases in the enclosing app. Recorded in the view under
1057 * `mirror-writers:<devicePubkey>`. The enclosing app is responsible
1058 * for watching that key range and issuing canonical in-band
1059 * addWriter ops on the matching autobases.
1060 *
1061 * @param {string} devicePubkey — this device's hyperident pubkey (hex)
1062 * @param {Object<string,string>} keys — { namespace: localKeyHex, ... }
1063 */
1064 /**
1065 * True if this peer is a writer on the identity autobase (can append).
1066 * Mirrors this check straight off the underlying autobase.
1067 */
1068 get writable () {
1069 return this._base ? this._base.writable : false
1070 }
1071
1072 /**
1073 * Subscribe to writable transitions. Callback fires when this peer
1074 * becomes (or was already) a writer on the identity autobase — useful
1075 * for apps that want to act the moment they can append.
1076 * Returns an unsubscribe function.
1077 */
1078 onWritable (cb) {
1079 if (!this._base) return () => {}
1080 if (this._base.writable) { queueMicrotask(() => { try { cb() } catch {} }) }
1081 const handler = () => { try { cb() } catch {} }
1082 this._base.on('writable', handler)
1083 return () => this._base.off('writable', handler)
1084 }
1085
1086 async registerMirrorWriters (devicePubkey, keys) {
1087 if (!devicePubkey || typeof devicePubkey !== 'string' || devicePubkey.length !== 64) {
1088 throw new Error('registerMirrorWriters requires 64-char hex devicePubkey')
1089 }
1090 if (!keys || typeof keys !== 'object') {
1091 throw new Error('registerMirrorWriters requires a namespace→hex map')
1092 }
1093 const op = {
1094 type: OpTypes.REGISTER_MIRROR_WRITERS,
1095 devicePubkey,
1096 keys
1097 }
1098 await this._base.append(encodeOp(op))
1099 }
1100
1101 /**
1102 * Read the most recent mirror-writer announcement for a device.
1103 */
1104 async getMirrorWriters (devicePubkey) {
1105 const entry = await this._view.get(`${this._prefix}mirror-writers:${devicePubkey}`)
1106 return entry ? entry.value : null
1107 }
1108
1109 /**
1110 * Iterate all mirror-writer announcements (for watcher wiring).
1111 */
1112 async * listMirrorWriters () {
1113 for await (const entry of this._view.createReadStream({
1114 gte: `${this._prefix}mirror-writers:`,
1115 lt: `${this._prefix}mirror-writers:\xff`
1116 })) {
1117 const devicePubkey = entry.key.slice((this._prefix + 'mirror-writers:').length)
1118 yield { devicePubkey, ...entry.value }
1119 }
1120 }
1121
1122 /**
1123 * Remove an Autobase writer via the op pipeline. Symmetric with addWriter.
1124 */
1125 async removeWriter (key) {
1126 const writerKey = typeof key === 'string'
1127 ? key
1128 : b4a.toString(key, 'hex')
1129 const op = {
1130 type: OpTypes.REMOVE_WRITER,
1131 writerKey
1132 }
1133 await this._base.append(encodeOp(op))
1134 }
1135
1136 /**
1137 * Close registry
1138 */
1139 async close () {
1140 if (this._base) await this._base.close()
1141 }
1142}
1143
1144export default BaseIdentityRegistry
package.json +5 -3
……@@ -1,20 +1,22 @@
11{
22 "name": "hyper-identity-core",
33 "version": "0.1.0",
4 "description": "The engine-independent half of the identity layer — ops, and the apply that linearizes them. No autobase, no autobee, no view.",
4 "description": "The engine-independent half of the identity layer \u2014 ops, and the apply that linearizes them. No autobase, no autobee, no view.",
55 "type": "module",
66 "main": "index.js",
77 "exports": {
88 ".": "./index.js",
99 "./apply": "./lib/apply.js",
10 "./operations": "./lib/operations.js"
10 "./operations": "./lib/operations.js",
11 "./registry": "./lib/registry.js"
1112 },
1213 "scripts": {
1314 "test": "brittle test/*.test.js",
1415 "test:bare": "bare ./node_modules/.bin/brittle test/*.test.js"
1516 },
1617 "dependencies": {
17 "b4a": "^1.6.4"
18 "b4a": "^1.6.4",
19 "hypercore-crypto": "^3.0.0"
1820 },
1921 "devDependencies": {
2022 "brittle": "^4.0.2"