@huynle/hub 0.0.0-stage → 0.6.0
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.
- package/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- 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
|
+
}
|