OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity-core / lib / registry.js

1145 lines · javascript ref:
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { createApplyFunction } from './apply.js'
import { encodeOp, OpTypes } from './operations.js'

const RESERVED_USERNAMES = [
  'api', 'admin', 'web', 'sync', 'static', 'assets', 'auth',
  'health', 'status', 'login', 'logout', 'register', 'settings'
]

const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/

/**
 * IdentityRegistry — Autobase-backed user and identity management
 *
 * Provides CRUD operations for users, identities, and permissions
 * using Autobase for multi-writer consensus.
 *
 * Generic: uses 'resources' instead of application-specific terms.
 */
export class BaseIdentityRegistry {
  // Which engine this binding runs on. Subclasses set it; the base has none, because having one
  // would mean the engine-free half had an opinion about engines.
  static engine = null
  get engine () { return this.constructor.engine }

  constructor (store, opts = {}) {
    this._store = store
    this._prefix = opts.prefix || ''
    this._base = null
    this._view = null
    this._ready = false

    // Separate Hyperbee for direct key lookups (identity records, pubkey index)
    // This is NOT the Autobase view — it's a direct-write store for identity metadata
    this.db = opts.db || null

    // Mirror support: when bootstrapKey is passed, join the primary's
    // Autobase instead of creating a fresh one.
    this._bootstrapKey = opts.bootstrapKey || null

    // keyPair: when provided, the Autobase writer's local core uses
    // this Ed25519 keypair instead of a corestore-derived one. This
    // lets the caller pin the autobase ROOT to something stable
    // (e.g. the hyperident device keypair) so the root is deterministic
    // across machines and doesn't need to be shipped as a separate
    // bootstrap key via bundles. The primary's autobase root ends up
    // equal to keyPair.publicKey; mirrors pass that same publicKey as
    // bootstrapKey to join.
    this._keyPair = opts.keyPair || null

    // fastForwardTo: the ORIGIN's `_system` core key (hex or buffer), obtained
    // via `base.getSystemKey()` on the origin and shipped in the deployment
    // bundle. A joining peer fast-forwards straight to that system core instead
    // of deriving its own.
    //
    // This is what keet does on every join: the invitation carries
    // `fastForwardTo: base.getSystemKey()` (keet-core/index.js:1991,1999) and
    // the joiner passes `{ key }` into Autobase (tasks/pairing/keys.js:46 →
    // index.js:1643). Without it a joiner must derive its own system state, and
    // a peer that ends up with an empty indexer set derives a DIFFERENT
    // `_system` core (its manifest is a function of the indexer set,
    // autobase/lib/store.js:541-578) — after which the origin's signed
    // checkpoints are for a core it does not hold, and it can never converge.
    this._fastForwardTo = opts.fastForwardTo || null
  }

  /**
   * Initialize Autobase with Hyperbee view
   */
  async ready () {
    if (this._ready) return

    const apply = createApplyFunction({ prefix: this._prefix, adaptHost: this._adaptHost() })

    const rootKey = this._bootstrapKey
      ? (typeof this._bootstrapKey === 'string'
          ? b4a.from(this._bootstrapKey, 'hex')
          : this._bootstrapKey)
      : null

    this._base = await this._createBase({ apply, rootKey })

    await this._base.ready()
    this._view = this._base.view
    this._ready = true
  }

  /**
   * Build the multi-writer log. THE ONLY ENGINE-SPECIFIC METHOD IN THIS CLASS.
   *
   * Everything else here — 19 appends, 15 updates, every read, every op — is spelled identically by
   * autobase and autobee, which is why the split is a subclass and not a fork.
   *
   * @abstract
   * @param {{ apply: Function, rootKey: Buffer|null }} opts
   */
  async _createBase (opts) {
    throw new Error('BaseIdentityRegistry: subclass must implement _createBase — this class has no engine')
  }

  /**
   * How this engine spells the core's writer-set vocabulary. See hyper-identity-core's README.
   * Null means "speaks `{ isIndexer }` natively" — which is autobee, not autobase.
   */
  _adaptHost () { return null }

  /**
   * Get the multi-writer log (for adding writers, etc.)
   */
  get base () { return this._base }

  /**
   * Get Hyperbee view
   */
  get view () { return this._view }

  // ==================== USER OPERATIONS ====================

