OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / b049a10

identity: 1155 lines -> 65, and what is left is the engine
IdentityRegistry now extends hyper-identity-core's BaseIdentityRegistry and supplies two things: `_createBase` (the Autobase construction, keet-core-hyperdb config verbatim) and `_adaptHost` (autobase's `{ indexer }` spelling of the core's `{ isIndexer }`). `static engine = 'autobase'` replaces the option. The parity harness now detects a wrong engine from the package's own declaration rather than from an option it was handed, which is strictly stronger — a package cannot misreport what it is. Suite 122/123, 342/343, unchanged through the move. The only red is [autobee], which is the package this split exists to make possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b049a10287bc8747ffe6615b241cf1c660656a70
by plankatron <fleeky@gmail.com> · 2026-09-06T21:03:10.000Z

Changed files (1) +33 -1151

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