OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / lib / router.js

2477 lines · javascript ref:
import path from 'bare-path'
import os from 'bare-os'
import fs from 'bare-fs'
import { SecureStorage } from './secure-storage.js'
// MetadataBase has its own identity — key lives in Corestore storage.
// No per-identity derivation. Share key with peers like a room invite.
import crypto from 'hypercore-crypto'
import { MetadataBase } from './metadata-base.js'
// v3 storage cutover (spec/state-base-design.md): the per-room game-state store
// is now StateBaseV3 (HyperDB view + hyperdispatch router). v2 state-base.js is
// kept as reference. Clean cutover — v3 uses a distinct state-bases-v3/ path.
import { StateBaseV3 as StateBase } from './state-base-v3.js'
import { RoomMeta, parseHsMessage, createHsMessage } from './room-meta.js'
import { DmShares } from './dm-shares.js'
import { LocalGraph } from './local-graph.js'
import { RegistryRoom } from './registry-room.js'
import { TopicAnnounce } from './topic-announce.js'
import { dbg } from './debug.js'
import { timed } from './timed.js'
// Room-state P3: keet membership + identity attestation for StateBase admission.
import z32 from 'z32'
import idEnc from 'hypercore-id-encoding'
import IdentityKey from 'keet-identity-key'

export class Router {
  constructor (backend, core, opts = {}) {
    this.backend = backend
    this.core = core
    this._storage = opts.storage || path.join(os.homedir(), '.config', 'keet-cli', 'default')
    this._vault = this._createVault(this._storage)
    this._registry = opts.registry || null
    this._swapCore = opts.swapCore || null
    this._brokenRooms = opts.brokenRooms || new Map()
    this._swapping = false
    this._startTime = Date.now()
    this._metadataBase = new MetadataBase(this._storage)
    this._roomMeta = new RoomMeta(this._metadataBase, { storagePath: os.homedir() })
    // Per-room game-state autobases (StateBase). Lazily opened on first
    // state.* RPC for a given roomId. Keyed by roomKey hex.
    this._stateBases = new Map()
    // hex -> the StateBase INSTANCE, recorded as soon as it is constructed (before
    // its open can hang), so state.internals can inspect a base whose cached open
    // promise in _stateBases never settles.
    this._stateBaseInstances = new Map()
    this._dmShares = new DmShares(backend)
    this._localGraph = new LocalGraph(backend, this._roomMeta)
    this._registryRoom = new RegistryRoom(backend, this._roomMeta)
    this._topicDisc = new TopicAnnounce(core, backend, this._roomMeta)
  }

  /**
   * Initialize MetadataBase (Autobase smart contract for metadata).
   * MetadataBase has its own identity — key lives in Corestore storage.
   * No per-identity derivation. Share key with peers like a room invite.
   */
  async initMetadata () {
    dbg('[metadata] Starting MetadataBase...')
    await this._metadataBase.ready()
    dbg('[metadata] Autobase ready — key: %s', this._metadataBase.key?.toString('hex')?.slice(0, 12))

    try {
      const { migrated } = await this._roomMeta.migrateLegacy()
      if (migrated > 0) dbg('[metadata] Migrated %d entries from legacy storage', migrated)
    } catch (err) {
      dbg('[metadata] Migration failed (non-fatal): %s', err.message)
    }

    dbg('[metadata] Ready — writable: %s', this._metadataBase.writable)
  }

  /**
   * Close MetadataBase (call on shutdown or identity swap).
   */
  async closeMetadata () {
    if (this._metadataBase) {
      await this._metadataBase.close()
    }
    for (const pending of this._stateBases.values()) {
      try {
        const sb = await pending
        await sb.close()
      } catch {}
    }
    this._stateBases.clear()
  }

  /**
   * Resolve (and open if needed) the per-room StateBase for roomId.
   * roomId is keet's canonical z32-encoded room identifier. We derive
   * a deterministic 32-byte room-tag = hash(roomId) for use as the
   * StateBase namespace and announce-topic seed. Every peer in the
   * keet room derives the same tag from the same roomId string.
   */
  async _getStateBase (roomId) {
    if (!roomId) throw new Error('Missing required param: roomId')
    const roomTag = crypto.hash(Buffer.from(String(roomId), 'utf-8'))
    const hex = roomTag.toString('hex')

    // Cache the ready-promise (not the half-initialized instance) so
    // concurrent callers all await the same setup. The cache entry MUST be
    // installed SYNCHRONOUSLY — before the first await — or two concurrent
    // state.* calls for the same not-yet-open room both pass this empty-cache
    // check, both construct a StateBase, and both open the SAME RocksDB
    // corestore. The loser throws "File descriptor could not be locked"
    // (hypercore-storage's exclusive fd-lock) and its catch deletes the
    // winner's cache entry, so the orphaned winner keeps the lock while every
    // subsequent call re-opens and re-collides → a permanent fd-lock loop in
    // which NO durable game-state ever loads or persists (the live "edits
    // don't persist / don't sync / time-of-day resets" bug). The earlier
    // ordering set the cache AFTER `await _stateBaseProviders` (which itself
    // awaits room.ready()), a wide race window. Proven: a 3-way concurrent
    // open yielded 1/3 success + permanent failure with set-after-await, and
    // 3/3 + healthy follow-up with the synchronous cache below.
    let pending = this._stateBases.get(hex)
    if (pending) return pending

    // Build the entire open (providers → construct → ready → seed members) as a
    // SINGLE promise and cache it before any await, so concurrent callers share
    // exactly one StateBase. `sb` is captured in the closure so the catch can
    // release a half-opened corestore lock.
    let sb = null
    pending = (async () => {
      // Room-state P3: wire keet membership + identity attestation so the
      // StateBase elects a deterministic creator and gates writer admission on
      // real room membership (see spec/room-state.md).
      const tag = 'state-base open ' + hex.slice(0, 12)
      const providers = await timed(tag + ' providers', this._stateBaseProviders(roomId))
      sb = new StateBase(this._storage, roomTag, providers)
      this._stateBaseInstances.set(hex, sb)
      await timed(tag + ' ready', sb.ready())
      // D1c seed: populate the StateBase's LOCAL membership copy (__members__)
      // from the keet room so apply can gate optimistic admission (D1a/D1b,
      // spec game-state.md §3.0/§4). No-op until we're a writer (a writer
      // projects; peers then replicate __members__). Best-effort, never fatal.
      try {
        const members = await timed(tag + ' getMembers', providers.getMembers())
        await timed(tag + ' projectMembers', sb.projectMembers(members))
      } catch {}
      return sb
    })()
    this._stateBases.set(hex, pending)
    try {
      return await pending
    } catch (err) {
      // Release the half-opened corestore lock before evicting — otherwise the
      // leaked lock makes every retry fail with the same fd-lock error.
      if (sb) { try { await sb.close() } catch {} }
      this._stateBases.delete(hex)
      throw err
    }
  }

  /**
   * Build the keet-backed providers a per-room StateBase needs (spec P3):
   *   selfKey      — this daemon's keet identity pubkey (z32-decoded memberId)
   *   getMembers   — the room's members as [{ key: identityPubkeyHex, role }]
   *   verifyAttest — IdentityKey.verify (pure; no core access)
   *   attest       — sign a writer-key announce as our keet identity, via
   *                  core.internalAttestData(data, room.identity) (keet's own
   *                  member-attestation primitive, keet-core/index.js:1849)
   * All keys are normalized to the 32-byte identity pubkey (hex) so election
   * and admission compare consistently (getMembers/getIdentity speak z32).
   */
  async _stateBaseProviders (roomId) {
    const self = this
    // Self identity for creator election + StateBase admission. CRITICAL: this must
    // be the ROOM-LEVEL member key (room.selfMember.key, set below) — the SAME id the
    // roster (getMembers) and chat use — NOT getIdentity().memberId. getIdentity
    // returns the local/global member id, which can DIFFER from the room member:
    // verified live, a daemon whose getIdentity().memberId=jiyzfo3h posts in + is
    // rostered as z9i4wpf3. Keying admission off the global id makes
    // _verifyAdmissionInApply look up the wrong id, miss the roster, and DENY a
    // legitimate (even admin) member — the #99 device→member gap. The global id here
    // is only the FALLBACK, used when the room / selfMember isn't resolvable.
    let selfKey = null
    try {
      const id = await self.backend.api.core.getIdentity()
      if (id && id.memberId) selfKey = z32.decode(id.memberId)
    } catch {}

    // The room's MEMBERS-ONLY encryptionKey (32 bytes) — keet's room secret,
    // NOT derivable from the public roomKey/discoveryKey. We use it BOTH as the
    // deterministic seed (so non-members can't even compute base.key) AND as
    // the base encryption key (content encrypted at rest) — exactly keet's
    // posture (it opens the room base `encrypted:true` with this.encryptionKey,
    // keet-core/index.js:315). Accessor: room.encryptionKey (= base.encryptionKey).
    // Absent (room not local / pre-join) → fall back to the v1 election path.
    let encryptionKey = null
    // (A) admission creds, captured while the room is open:
    let roomWriterKeyPair = null   // room.engine.local.keyPair — signs the admission proof
    let roomWriterManifest = null  // room.engine.local.manifest — binds signer→keet writer/device key
    const memberDevices = {}       // memberKey(hex) -> [keet writer/device key(hex)] for the whole room
    try {
      const room = self.core.internalOpenExistingRoom(idEnc.decode(roomId))
      try {
        // MUST await room.ready() first: internalOpenExistingRoom returns a
        // NOT-yet-open room, and `encryptionKey` is a getter that reads from
        // the room base — null until the base opens. keet-core itself always
        // does `await room.ready()` before using an internalOpenExistingRoom
        // result (keet-core index.js:770). Without this, a COLD-opened
        // existing room (e.g. a --pin-room peer at boot) yields encryptionKey
        // = null → StateBase silently falls back to the v1 announce/election
        // path instead of v2 deterministic+encrypted. (The earlier probe
        // passed only because its room was freshly created + already ready.)
        await room.ready()
        encryptionKey = room && room.encryptionKey ? Buffer.from(room.encryptionKey) : null
        // (A) keet room writer creds — used to sign + bind the StateBase admission
        // proof to our keet writer key (which keet maps to our per-room member).
        // keet-core >= 4.22 rooms are autobee: the writer core is room.engine.local
        // (RoomEngine → autobee.local). Older keet-core exposed room.base (Autobase).
        // Reading only room.base made both creds silently null on 4.22, so no 2nd
        // writer could be admitted (test/room-writer-creds.test.js).
        const engine = room.engine || room.base
        if (engine && engine.local) {
          roomWriterKeyPair = engine.local.keyPair || null
          roomWriterManifest = engine.local.manifest || null
        }
        // Self per-room member key = @keet/devices[our keet writer key].memberKey —
        // the id the roster is keyed by. getIdentity().memberId is the GLOBAL id and
        // is NOT in the room roster (the #99 mismatch). room.deviceKey = base.local.key.
        try {
          const selfDev = room.deviceKey ? await room.db.get('@keet/devices', { key: room.deviceKey }) : null
          if (selfDev && selfDev.memberKey) selfKey = Buffer.from(selfDev.memberKey)
        } catch (e) { console.error('[state-base] self member lookup failed: %s', e.message) }
        // device→member map for EVERY room member, from the replicated @keet/devices
        // collection — so the indexer can admit any member's keet writer (model A).
        try {
          const stream = room.db.find('@keet/devices', {})
          for await (const rec of stream) {
            const dk = rec && rec.key ? Buffer.from(rec.key).toString('hex') : null
            const mk = rec && rec.memberKey ? Buffer.from(rec.memberKey).toString('hex') : null
            if (dk && mk) { if (!memberDevices[mk]) memberDevices[mk] = []; memberDevices[mk].push(dk) }
          }
        } catch (e) { console.error('[state-base] @keet/devices scan failed: %s', e.message) }
      } finally { try { self.core.internalReleaseRoom(room) } catch {} }
    } catch (e) { console.error('[state-base] room encryptionKey lookup failed: %s', e.message) }
    const roomSeed = encryptionKey

    const getMembers = async () => {
      let ms
      try { ms = await self.backend.api.core.getMembers(roomId) } catch { return [] }
      const arr = Array.isArray(ms) ? ms : (ms && (ms.members || ms.data)) || []
      const out = []
      for (const m of arr) {
        const mid = m.memberId || m.id
        if (!mid) continue
        let role = 3
        if (m.status?.isAdmin || m.role === 1) role = 1
        else if (m.status?.isModerator || m.role === 2) role = 2
        else if (m.status?.isReadOnly || m.role === 4) role = 4
        try { const key = z32.decode(mid).toString('hex'); out.push({ key, role, devices: memberDevices[key] || [] }) } catch {}
      }
      // keet-core getMembers() excludes the local identity — add self by our
      // PER-ROOM member key (selfKey, resolved from @keet/devices), NOT the global
      // getIdentity() id, with our role + keet writer device(s). Without this an
      // admin is locked out of their own StateBase.
      try {
        if (selfKey) {
          const selfHex = Buffer.from(selfKey).toString('hex')
          if (!out.some(m => m.key === selfHex)) {
            const me = await self._selfNormalized(roomId) // role only
            out.push({ key: selfHex, role: (me && me.role) || 3, devices: memberDevices[selfHex] || [] })
          }
        }
      } catch {}
      return out
    }

    const verifyAttest = (proof, data, identity) => {
      try { return IdentityKey.verify(proof, data, { expectedIdentity: identity }) !== null } catch { return false }
    }

    // Attest a writer-key announce as our keet identity. The keet identity
    // record (core.local.get('@local/identity')) holds deviceKeyPair +
    // deviceProof directly (verified live), so we sign with keet's own
    // primitive — IdentityKey.attestData(data, deviceKeyPair, deviceProof),
    // exactly what keet-core's internalAttestData does (index.js:1849). No
    // room open / ref-counting needed; the device creds are identity-level.
    const attest = async (data) => {
      try {
        const localId = await self.core.local.get('@local/identity')
        if (!localId || !localId.deviceKeyPair || !localId.deviceProof) return null
        return IdentityKey.attestData(data, localId.deviceKeyPair, localId.deviceProof)
      } catch (e) {
        console.error('[state-base] attest failed: %s', e.message)
        return null
      }
    }

    return { selfKey, roomSeed, encryptionKey, getMembers, verifyAttest, attest, roomWriterKeyPair, roomWriterManifest }
  }