  /**
   * Create a new user
   */
  async createUser (opts = {}) {
    const userId = opts.userId || crypto.randomBytes(16).toString('hex')
    // Timestamp-preserving create: when a caller (e.g. the identity-publish
    // reconciler re-emitting a record it already holds) passes explicit
    // createdAt/updatedAt, forward them so the replayed op does NOT bump the
    // record's clock. The apply honours op.createdAt/op.updatedAt and falls
    // back to its own Date.now() when absent, so the default path (no opts)
    // is unchanged. Preserving timestamps is load-bearing for keyed LWW
    // convergence — a re-announce must never out-clock a peer's newer edit.
    const now = Date.now()
    const op = {
      type: OpTypes.CREATE_USER,
      userId,
      username: opts.username || null,
      name: opts.name || 'Anonymous',
      email: opts.email || null,
      profilePicture: opts.profilePicture || null,
      globalPermissions: opts.globalPermissions || ['read'],
      resources: opts.resources || [{ name: '*', permissions: ['read'] }],
      identities: opts.identities || [],
      createdAt: opts.createdAt || now,
      updatedAt: opts.updatedAt || opts.createdAt || now
    }
    await this._base.append(encodeOp(op))
    await this._base.update()
    // Read back from the view — apply may have modified the record (e.g. the
    // first user is promoted to admin).
    //
    // The read-back is ALSO the proof that the write landed. `append()` resolves
    // on a peer that can never linearize (admitted and `writable`, but with an
    // empty indexer set nothing is ever confirmed), and the record simply never
    // appears. The previous `return user || op` handed back the in-memory op,
    // whose userId was minted locally a few lines above — indistinguishable from
    // a persisted user. That turned a broken peer into SILENT DATA LOSS: every
    // request re-ran TOFU, produced a fresh phantom userId, and the profile page
    // read "Not authenticated yet" forever with no error logged anywhere.
    //
    // Never report success for a write that will not commit — the rule every
    // durable autobase app follows (autobase skill, admission-patterns.md).
    const user = await this.getUser(userId)
    if (!user) {
      const err = new Error('createUser: the write did not land — refusing to report success ' +
        'for a user that was not persisted (userId=' + userId + ')')
      err.code = 'EWRITE_NOT_PERSISTED'
      throw err
    }
    return user
  }

  /**
   * Get user by userId
   */
  async getUser (userId) {
    const entry = await this._view.get(`${this._prefix}user:${userId}`)
    return entry ? entry.value : null
  }

  /**
   * Get user by identity ID
   */
  async getUserByIdentity (identityId) {
    const link = await this._view.get(`${this._prefix}user-identity:${identityId}`)
    if (!link) return null
    return this.getUser(link.value.userId)
  }

  /**
   * Get user by username (indexed lookup)
   */
  async getUserByUsername (username) {
    if (!username) return null
    const entry = await this._view.get(`${this._prefix}username:${username}`)
    if (!entry) return null
    return this.getUser(entry.value.userId)
  }

  /**
   * Update user
   */
  async updateUser (userId, updates) {
    const op = {
      type: OpTypes.UPDATE_USER,
      userId,
      ...updates
    }
    await this._base.append(encodeOp(op))
    await this._base.update()
  }

  /**
   * Delete user
   */
  async deleteUser (userId) {
    await this._base.append(encodeOp({ type: OpTypes.DELETE_USER, userId }))
    await this._base.update()
  }

  /**
   * List all users
   */
  async listUsers () {
    const users = []
    for await (const entry of this._view.createReadStream({
      gte: `${this._prefix}user:`,
      lt: `${this._prefix}user:\xff`
    })) {
      if (!entry.value.mergedInto) users.push(entry.value)
    }
    return users
  }

  // ==================== IDENTITY OPERATIONS ====================

