OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon commits / a6c16fe

home.get: every identity's own home room (hyper-tps Home)
home.get -> { roomId, created }: the active identity's home room, created on first ask; its id is 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 (HOME_ROOM_MISSING), never a reason to create a second home. Calls are serialized so two first calls create one room. Design: hyper-tps docs/home-world-design.md §2. keetd must port this RPC. test/home-get.test.js (gate H2; red first: "Unknown method: home.get"): concurrent first calls -> one room; created:false after; on disk; per-identity homes across switch; missing room -> error, no new room. 11/11. Mutant probe: without the serialization 5 checks fail (two homes created). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a6c16fe326c2b1fb022d88ffb45171014afc6c2d
by plankatron <fleeky@gmail.com> · 2026-10-05T16:42:08.000Z

Changed files (4) +200 -0

lib/identity-registry.js +3 -0
……@@ -75,6 +75,9 @@ export class IdentityRegistry {
7575 if (!entry) throw new Error('Identity not found: ' + id)
7676 if (fields.name !== undefined) entry.name = fields.name
7777 if (fields.username !== undefined) entry.username = fields.username
78 // The identity's home room (router home.get). Lives with the identity, so an
79 // identity switch or restore switches homes with it.
80 if (fields.homeRoomId !== undefined) entry.homeRoomId = fields.homeRoomId
7881 await this.save()
7982 return entry
8083 }
lib/router.js +41 -0
……@@ -434,6 +434,7 @@ export class Router {
434434 case 'vault.export': return this._vaultExport(params)
435435
436436 // --- Identity Manager (multi-identity) ---
437 case 'home.get': return this._homeGet()
437438 case 'identity-manager.list': return this._imList()
438439 case 'identity-manager.active': return this._imActive()
439440 case 'identity-manager.create': return this._imCreate(params)
……@@ -1988,6 +1989,46 @@ export class Router {
19881989 return api.getCurrentRoomStats(params.roomId)
19891990 }
19901991
1992 // --- Home (hyper-tps docs/home-world-design.md §2) ---
1993
1994 // The active identity's home room: { roomId, created }. ONE per identity, created
1995 // on first ask, its id kept in identities.json (`homeRoomId`) so it lives with
1996 // the identity and survives restarts.
1997 //
1998 // ⛔ A POINTER TO A ROOM THAT IS GONE IS AN ERROR, NOT A REASON TO CREATE ANOTHER.
1999 // An idempotent-looking call that quietly replaces state on every launch is the
2000 // vault.setup trap (it replaced the identity each call). A missing home is
2001 // something to look at; the pointer is left as it is.
2002 //
2003 // SERIALIZED: two first calls (two game windows, a menu double-click) would
2004 // otherwise both see "no pointer" and create two rooms.
2005 //
2006 // Known window: a crash between createRoom and the registry save leaves an
2007 // unreferenced room, and the next call creates another. Not worth a two-phase
2008 // write in the JS daemon (accepted limitation until keetd).
2009 _homeGet () {
2010 const prev = this._homeChain || Promise.resolve()
2011 const next = prev.catch(() => {}).then(() => this._homeGetOnce())
2012 this._homeChain = next
2013 return next
2014 }
2015
2016 async _homeGetOnce () {
2017 if (!this._registry) throw new Error('Identity registry not available')
2018 const entry = this._registry.getActive()
2019 if (!entry) throw new Error('No active identity')
2020 if (entry.homeRoomId) {
2021 let info = null
2022 try { info = await this.backend.api.core.getRoomInfo(entry.homeRoomId) } catch {}
2023 if (!info) throw new Error('HOME_ROOM_MISSING: ' + entry.id + ' homeRoomId ' + entry.homeRoomId + ' is not a room on this identity')
2024 return { roomId: entry.homeRoomId, created: false }
2025 }
2026 const roomId = await this.backend.api.core.createRoom({ config: { title: 'Home' } })
2027 if (typeof roomId !== 'string' || roomId === '') throw new Error('home.get: createRoom returned ' + JSON.stringify(roomId))
2028 await this._registry.updateMeta(entry.id, { homeRoomId: roomId })
2029 return { roomId, created: true }
2030 }
2031
19912032 // --- Identity Manager ---
19922033
19932034 async _imList () {
package.json +1 -0
……@@ -29,6 +29,7 @@
2929 "scripts": {
3030 "test:identity-bootstrap": "bare test/identity-bootstrap.test.js",
3131 "test:identity-switch": "bare test/identity-switch.test.js",
32 "test:home-get": "HOME=$(mktemp -d) bare test/home-get.test.js",
3233 "postinstall": "node scripts/copy-keet-core-mirror.cjs"
3334 },
3435 "//pin-note": "ALL keet-stack versions pinned EXACTLY to what Keet AppImage 4.22.5 (keet-core 4.22.28) ships — from its resources/app/dep-versions.json, captured 2026-09-30 (hyper-fleeky/keet-audit-4.22/dep-versions.json). Refresh by extracting versions from a current AppImage; do not relax pins on a hunch. ONE DELIBERATE EXCEPTION: bare-inspector stays 6.0.1 (Keet 4.22.5 ships 6.1.0), because 6.1.0 declares engines bare >=1.29.0 and our runtimes are 1.28.x (pgh runtime 1.28.7, Windows bare.exe 1.28.7) — it throws UNSUPPORTED_ENGINE at boot. keet-core 4.22.28 requires only ^6.0.1. Lift when every runtime is >= 1.29.",
test/home-get.test.js +155 -0
……@@ -0,0 +1,155 @@
1// home.get — every identity has ONE home room (hyper-tps docs/home-world-design.md §2,
2// gate H2). The daemon owns the pointer: identities.json entry `homeRoomId`.
3//
4// 1. a fresh identity: two CONCURRENT home.get calls create ONE room
5// 2. a later call returns the same room, created:false
6// 3. the pointer is on disk (identities.json), so it survives a restart
7// 4. another identity gets its own home; switching back returns the first
8// 5. a pointer to a room that is gone -> HOME_ROOM_MISSING, and NO new room
9//
10// SAFETY: fresh tmp storage; ~/.config/keet-cli is never opened. RoomMeta's legacy
11// migration renames files under os.homedir(), so this REFUSES to run unless HOME
12// is a scratch dir too:
13//
14// cd hyper-fleeky/packages/keet-daemon && HOME=$(mktemp -d) bare test/home-get.test.js
15
16import fs from 'bare-fs'
17import path from 'bare-path'
18import os from 'bare-os'
19import Pipe from 'bare-pipe'
20import FramedStream from 'framed-stream'
21import { startDaemon } from '../index.js'
22
23let exitCode = 0
24const failures = []
25
26function ok (cond, msg) {
27 if (cond) console.log(' ok ' + msg)
28 else { console.log(' FAIL ' + msg); exitCode = 1; failures.push(msg) }
29}
30
31function rmrf (dir) {
32 try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
33}
34
35class TestClient {
36 constructor (socketPath) {
37 this._socketPath = socketPath
38 this._nextId = 1
39 this._pending = new Map()
40 }
41 connect () {
42 return new Promise((resolve, reject) => {
43 const pipe = new Pipe(this._socketPath)
44 pipe.once('error', reject)
45 pipe.once('connect', () => {
46 pipe.removeListener('error', reject)
47 this._pipe = pipe
48 const FS = FramedStream.default || FramedStream
49 this._framed = new FS(pipe)
50 this._framed.on('data', (buf) => {
51 let msg
52 try { msg = JSON.parse(buf.toString('utf-8')) } catch { return }
53 const p = this._pending.get(msg.id)
54 if (!p) return
55 this._pending.delete(msg.id)
56 if (msg.ok === false || msg.error) p.reject(new Error(msg.error || 'RPC failed'))
57 else p.resolve(msg.data !== undefined ? msg.data : msg)
58 })
59 resolve()
60 })
61 })
62 }
63 call (method, params = {}, timeoutMs = 60000) {
64 const id = this._nextId++
65 return new Promise((resolve, reject) => {
66 const timer = setTimeout(() => {
67 this._pending.delete(id)
68 reject(new Error('RPC timeout: ' + method))
69 }, timeoutMs)
70 this._pending.set(id, {
71 resolve: (v) => { clearTimeout(timer); resolve(v) },
72 reject: (e) => { clearTimeout(timer); reject(e) }
73 })
74 this._framed.write(Buffer.from(JSON.stringify({ id, method, params }), 'utf-8'))
75 })
76 }
77 close () {
78 try { this._framed && this._framed.destroy() } catch {}
79 try { this._pipe && this._pipe.destroy() } catch {}
80 }
81}
82
83function readRegistry (baseDir) {
84 return JSON.parse(fs.readFileSync(path.join(baseDir, 'identities.json'), 'utf-8'))
85}
86
87async function main () {
88 const home = os.homedir()
89 if (!home.startsWith(os.tmpdir())) {
90 console.log('REFUSING: HOME=' + home + ' is not under ' + os.tmpdir() + '. Run with HOME=$(mktemp -d).')
91 Bare.exit(2)
92 }
93 const baseDir = path.join(os.tmpdir(), 'kd-home-' + Date.now().toString(36))
94 fs.mkdirSync(baseDir, { recursive: true })
95 const socketPath = path.join(baseDir, 'd.sock')
96 console.log('[test] baseDir = ' + baseDir)
97
98 const daemon = await startDaemon({ storage: baseDir, socketPath, pidFile: path.join(baseDir, 'd.pid') })
99 const client = new TestClient(socketPath)
100 await client.connect()
101
102 // 1. concurrent first calls on a fresh identity -> one room
103 const [a, b] = await Promise.all([client.call('home.get'), client.call('home.get')])
104 ok(typeof a.roomId === 'string' && a.roomId.length > 0, 'home.get returns a roomId: ' + a.roomId)
105 ok(a.roomId === b.roomId, 'two concurrent calls agree on one room')
106 ok([a.created, b.created].filter(Boolean).length === 1, 'exactly one of them created it; got ' + a.created + '/' + b.created)
107 const rooms1 = await client.call('rooms')
108 ok(rooms1.filter(r => r.roomId === a.roomId).length === 1 && rooms1.length === 1, 'one room exists; got ' + rooms1.length)
109
110 // 2. a later call: same room, not created
111 const c = await client.call('home.get')
112 ok(c.roomId === a.roomId && c.created === false, 'later call returns it with created:false')
113
114 // 3. on disk
115 const reg = readRegistry(baseDir)
116 const entry0 = reg.identities.find(e => e.id === reg.activeId)
117 ok(entry0 && entry0.homeRoomId === a.roomId, 'identities.json holds homeRoomId for ' + reg.activeId)
118
119 // 4. another identity has its own home; switching back returns the first
120 const created = await client.call('identity-manager.create', { name: 'home-test-2' })
121 await client.call('identity-manager.switch', { id: created.id }, 60000)
122 const d = await client.call('home.get')
123 ok(d.created === true && d.roomId !== a.roomId, created.id + ' gets its own home: ' + d.roomId)
124 await client.call('identity-manager.switch', { id: 'identity-0' }, 60000)
125 const e = await client.call('home.get')
126 ok(e.roomId === a.roomId && e.created === false, 'switching back returns the first home')
127
128 // 5. a pointer to a room that is gone: error, and no new room
129 const live = daemon.registry.getActive()
130 const realHome = live.homeRoomId
131 live.homeRoomId = 'y'.repeat(52) // well-formed z32 length, no such room
132 const before = (await client.call('rooms')).length
133 let err = null
134 try { await client.call('home.get') } catch (x) { err = x }
135 ok(err && /HOME_ROOM_MISSING/.test(err.message), 'missing room -> HOME_ROOM_MISSING; got ' + (err ? err.message : 'no error'))
136 const after = (await client.call('rooms')).length
137 ok(after === before, 'no new room created on a missing home (' + before + ' -> ' + after + ')')
138 ok(daemon.registry.getActive().homeRoomId === 'y'.repeat(52), 'the pointer is left as it was for someone to look at')
139 live.homeRoomId = realHome
140
141 client.close()
142 try { await daemon.router.core.close() } catch {}
143 try { await daemon.core.close() } catch {}
144 try { await daemon.uds.stop() } catch {}
145 rmrf(baseDir)
146
147 console.log('')
148 console.log(exitCode === 0 ? 'ALL PASSED' : failures.length + ' FAILED:\n - ' + failures.join('\n - '))
149 Bare.exit(exitCode)
150}
151
152main().catch(err => {
153 console.error('uncaught:', err && err.stack ? err.stack : err)
154 Bare.exit(1)
155})