  // Normalized keet room members as [{ key: identityPubkeyHex, role }] — the
  // same shape the StateBase admission/election path consumes. Standalone so the
  // membership-change RPCs can re-project without rebuilding the full providers.
  async _normalizedRoomMembers (roomId) {
    let ms
    try { ms = await this.backend.api.core.getMembers(roomId) } catch { return [] }
    const arr = Array.isArray(ms) ? ms : (ms && (ms.members || ms.data)) || []
    const out = []
    for (const m of arr) {
      const mid = m.memberId || m.id
      if (!mid) continue
      let role = 3
      if (m.status?.isAdmin || m.role === 1) role = 1
      else if (m.status?.isModerator || m.role === 2) role = 2
      else if (m.status?.isReadOnly || m.role === 4) role = 4
      try { out.push({ key: z32.decode(mid).toString('hex'), role }) } catch {}
    }
    // include self — getMembers() excludes the local identity (see _selfNormalized)
    try {
      const me = await this._selfNormalized(roomId)
      if (me && !out.some(m => m.key === me.key)) out.push(me)
    } catch {}
    return out
  }

  // D1c: re-project keet membership into the room's already-open StateBase after
  // a membership change. Event-driven (called from the membership RPCs), NO timer.
  // Best-effort: no-op if the StateBase isn't open or this daemon isn't a writer.
  async _reprojectMembers (roomId) {
    try {
      const hex = crypto.hash(Buffer.from(String(roomId), 'utf-8')).toString('hex')
      const pending = this._stateBases.get(hex)
      if (!pending) return
      const sb = await pending
      await sb.projectMembers(await this._normalizedRoomMembers(roomId))
    } catch {}
  }

  /**
   * Re-initialize MetadataBase for a new identity storage path.
   * Called during identity swap (swapCore).
   */
  async swapMetadata (newStoragePath) {
    await this.closeMetadata()
    this._storage = newStoragePath
    this._metadataBase = new MetadataBase(newStoragePath)
    this._roomMeta = new RoomMeta(this._metadataBase, { storagePath: os.homedir() })
    this._registryRoom = new RegistryRoom(this.backend, this._roomMeta)
    this._topicDisc = new TopicAnnounce(this.core, this.backend, this._roomMeta)
    await this.initMetadata()
  }

  _createVault (storagePath) {
    return new SecureStorage(path.join(storagePath, 'mnemonic.enc'))
  }

  async handle (method, params = {}) {
    // Allow identity-manager.list/active even during swap so UI can poll
    if (this._swapping && !method.startsWith('identity-manager.')) {
      return { ok: false, error: 'Identity switch in progress' }
    }
    try {
      const data = await this._dispatch(method, params)
      return { ok: true, data }
    } catch (err) {
      return { ok: false, error: err.message || String(err) }
    }
  }

  async _dispatch (method, params) {
    switch (method) {
      case 'identity': return this._identity()
      case 'identity.deviceKeypair': return this._identityDeviceKeypair()
      case 'identity.attest': return this._identityAttest(params)
      case 'identity.verify': return this._identityVerify(params)
      case 'identity.recovery': return this._identityRecovery()
      case 'identity.restore': return this._identityRestore(params)
      case 'identity.setName': return this._identitySetName(params)
      case 'rooms': return this._rooms()
      case 'room': return this._room(params)
      case 'room.create': return this._roomCreate(params)
      case 'room.join': return this._roomJoin(params)
      case 'room.invite': return this._roomInvite(params)
      case 'room.leave': return this._roomLeave(params)
      case 'room.purge': return this._roomPurge(params)
      case 'send': return this._send(params)
      case 'read': return this._read(params)
      case 'members': return this._members(params)
      case 'members.role': return this._changeMemberRole(params)
      case 'dm.request': return this._dmRequest(params)
      case 'watch': return this._watch(params)
      case 'call.authorize': return this._callAuthorize(params)
      case 'call.deauthorize': return this._callDeauthorize(params)
      case 'call.join': return this._callJoinPeer(params)
      case 'call.leave': return this._callLeavePeer(params)
      case 'call.presence': return this._callPresence(params)
      case 'call.setPresent': return this._callSetPresent(params)
      case 'call.setNotPresent': return this._callSetNotPresent(params)
      case 'call.tracks': return this._callGetTracks(params)
      case 'call.requestTrack': return this._callRequestTrack(params)
      case 'call.unrequestTrack': return this._callUnrequestTrack(params)
      case 'call.shareTrack': return this._callShareTrack(params)
      case 'call.unshareTrack': return this._callUnshareTrack(params)
      case 'call.signal': return this._callSignalMedia(params)
      // call.raw removed — arbitrary function execution (security audit D-F3)
      case 'msg.edit': return this._editMessage(params)
      case 'msg.delete': return this._deleteMessage(params)
      case 'msg.pin': return this._pinMessage(params)
      case 'msg.unpin': return this._unpinMessage(params)
      case 'msg.pinned': return this._getPinned(params)
      case 'msg.search': return this._searchMessages(params)
      case 'msg.raw': return this._msgRaw(params)
      case 'react': return this._addReaction(params)
      case 'unreact': return this._removeReaction(params)
      case 'reactions': return this._getReactions(params)
      case 'mute': return this._muteMember(params)
      case 'unmute': return this._unmuteMember(params)
      case 'block': return this._blockMember(params)
      case 'unblock': return this._unblockMember(params)
      case 'kick': return this._removeMember(params)
      case 'blocked': return this._getBlocked(params)
      // raw removed — arbitrary function execution (security audit D-F3)
      case 'status': return this._status()
      case 'shutdown': return this._shutdown()

      // --- Introspection ---
      case 'introspect': return this._introspect(params)

      // --- Identity & Username ---
      case 'identity.confirm': return this._identityConfirm()
      case 'identity.store': return this._identityStore(params)
      case 'identity.register': return this._identityRegister(params)
      case 'identity.checkName': return this._identityCheckName(params)
      case 'identity.lookup': return this._identityLookup(params)
      case 'identity.lookupMember': return this._identityLookupMember(params)

      // --- Vault (encrypted mnemonic) ---
      case 'vault.status': return this._vaultStatus()
      case 'vault.setup': return this._vaultSetup(params)
      case 'vault.unlock': return this._vaultUnlock(params)
      case 'vault.import': return this._vaultImport(params)
      case 'vault.export': return this._vaultExport(params)

      // --- Identity Manager (multi-identity) ---
      case 'home.get': return this._homeGet()
      case 'identity-manager.list': return this._imList()
      case 'identity-manager.active': return this._imActive()
      case 'identity-manager.create': return this._imCreate(params)
      case 'identity-manager.switch': return this._imSwitch(params)
      case 'identity-manager.delete': return this._imDelete(params)
      case 'identity-manager.update': return this._imUpdate(params)
      case 'identity-manager.export': return this._imExport(params)
      case 'identity-manager.import': return this._imImport(params)
      case 'identity-manager.repair': return this._imRepair()
      case 'identity-manager.gc': return this._imGc()
      case 'identity-manager.diagnose': return this._imDiagnose(params)

      // --- Room Config & Management ---
      case 'room.config': return this._roomConfig(params)
      case 'room.config.set': return this._roomConfigSet(params)
      case 'meta.get': return this._metaGet(params)
      case 'meta.set': return this._metaSet(params)
      case 'meta.list': return this._metaList()
      case 'meta.search': return this._metaSearch(params)
      case 'meta.key': return this._metaKey()
      case 'meta.invite': return this._metaInvite()
      case 'meta.join': return this._metaJoin(params)
      case 'meta.writers.add': return this._metaAddWriter(params)
      case 'meta.writers.remove': return this._metaRemoveWriter(params)
      case 'state.set': return this._stateSet(params)
      case 'state.get': return this._stateGet(params)
      case 'state.list': return this._stateList(params)
      case 'state.del': return this._stateDel(params)
      case 'state.info': return this._stateInfo(params)
      case 'state.diag': return this._stateDiag(params)
      case 'state.internals': return this._stateInternals(params)
      case 'state.join': return this._stateJoin(params)
      case 'state.reset': return this._stateReset(params)
      case 'room.bookmark': return this._roomBookmark(params)
      case 'room.unbookmark': return this._roomUnbookmark(params)
      case 'room.preferences': return this._roomPreferences(params)
      case 'room.preferences.set': return this._roomPreferencesSet(params)
      case 'room.search': return this._roomSearch(params)
      case 'room.unread': return this._roomUnread(params)

      // --- DM Management ---
      case 'dm.list': return this._dmList()
      case 'dm.requests': return this._dmRequests(params)
      case 'dm.accept': return this._dmAccept(params)
      case 'dm.reject': return this._dmReject(params)
      case 'dm.share': return this._dmShare(params)
      case 'dm.shares': return this._dmSharesList(params)

      // --- Graph Computation ---
      case 'graph.compute': return this._graphCompute(params)
      case 'graph.clusters': return this._graphClusters(params)
      case 'graph.edges': return this._graphEdges(params)

      // --- Registry Rooms ---
      case 'registry.rooms': return this._registryRooms()
      case 'registry.list': return this._registryList(params)
      case 'registry.add': return this._registryAdd(params)
      case 'registry.remove': return this._registryRemove(params)

      // --- Topic Discovery (DHT) ---
      case 'topic.announce': return this._topicAnnounce(params)
      case 'topic.unannounce': return this._topicUnannounce(params)
      case 'topic.search': return this._topicSearch(params)
      case 'topic.announceAll': return this._topicAnnounceAll()

      // --- File Management ---
      case 'file.send': return this._fileSend(params)
      case 'file.sendFromPath': return this._fileSendFromPath(params)
      case 'file.list': return this._fileList(params)
      case 'file.info': return this._fileInfo(params)
      case 'file.download': return this._fileDownload(params)
      case 'file.cancel': return this._fileCancel(params)
      case 'file.remove': return this._fileRemove(params)
      case 'file.clear': return this._fileClear(params)
      case 'file.preview': return this._filePreview(params)
      case 'file.path': return this._filePath(params)
      case 'file.fetch': return this._fileFetch(params)

      // --- Advanced Members ---
      case 'members.active': return this._membersActive(params)
      case 'members.mods': return this._membersMods(params)
      case 'members.count': return this._membersCount(params)
      case 'members.connected': return this._membersConnected(params)
      case 'members.search': return this._membersSearch(params)

      // --- Contacts ---
      case 'contact.info': return this._contactInfo(params)
      case 'contact.self': return this._contactSelf()
      case 'dm.fromContact': return this._dmFromContact(params)

      // --- Sub-APIs ---
      case 'network': return this._networkQuery()
      case 'preferences': return this._preferencesQuery()
      case 'preferences.set': return this._preferencesSet(params)
      case 'debug.stats': return this._debugStats(params)

      // --- System ---
      case 'version': return this._getVersion()
      case 'stats': return this._getStats()
      case 'swarm.debug': return this._swarmDebug()

      default: throw new Error(`Unknown method: ${method}`)
    }
  }