  /**
   * Register an identity record.
   *
   * Writes through the Autobase apply (`REGISTER_IDENTITY` op) so the
   * full record + reverse indexes (pubkey, sshkey, noisekey) replicate
   * to mirror peers via the autobase view. Keeps a parallel write to
   * the local Hyperbee `db` for backward-compat with daemons that
   * still read from there during the migration window.
   *
   * Pre-fix this method only wrote to `db` (local-only) — see
   * pear-git-v3/test/identity-record-replication.test.js for the
   * proof that putting it in the view fixes cross-daemon visibility.
   *
   * @param {Object} identity - { identityId, method, publicKey, credentials, ... }
   */
  async registerIdentity (identity) {
    if (!identity || !identity.identityId || !identity.method) {
      throw new Error('registerIdentity requires { identityId, method, ... }')
    }
    // 1. Append op to autobase — view picks up record + reverse indexes
    //    via apply, replicates to all peers.
    await this._base.append(encodeOp({
      type: OpTypes.REGISTER_IDENTITY,
      identity
    }))

    // 2. Mirror to local db too, so legacy code paths reading directly
    //    from db keep working until everything reads from the view.
    //    (This write is local-only; no harm if it diverges — view is
    //    the source of truth.)
    if (this.db) {
      try {
        await this.db.put(`identity:${identity.identityId}`, identity)
        if (identity.method === 'ssh-key' && identity.credentials && identity.credentials.fingerprint) {
          await this.db.put(`sshkey:${identity.credentials.fingerprint}`, { identityId: identity.identityId })
        } else if (identity.method === 'noise-key' && identity.credentials && identity.credentials.publicKey) {
          await this.db.put(`noisekey:${identity.credentials.publicKey}`, { identityId: identity.identityId })
        }
        if (identity.publicKey) {
          await this.db.put(`pubkey:${identity.publicKey}`, { identityId: identity.identityId })
        }
        if (identity.method === 'atproto' && identity.credentials) {
          if (identity.credentials.did) {
            await this.db.put(`atproto-did:${identity.credentials.did}`, { identityId: identity.identityId })
          }
          if (identity.credentials.handle) {
            await this.db.put(`atproto-handle:${identity.credentials.handle}`, { identityId: identity.identityId })
          }
        }
      } catch {
        // db is best-effort cache; failure here doesn't undo the autobase append
      }
    }
  }

  /**
   * Get identity by ID. Try the autobase view first (replicated, source
   * of truth post-fix); fall back to the local db (legacy/cache).
   */
  async getIdentity (identityId) {
    if (this._view) {
      const entry = await this._view.get(`${this._prefix}identity:${identityId}`).catch(() => null)
      if (entry && entry.value) return entry.value
    }
    if (!this.db) return null
    const entry = await this.db.get(`identity:${identityId}`).catch(() => null)
    return entry ? entry.value : null
  }

  /**
   * Get identity by public key. View first, then db fallback.
   */
  async getIdentityByPublicKey (publicKey) {
    if (this._view) {
      const idx = await this._view.get(`${this._prefix}pubkey:${publicKey}`).catch(() => null)
      if (idx && idx.value && idx.value.identityId) {
        const id = await this.getIdentity(idx.value.identityId)
        if (id) return id
      }
    }
    if (!this.db) return null
    const entry = await this.db.get(`pubkey:${publicKey}`).catch(() => null)
    if (!entry) return null
    return this.getIdentity(entry.value.identityId)
  }

  /**
   * Get identity by atproto did:plc. View first, then db fallback.
   * (The `atproto` provider registers identityId = did, so this also
   * covers direct getIdentity(did); the index makes it explicit + future-proof.)
   */
  async getIdentityByDid (did) {
    if (this._view) {
      const idx = await this._view.get(`${this._prefix}atproto-did:${did}`).catch(() => null)
      if (idx && idx.value && idx.value.identityId) {
        const id = await this.getIdentity(idx.value.identityId)
        if (id) return id
      }
    }
    if (!this.db) return null
    const entry = await this.db.get(`atproto-did:${did}`).catch(() => null)
    if (!entry) return null
    return this.getIdentity(entry.value.identityId)
  }

  /**
   * Get identity by atproto handle (e.g. alice.7rnx.net). View first, then db.
   * This is the multi-user-handle lookup: handle -> the bound identity.
   */
  async getIdentityByAtprotoHandle (handle) {
    if (this._view) {
      const idx = await this._view.get(`${this._prefix}atproto-handle:${handle}`).catch(() => null)
      if (idx && idx.value && idx.value.identityId) {
        const id = await this.getIdentity(idx.value.identityId)
        if (id) return id
      }
    }
    if (!this.db) return null
    const entry = await this.db.get(`atproto-handle:${handle}`).catch(() => null)
    if (!entry) return null
    return this.getIdentity(entry.value.identityId)
  }

  /**
   * Get identity by SSH key fingerprint. View first, then db fallback.
   */
  async getIdentityBySSHKey (fingerprint) {
    if (this._view) {
      const idx = await this._view.get(`${this._prefix}sshkey:${fingerprint}`).catch(() => null)
      if (idx && idx.value && idx.value.identityId) {
        const id = await this.getIdentity(idx.value.identityId)
        if (id) return id
      }
    }
    if (!this.db) return null
    const entry = await this.db.get(`sshkey:${fingerprint}`).catch(() => null)
    if (!entry) return null
    return this.getIdentity(entry.value.identityId)
  }

