@huynle/hub 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,653 @@
1
+ // Persisted central server state: enrolled machines, paired devices, web push keys and subscriptions,
2
+ // speech settings. Only sha256 hashes of tokens are stored; speech provider API keys are stored as given
3
+ // (they must be sent upstream) and never returned by the API. The file is private (dir 0700, file 0600)
4
+ // and written atomically.
5
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
6
+ import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
7
+ import path from "node:path"
8
+ import type { StoredSpeech } from "./speech/settings"
9
+ import type { ModelSelection, SessionModelPrefs } from "../protocol"
10
+
11
+ /** Owner of everything in pairing mode, and of records from before 0.3.0. */
12
+ export const LOCAL_USER = "local"
13
+ /** Audit entries kept (oldest dropped first). */
14
+ export const AUDIT_LIMIT = 500
15
+ /** Audit entries returned by auditLog(). */
16
+ export const AUDIT_VIEW_LIMIT = 200
17
+ /** Per-user sessions with explicit Voice/Worker model choices. */
18
+ export const MODEL_PREFS_LIMIT = 500
19
+ /** Most sessions with a read mark kept per user (oldest marks dropped first). */
20
+ export const READS_LIMIT = 1000
21
+
22
+ export type Role = "admin" | "user"
23
+
24
+ export interface UserRecord {
25
+ /** "local" for the pairing-mode owner; random base64url otherwise. */
26
+ id: string
27
+ /** Lowercased; absent for "local". */
28
+ email?: string
29
+ name?: string
30
+ role: Role
31
+ created: number
32
+ lastSeen: number
33
+ disabled?: boolean
34
+ /** Per-user speech settings (same shape as the server default in StateData.speech). */
35
+ speech?: StoredSpeech
36
+ /** The phone's session view: hidden and pinned sessions and workspaces (synced across devices). */
37
+ viewPrefs?: ViewPrefs
38
+ /** Session ID → per-session Voice and complex-worker model overrides, oldest first. */
39
+ modelPrefs?: Record<string, SessionModelPrefs>
40
+ /** Session ID → when the user last read it (ms), least recently marked first. */
41
+ reads?: Record<string, number>
42
+ /** Sessions whose notifications the user muted, oldest first. */
43
+ mutedSessions?: string[]
44
+ }
45
+
46
+ /** Workspace keys are the phone's group keys: `herdr:<workspaceID>` or a folder path. */
47
+ export interface ViewPrefs {
48
+ hiddenSessions: string[]
49
+ hiddenWorkspaces: string[]
50
+ pinnedSessions: string[]
51
+ pinnedWorkspaces: string[]
52
+ }
53
+
54
+ export const VIEW_PREF_KEYS = ["hiddenSessions", "hiddenWorkspaces", "pinnedSessions", "pinnedWorkspaces"] as const
55
+
56
+ export interface AuditEntry {
57
+ at: number
58
+ /** User ID or "system". */
59
+ actor: string
60
+ action: string
61
+ target?: string
62
+ detail?: string
63
+ }
64
+
65
+ export interface MachineRecord {
66
+ id: string
67
+ name: string
68
+ tokenHash: string
69
+ created: number
70
+ lastSeen: number
71
+ revoked?: boolean
72
+ ownerID: string
73
+ }
74
+
75
+ /** Which notifications a device wants. */
76
+ export interface PushPrefs {
77
+ /** A session sent to the phone from the terminal. */
78
+ handoff: boolean
79
+ /** A session asks a question (form). */
80
+ question: boolean
81
+ /** A session waits for a permission. */
82
+ permission: boolean
83
+ /** A root session's run succeeded. */
84
+ finished: boolean
85
+ /** A root session's run failed. */
86
+ failed: boolean
87
+ /** Conversation mode replies while no device is watching the session. */
88
+ say: boolean
89
+ }
90
+
91
+ export const PUSH_PREF_KEYS = ["handoff", "question", "permission", "finished", "failed", "say"] as const satisfies readonly (keyof PushPrefs)[]
92
+
93
+ /** Defaults as in 0.5.0: hand-offs and anything blocking on, finished (which covered failures) off. */
94
+ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
95
+ handoff: true,
96
+ question: true,
97
+ permission: true,
98
+ finished: false,
99
+ failed: false,
100
+ say: true,
101
+ })
102
+
103
+ /** 0.5.0's prefs: one toggle for questions and permissions, one for every finished run. */
104
+ export interface LegacyPushPrefs {
105
+ blocked?: boolean
106
+ }
107
+
108
+ /** A device's quiet hours: local "HH:MM" times in an IANA time zone; start > end spans midnight. */
109
+ export interface QuietHours {
110
+ start: string
111
+ end: string
112
+ timeZone: string
113
+ }
114
+
115
+ const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/
116
+
117
+ /** A valid IANA time zone name for Intl. */
118
+ export function validTimeZone(zone: unknown): zone is string {
119
+ if (typeof zone !== "string" || !zone || zone.length > 64) return false
120
+ try {
121
+ new Intl.DateTimeFormat("en-US", { timeZone: zone })
122
+ return true
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ /** Quiet hours from untrusted input, or undefined when invalid. */
129
+ export function quietHoursInput(value: unknown): QuietHours | undefined {
130
+ if (typeof value !== "object" || value === null) return undefined
131
+ const { start, end, timeZone } = value as Record<string, unknown>
132
+ if (typeof start !== "string" || typeof end !== "string" || !CLOCK_TIME.test(start) || !CLOCK_TIME.test(end)) return undefined
133
+ if (!validTimeZone(timeZone)) return undefined
134
+ return { start, end, timeZone }
135
+ }
136
+
137
+ /** Most sessions a user can mute (oldest are forgotten first). */
138
+ export const MUTED_SESSIONS_LIMIT = 500
139
+
140
+ /** Current prefs from a stored (possibly 0.5.0) record. */
141
+ function normalizePushPrefs(stored: (Partial<PushPrefs> & LegacyPushPrefs) | undefined): PushPrefs {
142
+ const out: PushPrefs = { ...DEFAULT_PUSH_PREFS }
143
+ if (!stored) return out
144
+ if (typeof stored.blocked === "boolean") {
145
+ out.question = stored.blocked
146
+ out.permission = stored.blocked
147
+ // 0.5.0's "finished" covered failed runs too.
148
+ if (typeof stored.finished === "boolean" && typeof stored.failed !== "boolean") out.failed = stored.finished
149
+ }
150
+ for (const key of PUSH_PREF_KEYS) if (typeof stored[key] === "boolean") out[key] = stored[key]
151
+ return out
152
+ }
153
+
154
+ export interface DeviceRecord {
155
+ id: string
156
+ name: string
157
+ tokenHash: string
158
+ created: number
159
+ lastSeen: number
160
+ pushPrefs?: Partial<PushPrefs> & LegacyPushPrefs
161
+ quietHours?: QuietHours
162
+ ownerID: string
163
+ }
164
+
165
+ export interface VapidKeys {
166
+ publicKey: string
167
+ privateKey: string
168
+ }
169
+
170
+ export interface PushTarget {
171
+ endpoint: string
172
+ keys: { p256dh: string; auth: string }
173
+ }
174
+
175
+ export interface PushSubscriptionRecord {
176
+ deviceID: string
177
+ endpoint: string
178
+ keys: { p256dh: string; auth: string }
179
+ created: number
180
+ }
181
+
182
+ export interface StateData {
183
+ machines: MachineRecord[]
184
+ devices: DeviceRecord[]
185
+ vapid?: VapidKeys
186
+ push: PushSubscriptionRecord[]
187
+ /** Server default speech settings (below each user's own, above hub.json and the env). */
188
+ speech?: StoredSpeech
189
+ users: UserRecord[]
190
+ /** Newest last, at most AUDIT_LIMIT. */
191
+ audit: AuditEntry[]
192
+ }
193
+
194
+ export interface StoreOptions {
195
+ /** pairing: the local user is (re-)enabled as admin on open. */
196
+ readonly mode?: "pairing" | "oidc"
197
+ /** chmod of the state directory (injected in tests). */
198
+ readonly chmodDir?: (dir: string, mode: number) => void
199
+ /** Whether the process can write into the state directory (injected in tests). */
200
+ readonly writable?: (dir: string) => boolean
201
+ /** One-line warnings (default console.warn). */
202
+ readonly warn?: (message: string) => void
203
+ }
204
+
205
+ const canWrite = (dir: string): boolean => {
206
+ try {
207
+ accessSync(dir, fsConstants.W_OK | fsConstants.X_OK)
208
+ return true
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Creates the state directory and restricts it to the owner. A volume mount root owned by another user
216
+ * (a fresh Kubernetes PVC: root-owned, group-writable through fsGroup) cannot be chmod-ed by the hub;
217
+ * that is only a warning while the directory is writable, since every file in it is written 0600.
218
+ */
219
+ function prepareStateDir(dir: string, options: StoreOptions): void {
220
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
221
+ try {
222
+ ;(options.chmodDir ?? chmodSync)(dir, 0o700)
223
+ } catch (cause) {
224
+ const code = (cause as NodeJS.ErrnoException | undefined)?.code
225
+ if ((code !== "EPERM" && code !== "EACCES") || !(options.writable ?? canWrite)(dir)) throw cause
226
+ ;(options.warn ?? console.warn)(
227
+ `[hub] cannot restrict ${dir} to mode 0700 (${code}; not its owner); continuing, state files are written 0600`,
228
+ )
229
+ }
230
+ }
231
+
232
+ /** Writes of lastSeen alone are throttled to this interval. */
233
+ const TOUCH_PERSIST_MS = 60_000
234
+
235
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
236
+
237
+ export const newToken = (): string => randomBytes(32).toString("base64url")
238
+ export const hashToken = (token: string): string => createHash("sha256").update(token).digest("hex")
239
+ export const newID = (prefix: string): string => `${prefix}_${randomBytes(9).toString("base64url")}`
240
+ const newUserID = (): string => randomBytes(12).toString("base64url")
241
+
242
+ /** Constant-time comparison of two hex digests. */
243
+ function sameHash(a: string, b: string): boolean {
244
+ const x = Buffer.from(a, "hex")
245
+ const y = Buffer.from(b, "hex")
246
+ return x.length === y.length && x.length > 0 && timingSafeEqual(x, y)
247
+ }
248
+
249
+ function findByToken<T extends { tokenHash: string }>(records: readonly T[], token: string): T | undefined {
250
+ if (!token) return undefined
251
+ const hash = hashToken(token)
252
+ let found: T | undefined
253
+ // Compare against every record so timing does not reveal the position of a match.
254
+ for (const record of records) if (sameHash(record.tokenHash, hash)) found = record
255
+ return found
256
+ }
257
+
258
+ export class Store {
259
+ private constructor(
260
+ private readonly file: string,
261
+ private readonly state: StateData,
262
+ private readonly now: () => number,
263
+ ) {}
264
+
265
+ static open(dir: string, now: () => number = Date.now, options: StoreOptions = {}): Store {
266
+ prepareStateDir(dir, options)
267
+ const file = path.join(dir, "state.json")
268
+ let state: StateData = { machines: [], devices: [], push: [], users: [], audit: [] }
269
+ if (existsSync(file)) {
270
+ try {
271
+ const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<StateData>
272
+ state = {
273
+ machines: Array.isArray(raw.machines) ? raw.machines : [],
274
+ devices: Array.isArray(raw.devices) ? raw.devices : [],
275
+ ...(raw.vapid ? { vapid: raw.vapid } : {}),
276
+ push: Array.isArray(raw.push) ? raw.push : [],
277
+ ...(isRecord(raw.speech) ? { speech: raw.speech } : {}),
278
+ users: Array.isArray(raw.users) ? raw.users : [],
279
+ audit: Array.isArray(raw.audit) ? raw.audit : [],
280
+ }
281
+ } catch (cause) {
282
+ throw new Error(`Cannot read ${file}: ${cause instanceof Error ? cause.message : cause}. Fix or move it away.`)
283
+ }
284
+ }
285
+ migrate(state, now(), options)
286
+ const store = new Store(file, state, now)
287
+ store.save()
288
+ return store
289
+ }
290
+
291
+ data(): Readonly<StateData> {
292
+ return this.state
293
+ }
294
+
295
+ machines(): readonly MachineRecord[] {
296
+ return this.state.machines
297
+ }
298
+
299
+ devices(): readonly DeviceRecord[] {
300
+ return this.state.devices
301
+ }
302
+
303
+ addMachine(name: string, ownerID: string = LOCAL_USER): { machine: MachineRecord; token: string } {
304
+ const token = newToken()
305
+ const t = this.now()
306
+ const machine: MachineRecord = { id: newID("mch"), name, tokenHash: hashToken(token), created: t, lastSeen: t, ownerID }
307
+ this.state.machines.push(machine)
308
+ this.save()
309
+ return { machine, token }
310
+ }
311
+
312
+ /** Non-revoked machine for a bearer token. */
313
+ findMachine(token: string): MachineRecord | undefined {
314
+ return findByToken(
315
+ this.state.machines.filter((machine) => !machine.revoked),
316
+ token,
317
+ )
318
+ }
319
+
320
+ revokeMachine(id: string): boolean {
321
+ const machine = this.state.machines.find((m) => m.id === id)
322
+ if (!machine || machine.revoked) return false
323
+ machine.revoked = true
324
+ this.save()
325
+ return true
326
+ }
327
+
328
+ touchMachine(id: string): void {
329
+ this.touch(this.state.machines.find((m) => m.id === id))
330
+ }
331
+
332
+ addDevice(name: string, ownerID: string = LOCAL_USER): { device: DeviceRecord; token: string } {
333
+ const token = newToken()
334
+ const t = this.now()
335
+ const device: DeviceRecord = { id: newID("dev"), name, tokenHash: hashToken(token), created: t, lastSeen: t, ownerID }
336
+ this.state.devices.push(device)
337
+ this.save()
338
+ return { device, token }
339
+ }
340
+
341
+ findDevice(token: string): DeviceRecord | undefined {
342
+ return findByToken(this.state.devices, token)
343
+ }
344
+
345
+ removeDevice(id: string): boolean {
346
+ const before = this.state.devices.length
347
+ this.state.devices = this.state.devices.filter((d) => d.id !== id)
348
+ if (this.state.devices.length === before) return false
349
+ this.state.push = this.state.push.filter((p) => p.deviceID !== id)
350
+ this.save()
351
+ return true
352
+ }
353
+
354
+ // ---------- web push ----------
355
+
356
+ vapid(): VapidKeys | undefined {
357
+ return this.state.vapid
358
+ }
359
+
360
+ setVapid(keys: VapidKeys): void {
361
+ this.state.vapid = { publicKey: keys.publicKey, privateKey: keys.privateKey }
362
+ this.save()
363
+ }
364
+
365
+ pushSubscriptions(): readonly PushSubscriptionRecord[] {
366
+ return this.state.push
367
+ }
368
+
369
+ /** Adds or replaces (by endpoint) a subscription; an endpoint belongs to one device. */
370
+ addPushSubscription(deviceID: string, target: PushTarget): PushSubscriptionRecord {
371
+ const record: PushSubscriptionRecord = {
372
+ deviceID,
373
+ endpoint: target.endpoint,
374
+ keys: { p256dh: target.keys.p256dh, auth: target.keys.auth },
375
+ created: this.now(),
376
+ }
377
+ const index = this.state.push.findIndex((p) => p.endpoint === target.endpoint)
378
+ if (index >= 0) this.state.push[index] = record
379
+ else this.state.push.push(record)
380
+ this.save()
381
+ return record
382
+ }
383
+
384
+ /** Removes a device's own subscription. */
385
+ removePushSubscription(deviceID: string, endpoint: string): boolean {
386
+ return this.dropPush((p) => p.deviceID === deviceID && p.endpoint === endpoint)
387
+ }
388
+
389
+ /** Removes a subscription the push service reported as gone. */
390
+ dropPushSubscription(endpoint: string): boolean {
391
+ return this.dropPush((p) => p.endpoint === endpoint)
392
+ }
393
+
394
+ pushPrefs(deviceID: string): PushPrefs {
395
+ const device = this.state.devices.find((d) => d.id === deviceID)
396
+ return normalizePushPrefs(device?.pushPrefs)
397
+ }
398
+
399
+ /** Applies the boolean toggles given; a legacy `blocked` sets question and permission. */
400
+ setPushPrefs(deviceID: string, prefs: Partial<PushPrefs> & LegacyPushPrefs): PushPrefs {
401
+ const device = this.state.devices.find((d) => d.id === deviceID)
402
+ if (!device) return { ...DEFAULT_PUSH_PREFS }
403
+ const next = this.pushPrefs(deviceID)
404
+ if (typeof prefs.blocked === "boolean") next.question = next.permission = prefs.blocked
405
+ for (const key of PUSH_PREF_KEYS) if (typeof prefs[key] === "boolean") next[key] = prefs[key]
406
+ device.pushPrefs = next
407
+ this.save()
408
+ return { ...next }
409
+ }
410
+
411
+ quietHours(deviceID: string): QuietHours | undefined {
412
+ const quiet = this.state.devices.find((d) => d.id === deviceID)?.quietHours
413
+ return quiet ? { ...quiet } : undefined
414
+ }
415
+
416
+ /** Sets (validated by the caller) or clears (null) a device's quiet hours. */
417
+ setQuietHours(deviceID: string, quiet: QuietHours | null): QuietHours | undefined {
418
+ const device = this.state.devices.find((d) => d.id === deviceID)
419
+ if (!device) return undefined
420
+ if (quiet) device.quietHours = { start: quiet.start, end: quiet.end, timeZone: quiet.timeZone }
421
+ else delete device.quietHours
422
+ this.save()
423
+ return this.quietHours(deviceID)
424
+ }
425
+
426
+ mutedSessions(userID: string): string[] {
427
+ return [...(this.user(userID)?.mutedSessions ?? [])]
428
+ }
429
+
430
+ setSessionMuted(userID: string, sessionID: string, muted: boolean): string[] {
431
+ const user = this.user(userID)
432
+ if (!user) return []
433
+ const list = (user.mutedSessions ?? []).filter((id) => id !== sessionID)
434
+ if (muted) list.push(sessionID)
435
+ while (list.length > MUTED_SESSIONS_LIMIT) list.shift()
436
+ user.mutedSessions = list
437
+ this.save()
438
+ return [...list]
439
+ }
440
+
441
+ private dropPush(match: (record: PushSubscriptionRecord) => boolean): boolean {
442
+ const before = this.state.push.length
443
+ this.state.push = this.state.push.filter((p) => !match(p))
444
+ if (this.state.push.length === before) return false
445
+ this.save()
446
+ return true
447
+ }
448
+
449
+ // ---------- speech settings ----------
450
+
451
+ /** A copy of the stored speech settings (callers may not mutate the state). */
452
+ speechSettings(): StoredSpeech {
453
+ return structuredClone(this.state.speech ?? {})
454
+ }
455
+
456
+ setSpeechSettings(settings: StoredSpeech): void {
457
+ this.state.speech = structuredClone(settings)
458
+ this.save()
459
+ }
460
+
461
+ // ---------- users ----------
462
+
463
+ users(): readonly UserRecord[] {
464
+ return this.state.users
465
+ }
466
+
467
+ user(id: string): UserRecord | undefined {
468
+ return this.state.users.find((u) => u.id === id)
469
+ }
470
+
471
+ userByEmail(email: string): UserRecord | undefined {
472
+ const wanted = email.trim().toLowerCase()
473
+ return this.state.users.find((u) => u.email === wanted)
474
+ }
475
+
476
+ /**
477
+ * Creates the user for a (verified, lowercased) email, or updates its name and lastSeen. Role
478
+ * "admin" promotes an existing user (admins come from the config); "user" never demotes.
479
+ */
480
+ upsertUser(input: { email: string; name?: string; role: Role }): UserRecord {
481
+ const email = input.email.trim().toLowerCase()
482
+ const t = this.now()
483
+ let user = this.userByEmail(email)
484
+ if (!user) {
485
+ user = { id: newUserID(), email, ...(input.name ? { name: input.name } : {}), role: input.role, created: t, lastSeen: t }
486
+ this.state.users.push(user)
487
+ } else {
488
+ if (input.name) user.name = input.name
489
+ if (input.role === "admin") user.role = "admin"
490
+ user.lastSeen = t
491
+ }
492
+ this.save()
493
+ return user
494
+ }
495
+
496
+ setUser(id: string, change: { role?: Role; disabled?: boolean }): UserRecord | undefined {
497
+ const user = this.user(id)
498
+ if (!user) return undefined
499
+ if (change.role) user.role = change.role
500
+ if (change.disabled === true) user.disabled = true
501
+ else if (change.disabled === false) delete user.disabled
502
+ this.save()
503
+ return user
504
+ }
505
+
506
+ touchUser(id: string): void {
507
+ this.touch(this.user(id))
508
+ }
509
+
510
+ /**
511
+ * First admin sign-in in oidc mode: every record owned by "local" moves to the admin and "local" is
512
+ * disabled. False (nothing changed) once done.
513
+ */
514
+ migrateLocal(adminID: string): boolean {
515
+ const local = this.user(LOCAL_USER)
516
+ if (!local || local.disabled || adminID === LOCAL_USER) return false
517
+ for (const record of [...this.state.machines, ...this.state.devices]) if (record.ownerID === LOCAL_USER) record.ownerID = adminID
518
+ local.disabled = true
519
+ this.audit({ actor: "system", action: "migrate.local", target: adminID })
520
+ return true
521
+ }
522
+
523
+ // ---------- audit ----------
524
+
525
+ /** Appends an audit entry. Never pass tokens, codes, keys or session content. */
526
+ audit(entry: Omit<AuditEntry, "at">): void {
527
+ this.state.audit.push({ at: this.now(), ...entry })
528
+ if (this.state.audit.length > AUDIT_LIMIT) this.state.audit.splice(0, this.state.audit.length - AUDIT_LIMIT)
529
+ this.save()
530
+ }
531
+
532
+ /** Newest first, at most AUDIT_VIEW_LIMIT. */
533
+ auditLog(): AuditEntry[] {
534
+ return this.state.audit.slice(-AUDIT_VIEW_LIMIT).reverse().map((entry) => ({ ...entry }))
535
+ }
536
+
537
+ // ---------- per-user speech ----------
538
+
539
+ userSpeech(id: string): StoredSpeech {
540
+ return structuredClone(this.user(id)?.speech ?? {})
541
+ }
542
+
543
+ setUserSpeech(id: string, settings: StoredSpeech): void {
544
+ const user = this.user(id)
545
+ if (!user) return
546
+ user.speech = structuredClone(settings)
547
+ this.save()
548
+ }
549
+
550
+ // ---------- per-user view preferences ----------
551
+
552
+ viewPrefs(id: string): ViewPrefs {
553
+ const stored = this.user(id)?.viewPrefs
554
+ const out = {} as ViewPrefs
555
+ for (const key of VIEW_PREF_KEYS) out[key] = Array.isArray(stored?.[key]) ? [...stored[key]] : []
556
+ return out
557
+ }
558
+
559
+ /** Replaces the lists given (callers validate); returns the result. */
560
+ setViewPrefs(id: string, change: Partial<ViewPrefs>): ViewPrefs {
561
+ const user = this.user(id)
562
+ if (!user) return this.viewPrefs(id)
563
+ const next = this.viewPrefs(id)
564
+ for (const key of VIEW_PREF_KEYS) if (change[key]) next[key] = [...change[key]]
565
+ user.viewPrefs = next
566
+ this.save()
567
+ return this.viewPrefs(id)
568
+ }
569
+
570
+ // ---------- per-user, per-session model preferences ----------
571
+
572
+ modelPrefs(id: string, sessionID: string): SessionModelPrefs {
573
+ const prefs = this.user(id)?.modelPrefs?.[sessionID]
574
+ return prefs ? structuredClone(prefs) : {}
575
+ }
576
+
577
+ setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): SessionModelPrefs {
578
+ const user = this.user(id)
579
+ if (!user) return {}
580
+ const all = user.modelPrefs ?? (user.modelPrefs = {})
581
+ const next: { voice?: ModelSelection; worker?: ModelSelection } = this.modelPrefs(id, sessionID)
582
+ for (const key of ["voice", "worker"] as const) {
583
+ if (!(key in change)) continue
584
+ const value = change[key]
585
+ if (value === null) delete next[key]
586
+ else if (value !== undefined) next[key] = structuredClone(value)
587
+ }
588
+ delete all[sessionID]
589
+ if (next.voice || next.worker) all[sessionID] = next
590
+ while (Object.keys(all).length > MODEL_PREFS_LIMIT) delete all[Object.keys(all)[0]!]
591
+ this.save()
592
+ return this.modelPrefs(id, sessionID)
593
+ }
594
+
595
+ // ---------- per-user read marks (unread dots) ----------
596
+
597
+ reads(id: string): Record<string, number> {
598
+ return { ...this.user(id)?.reads }
599
+ }
600
+
601
+ /** Marks a session read at `at`; a mark never moves back. Returns all marks. */
602
+ markRead(id: string, sessionID: string, at: number): Record<string, number> {
603
+ const user = this.user(id)
604
+ if (!user) return {}
605
+ const all = user.reads ?? (user.reads = {})
606
+ const next = Math.max(all[sessionID] ?? 0, at)
607
+ delete all[sessionID]
608
+ all[sessionID] = next
609
+ while (Object.keys(all).length > READS_LIMIT) delete all[Object.keys(all)[0]!]
610
+ this.save()
611
+ return this.reads(id)
612
+ }
613
+
614
+ touchDevice(id: string): void {
615
+ this.touch(this.state.devices.find((d) => d.id === id))
616
+ }
617
+
618
+ private touch(record: { lastSeen: number } | undefined): void {
619
+ if (!record) return
620
+ const t = this.now()
621
+ if (t - record.lastSeen < TOUCH_PERSIST_MS) return
622
+ record.lastSeen = t
623
+ this.save()
624
+ }
625
+
626
+ private save(): void {
627
+ const tmp = `${this.file}.${process.pid}.tmp`
628
+ writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 })
629
+ chmodSync(tmp, 0o600)
630
+ renameSync(tmp, this.file)
631
+ }
632
+ }
633
+
634
+ /** Idempotent upgrade of older states: the local user exists, every machine and device has an owner. */
635
+ function migrate(state: StateData, t: number, options: StoreOptions): void {
636
+ let local = state.users.find((u) => u.id === LOCAL_USER)
637
+ if (!local) {
638
+ local = { id: LOCAL_USER, role: "admin", created: t, lastSeen: t }
639
+ state.users.unshift(local)
640
+ }
641
+ if (options.mode === "pairing") {
642
+ // Pairing mode has exactly one owner, and it must be able to sign in and manage the hub.
643
+ local.role = "admin"
644
+ delete local.disabled
645
+ }
646
+ for (const record of [...state.machines, ...state.devices]) {
647
+ if (typeof record.ownerID !== "string" || !record.ownerID) record.ownerID = LOCAL_USER
648
+ }
649
+ // 0.5.0 push prefs ({handoff, blocked, finished, say}) → the split toggles.
650
+ for (const device of state.devices) {
651
+ if (device.pushPrefs && "blocked" in device.pushPrefs) device.pushPrefs = normalizePushPrefs(device.pushPrefs)
652
+ }
653
+ }