  // --- Identity ---

  async _identity () {
    const identity = await this.backend.api.core.getIdentity()
    if (!identity) return null
    // keet-core 4.14.4's getIdentity() returns memberId (z32 of the identity
    // publicKey, see encode.js:186 `memberId: e.encode(t.key)`) and devices[]
    // but no top-level `publicKey`. Surface `publicKey` as an alias so the
    // canonical "what's my identity key" shape works for hyper-tps + tests
    // without each caller having to know the keet-core encoding detail.
    return { ...identity, publicKey: identity.memberId }
  }

  // Identity unification (spec/state-base-design.md §4): expose this daemon's keet
  // DEVICE keypair (+ deviceProof + memberId) so the game can join the multiplayer
  // mesh under its keet device identity instead of a random per-session DHT key.
  // The device key (not the mnemonic master, not the shared memberId) is the right
  // per-instance identity; deviceProof lets a peer verify it maps to memberId via
  // IdentityKey.verify. UDS-only (keet-daemon has no HTTP transport) — this returns
  // a secret key, so it must never leave the local owner's machine.
  async _identityDeviceKeypair () {
    const localId = await this.core.local.get('@local/identity')
    if (!localId || !localId.deviceKeyPair) throw new Error('no device identity available')
    let memberId = null
    try { const id = await this.backend.api.core.getIdentity(); memberId = id ? id.memberId : null } catch {}
    const hx = (b) => (b == null ? null : Buffer.from(b).toString('hex'))
    return {
      memberId,
      deviceKeyPair: {
        publicKey: hx(localId.deviceKeyPair.publicKey),
        secretKey: hx(localId.deviceKeyPair.secretKey)
      },
      deviceProof: hx(localId.deviceProof)
    }
  }

  // Prove "this data is vouched for by keet member X" (hyper-games #89 §3.3): a game
  // peer attests its mesh (DHT) public key, and the peer it connects to verifies
  // that against the key the Noise handshake authenticated before showing X's
  // ROSTER name. keet's own primitive: IdentityKey.attestData with the device
  // credentials (= keet-core internalAttestData). Returns only the proof — never
  // the device secret, unlike identity.deviceKeypair.
  async _identityAttest (params) {
    if (!params.data) throw new Error('Missing required param: data (hex)')
    // getIdentity FIRST: it waits for keet-core's onlineBoot, and @local/identity
    // is null until then (measured on fresh storage).
    const id = await this.backend.api.core.getIdentity()
    if (!id || !id.memberId) throw new Error('no identity memberId')
    const localId = await this.core.local.get('@local/identity')
    if (!localId || !localId.deviceKeyPair || !localId.deviceProof) throw new Error('no device identity available')
    const proof = IdentityKey.attestData(Buffer.from(String(params.data), 'hex'), localId.deviceKeyPair, localId.deviceProof)
    return { memberId: id.memberId, proof: Buffer.from(proof).toString('hex') }
  }

  // Check an identity.attest proof for (data, memberId). Pure — no keet-core state.
  // Malformed input is a plain "no", never a throw: the caller is deciding whether
  // to trust a remote peer, and garbage from one must not look like an error here.
  async _identityVerify (params) {
    try {
      const identity = z32.decode(String(params.memberId))
      if (identity.byteLength !== 32) return { ok: false }
      const proof = Buffer.from(String(params.proof), 'hex')
      const data = Buffer.from(String(params.data), 'hex')
      if (proof.byteLength === 0 || data.byteLength === 0) return { ok: false }
      return { ok: IdentityKey.verify(proof, data, { expectedIdentity: identity }) !== null }
    } catch {
      return { ok: false }
    }
  }

  async _identityRecovery () {
    const phrase = await this.backend.api.core.getRecoveryPhrase()
    return { phrase }
  }

  async _identityRestore (params) {
    if (!params.phrase) throw new Error('Missing required param: phrase')
    await this.backend.api.core.restoreIdentity(params.phrase)
    return { ok: true }
  }

  async _identitySetName (params) {
    if (!params.name) throw new Error('Missing required param: name')
    await this.backend.api.core.updateIdentityProfile({ displayName: params.name })
    return { ok: true, name: params.name }
  }

  // --- Vault ---

  async _vaultStatus () {
    return { exists: this._vault.exists() }
  }

  // Back up THIS identity's mnemonic under a password. It never replaces the
  // identity. keet-core's confirmRecoveryPhrase() drops the entropy
  // (persist defaults to false), after which getRecoveryPhrase() is null — so
  // read the phrase FIRST and confirm only once the vault is on disk. This used
  // to confirm first and answer the null with a NEW mnemonic + restoreIdentity,
  // swapping the identity on every call (test/vault-setup-keeps-identity).
  async _vaultSetup (params) {
    if (!params.password) throw new Error('Missing required param: password')

    const raw = await this.backend.api.core.getRecoveryPhrase()
    let fromCore = typeof raw === 'string' && raw.length > 0 ? raw : (raw && raw.phrase) || null

    if (!fromCore && !this._vault.exists()) {
      // keet-core no longer holds the phrase (Keet desktop confirms it away) and
      // there is no vault: only the player has it. They can type it in; the
      // storeRecoveryPhrase below refuses any phrase that is not this identity's.
      // Never vault.import here — that restores, i.e. replaces, the identity.
      if (!params.phrase) {
        throw new Error('keet-core no longer holds this identity\'s recovery phrase and there is no vault: pass phrase (the 24 words) to back it up')
      }
      fromCore = String(params.phrase).trim().split(/\s+/).join(' ')
    }

    if (!fromCore) {
      // The vault is the only copy, so a new password needs the old one.
      if (!params.oldPassword) throw new Error('A vault already exists: pass oldPassword to change its password')
      const plaintext = await this._vault.decrypt(Buffer.from(params.oldPassword))
      await this._vault.encrypt(plaintext, Buffer.from(params.password))
      plaintext.fill(0)
      return { ok: true }
    }

    // storeRecoveryPhrase throws 'Wrong recovery phrase' unless it is this identity's.
    await this.backend.api.core.storeRecoveryPhrase(fromCore)
    await this._vault.encrypt(Buffer.from(fromCore), Buffer.from(params.password))
    await this.backend.api.core.confirmRecoveryPhrase()
    return { ok: true }
  }

  async _vaultUnlock (params) {
    if (!params.password) throw new Error('Missing required param: password')
    const plaintext = await this._vault.decrypt(Buffer.from(params.password))
    const phrase = plaintext.toString()
    await this.backend.api.core.restoreIdentity(phrase)
    await this._postRestoreRepair()
    return { ok: true }
  }

  async _vaultImport (params) {
    if (!params.phrase) throw new Error('Missing required param: phrase')
    if (!params.password) throw new Error('Missing required param: password')
    await this.backend.api.core.restoreIdentity(params.phrase)
    await this._postRestoreRepair()
    await this._vault.encrypt(Buffer.from(params.phrase), Buffer.from(params.password))
    return { ok: true }
  }

  // Repair room writers after identity restore (keypair change)
  async _postRestoreRepair () {
    let rooms = []
    try {
      const result = await this.backend.api.core.getRecentRooms()
      rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
    } catch (err) {
      console.log('[vault] Post-restore getRecentRooms failed:', err.message)
    }

    for (const room of rooms) {
      const roomId = room.roomId || room.id || room.key
      if (!roomId) continue

      try {
        await this.backend.api.core.repair(roomId)
        console.log('[vault] repair(%s) succeeded', roomId.slice(0, 8))
      } catch (err) {
        console.log('[vault] repair(%s) failed (non-fatal): %s', roomId.slice(0, 8), err.message)
      }

      try {
        await this.backend.api.core.rotateWriter(roomId)
        console.log('[vault] rotateWriter(%s) succeeded', roomId.slice(0, 8))
      } catch (err) {
        console.log('[vault] rotateWriter(%s) failed (non-fatal): %s', roomId.slice(0, 8), err.message)
      }
    }
  }

  async _vaultExport (params) {
    if (!params.password) throw new Error('Missing required param: password')
    const plaintext = await this._vault.decrypt(Buffer.from(params.password))
    return { phrase: plaintext.toString() }
  }

  // --- Rooms ---