  /**
   * Get identity by Noise protocol public key. View first, then db fallback.
   */
  async getIdentityByNoiseKey (noiseKey) {
    if (this._view) {
      const idx = await this._view.get(`${this._prefix}noisekey:${noiseKey}`).catch(() => null)
      if (idx && idx.value && idx.value.identityId) {
        const id = await this.getIdentity(idx.value.identityId)
        if (id) return id
      }
    }
    if (!this.db) return null
    const entry = await this.db.get(`noisekey:${noiseKey}`).catch(() => null)
    if (!entry) return null
    return this.getIdentity(entry.value.identityId)
  }

  /**
   * Link identity to user via Autobase
   *
   * SECURITY: This method moves an identity to a user account. Callers MUST
   * verify ownership before calling (e.g., via challenge-response signature).
   * The registry itself cannot verify ownership because it doesn't have the
   * request context. Auth providers are responsible for proving key ownership
   * before calling this.
   *
   * @param {string} identityId - Identity to link
   * @param {string} userId - User to link to
   * @param {Object} [opts] - Options
   * @param {boolean} [opts.verified] - Set to true to confirm caller has verified ownership
   */
  async linkIdentityToUser (identityId, userId, opts = {}) {
    // Guard: require explicit verification flag to prevent accidental misuse
    if (!opts.verified) {
      throw new Error('linkIdentityToUser requires opts.verified=true — caller must verify key ownership first')
    }

    // Check if identity is already linked to a different user
    if (this.db) {
      const identity = await this.getIdentity(identityId)
      if (identity && identity.userId && identity.userId !== userId) {
        throw new Error(`Identity already linked to user ${identity.userId}`)
      }
    }

    // Update direct-write db first (source of truth for identity records)
    if (this.db) {
      const identity = await this.getIdentity(identityId)
      if (identity) {
        identity.userId = userId
        identity.updatedAt = Date.now()
        await this.db.put(`identity:${identityId}`, identity)
      }
    }

    // Then append to Autobase for multi-writer consensus
    await this._base.append(encodeOp({
      type: OpTypes.LINK_IDENTITY,
      identityId,
      userId
    }))
    await this._base.update()
  }

  /**
   * Add identity to user
   */
  async addIdentityToUser (userId, identityId) {
    await this._base.append(encodeOp({
      type: OpTypes.ADD_IDENTITY,
      userId,
      identityId
    }))
    await this._base.update()
  }

  /**
   * Remove identity from user
   */
  async removeIdentityFromUser (userId, identityId) {
    await this._base.append(encodeOp({
      type: OpTypes.REMOVE_IDENTITY,
      userId,
      identityId
    }))
    await this._base.update()
  }

  /**
   * List user's identities
   */
  async listUserIdentities (userId) {
    const user = await this.getUser(userId)
    if (!user) return []

    const identities = []
    for (const id of user.identities) {
      const identity = await this.getIdentity(id)
      if (identity) identities.push(identity)
    }
    return identities
  }

  // ==================== PERMISSIONS ====================

  /**
   * Upgrade permissions for user on resource
   * @param {string} userId
   * @param {string} resourceName - Resource name or '*' for global
   * @param {string[]} permissions - ['read', 'write', 'admin']
   */
  async upgradePermissions (userId, resourceName, permissions) {
    await this._base.append(encodeOp({
      type: OpTypes.UPGRADE_PERMISSIONS,
      userId,
      resourceName,
      permissions
    }))
    await this._base.update()
  }

  /**
   * Check if any admin user exists
   */
  async hasAnyAdmin () {
    return this._hasAnyAdmin()
  }

  async _hasAnyAdmin () {
    for await (const entry of this._view.createReadStream({
      gte: `${this._prefix}user:`,
      lt: `${this._prefix}user:\xff`
    })) {
      const user = entry.value
      if (user.mergedInto) continue
      if (user.globalPermissions && user.globalPermissions.includes('admin')) return true
    }
    return false
  }

  // ==================== DEACTIVATION ====================

  /**
   * Deactivate user — blocks auth while preserving data
   * @param {string} userId
   * @param {{ reason?: string }} opts
   */
  async deactivateUser (userId, opts = {}) {
    await this._base.append(encodeOp({
      type: OpTypes.DEACTIVATE_USER,
      userId,
      reason: opts.reason || null
    }))
    await this._base.update()
  }

