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
}
}