@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.
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,310 @@
1
+ // Hub configuration: HUB_* environment variables (legacy VOICE_* still read, see ../env.ts) over the
2
+ // optional hub config file (~/.config/hub/hub.json or $HUB_CONFIG) over built-in defaults. The file's speech
3
+ // settings sit between the phone's saved settings and the env (see speech/settings.ts).
4
+ import { existsSync, readFileSync, statSync } from "node:fs"
5
+ import { homedir } from "node:os"
6
+ import path from "node:path"
7
+ import { hubEnv } from "../env"
8
+ import { isLoopbackHost } from "../loopback"
9
+ import { APP_DIR, configBase, legacyPaths, migrateFirst, stateBase } from "../migrate"
10
+ import { updateSpeech, type StoredSpeech } from "./speech/settings"
11
+
12
+ export const GOOGLE_ISSUER = "https://accounts.google.com"
13
+
14
+ /** How browsers get access: pairing codes only (one owner), or OpenID Connect sign-in (several users). */
15
+ export type AuthConfig =
16
+ | { readonly mode: "pairing" }
17
+ | {
18
+ readonly mode: "oidc"
19
+ /** Without a trailing slash; discovery at <issuer>/.well-known/openid-configuration. */
20
+ readonly issuer: string
21
+ readonly clientId: string
22
+ /** Non-enumerable: never serialized or printed with the config. */
23
+ readonly clientSecret: string
24
+ /** Lowercased emails and @domain entries. */
25
+ readonly allowed: readonly string[]
26
+ /** Lowercased admin emails (also allowed). */
27
+ readonly admins: readonly string[]
28
+ /** Dev only (HUB_OIDC_INSECURE_ISSUER=1): the issuer is plain http on loopback. */
29
+ readonly insecureIssuer?: true
30
+ }
31
+
32
+ export type OidcConfig = Extract<AuthConfig, { mode: "oidc" }>
33
+
34
+ /** hub.json `auth`, validated (lists already split and lowercased). */
35
+ export interface HubAuth {
36
+ readonly mode?: "pairing" | "oidc"
37
+ readonly issuer?: string
38
+ readonly clientId?: string
39
+ readonly clientSecret?: string
40
+ readonly allowed?: readonly string[]
41
+ readonly admins?: readonly string[]
42
+ }
43
+
44
+ export interface CentralConfig {
45
+ readonly host: string
46
+ readonly port: number
47
+ /** Origin used in links, cookies (Secure when https) and CSRF checks; no trailing slash. */
48
+ readonly publicURL: string
49
+ readonly stateDir: string
50
+ readonly apiKey: string | undefined
51
+ readonly sttModel: string
52
+ readonly ttsModel: string
53
+ readonly voice: string
54
+ readonly maxAudioBytes: number
55
+ readonly maxSpeechChars: number
56
+ readonly turnTimeoutMs: number
57
+ /** VAPID subject (mailto: or https: URL) sent to push services. */
58
+ readonly vapidSubject: string
59
+ /** Speech settings from hub.json (below the phone's saved settings, above the env). */
60
+ readonly speech?: StoredSpeech
61
+ readonly auth: AuthConfig
62
+ }
63
+
64
+ /** hub.json, validated. */
65
+ export interface HubFile {
66
+ readonly host?: string
67
+ readonly port?: number
68
+ readonly publicURL?: string
69
+ readonly stateDir?: string
70
+ readonly speech?: StoredSpeech
71
+ readonly auth?: HubAuth
72
+ }
73
+
74
+ type Env = Readonly<Record<string, string | undefined>>
75
+
76
+ const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth"])
77
+ const AUTH_KEYS = new Set(["mode", "issuer", "clientId", "clientSecret", "allowed", "admins"])
78
+ const EMAIL = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/
79
+ const DOMAIN = /^@[^\s@,]+\.[^\s@,]+$/
80
+
81
+ /** Splits a comma list (or takes an array), trims and lowercases; empty entries dropped. */
82
+ function emailList(value: string | readonly string[] | undefined): string[] {
83
+ const items = typeof value === "string" ? value.split(",") : (value ?? [])
84
+ return items.map((item) => item.trim().toLowerCase()).filter(Boolean)
85
+ }
86
+
87
+ /** True when the (verified) email is an admin or on the allow-list: exact email or `@domain` (no subdomains). */
88
+ export function emailAllowed(auth: { readonly allowed: readonly string[]; readonly admins: readonly string[] }, email: string): boolean {
89
+ const e = email.trim().toLowerCase()
90
+ if (!EMAIL.test(e)) return false
91
+ const domain = e.slice(e.lastIndexOf("@"))
92
+ return auth.admins.includes(e) || auth.allowed.includes(e) || auth.allowed.includes(domain)
93
+ }
94
+
95
+ function int(value: string | undefined, fallback: number, min = 1): number {
96
+ if (value === undefined || value.trim() === "") return fallback
97
+ const n = Number(value)
98
+ return Number.isInteger(n) && n >= min ? n : fallback
99
+ }
100
+
101
+ const expandHome = (p: string, home: string) => (p === "~" ? home : p.startsWith("~/") ? path.join(home, p.slice(2)) : p)
102
+
103
+ export function hubConfigPath(env: Env = process.env, home: string = homedir()): string {
104
+ const explicit = hubEnv(env).HUB_CONFIG
105
+ if (explicit) return explicit
106
+ return path.join(configBase(env, home), APP_DIR, "hub.json")
107
+ }
108
+
109
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
110
+
111
+ /** Removes apiKey from both speech sections; true when there was one. */
112
+ function stripKeys(speech: Record<string, unknown>): boolean {
113
+ let found = false
114
+ for (const kind of ["stt", "tts"]) {
115
+ const section = speech[kind]
116
+ if (isObj(section) && "apiKey" in section) {
117
+ found = true
118
+ const { apiKey: _drop, ...rest } = section
119
+ speech[kind] = rest
120
+ }
121
+ }
122
+ return found
123
+ }
124
+
125
+ /**
126
+ * Reads and validates hub.json. A missing file yields no settings. Invalid JSON or values throw (with
127
+ * the file name). API keys are refused, with a warning, when the file is readable by group or others.
128
+ */
129
+ export function readHubFile(file: string, home: string = homedir()): { file?: HubFile; warnings: string[] } {
130
+ if (!existsSync(file)) return { warnings: [] }
131
+ const fail = (message: string): never => {
132
+ throw new Error(`${file}: ${message}`)
133
+ }
134
+ let raw: unknown
135
+ try {
136
+ raw = JSON.parse(readFileSync(file, "utf8"))
137
+ } catch (cause) {
138
+ return fail(`not valid JSON (${cause instanceof Error ? cause.message : cause})`)
139
+ }
140
+ if (!isObj(raw)) return fail("expected a JSON object")
141
+
142
+ const warnings: string[] = []
143
+ for (const key of Object.keys(raw)) if (!HUB_KEYS.has(key)) warnings.push(`${file}: unknown key "${key}" ignored`)
144
+
145
+ const hub: { -readonly [K in keyof HubFile]: HubFile[K] } = {}
146
+ if (raw.host !== undefined) {
147
+ if (typeof raw.host !== "string" || !raw.host.trim()) fail("host must be a non-empty string")
148
+ hub.host = (raw.host as string).trim()
149
+ }
150
+ if (raw.port !== undefined) {
151
+ const port = raw.port
152
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) fail("port must be an integer from 0 to 65535")
153
+ hub.port = port as number
154
+ }
155
+ if (raw.publicURL !== undefined) {
156
+ if (typeof raw.publicURL !== "string" || !raw.publicURL.trim()) fail("publicURL must be a string")
157
+ hub.publicURL = (raw.publicURL as string).trim()
158
+ }
159
+ if (raw.stateDir !== undefined) {
160
+ if (typeof raw.stateDir !== "string" || !raw.stateDir.trim()) fail("stateDir must be a non-empty string")
161
+ hub.stateDir = expandHome((raw.stateDir as string).trim(), home)
162
+ }
163
+ if (raw.auth !== undefined) hub.auth = readHubAuth(raw.auth, file, warnings, fail)
164
+ if (raw.speech !== undefined) {
165
+ if (!isObj(raw.speech)) fail("speech must be an object with stt and/or tts")
166
+ const speech = { ...(raw.speech as Record<string, unknown>) }
167
+ const shared = (statSync(file).mode & 0o044) !== 0
168
+ if (shared && stripKeys(speech)) {
169
+ warnings.push(`${file} is readable by other users, so its API keys are ignored. Fix it with: chmod 600 ${file}`)
170
+ }
171
+ try {
172
+ hub.speech = updateSpeech({}, speech)
173
+ } catch (cause) {
174
+ fail(`speech: ${cause instanceof Error ? cause.message : cause}`)
175
+ }
176
+ }
177
+ return { file: hub, warnings }
178
+ }
179
+
180
+ function readHubAuth(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubAuth {
181
+ if (!isObj(raw)) fail("auth must be an object")
182
+ const input = raw as Record<string, unknown>
183
+ for (const key of Object.keys(input)) if (!AUTH_KEYS.has(key)) warnings.push(`${file}: unknown key "auth.${key}" ignored`)
184
+ const auth: { -readonly [K in keyof HubAuth]: HubAuth[K] } = {}
185
+ if (input.mode !== undefined) {
186
+ if (input.mode !== "pairing" && input.mode !== "oidc") fail('auth.mode must be "pairing" or "oidc"')
187
+ auth.mode = input.mode as "pairing" | "oidc"
188
+ }
189
+ for (const key of ["issuer", "clientId", "clientSecret"] as const) {
190
+ const value = input[key]
191
+ if (value === undefined) continue
192
+ if (typeof value !== "string" || !value.trim()) fail(`auth.${key} must be a non-empty string`)
193
+ auth[key] = (value as string).trim()
194
+ }
195
+ for (const key of ["allowed", "admins"] as const) {
196
+ const value = input[key]
197
+ if (value === undefined) continue
198
+ const ok = typeof value === "string" || (Array.isArray(value) && value.every((v) => typeof v === "string"))
199
+ if (!ok) fail(`auth.${key} must be a list of emails (array of strings or a comma list)`)
200
+ auth[key] = emailList(value as string | string[])
201
+ }
202
+ if (auth.clientSecret !== undefined && (statSync(file).mode & 0o044) !== 0) {
203
+ delete auth.clientSecret
204
+ warnings.push(`${file} is readable by other users, so its auth.clientSecret is ignored. Fix it with: chmod 600 ${file}`)
205
+ }
206
+ return auth
207
+ }
208
+
209
+ /** Validates the OIDC issuer: https, or plain http on loopback when the dev flag is set. No trailing slash. */
210
+ function issuerURL(raw: string, insecure: boolean): string {
211
+ let url: URL
212
+ try {
213
+ url = new URL(raw)
214
+ } catch {
215
+ throw new Error(`HUB_OIDC_ISSUER (auth.issuer) must be a URL, got ${JSON.stringify(raw)}`)
216
+ }
217
+ if (url.username || url.password || url.search || url.hash) {
218
+ throw new Error("HUB_OIDC_ISSUER (auth.issuer) must not contain credentials, a query or a fragment")
219
+ }
220
+ if (url.protocol === "http:" && insecure) {
221
+ if (!isLoopbackHost(url.hostname)) throw new Error("HUB_OIDC_INSECURE_ISSUER only allows a plain http issuer on loopback (dev only)")
222
+ } else if (url.protocol !== "https:") {
223
+ throw new Error(`HUB_OIDC_ISSUER (auth.issuer) must be an https:// URL, got ${JSON.stringify(raw)}`)
224
+ }
225
+ return url.toString().replace(/\/+$/, "")
226
+ }
227
+
228
+ /** Auth settings from env over hub.json. Throws (without echoing secrets) when oidc is incomplete. */
229
+ function authConfig(env: Env, file: HubAuth = {}): AuthConfig {
230
+ const mode = (env.HUB_AUTH?.trim() || file.mode || "pairing").toLowerCase()
231
+ if (mode === "pairing") return { mode: "pairing" }
232
+ if (mode !== "oidc") throw new Error(`HUB_AUTH must be "pairing" or "oidc", got ${JSON.stringify(env.HUB_AUTH)}`)
233
+ const insecure = env.HUB_OIDC_INSECURE_ISSUER === "1"
234
+ const issuer = issuerURL(env.HUB_OIDC_ISSUER?.trim() || file.issuer || GOOGLE_ISSUER, insecure)
235
+ const required = (value: string | undefined, name: string): string => {
236
+ if (!value) throw new Error(`${name} is required for HUB_AUTH=oidc`)
237
+ return value
238
+ }
239
+ const clientId = required(env.HUB_OIDC_CLIENT_ID?.trim() || file.clientId, "HUB_OIDC_CLIENT_ID (auth.clientId)")
240
+ const clientSecret = required(env.HUB_OIDC_CLIENT_SECRET?.trim() || file.clientSecret, "HUB_OIDC_CLIENT_SECRET (auth.clientSecret)")
241
+ const allowed = emailList(env.HUB_OIDC_ALLOWED ?? file.allowed)
242
+ const admins = emailList(env.HUB_ADMIN_EMAILS ?? file.admins)
243
+ if (allowed.length === 0) throw new Error("HUB_OIDC_ALLOWED (auth.allowed) is required for HUB_AUTH=oidc: list emails and/or @domain entries")
244
+ if (admins.length === 0) throw new Error("HUB_ADMIN_EMAILS (auth.admins) needs at least one admin email for HUB_AUTH=oidc")
245
+ const bad = allowed.find((entry) => !EMAIL.test(entry) && !DOMAIN.test(entry))
246
+ if (bad !== undefined) throw new Error(`HUB_OIDC_ALLOWED (auth.allowed): ${JSON.stringify(bad)} is not an email or @domain`)
247
+ const badAdmin = admins.find((entry) => !EMAIL.test(entry))
248
+ if (badAdmin !== undefined) throw new Error(`HUB_ADMIN_EMAILS (auth.admins): ${JSON.stringify(badAdmin)} is not an email`)
249
+ const auth = { mode: "oidc" as const, issuer, clientId, allowed, admins, ...(insecure ? { insecureIssuer: true as const } : {}) }
250
+ // A getter: printed as [Getter], skipped by JSON.stringify.
251
+ Object.defineProperty(auth, "clientSecret", { get: () => clientSecret, enumerable: false })
252
+ return auth as unknown as OidcConfig
253
+ }
254
+
255
+ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(), file: HubFile = {}): CentralConfig {
256
+ const env = hubEnv(rawEnv)
257
+ const host = env.HUB_HOST || file.host || "127.0.0.1"
258
+ const port = int(env.HUB_PORT, file.port ?? 4097, 0)
259
+ // A wildcard bind address is no origin a browser uses: default to the loopback one (Docker -p 127.0.0.1:…).
260
+ const linkHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host
261
+ const publicURL = (env.HUB_PUBLIC_URL || file.publicURL || `http://${linkHost.includes(":") ? `[${linkHost}]` : linkHost}:${port}`).replace(/\/+$/, "")
262
+ if (!/^https?:\/\/[^\s/]+$/.test(publicURL)) {
263
+ throw new Error(`HUB_PUBLIC_URL must be an http(s) origin without a path, got ${JSON.stringify(publicURL)}`)
264
+ }
265
+ return {
266
+ host,
267
+ port,
268
+ publicURL,
269
+ stateDir: env.HUB_STATE_DIR || file.stateDir || path.join(stateBase(home), APP_DIR),
270
+ apiKey: env.OPENROUTER_API_KEY || undefined,
271
+ sttModel: env.HUB_STT_MODEL || "openai/gpt-4o-mini-transcribe",
272
+ ttsModel: env.HUB_TTS_MODEL || "microsoft/mai-voice-2.1-flash",
273
+ voice: env.HUB_VOICE || "en-US-Harper:MAI-Voice-2.1-Flash",
274
+ maxAudioBytes: int(env.HUB_MAX_AUDIO_BYTES, 10 * 1024 * 1024),
275
+ maxSpeechChars: int(env.HUB_MAX_SPEECH_CHARS, 800),
276
+ turnTimeoutMs: int(env.HUB_TURN_TIMEOUT_MS, 600_000),
277
+ // Push services want a contact: an https URL or mailto. The public URL works for hosted hubs.
278
+ vapidSubject: env.HUB_VAPID_SUBJECT || (publicURL.startsWith("https://") ? publicURL : "mailto:hub@localhost"),
279
+ ...(file.speech ? { speech: file.speech } : {}),
280
+ auth: authConfig(env, file.auth),
281
+ }
282
+ }
283
+
284
+ export interface LoadOptions {
285
+ /** Where one-time migration notices go (default console). */
286
+ readonly log?: (message: string) => void
287
+ }
288
+
289
+ /**
290
+ * Reads hub.json (if any) and the environment. Throws on an invalid file or public URL. On first start,
291
+ * copies the legacy hub.json and state directory (opencode-hub first, else opencode-voice) to their default
292
+ * Hub locations (never for paths set by env or hub.json; the old copies are kept).
293
+ */
294
+ export function loadCentralConfig(
295
+ env: Env = process.env,
296
+ home: string = homedir(),
297
+ options: LoadOptions = {},
298
+ ): { config: CentralConfig; warnings: string[]; path: string; loaded: boolean } {
299
+ const resolved = hubEnv(env)
300
+ const migrate = options.log ? { log: options.log } : {}
301
+ const file = hubConfigPath(env, home)
302
+ if (!resolved.HUB_CONFIG) migrateFirst(legacyPaths(configBase(env, home), "hub.json"), file, migrate)
303
+ const { file: hub, warnings } = readHubFile(file, home)
304
+ const config = centralConfig(env, home, hub)
305
+ if (!resolved.HUB_STATE_DIR && !hub?.stateDir) {
306
+ // hub.pid is the spoke's auto-start lock for the legacy location, not state.
307
+ migrateFirst(legacyPaths(stateBase(home)), config.stateDir, { ...migrate, skip: ["hub.pid"] })
308
+ }
309
+ return { config, warnings, path: file, loaded: hub !== undefined }
310
+ }