  /**
   * Reactivate user — re-enable auth
   * @param {string} userId
   */
  async reactivateUser (userId) {
    await this._base.append(encodeOp({
      type: OpTypes.REACTIVATE_USER,
      userId
    }))
    await this._base.update()
  }

  /**
   * Check if user is deactivated
   * @param {string} userId
   * @returns {Promise<boolean>}
   */
  async isDeactivated (userId) {
    const user = await this.getUser(userId)
    return !!(user && user.deactivatedAt)
  }

  // ==================== ACCOUNT MERGE ====================

  /**
   * Merge two accounts
   */
  async mergeAccounts (primaryUserId, secondaryUserId) {
    await this._base.append(encodeOp({
      type: OpTypes.MERGE_ACCOUNTS,
      primaryUserId,
      secondaryUserId
    }))
    await this._base.update()
  }

  // ==================== REVOCATION ====================

  /**
   * Revoke an identity
   */
  async revokeIdentity (identityId, opts = {}) {
    await this._base.append(encodeOp({
      type: OpTypes.REVOKE_IDENTITY,
      identityId,
      reason: opts.reason || null,
      revokedAt: Date.now()
    }))
    await this._base.update()

    // Also update direct-write store
    if (this.db) {
      const identity = await this.getIdentity(identityId)
      if (identity) {
        identity.revoked = true
        identity.revokedAt = Date.now()
        await this.db.put(`identity:${identityId}`, identity)
      }
    }
  }

  /**
   * Restore a revoked identity
   */
  async restoreIdentity (identityId) {
    await this._base.append(encodeOp({
      type: OpTypes.RESTORE_IDENTITY,
      identityId
    }))
    await this._base.update()

    if (this.db) {
      const identity = await this.getIdentity(identityId)
      if (identity) {
        delete identity.revoked
        delete identity.revokedAt
        await this.db.put(`identity:${identityId}`, identity)
      }
    }
  }

  /**
   * List revoked identities
   */
  async listRevokedIdentities () {
    if (!this.db) return []
    const revoked = []
    for await (const entry of this.db.createReadStream({
      gte: 'identity:',
      lt: 'identity:\xff'
    })) {
      if (entry.value.revoked) revoked.push(entry.value)
    }
    return revoked
  }

  // ==================== AUTO-REGISTRATION ====================

  /**
   * Auto-register: atomically create user + identity for first-time auth.
   * First user gets admin, subsequent get read-only.
   *
   * @param {Object} identityData - { identityId, method, credentials, name? }
   * @returns {{ user: Object, identity: Object }}
   */
  async autoRegister (identityData) {
    // SECURITY: Always request read-only permissions. The apply function
    // deterministically promotes the FIRST CREATE_USER to admin based on
    // Autobase linearization order. This eliminates the TOCTOU race
    // condition where two writers both see "no admin" and both get admin.
    const user = await this.createUser({
      name: identityData.name || 'Anonymous',
      globalPermissions: ['read'],
      resources: [{ name: '*', permissions: ['read'] }]
    })

    // Register identity in direct-write store
    const identity = {
      identityId: identityData.identityId,
      method: identityData.method,
      publicKey: identityData.credentials ? identityData.credentials.publicKey : identityData.identityId,
      userId: user.userId,
      createdAt: Date.now()
    }
    await this.registerIdentity(identity)

    // Link identity to user (safe: autoRegister is called after auth provider verification)
    await this.linkIdentityToUser(identity.identityId, user.userId, { verified: true })

    return { user, identity }
  }

  /**
   * Check if any users exist
   */
  async _hasAnyUsers () {
    for await (const _ of this._view.createReadStream({ // eslint-disable-line no-unused-vars
      gte: `${this._prefix}user:`,
      lt: `${this._prefix}user:\xff`,
      limit: 1
    })) {
      return true
    }
    return false
  }

  // ==================== USERNAME MANAGEMENT ====================

  /**
   * Validate username format
   * @param {string} username
   * @returns {{ valid: boolean, reason?: string }}
   */
  validateUsername (username) {
    if (!username || typeof username !== 'string') {
      return { valid: false, reason: 'Username is required' }
    }

    const lower = username.toLowerCase()
    if (lower !== username) {
      return { valid: false, reason: 'Username must be lowercase' }
    }

    if (username.length < 3 || username.length > 32) {
      return { valid: false, reason: 'Username must be 3-32 characters' }
    }

    if (!USERNAME_REGEX.test(username)) {
      return { valid: false, reason: 'Username must be lowercase alphanumeric with dashes, no leading/trailing dash' }
    }

    if (username.includes('--')) {
      return { valid: false, reason: 'Username must not contain consecutive dashes' }
    }

    if (RESERVED_USERNAMES.includes(username)) {
      return { valid: false, reason: 'Username is reserved' }
    }

    return { valid: true }
  }