  async _rooms () {
    const result = await this.backend.api.core.getRecentRooms()
    const rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
    const out = []
    for (const room of rooms) {
      const roomId = room.roomId || room.id || room.key || '?'
      let title = room.config?.title || room.title || ''
      let roomType = room.config?.roomType || room.roomType || 0
      // Always fetch fresh title from getRoomInfo (getRecentRooms caches stale titles)
      if (roomId !== '?') {
        try {
          const info = await this.backend.api.core.getRoomInfo(roomId)
          const fresh = info?.config?.title || info?.title || ''
          if (fresh) title = fresh
          if (info?.config?.roomType != null) roomType = info.config.roomType
        } catch {}
      }
      out.push({ roomId, title: title || '(untitled)', roomType: Number(roomType) || 0 })
    }
    return out
  }

  async _room (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getRoomInfo(params.roomId)
  }

  async _roomCreate (params) {
    if (!params.title) throw new Error('Missing required param: title')
    // V1 API's formatRoomCreationOptions reads config.title + config.roomType
    const config = { title: params.title }
    if (params.roomType) config.roomType = String(params.roomType)
    return this.backend.api.core.createRoom({ config })
  }

  async _roomJoin (params) {
    if (!params.invite) throw new Error('Missing required param: invite')
    let invite = params.invite.trim()

    // Strip pear://keet/ or punch:// prefixes — keet-core expects raw invite token
    if (invite.startsWith('pear://keet/')) invite = invite.slice('pear://keet/'.length)
    else if (invite.startsWith('punch://')) invite = invite.slice('punch://'.length)

    try {
      const result = await this.backend.api.core.startPairingRoom(invite)
      return { status: 'pairing', ...(result || {}) }
    } catch (err) {
      // Fallback to pairRoom if startPairingRoom fails
      try {
        const result = await this.backend.api.core.pairRoom(invite)
        return { status: 'joined', ...(result || {}) }
      } catch {
        throw err
      }
    }
  }

  async _roomInvite (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const opts = {}
    if (params.role !== undefined) opts.role = params.role
    // readOnly: keet-core constructs a stateless @keet/sendable-invitation that
    // bakes in roomKey + encryptionKey + fastForwardTo + seed and is NOT
    // dispatched into the room autobase. Anyone holding the string can join
    // forever as a read-only member. Used for hardcoded public-registry invites.
    if (params.readOnly === true) opts.readOnly = true
    if (params.expiration !== undefined) opts.expiration = params.expiration
    const result = await this.backend.api.core.createInvitation(params.roomId, opts)
    if (typeof result === 'string') {
      return { token: result }
    }
    return result
  }

  async _roomLeave (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this.backend.api.core.leaveRoom(params.roomId)
    return { ok: true }
  }

  async _roomPurge (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    // Leave the room first
    try {
      await this.backend.api.core.leaveRoom(params.roomId)
    } catch (err) {
      // Ignore leave errors (might already be left)
      console.log('[router] room.purge: leaveRoom error (non-fatal):', err.message)
    }
    // Remove local room data from the storage directory
    const fs = await import('bare-fs')
    const roomDir = path.join(this._storage, '0', 'db', 'rooms', params.roomId)
    try {
      await fs.promises.rm(roomDir, { recursive: true, force: true })
      console.log('[router] room.purge: removed', roomDir)
    } catch (err) {
      console.log('[router] room.purge: rm failed (may not exist):', err.message)
    }
    return { ok: true, purged: params.roomId }
  }

  // --- Messages ---