  /**
   * Set username for a user (unique, validated)
   * @param {string} userId
   * @param {string} username
   * @returns {Promise<Object>} Updated user from view
   */
  async setUsername (userId, username) {
    const validation = this.validateUsername(username)
    if (!validation.valid) {
      const err = new Error(validation.reason)
      err.code = 'VALIDATION_ERROR'
      err.status = 400
      throw err
    }

    // Check uniqueness in view
    const existing = await this._view.get(`${this._prefix}username:${username}`)
    if (existing && existing.value.userId !== userId) {
      const err = new Error('Username already taken')
      err.code = 'CONFLICT'
      err.status = 409
      throw err
    }

    // Emit Autobase op for multi-writer consensus
    await this._base.append(encodeOp({
      type: OpTypes.SET_USERNAME,
      userId,
      username
    }))
    await this._base.update()

    // Also update direct-write db if present
    if (this.db) {
      const user = await this.getUser(userId)
      if (user) {
        if (user.username) {
          await this.db.del(`username:${user.username}`)
        }
        await this.db.put(`username:${username}`, { userId })
      }
    }

    return this.getUser(userId)
  }

  /**
   * Check if username is available
   * @param {string} username
   * @returns {Promise<boolean>}
   */
  async isUsernameAvailable (username) {
    const validation = this.validateUsername(username)
    if (!validation.valid) return false

    const entry = await this._view.get(`${this._prefix}username:${username}`)
    return !entry
  }

  /**
   * List all identities from direct-write store (admin)
   * @returns {Promise<Object[]>}
   */
  async listAllIdentities () {
    if (!this.db) return []
    const identities = []
    for await (const entry of this.db.createReadStream({
      gte: 'identity:', lt: 'identity:\xff'
    })) {
      identities.push(entry.value)
    }
    return identities
  }

  // ==================== PROFILE UPDATES ====================

  /**
   * Update user profile (name, picture, theme)
   * @param {string} userId
   * @param {Object} updates - { name?, profilePicture?, theme? }
   * @returns {Promise<Object>} Updated user
   */
  async updateUserProfile (userId, updates) {
    const filtered = {}
    if (updates.name !== undefined) filtered.name = updates.name
    if (updates.profilePicture !== undefined) filtered.profilePicture = updates.profilePicture
    if (updates.theme !== undefined) filtered.theme = updates.theme

    await this.updateUser(userId, filtered)
    return this.getUser(userId)
  }

  // ==================== REVOCATION (ENHANCED) ====================

  /**
   * Check if identity is revoked (fast lookup via view index)
   * @param {string} identityId
   * @returns {Promise<boolean>}
   */
  async isRevoked (identityId) {
    // Check Autobase view revocation index
    const revoked = await this._view.get(`${this._prefix}revoked:${identityId}`)
    if (revoked) return true

    // Fallback: check direct-write identity record
    if (this.db) {
      const identity = await this.getIdentity(identityId)
      if (identity && identity.revoked) return true
    }
    return false
  }

  // ==================== USER QUERIES ====================

  /**
   * Get user with all linked identity objects resolved
   * @param {string} userId
   * @returns {Promise<{ user: Object, identities: Object[] } | null>}
   */
  async getUserWithIdentities (userId) {
    const user = await this.getUser(userId)
    if (!user) return null

    const identities = []
    for (const id of (user.identities || [])) {
      const identity = await this.getIdentity(id)
      if (identity) identities.push(identity)
    }

    return { user, identities }
  }

  /**
   * List users who have set a username (public directory)
   * @returns {Promise<Object[]>} Users with username, name, createdAt
   */
  async listPublicUsers () {
    const users = []
    for await (const entry of this._view.createReadStream({
      gte: `${this._prefix}user:`,
      lt: `${this._prefix}user:\xff`
    })) {
      const user = entry.value
      if (user.username && !user.mergedInto) {
        users.push({
          userId: user.userId,
          username: user.username,
          name: user.name,
          createdAt: user.createdAt
        })
      }
    }
    return users
  }

  /**
   * Delete user and cascade cleanup (identities, indexes)
   * @param {string} userId
   */
  async deleteUserCascade (userId) {
    const user = await this.getUser(userId)
    if (!user) return

    // Remove identity records and pubkey indexes from direct-write db
    if (this.db) {
      for (const identityId of (user.identities || [])) {
        const identity = await this.getIdentity(identityId)
        if (identity && identity.publicKey) {
          await this.db.del(`pubkey:${identity.publicKey}`)
        }
        await this.db.del(`identity:${identityId}`)
      }

      // Remove username index
      if (user.username) {
        await this.db.del(`username:${user.username}`)
      }
    }

    // Delete user via Autobase
    await this.deleteUser(userId)
  }

  /**
   * Import a keypair into an existing user account.
   * Registers the new identity and links it to the user.
   * Used for device linking — same person, new browser/device.
   *
   * @param {string} userId - Existing user to link to
   * @param {string} publicKey - New public key (44-char base64)
   * @param {string} method - Auth method (default: 'web-identity')
   * @returns {Promise<Object>} The new identity record
   */
  async importIdentity (userId, publicKey, method = 'web-identity') {
    // Check user exists
    const user = await this.getUser(userId)
    if (!user) throw new Error('User not found')

    // Check identity doesn't already exist
    const existing = await this.getIdentityByPublicKey(publicKey)
    if (existing) {
      if (existing.userId === userId) return existing // Already linked
      throw new Error('This key is already linked to a different user')
    }

    const identity = {
      identityId: publicKey,
      method,
      publicKey,
      userId,
      createdAt: Date.now()
    }
    await this.registerIdentity(identity)
    await this.linkIdentityToUser(publicKey, userId, { verified: true })

    return identity
  }

  // ==================== LINK TOKENS (F.7.2) ====================

  /**
   * Generate a short-lived link token bound to a user. The token itself
   * is returned to the caller; only the SHA-256 hash is stored in the
   * Autobase view, so reading the registry can't replay tokens.
   *
   * @param {string} userId       - The target user the token will link to
   * @param {object} [opts]
   * @param {number} [opts.ttlMs] - Token lifetime; default 5 min
   * @returns {Promise<{token, tokenHash, expiresAt}>}
   */
  async createLinkToken (userId, opts = {}) {
    if (!userId) throw new Error('createLinkToken requires { userId }')
    const ttl = typeof opts.ttlMs === 'number' ? opts.ttlMs : 5 * 60 * 1000
    const token = crypto.randomBytes(16).toString('hex')
    const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
    const expiresAt = Date.now() + ttl

    await this._base.append(encodeOp({
      type: OpTypes.CREATE_LINK_TOKEN,
      tokenHash,
      userId,
      expiresAt,
      createdAt: Date.now()
    }))
    await this._base.update()

    return { token, tokenHash, expiresAt }
  }

  /**
   * Look up a link token row by its raw token (re-hashes for lookup).
   * Returns the stored row or null.
   */
  async getLinkToken (token) {
    if (!token) return null
    const tokenHash = crypto.hash(b4a.from(token)).toString('hex')
    const entry = await this._view.get(`${this._prefix}link-token:${tokenHash}`)
    return entry ? entry.value : null
  }

  /**
   * Consume a link token: validates expiry + single-use, attaches a new
   * identity to the token's target user, optionally deactivates a
   * throwaway user the new identity used to belong to. The whole sequence
   * runs in a single Autobase op (CONSUME_LINK_TOKEN) so apply-time
   * checks are atomic.
   *
   * @param {string} token            - Raw token (re-hashed internally)
   * @param {string} newIdentityId    - Identity to attach to target user
   * @param {object} [opts]
   * @param {string} [opts.deactivateUserId] - Throwaway user to deactivate
   * @returns {Promise<{userId, newIdentityId}>}
   */
  async consumeLinkToken (token, newIdentityId, opts = {}) {
    if (!token) throw new Error('consumeLinkToken requires { token }')
    if (!newIdentityId) throw new Error('consumeLinkToken requires { newIdentityId }')

    // Pre-flight: classify the failure reason for the caller. The apply
    // function silently no-ops on the same conditions (defence in depth).
    const stored = await this.getLinkToken(token)
    if (!stored) throw new Error('link token not found')
    if (stored.consumed) throw new Error('link token already consumed')
    if (stored.expiresAt && stored.expiresAt < Date.now()) throw new Error('link token expired')

    const tokenHash = crypto.hash(b4a.from(token)).toString('hex')

    await this._base.append(encodeOp({
      type: OpTypes.CONSUME_LINK_TOKEN,
      tokenHash,
      newIdentityId,
      deactivateUserId: opts.deactivateUserId || null
    }))
    await this._base.update()

    // Update direct-write identity record so reverse lookups stay coherent
    if (this.db) {
      try {
        const ident = await this.getIdentity(newIdentityId)
        if (ident) {
          ident.userId = stored.userId
          ident.updatedAt = Date.now()
          await this.db.put(`identity:${newIdentityId}`, ident)
        }
      } catch {}
    }

    return { userId: stored.userId, newIdentityId }
  }