  async _send (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.text) throw new Error('Missing required param: text')
    const result = await this.backend.api.core.addChatMessage(params.roomId, params.text, {})
    return result || { ok: true }
  }

  async _read (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const limit = params.limit || 20
    const opts = { limit, reverse: true }
    // Pagination: lte = load messages with seq <= value (older messages)
    if (params.lte != null) opts.lte = params.lte
    if (params.gte != null) opts.gte = params.gte
    const result = await this.backend.api.core.getChatMessages(params.roomId, opts)
    const messages = Array.isArray(result) ? result : (result?.messages || result?.data || result?.events || [])

    // Resolve member display names
    const nameMap = await this._getMemberNames(params.roomId)

    return [...(messages || [])].reverse().map(msg => {
      const memberId = msg.memberId || 'unknown'
      const generic = msg.message?.generic || null
      // Normalize system: only set if there's an actual event (non-null/non-false value)
      let system = null
      if (generic && typeof generic === 'object') {
        for (const v of Object.values(generic)) {
          if (v !== null && v !== false) { system = generic; break }
        }
      } else if (generic) {
        system = generic
      }
      const files = extractFiles(msg)
      return {
        memberId,
        name: nameMap[memberId] || memberId.slice(0, 8),
        text: msg.chat?.text || msg.text || msg.message?.text || '',
        timestamp: msg.timestamp || null,
        deleted: msg.deleted || false,
        system,
        files,
        seq: msg.seq ?? msg.clock ?? null,
        id: msg.id || null
      }
    }).filter(m => m.text || m.system || m.files)
  }

  async _getMemberNames (roomId) {
    try {
      const result = await this.backend.api.core.getMembers(roomId)
      const members = Array.isArray(result) ? result : (result?.members || result?.data || [])
      const map = {}
      for (const m of (members || [])) {
        const id = m.memberId || m.id
        if (id) map[id] = m.displayName || m.name || id.slice(0, 8)
      }
      return map
    } catch {
      return {}
    }
  }

  async _watch () {
    // watch is handled at the transport level (SSE / socket push)
    throw new Error('watch must be handled by transport layer')
  }

  // --- Members ---

  async _changeMemberRole (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.memberId) throw new Error('Missing required param: memberId')
    if (params.role === undefined) throw new Error('Missing required param: role (1=admin, 2=mod, 3=peer)')
    await this.backend.api.core.changeMemberRole(params.roomId, {
      memberId: params.memberId,
      role: params.role
    })
    this._reprojectMembers(params.roomId).catch(() => {}) // D1c: keep __members__ current
    return { ok: true }
  }

  async _members (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const result = await this.backend.api.core.getMembers(params.roomId)
    const members = Array.isArray(result) ? result : (result?.members || result?.data || [])
    const out = (members || []).map(m => {
      let role = 'peer'
      if (m.status?.isAdmin) role = 'admin'
      else if (m.status?.isModerator) role = 'mod'
      else if (m.role === 1) role = 'admin'
      else if (m.role === 2) role = 'mod'
      else if (m.role === 4) role = 'read-only'
      return {
        memberId: m.memberId || m.id || '?',
        name: m.displayName || m.name || m.memberId || '?',
        role,
        online: m.status?.isActive || m.online || false,
        canDM: m.canDM || false
      }
    })
    // keet-core getMembers() returns @keet/members (OTHER members) and never
    // includes the local identity (self lives in @local/rooms.selfMember). Add
    // self so "am I a member" checks (overlay, route-gating) don't false-negative.
    const me = await this._selfMember()
    if (me && me.memberId && !out.some(m => m.memberId === me.memberId)) out.push(me)
    return out
  }

  // Self's own room member record, same shape as _members() entries. getMembers()
  // structurally excludes self; getIdentity() surfaces the selfMember (keet-core
  // _getIdentity → v(o.selfMember), room-scoped role).
  async _selfMember () {
    try {
      const id = await this.backend.api.core.getIdentity()
      if (!id || !id.memberId) return null
      let role = 'peer'
      if (id.status?.isAdmin) role = 'admin'
      else if (id.status?.isModerator) role = 'mod'
      else if (id.status?.isReadOnly) role = 'read-only'
      return {
        memberId: id.memberId,
        name: id.displayName || id.name || id.memberId,
        role,
        online: true,
        canDM: false
      }
    } catch { return null }
  }

  // Self as a normalized admission entry { key: identityPubkeyHex, role:int }.
  // Normalized SELF member { key, role } for the roster self-add. When a roomId is
  // given, uses the ROOM-LEVEL self member key (room.selfMember.key) — the same id
  // the roster + admission compare on — falling back to the global
  // getIdentity().memberId. Keying self off the global id is what made an admin's
  // StateBase admission miss the roster (see #99 + _stateBaseProviders).
  async _selfNormalized (roomId) {
    try {
      const id = await this.backend.api.core.getIdentity()
      let keyHex = id && id.memberId ? z32.decode(id.memberId).toString('hex') : null
      let role = 3
      if (id?.status?.isAdmin) role = 1
      else if (id?.status?.isModerator) role = 2
      else if (id?.status?.isReadOnly) role = 4
      if (roomId) {
        try {
          const room = this.core.internalOpenExistingRoom(idEnc.decode(roomId))
          try {
            await room.ready()
            if (room.selfMember && room.selfMember.key) keyHex = Buffer.from(room.selfMember.key).toString('hex')
            const st = room.selfMember && room.selfMember.status
            if (st) { if (st.isAdmin) role = 1; else if (st.isModerator) role = 2; else if (st.isReadOnly) role = 4 }
          } finally { try { this.core.internalReleaseRoom(room) } catch {} }
        } catch {}
      }
      if (!keyHex) return null
      return { key: keyHex, role }
    } catch { return null }
  }

  // --- DMs ---

  async _dmRequest (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.memberId) throw new Error('Missing required param: memberId')
    const message = params.message || ''
    const result = await this.backend.api.core.sendDMRequest(
      params.roomId,
      params.memberId,
      { message, receivers: [params.memberId] }
    )
    return result || { ok: true }
  }

  // --- Message Operations ---

  async _editMessage (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    if (!params.text) throw new Error('Missing required param: text')
    await this.backend.api.core.updateChatMessage(params.roomId, params.messageId, params.text)
    return { ok: true }
  }

  async _deleteMessage (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    await this.backend.api.core.removeChatMessage(params.roomId, params.messageId)
    return { ok: true }
  }

  async _pinMessage (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    await this.backend.api.core.pinChatMessage(params.roomId, params.messageId)
    return { ok: true }
  }

  async _unpinMessage (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    await this.backend.api.core.unpinChatMessage(params.roomId, params.messageId)
    return { ok: true }
  }

  async _getPinned (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getPinnedChatMessages(params.roomId)
  }

  async _searchMessages (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.query) throw new Error('Missing required param: query')
    return this.backend.api.core.searchChatMessages(params.roomId, { query: params.query })
  }

  async _msgRaw (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const limit = params.limit || 10
    const result = await this.backend.api.core.getChatMessages(params.roomId, {
      limit,
      reverse: true
    })
    const messages = Array.isArray(result) ? result : (result?.messages || result?.data || result?.events || [])
    return [...(messages || [])].reverse().map(msg => {
      // Return all raw fields for inspection
      const out = { _keys: Object.keys(msg) }
      for (const key of Object.keys(msg)) {
        const val = msg[key]
        if (val && typeof val === 'object') {
          out[key] = { _keys: Object.keys(val), ...val }
        } else {
          out[key] = val
        }
      }
      return out
    })
  }

  // --- Reactions ---

  async _addReaction (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    if (!params.emoji) throw new Error('Missing required param: emoji')
    await this.backend.api.core.addReaction(params.roomId, params.messageId, params.emoji)
    return { ok: true }
  }

  async _removeReaction (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    if (!params.emoji) throw new Error('Missing required param: emoji')
    await this.backend.api.core.removeReaction(params.roomId, params.messageId, params.emoji)
    return { ok: true }
  }

  async _getReactions (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.messageId) throw new Error('Missing required param: messageId')
    return this.backend.api.core.getReactions(params.roomId, params.messageId)
  }

  // --- Moderation ---

  async _muteMember (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.memberId) throw new Error('Missing required param: memberId')
    await this.backend.api.core.muteMember(params.roomId, params.memberId)
    return { ok: true }
  }

  async _unmuteMember (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.memberId) throw new Error('Missing required param: memberId')
    await this.backend.api.core.unmuteMember(params.roomId, params.memberId)
    return { ok: true }
  }

  async _blockMember (params) {
    if (!params.memberId) throw new Error('Missing required param: memberId')
    await this.backend.api.core.blockMember(params.memberId)
    return { ok: true }
  }

  async _unblockMember (params) {
    if (!params.memberId) throw new Error('Missing required param: memberId')
    await this.backend.api.core.unblockMember(params.memberId)
    return { ok: true }
  }

  async _removeMember (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.memberId) throw new Error('Missing required param: memberId')
    await this.backend.api.core.removeMember(params.roomId, params.memberId)
    this._reprojectMembers(params.roomId).catch(() => {}) // D1c: keep __members__ current
    return { ok: true }
  }

  async _getBlocked (params) {
    return this.backend.api.core.getBlockedMembers()
  }

  // --- Calls ---

  _callApi () {
    const api = this.backend.api.call
    if (!api) throw new Error('Call API not available')
    return api
  }

  async _callAuthorize (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this._callApi().authorize(params.roomId)
    return { ok: true }
  }

  async _callDeauthorize (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this._callApi().deauthorize(params.roomId)
    return { ok: true }
  }

  async _callJoinPeer (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    await this._callApi().joinPeer(params.roomId, params.peerId)
    return { ok: true }
  }

  async _callLeavePeer (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    await this._callApi().leavePeer(params.roomId, params.peerId)
    return { ok: true }
  }

  async _callPresence (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._callApi().getPresence(params.roomId)
  }

  async _callSetPresent (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this._callApi().setPresent(params.roomId)
    return { ok: true }
  }

  async _callSetNotPresent (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this._callApi().setNotPresent(params.roomId)
    return { ok: true }
  }

  async _callGetTracks (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._callApi().getAvailableTracks(params.roomId)
  }

  async _callRequestTrack (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    if (!params.trackKind) throw new Error('Missing required param: trackKind (audio|video|screen)')
    await this._callApi().requestTrack(params.roomId, params.peerId, params.trackKind)
    return { ok: true }
  }

  async _callUnrequestTrack (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    if (!params.trackKind) throw new Error('Missing required param: trackKind')
    await this._callApi().unrequestTrack(params.roomId, params.peerId, params.trackKind)
    return { ok: true }
  }

  async _callShareTrack (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    if (!params.trackKind) throw new Error('Missing required param: trackKind')
    await this._callApi().shareTrack(params.roomId, params.peerId, params.trackKind, params.trackData || null)
    return { ok: true }
  }

  async _callUnshareTrack (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    if (!params.trackKind) throw new Error('Missing required param: trackKind')
    await this._callApi().unshareTrack(params.roomId, params.peerId, params.trackKind)
    return { ok: true }
  }

  async _callSignalMedia (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.peerId) throw new Error('Missing required param: peerId')
    if (!params.signal) throw new Error('Missing required param: signal')
    await this._callApi().signalMedia(params.roomId, params.peerId, params.signal)
    return { ok: true }
  }

  // call.raw and raw removed — arbitrary function execution vectors (security audit D-F3)

  // --- Streaming ---

  subscribe (roomId) {
    return this.backend.api.core.subscribeChatMessages(roomId)
  }

  // --- System ---

  async _status () {
    const identity = await this.backend.api.core.getIdentity()
    const rooms = await this._rooms()
    // Read daemon version from package.json
    let daemonVersion = 'unknown'
    try {
      const pkgPath = path.join(path.dirname(import.meta.url.replace('file://', '')), '..', 'package.json')
      daemonVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version
    } catch {}
    return {
      uptime: Math.floor((Date.now() - this._startTime) / 1000),
      rooms: rooms.length,
      daemonVersion,
      identity: identity ? { memberId: identity.memberId, name: identity.displayName } : null
    }
  }

  async _shutdown () {
    setTimeout(async () => {
      await this.core.close()
      Bare.exit(0)
    }, 500)
    return { ok: true, message: 'shutting down' }
  }

  // --- Introspection ---

  async _introspect (params) {
    const target = params.api || 'core'
    const api = target === 'core' ? this.backend.api.core
      : target === 'call' ? this.backend.api.call
      : target === 'preferences' ? this.backend.api.preferences
      : target === 'network' ? this.backend.api.network
      : target === 'debug' ? this.backend.api.debug
      : null
    if (!api) throw new Error(`Unknown API: ${target}`)
    const methods = []
    for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(api))) {
      if (key === 'constructor') continue
      if (typeof api[key] === 'function') methods.push(key)
    }
    for (const key of Object.keys(api)) {
      if (typeof api[key] === 'function' && !methods.includes(key)) methods.push(key)
    }
    return { api: target, methods: methods.sort(), count: methods.length }
  }

  // --- Identity & Username ---

  async _identityConfirm () {
    await this.backend.api.core.confirmRecoveryPhrase()
    return { ok: true }
  }

  async _identityStore (params) {
    if (!params.phrase) throw new Error('Missing required param: phrase')
    await this.backend.api.core.storeRecoveryPhrase(params.phrase)
    return { ok: true }
  }

  async _identityRegister (params) {
    if (!params.username) throw new Error('Missing required param: username')
    const result = await this.backend.api.core.registerUsername(params.username)
    return { ok: true, result }
  }

  async _identityCheckName (params) {
    if (!params.username) throw new Error('Missing required param: username')
    const available = await this.backend.api.core.checkUsername(params.username)
    return { username: params.username, available }
  }

  async _identityLookup (params) {
    if (!params.username) throw new Error('Missing required param: username')
    return this.backend.api.core.lookupUsername(params.username)
  }

  // keet-core's lookupUserByMemberId takes ONE argument, the member key (4.22.28
  // _lookupUserByMemberId(e) decodes e for the username registry). This passed
  // (roomId, memberId), so keet-core looked up the ROOM key (hyper-games #89 defect
  // 8). roomId is still accepted from callers but the lookup is identity-wide.
  async _identityLookupMember (params) {
    if (!params.memberId) throw new Error('Missing required param: memberId')
    return this.backend.api.core.lookupUserByMemberId(params.memberId)
  }

  // --- Room Config & Management ---

  async _roomConfig (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getConfig(params.roomId)
  }

  async _roomConfigSet (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.key) throw new Error('Missing required param: key')
    if (params.value === undefined) throw new Error('Missing required param: value')
    await this.backend.api.core.updateConfig(params.roomId, params.key, params.value)
    return { ok: true }
  }

  // --- Room Metadata (Autobase smart contract) ---

  async _metaGet (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._roomMeta.get(params.roomId)
  }

  async _metaSet (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.metadata || typeof params.metadata !== 'object') {
      throw new Error('Missing required param: metadata (object)')
    }
    return this._roomMeta.set(params.roomId, params.metadata)
  }

  async _metaList () {
    return this._roomMeta.getAll()
  }

  async _metaSearch (params) {
    if (!params.topic) throw new Error('Missing required param: topic')
    return this._roomMeta.searchByTopic(params.topic)
  }

  async _metaKey () {
    const key = this._metadataBase.key
    return {
      key: key ? key.toString('hex') : null,
      localKey: this._metadataBase.localKey?.toString('hex') || null,
      writable: this._metadataBase.writable
    }
  }

  async _metaInvite () {
    const key = this._metadataBase.key
    if (!key) throw new Error('MetadataBase not ready')
    return { key: key.toString('hex') }
  }

  async _metaJoin (params) {
    if (!params.key) throw new Error('Missing required param: key (64-char hex)')
    if (typeof params.key !== 'string' || params.key.length !== 64) {
      throw new Error('Invalid key: must be 64-char hex string')
    }
    const keyBuf = Buffer.from(params.key, 'hex')
    dbg('[metadata] Joining remote MetadataBase: %s', params.key.slice(0, 12))

    await this._metadataBase.joinKey(keyBuf)

    // Re-create RoomMeta wrapper over the new MetadataBase
    this._roomMeta = new RoomMeta(this._metadataBase, { storagePath: os.homedir() })
    this._registryRoom = new RegistryRoom(this.backend, this._roomMeta)
    this._topicDisc = new TopicAnnounce(this.core, this.backend, this._roomMeta)

    dbg('[metadata] Joined — key: %s, writable: %s', this._metadataBase.key?.toString('hex')?.slice(0, 12), this._metadataBase.writable)
    return {
      ok: true,
      key: this._metadataBase.key.toString('hex'),
      localKey: this._metadataBase.localKey?.toString('hex') || null,
      writable: this._metadataBase.writable
    }
  }

  async _metaAddWriter (params) {
    if (!params.key) throw new Error('Missing required param: key (hex public key)')
    const keyBuf = Buffer.from(params.key, 'hex')
    await this._metadataBase.addWriter(keyBuf, { indexer: params.indexer || false })
    return { ok: true, key: params.key }
  }

  async _metaRemoveWriter (params) {
    if (!params.key) throw new Error('Missing required param: key (hex public key)')
    const keyBuf = Buffer.from(params.key, 'hex')
    await this._metadataBase.removeWriter(keyBuf)
    return { ok: true, key: params.key }
  }

  // --- Per-Room Game State (StateBase autobase) ---

  async _stateSet (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.key) throw new Error('Missing required param: key')
    if (params.value === undefined) throw new Error('Missing required param: value')
    const sb = await this._getStateBase(params.roomId)
    await sb.set(params.key, params.value)
    return { ok: true }
  }

  async _stateGet (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.key) throw new Error('Missing required param: key')
    const sb = await this._getStateBase(params.roomId)
    return { value: await sb.get(params.key) }
  }

  async _stateList (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const sb = await this._getStateBase(params.roomId)
    return { entries: await sb.list(params.prefix || '') }
  }

  async _stateDel (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.key) throw new Error('Missing required param: key')
    const sb = await this._getStateBase(params.roomId)
    await sb.del(params.key)
    return { ok: true }
  }

  async _stateInfo (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const sb = await this._getStateBase(params.roomId)
    return {
      hasBootstrap: sb.hasBootstrap,
      // HONEST raw autobase flag (= localWriter active). The game's in-game
      // indicator reads this. effectiveWritable() was REVERTED here: it reported
      // green off a stale system writer-set entry while writes were actually
      // denied (the per-room-identity mismatch, #99 follow-up) — a false green
      // that masked the real "writes don't persist" bug. Better to under-report
      // (red while admission is pending) than lie green.
      writable: sb.base ? sb.base.writable : false,
      stateBaseKey: sb.stateBaseKey?.toString('hex') || null
    }
  }

  // Actively request writer/indexer admission for this room's StateBase (the
  // anchor-promotion path). Opens the room (lazy) then drives requestWriter:
  // appends a keyless optimistic admit block so an online indexer promotes us
  // (INDEXER if our keet role is admin/mod). Lets an always-on --pin-room
  // admin become a durable indexer that admits members when the creator is gone.
  async _stateJoin (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const sb = await this._getStateBase(params.roomId)
    // claimIndexer: a designated always-on admin anchor takes the SOLE indexer
    // slot (move-indexer) so the room finalizes even when the creator is offline.
    return await sb.requestWriter({ timeoutMs: params.timeoutMs || (params.claimIndexer ? 30000 : 15000), claimIndexer: !!params.claimIndexer })
  }

  // READ-ONLY diagnostic over the LIVE (already-open) per-room StateBase(s).
  // Reflects exactly what the running daemon — and thus the game — sees, which a
  // fresh out-of-band probe cannot. Reads only: writable, writer/indexer count,
  // swarm peers, the __members__ projection (+ whether self is in it), entry
  // count, and broken-room status. NEVER writes to any store, and does NOT
  // force-open a room (no swarm/side-effects) unless params.open === true.
  //   params: { roomId?, open? }   — no roomId → report every open StateBase.
  // Autobase processing state of a room's StateBase WITHOUT awaiting its open —
  // the one diagnostic that still answers when the open or an append is stuck
  // (every state.* call, state.diag included, awaits the cached open promise).
  // No roomId → every instance. Opens nothing.
  _stateInternals (params = {}) {
    const one = (hex, sb) => ({ room: hex.slice(0, 12), pending: this._stateBases.has(hex), ...sb.internals() })
    if (params.roomId) {
      const hex = crypto.hash(Buffer.from(String(params.roomId), 'utf-8')).toString('hex')
      const sb = this._stateBaseInstances.get(hex)
      return sb ? one(hex, sb) : { room: hex.slice(0, 12), instance: false }
    }
    return { rooms: [...this._stateBaseInstances.entries()].map(([hex, sb]) => one(hex, sb)) }
  }

  async _stateDiag (params = {}) {
    const report = async (roomId, hex, pending) => {
      const out = { room: roomId ? String(roomId).slice(0, 16) : hex.slice(0, 12), open: false }
      out.broken = this._brokenRooms.get(hex) || this._brokenRooms.get(roomId) || null
      if (!pending) return out
      let sb
      try { sb = await pending } catch (e) { out.error = e.message; return out }
      out.open = true
      out.baseWritable = sb.base ? sb.base.writable : false
      // HONEST: report the raw autobase flag (REVERTED from effectiveWritable, which
      // gave a false green off a stale writer-set entry while writes were denied —
      // #99 follow-up). selfIsAdmittedWriter kept as a SEPARATE diagnostic field.
      out.writable = out.baseWritable
      try { out.selfIsAdmittedWriter = (sb.base && sb.base.system && sb.base.local && sb.base.local.key) ? await sb.base.system.has(sb.base.local.key) : null } catch { out.selfIsAdmittedWriter = null }
      out.selfKey = sb._selfKey ? sb._selfKey.toString('hex').slice(0, 16) : null
      try { out.writers = sb.base && sb.base.activeWriters ? sb.base.activeWriters.size : null } catch {}
      try { out.indexers = sb.base && sb.base.linearizer && sb.base.linearizer.indexers ? sb.base.linearizer.indexers.length : null } catch {}
      try { out.swarmPeers = sb.swarm ? sb.swarm.connections.size : null } catch {}
      try { out.baseLength = sb.base ? sb.base.length : null } catch {}
      try {
        const all = await sb.list('')
        let members = null
        if (all.__members__ !== undefined) members = typeof all.__members__ === 'string' ? JSON.parse(all.__members__) : all.__members__
        const keys = members ? Object.keys(members) : []
        out.memberCount = keys.length
        out.members = keys.map(k => k.slice(0, 8))
        const selfFull = sb._selfKey ? sb._selfKey.toString('hex') : null
        // selfInMembers = ELIGIBLE (present in the roster projection). Whether an
        // indexer actually admitted us is out.selfIsAdmittedWriter / out.writable —
        // roster membership alone must NOT promote writable (#99 false-green). When
        // selfInMembers=true but selfIsAdmittedWriter=false, this peer is the bug
        // signature: a member the room's indexer never admitted (writes won't finalize).
        out.selfInMembers = !!(selfFull && members && (members[selfFull] != null || members[selfFull.toLowerCase()] != null))
        out.entryCount = Object.keys(all).filter(k => k !== '__members__').length
      } catch (e) { out.listError = e.message }
      return out
    }
    if (params.roomId) {
      const hex = crypto.hash(Buffer.from(String(params.roomId), 'utf-8')).toString('hex')
      let pending = this._stateBases.get(hex)
      if (!pending && params.open) pending = this._getStateBase(params.roomId)
      return await report(params.roomId, hex, pending)
    }
    const rooms = []
    for (const [hex, pending] of this._stateBases.entries()) rooms.push(await report(null, hex, pending))
    return { openCount: this._stateBases.size, brokenCount: this._brokenRooms.size, rooms }
  }

  // Explicitly restart a room's StateBase: quarantine its corestore (renamed
  // aside, NEVER deleted) and reopen fresh. For when a room's game-state is
  // corrupt/wedged and the operator wants a clean slate (the auto-recovery in
  // StateBase.ready() handles the common "won't even open" case; this is the
  // manual escape hatch, and also resets a base that opened but is misbehaving).
  // A member re-replicates the room's state from peers afterwards.
  async _stateReset (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const hex = crypto.hash(Buffer.from(String(params.roomId), 'utf-8')).toString('hex')

    // Evict + close any cached (possibly half-open) instance to release its lock.
    const pending = this._stateBases.get(hex)
    this._stateBases.delete(hex)
    if (pending) {
      try { const sb = await pending; await sb.close() } catch {}
    }

    // Quarantine the on-disk corestore dir (never delete). MUST match
    // StateBaseV3's path (`state-bases-v3/<roomTag>`) — v3 cutover moved it off
    // the v2 `state-bases/` dir, so resetting the v2 path would no-op.
    const dir = path.join(this._storage, 'state-bases-v3', hex)
    let quarantined = null
    try {
      if (fs.existsSync(dir)) {
        quarantined = dir + '.corrupt-' + Date.now()
        fs.renameSync(dir, quarantined)
      }
    } catch (e) {
      throw new Error('state.reset: could not quarantine ' + dir + ': ' + e.message)
    }

    // Reopen fresh via the normal lazy path.
    const sb = await this._getStateBase(params.roomId)
    return {
      ok: true,
      reset: true,
      quarantined,
      writable: sb.writable,
      stateBaseKey: sb.stateBaseKey?.toString('hex') || null
    }
  }

  async _roomBookmark (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this.backend.api.core.bookmark(params.roomId)
    return { ok: true }
  }

  async _roomUnbookmark (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this.backend.api.core.unbookmark(params.roomId)
    return { ok: true }
  }

  async _roomPreferences (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getRoomPreferences(params.roomId)
  }

  async _roomPreferencesSet (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const { roomId, ...prefs } = params
    await this.backend.api.core.updateRoomPreferences(roomId, prefs)
    return { ok: true }
  }

  async _roomSearch (params) {
    if (!params.query) throw new Error('Missing required param: query')
    return this.backend.api.core.searchRooms(params.query)
  }

  async _roomUnread (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (params.unread === undefined) throw new Error('Missing required param: unread')
    await this.backend.api.core.setRoomUnread(params.roomId, params.unread)
    return { ok: true }
  }

  // --- DM Management ---

  async _dmList () {
    // No getDmList — use getDmRequestsByStatus(1) for accepted DMs
    return this.backend.api.core.getDmRequestsByStatus(1)
  }

  async _dmRequests (params) {
    const status = params.status || 3 // default: pending
    return this.backend.api.core.getDmRequestsByStatus(status)
  }

  async _dmAccept (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this.backend.api.core.acceptDmRequest(params.roomId)
    return { ok: true }
  }

  async _dmReject (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    await this.backend.api.core.rejectDmRequest(params.roomId)
    return { ok: true }
  }

  // --- DM Room Shares (_hs:room-share messages) ---

  async _dmShare (params) {
    if (!params.dmRoomId) throw new Error('Missing required param: dmRoomId')
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._dmShares.share(params.dmRoomId, {
      roomId: params.roomId,
      title: params.title,
      topics: params.topics,
      inviteToken: params.inviteToken
    })
  }

  async _dmSharesList (params) {
    if (!params.dmRoomId) throw new Error('Missing required param: dmRoomId')
    return this._dmShares.list(params.dmRoomId)
  }

  // --- Graph Computation ---

  async _graphCompute (params) {
    return this._localGraph.compute(params)
  }

  async _graphClusters (params) {
    return this._localGraph.clusters(params)
  }

  async _graphEdges (params) {
    return this._localGraph.edges(params)
  }

  // --- Registry Rooms ---

  async _registryRooms () {
    return this._registryRoom.rooms()
  }

  async _registryList (params) {
    if (!params.registryRoomId) throw new Error('Missing required param: registryRoomId')
    return this._registryRoom.list(params.registryRoomId, params.query || null)
  }

  async _registryAdd (params) {
    if (!params.registryRoomId) throw new Error('Missing required param: registryRoomId')
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._registryRoom.add(params.registryRoomId, params)
  }

  async _registryRemove (params) {
    if (!params.registryRoomId) throw new Error('Missing required param: registryRoomId')
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._registryRoom.remove(params.registryRoomId, params)
  }

  // --- Topic Discovery (DHT) ---

  async _topicAnnounce (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._topicDisc.announce(params.roomId)
  }

  async _topicUnannounce (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this._topicDisc.unannounce(params.roomId)
  }

  async _topicSearch (params) {
    if (!params.query) throw new Error('Missing required param: query')
    return this._topicDisc.search(params.query)
  }

  async _topicAnnounceAll () {
    return this._topicDisc.announceAll()
  }

  // --- File Management ---

  async _fileSend (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.file) throw new Error('Missing required param: file')
    return this.backend.api.core.sendFile(params.roomId, params.file)
  }

  async _fileSendFromPath (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.filePath) throw new Error('Missing required param: filePath')

    const filePath = params.filePath
    if (!fs.existsSync(filePath)) throw new Error('File not found: ' + filePath)

    const fileName = params.fileName || path.basename(filePath)
    const mimeType = params.mimeType || this._guessMimeType(fileName)
    const data = fs.readFileSync(filePath)

    // Build file object with inlined data for keet-core sendFile
    const file = {
      metadata: {
        name: fileName,
        mimetype: mimeType
      },
      pointer: {
        inlined: data
      }
    }

    // Add image dimensions if it's an image
    if (mimeType.startsWith('image/') && params.width && params.height) {
      file.metadata.dimensions = {
        width: params.width,
        height: params.height
      }
    }

    return this.backend.api.core.sendFile(params.roomId, file)
  }

  _guessMimeType (fileName) {
    const ext = (fileName.split('.').pop() || '').toLowerCase()
    const types = {
      jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
      gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
      mp4: 'video/mp4', webm: 'video/webm', ogg: 'video/ogg',
      mov: 'video/quicktime', mkv: 'video/x-matroska',
      mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac',
      pdf: 'application/pdf', zip: 'application/zip',
      txt: 'text/plain', json: 'application/json'
    }
    return types[ext] || 'application/octet-stream'
  }

  async _fileList (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    const files = await this.backend.api.core.getLocalFiles(params.roomId)
    // Normalize to flat format and filter out preview-only entries
    return files
      .filter(f => !f.metadata?.isPreview)
      .map(f => {
        const meta = f.metadata || {}
        const pointer = f.pointer || {}
        const entry = f.entry || {}
        const entryPreview = entry.preview || {}
        return {
          id: meta.name || pointer.externalBlob?.id || null,
          name: meta.name || null,
          type: meta.mimetype || null,
          size: pointer.externalBlob?.blob?.byteLength || 0,
          dimensions: meta.dimensions ? { width: meta.dimensions.width, height: meta.dimensions.height } : null,
          httpLink: entry.httpLink || null,
          preview: {
            small: entryPreview.small?.httpLink || null,
            medium: entryPreview.medium?.httpLink || null,
            large: entryPreview.large?.httpLink || null
          }
        }
      })
  }

  async _fileInfo (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    return this.backend.api.core.getFileEntry(params.roomId, params.fileId)
  }

  async _fileDownload (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    await this.backend.api.core.startDownload(params.roomId, params.fileId)
    return { ok: true }
  }

  async _fileCancel (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    await this.backend.api.core.cancelDownload(params.roomId, params.fileId)
    return { ok: true }
  }

  async _fileRemove (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    await this.backend.api.core.removeFile(params.roomId, params.fileId)
    return { ok: true }
  }

  async _fileClear (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    await this.backend.api.core.clearFile(params.roomId, params.fileId)
    return { ok: true }
  }

  async _filePreview (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    const size = params.size || 'small' // small, medium, large

    // getFileEntry doesn't work — use getLocalFiles and search by name (our primary ID)
    const files = await this.backend.api.core.getLocalFiles(params.roomId)
    // Search by filename first (primary ID), then by externalBlob.id (legacy)
    let file = files.find(f => (f.metadata?.name || '') === params.fileId)
    if (!file) file = files.find(f => f.pointer?.externalBlob?.id === params.fileId)
    if (!file) {
      const names = files.map(f => f.metadata?.name || '?').join(', ')
      console.log('[file.preview] not found:', params.fileId, 'available:', names)
      throw new Error('File not found: ' + params.fileId)
    }

    const meta = file.metadata || {}
    const entryPreview = file.entry?.preview || {}
    const sizeData = entryPreview[size] || entryPreview.small || entryPreview.medium || null
    const previewLink = sizeData?.httpLink || null
    const fullLink = file.entry?.httpLink || null
    // Fall back to full file link when preview-size link is unavailable
    const httpLink = previewLink || fullLink
    console.log('[file.preview] size:', size, 'previewLink:', previewLink, 'fullLink:', fullLink, 'using:', httpLink)
    return {
      fileId: params.fileId,
      name: meta.name || null,
      type: meta.mimetype || null,
      size: file.pointer?.externalBlob?.blob?.byteLength || 0,
      dimensions: meta.dimensions || null,
      httpLink,
      fullHttpLink: fullLink
    }
  }

  async _fileFetch (params) {
    if (!params.url) throw new Error('Missing required param: url (httpLink URL from file preview)')
    const { default: fetch } = await import('bare-fetch')
    const resp = await fetch(params.url)
    if (!resp.ok) throw new Error(`Fetch failed: ${resp.status} ${resp.statusText}`)
    const buf = Buffer.from(await resp.arrayBuffer())
    const contentType = resp.headers.get('content-type') || 'application/octet-stream'
    return { base64: buf.toString('base64'), contentType, size: buf.length }
  }

  async _filePath (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.fileId) throw new Error('Missing required param: fileId')
    const fs = await import('bare-fs')
    // Check if keet-core has a getFilePath or similar
    try {
      const entry = await this.backend.api.core.getFileEntry(params.roomId, params.fileId)
      if (entry && (entry.path || entry.localPath || entry.filePath)) {
        const p = entry.path || entry.localPath || entry.filePath
        try {
          await fs.promises.stat(p)
          return { path: p, exists: true }
        } catch {
          return { path: p, exists: false }
        }
      }
    } catch {}
    // Fallback: search in known room file directories
    const roomFileDir = path.join(this._storage, '0', 'db', 'rooms', params.roomId, 'files')
    try {
      const files = await fs.promises.readdir(roomFileDir)
      for (const f of files) {
        if (f.includes(params.fileId) || f === params.fileId) {
          const fullPath = path.join(roomFileDir, f)
          return { path: fullPath, exists: true }
        }
      }
    } catch {}
    return { path: null, exists: false }
  }

  // --- Advanced Members ---

  async _membersActive (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getActiveMembers(params.roomId)
  }

  async _membersMods (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getModerators(params.roomId)
  }

  async _membersCount (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getMemberCount(params.roomId)
  }

  async _membersConnected (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return this.backend.api.core.getConnectedMembers(params.roomId)
  }

  async _membersSearch (params) {
    if (!params.roomId) throw new Error('Missing required param: roomId')
    if (!params.query) throw new Error('Missing required param: query')
    return this.backend.api.core.getSearchMembers(params.roomId, params.query)
  }

  // --- Contacts ---

  async _contactInfo (params) {
    if (!params.memberId) throw new Error('Missing required param: memberId')
    return this.backend.api.core.getContactInfo(params.memberId)
  }

  async _contactSelf () {
    return this.backend.api.core.getSelfContactInfo()
  }

  async _dmFromContact (params) {
    if (!params.memberId) throw new Error('Missing required param: memberId')
    return this.backend.api.core.sendDMFromContact(params.memberId)
  }

  // --- Sub-APIs ---

  async _networkQuery () {
    const api = this.backend.api.network
    if (!api) throw new Error('Network API not available')
    return api.query()
  }

  async _preferencesQuery () {
    const api = this.backend.api.preferences
    if (!api) throw new Error('Preferences API not available')
    return api.query()
  }

  async _preferencesSet (params) {
    const api = this.backend.api.preferences
    if (!api) throw new Error('Preferences API not available')
    await api.update(params)
    return { ok: true }
  }

  async _debugStats (params) {
    const api = this.backend.api.debug
    if (!api) throw new Error('Debug API not available')
    if (!params.roomId) throw new Error('Missing required param: roomId')
    return api.getCurrentRoomStats(params.roomId)
  }

  // --- Home (hyper-tps docs/home-world-design.md §2) ---

  // The active identity's home room: { roomId, created }. ONE per identity, created
  // on first ask, its id kept in identities.json (`homeRoomId`) so it lives with
  // the identity and survives restarts.
  //
  // ⛔ A POINTER TO A ROOM THAT IS GONE IS AN ERROR, NOT A REASON TO CREATE ANOTHER.
  // An idempotent-looking call that quietly replaces state on every launch is the
  // vault.setup trap (it replaced the identity each call). A missing home is
  // something to look at; the pointer is left as it is.
  //
  // SERIALIZED: two first calls (two game windows, a menu double-click) would
  // otherwise both see "no pointer" and create two rooms.
  //
  // Known window: a crash between createRoom and the registry save leaves an
  // unreferenced room, and the next call creates another. Not worth a two-phase
  // write in the JS daemon (accepted limitation until keetd).
  _homeGet () {
    const prev = this._homeChain || Promise.resolve()
    const next = prev.catch(() => {}).then(() => this._homeGetOnce())
    this._homeChain = next
    return next
  }

  async _homeGetOnce () {
    if (!this._registry) throw new Error('Identity registry not available')
    const entry = this._registry.getActive()
    if (!entry) throw new Error('No active identity')
    if (entry.homeRoomId) {
      let info = null
      try { info = await this.backend.api.core.getRoomInfo(entry.homeRoomId) } catch {}
      if (!info) throw new Error('HOME_ROOM_MISSING: ' + entry.id + ' homeRoomId ' + entry.homeRoomId + ' is not a room on this identity')
      return { roomId: entry.homeRoomId, created: false }
    }
    const roomId = await this.backend.api.core.createRoom({ config: { title: 'Home' } })
    if (typeof roomId !== 'string' || roomId === '') throw new Error('home.get: createRoom returned ' + JSON.stringify(roomId))
    await this._registry.updateMeta(entry.id, { homeRoomId: roomId })
    return { roomId, created: true }
  }

  // --- Identity Manager ---

  async _imList () {
    if (!this._registry) throw new Error('Identity registry not available')
    return {
      activeId: this._registry.getActiveId(),
      identities: this._registry.list()
    }
  }

  async _imActive () {
    if (!this._registry) throw new Error('Identity registry not available')
    const entry = this._registry.getActive()
    if (!entry) throw new Error('No active identity')
    const identity = await this.backend.api.core.getIdentity()
    return { ...entry, identity: identity || null }
  }

  async _imCreate (params) {
    if (!this._registry) throw new Error('Identity registry not available')
    if (!params.name) throw new Error('Missing required param: name')
    return this._registry.create({ name: params.name })
  }

  async _imSwitch (params) {
    if (!this._registry) throw new Error('Identity registry not available')
    if (!this._swapCore) throw new Error('Core swap not available')
    if (!params.id) throw new Error('Missing required param: id')
    if (this._swapping) throw new Error('Identity switch already in progress')

    const entry = this._registry.get(params.id)
    if (!entry) throw new Error('Identity not found: ' + params.id)

    this._swapping = true
    try {
      await this._swapCore(entry.dir)
      await this._registry.setActive(params.id)

      // Auto-repair after identity switch to fix writer mismatches
      await this._postRestoreRepair()

      return { ok: true, active: entry }
    } finally {
      this._swapping = false
    }
  }

  async _imDelete (params) {
    if (!this._registry) throw new Error('Identity registry not available')
    if (!params.id) throw new Error('Missing required param: id')
    const deleteFiles = params.deleteFiles !== false
    return this._registry.remove(params.id, deleteFiles)
  }

  async _imUpdate (params) {
    if (!this._registry) throw new Error('Identity registry not available')
    if (!params.id) throw new Error('Missing required param: id')
    const fields = {}
    if (params.name !== undefined) fields.name = params.name
    if (params.username !== undefined) fields.username = params.username
    return this._registry.updateMeta(params.id, fields)
  }

  async _imExport (params) {
    if (!this._registry) throw new Error('Identity registry not available')
    if (!params.id) throw new Error('Missing required param: id')
    if (!params.password) throw new Error('Missing required param: password')

    const entry = this._registry.get(params.id)
    if (!entry) throw new Error('Identity not found: ' + params.id)

    const vault = new SecureStorage(path.join(entry.dir, 'mnemonic.enc'))
    if (!vault.exists()) throw new Error('No vault for identity: ' + params.id)

    const plaintext = await vault.decrypt(Buffer.from(params.password))
    return { phrase: plaintext.toString() }
  }

  async _imImport (params) {
    if (!this._registry) throw new Error('Identity registry not available')
    if (!this._swapCore) throw new Error('Core swap not available')
    if (!params.phrase) throw new Error('Missing required param: phrase')
    if (!params.password) throw new Error('Missing required param: password')
    if (!params.name) throw new Error('Missing required param: name')
    if (this._swapping) throw new Error('Identity switch already in progress')

    this._swapping = true
    try {
      // Create new identity entry
      const entry = await this._registry.create({ name: params.name })

      // Swap core to the new storage
      await this._swapCore(entry.dir)
      await this._registry.setActive(entry.id)

      // Restore identity from phrase
      await this.backend.api.core.restoreIdentity(params.phrase)

      // Encrypt vault
      const vault = new SecureStorage(path.join(entry.dir, 'mnemonic.enc'))
      await vault.encrypt(Buffer.from(params.phrase), Buffer.from(params.password))

      return { ok: true, identity: entry }
    } catch (err) {
      this._swapping = false
      throw err
    } finally {
      this._swapping = false
    }
  }

  // --- Identity Repair ---

  async _imRepair () {
    const log = []

    // Get all rooms to repair per-room
    let rooms = []
    try {
      const result = await this.backend.api.core.getRecentRooms()
      rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
      log.push({ step: 'listRooms', ok: true, count: rooms.length })
    } catch (err) {
      log.push({ step: 'listRooms', ok: false, error: err.message })
    }

    // Per-room: repair() and rotateWriter() both need a room key
    for (const room of rooms) {
      const roomId = room.roomId || room.id || room.key
      if (!roomId) continue

      try {
        await this.backend.api.core.repair(roomId)
        log.push({ step: 'repair', room: roomId.slice(0, 8), ok: true })
      } catch (err) {
        log.push({ step: 'repair', room: roomId.slice(0, 8), ok: false, error: err.message })
      }

      try {
        await this.backend.api.core.rotateWriter(roomId)
        log.push({ step: 'rotateWriter', room: roomId.slice(0, 8), ok: true })
      } catch (err) {
        log.push({ step: 'rotateWriter', room: roomId.slice(0, 8), ok: false, error: err.message })
      }
    }

    // Global: resyncPrivateProfile
    try {
      await this.backend.api.core.resyncPrivateProfile()
      log.push({ step: 'resyncPrivateProfile', ok: true })
    } catch (err) {
      log.push({ step: 'resyncPrivateProfile', ok: false, error: err.message })
    }

    // Confirm identity state
    try {
      const identity = await this.backend.api.core.getIdentity()
      log.push({ step: 'identity', ok: true, memberId: identity?.memberId, displayName: identity?.displayName })
    } catch (err) {
      log.push({ step: 'identity', ok: false, error: err.message })
    }

    console.log('[router] identity repair log:', JSON.stringify(log, null, 2))
    return { log }
  }

  // --- GC: selectively remove broken rooms ---
  // Detection: onerror callback derives roomId from roomKey buffer via
  // hypercore-crypto.discoveryKey() + z32.encode(). The tracked brokenRooms
  // map is the reliable signal since KEET_CONTRACT_ABORT fires asynchronously
  // during room open (reads succeed, writes fail).

  async _imGc () {
    const log = []

    // Report tracked broken rooms from onerror callback
    const brokenIds = [...this._brokenRooms.keys()]
    if (brokenIds.length > 0) {
      log.push({
        step: 'tracked',
        count: brokenIds.length,
        rooms: brokenIds.map(id => ({ roomId: id.slice(0, 12), error: this._brokenRooms.get(id) }))
      })
      console.log(`[gc] ${brokenIds.length} broken room(s) tracked from core errors`)
    } else {
      log.push({ step: 'tracked', count: 0, message: 'No broken rooms detected since startup' })
      console.log('[gc] No broken rooms tracked — nothing to clean')
      return { log }
    }

    // Get room titles for the log
    let titleMap = {}
    try {
      const result = await this.backend.api.core.getRecentRooms()
      const rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
      log.push({ step: 'before', roomCount: rooms.length })
      for (const r of rooms) {
        const rid = r.roomId || r.id || r.key
        if (rid) titleMap[rid] = r.config?.title || r.title || rid.slice(0, 12)
      }
    } catch (err) {
      log.push({ step: 'before', error: err.message })
    }

    // Purge broken rooms via leaveRoom
    let deleted = 0
    for (const roomId of brokenIds) {
      const title = titleMap[roomId] || roomId.slice(0, 12)
      try {
        await this.backend.api.core.leaveRoom(roomId)
        deleted++
        log.push({ step: 'deleted', room: roomId.slice(0, 12), title })
        console.log(`[gc] purged: ${title} (${roomId.slice(0, 12)})`)
      } catch (err) {
        log.push({ step: 'delete-failed', room: roomId.slice(0, 12), error: err.message })
        console.log(`[gc] purge failed: ${roomId.slice(0, 12)} — ${err.message}`)
      }
    }

    // Clear tracked broken rooms
    this._brokenRooms.clear()

    // Verify final state
    try {
      const result = await this.backend.api.core.getRecentRooms()
      const remaining = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
      log.push({ step: 'afterGc', roomCount: remaining.length, deleted })
    } catch (err) {
      log.push({ step: 'afterGc', error: err.message })
    }

    console.log('[gc] GC complete:', JSON.stringify(log, null, 2))
    return { log }
  }

  // --- Deep diagnostics ---

  async _imDiagnose (params) {
    const report = {}

    // 1. List all core API methods
    const coreMethods = []
    const coreApi = this.backend.api.core
    for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(coreApi))) {
      if (key === 'constructor') continue
      if (typeof coreApi[key] === 'function') coreMethods.push(key)
    }
    for (const key of Object.keys(coreApi)) {
      if (typeof coreApi[key] === 'function' && !coreMethods.includes(key)) coreMethods.push(key)
    }
    report.coreMethods = coreMethods.sort()
    report.coreMethodCount = coreMethods.length

    // 2. Get detailed room info for each room
    report.rooms = []
    try {
      const result = await this.backend.api.core.getRecentRooms()
      const rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
      for (const room of rooms) {
        const roomId = room.roomId || room.id || room.key
        if (!roomId) continue

        const roomReport = { roomId: roomId.slice(0, 12), rawFields: Object.keys(room) }

        // Get full room info
        try {
          const info = await this.backend.api.core.getRoomInfo(roomId)
          roomReport.info = info ? Object.keys(info) : null
          roomReport.isWritable = info?.isWritable
          roomReport.status = info?.status
          roomReport.title = info?.config?.title || info?.title
        } catch (err) {
          roomReport.infoError = err.message
        }

        // Get room keys
        try {
          const keys = await this.backend.api.core.getRoomKeys(roomId)
          roomReport.keys = keys ? Object.keys(keys) : null
        } catch (err) {
          roomReport.keysError = err.message
        }

        // Get room stats
        try {
          const stats = await this.backend.api.core.getRoomStats(roomId)
          roomReport.stats = stats
        } catch (err) {
          roomReport.statsError = err.message
        }

        report.rooms.push(roomReport)
      }
    } catch (err) {
      report.roomsError = err.message
    }

    // 3. Identity state
    try {
      const identity = await this.backend.api.core.getIdentity()
      report.identity = {
        memberId: identity?.memberId,
        displayName: identity?.displayName,
        username: identity?.username,
        fields: identity ? Object.keys(identity) : null
      }
    } catch (err) {
      report.identityError = err.message
    }

    // 4. KeetCore internal state (what methods exist on the core object itself)
    const coreObjMethods = []
    try {
      for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(this.core))) {
        if (key === 'constructor') continue
        if (typeof this.core[key] === 'function') coreObjMethods.push(key)
      }
    } catch {}
    report.keetCoreMethods = coreObjMethods.sort()

    console.log('[diagnose] Report:', JSON.stringify(report, null, 2))
    return report
  }

  // --- System (extended) ---

  async _getVersion () {
    return this.backend.api.core.getVersion()
  }

  async _getStats () {
    return this.backend.api.core.getStats()
  }

  async _swarmDebug () {
    const keet = this.core
    const result = { keetState: keet.state }

    try {
      const swarm = keet.swarm
      result.swarmExists = !!swarm
      if (swarm) {
        result.swarmConnections = swarm.connections ? swarm.connections.size : 0
        result.swarmPeersMap = swarm.peers ? swarm.peers.size : 0
        result.swarmExplicitPeers = swarm.explicitPeers ? swarm.explicitPeers.size : 0
        // Check topics/discovery map
        const disc = swarm._discovery || swarm.discovery
        result.swarmTopicCount = disc ? (disc.size || 0) : 'no _discovery'
        if (swarm.dht) {
          result.dhtOnline = swarm.dht.online
          result.dhtPort = swarm.dht.port
          result.dhtHost = swarm.dht.host
          result.dhtRandomized = swarm.dht.randomized
        }
      }
    } catch (e) { result.swarmError = e.message }

    try {
      result.blindPeeringExists = !!keet.blindPeering
      if (keet.blindPeering) {
        result.blindPeeringActive = keet.blindPeering._active ? keet.blindPeering._active.size : 'no _active'
      }
    } catch (e) { result.blindPeeringError = e.message }

    result.discoveryExists = !!keet.discovery

    try {
      const sw = keet.swarmWorker
      if (sw) {
        result.swarmWorkerBackgrounded = sw._backgrounded
        result.swarmWorkerServerMode = sw._serverModeAllowed
        result.swarmWorkerDeferSwarming = sw._deferSwarming
        result.swarmWorkerSessionCount = sw.sessions ? sw.sessions.size : 0

        const sessions = []
        if (sw.sessions) {
          for (const [id, s] of sw.sessions) {
            sessions.push({
              id: id.slice(0, 12),
              roomId: s.roomId ? s.roomId.slice(0, 12) : null,
              status: s.status,
              swarmStatus: s.swarmStatus,
              hasSwarmJoin: !!s.swarm
            })
          }
        }
        result.sessions = sessions
      }
    } catch (e) { result.swarmWorkerError = e.message }

    return result
  }

}