  // ==================== FEDERATION & LIFECYCLE ====================

  /**
   * Add an Autobase writer via the op pipeline. Appends an ADD_WRITER
   * op that (a) records the writer in the view for audit, and (b) is
   * picked up by identity-apply.js to call `base.addWriter` on the
   * Autobase host. Going through the op pipeline (instead of calling
   * `_base.addWriter` directly) ensures the writer registration
   * linearizes across replicas and is visible in the shared view.
   *
   * @param {string|Buffer} key - writer's local core key (hex or buffer)
   * @param {object} [opts]
   * @param {string} [opts.role='writer'] - 'writer' | 'indexer'
   * @param {string} [opts.addedBy] - who added this writer (audit)
   */
  async addWriter (key, opts = {}) {
    const writerKey = typeof key === 'string'
      ? key
      : b4a.toString(key, 'hex')
    if (!writerKey || writerKey.length !== 64) {
      throw new Error('addWriter requires a 32-byte (64-char hex) writer key')
    }
    const op = {
      type: OpTypes.ADD_WRITER,
      writerKey,
      role: opts.role || 'writer',
      addedBy: opts.addedBy || null
    }
    await this._base.append(encodeOp(op))
  }

  /**
   * Announce the local-writer keys a linked device has on OTHER
   * autobases in the enclosing app. Recorded in the view under
   * `mirror-writers:<devicePubkey>`. The enclosing app is responsible
   * for watching that key range and issuing canonical in-band
   * addWriter ops on the matching autobases.
   *
   * @param {string} devicePubkey  — this device's hyperident pubkey (hex)
   * @param {Object<string,string>} keys  — { namespace: localKeyHex, ... }
   */
  /**
   * True if this peer is a writer on the identity autobase (can append).
   * Mirrors this check straight off the underlying autobase.
   */
  get writable () {
    return this._base ? this._base.writable : false
  }

  /**
   * Subscribe to writable transitions. Callback fires when this peer
   * becomes (or was already) a writer on the identity autobase — useful
   * for apps that want to act the moment they can append.
   * Returns an unsubscribe function.
   */
  onWritable (cb) {
    if (!this._base) return () => {}
    if (this._base.writable) { queueMicrotask(() => { try { cb() } catch {} }) }
    const handler = () => { try { cb() } catch {} }
    this._base.on('writable', handler)
    return () => this._base.off('writable', handler)
  }

  async registerMirrorWriters (devicePubkey, keys) {
    if (!devicePubkey || typeof devicePubkey !== 'string' || devicePubkey.length !== 64) {
      throw new Error('registerMirrorWriters requires 64-char hex devicePubkey')
    }
    if (!keys || typeof keys !== 'object') {
      throw new Error('registerMirrorWriters requires a namespace→hex map')
    }
    const op = {
      type: OpTypes.REGISTER_MIRROR_WRITERS,
      devicePubkey,
      keys
    }
    await this._base.append(encodeOp(op))
  }

  /**
   * Read the most recent mirror-writer announcement for a device.
   */
  async getMirrorWriters (devicePubkey) {
    const entry = await this._view.get(`${this._prefix}mirror-writers:${devicePubkey}`)
    return entry ? entry.value : null
  }

  /**
   * Iterate all mirror-writer announcements (for watcher wiring).
   */
  async * listMirrorWriters () {
    for await (const entry of this._view.createReadStream({
      gte: `${this._prefix}mirror-writers:`,
      lt: `${this._prefix}mirror-writers:\xff`
    })) {
      const devicePubkey = entry.key.slice((this._prefix + 'mirror-writers:').length)
      yield { devicePubkey, ...entry.value }
    }
  }

  /**
   * Remove an Autobase writer via the op pipeline. Symmetric with addWriter.
   */
  async removeWriter (key) {
    const writerKey = typeof key === 'string'
      ? key
      : b4a.toString(key, 'hex')
    const op = {
      type: OpTypes.REMOVE_WRITER,
      writerKey
    }
    await this._base.append(encodeOp(op))
  }

  /**
   * Close registry
   */
  async close () {
    if (this._base) await this._base.close()
  }
}

export default BaseIdentityRegistry