// DISCOVERY HANDLERS REMOVED — Step 6: replaced by registry rooms + topic discovery
// See registry-room.js and topic-announce.js

// Extract file attachments from a raw keet-core chat message.
// Structure: msg.message.files[] is an array of {metadata, pointer, preview, entry}.
// msg.files is an object with numeric keys (NOT an array).
// msg.file is a single file object (same as files[0]).
function extractFiles (msg) {
  // Primary source: message.files (array)
  let files = msg.message?.files
  if (Array.isArray(files) && files.length > 0) {
    return files.map(f => normalizeFileEntry(f))
  }
  // Fallback: msg.file (singular, for single-file messages)
  if (msg.file && typeof msg.file === 'object' && msg.file.metadata) {
    return [normalizeFileEntry(msg.file)]
  }
  return null
}

function normalizeFileEntry (f) {
  const meta = f.metadata || {}
  const pointer = f.pointer || {}
  const dims = meta.dimensions || null
  const blob = pointer.externalBlob?.blob || pointer.blob || null
  // Use filename as unique ID — externalBlob.id is shared across all files in a room
  const fallbackId = meta.name || pointer.externalBlob?.id || null

  // Extract preview httpLinks from entry
  const entry = f.entry || {}
  const entryPreview = entry.preview || {}
  const smallLink = entryPreview.small?.httpLink || null
  const mediumLink = entryPreview.medium?.httpLink || null
  const largeLink = entryPreview.large?.httpLink || entryPreview.large?.own?.httpLink || null
  const fullLink = entry.httpLink || null

  return {
    id: fallbackId,
    name: meta.name || null,
    type: meta.mimetype || null,
    size: blob?.byteLength || 0,
    dimensions: dims ? { width: dims.width, height: dims.height } : null,
    httpLink: fullLink,
    preview: (smallLink || mediumLink || largeLink) ? {
      small: smallLink,
      medium: mediumLink,
      large: largeLink
    } : null
  }
}