@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,1247 @@
|
|
|
1
|
+
// Hub: HTTP routes (web UI, JSON API, SSE, enrollment) and the spoke WebSocket.
|
|
2
|
+
// Phones authenticate with the `voice_device` cookie; spokes with machine bearer tokens. Source IPs
|
|
3
|
+
// (including loopback) are never trusted.
|
|
4
|
+
import type { Server } from "bun"
|
|
5
|
+
import { diskAssets, type WebAssets } from "./assets"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type TurnResult } from "../protocol"
|
|
8
|
+
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
9
|
+
import { speakable } from "../speakable"
|
|
10
|
+
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
11
|
+
import { emailAllowed, type CentralConfig } from "./config"
|
|
12
|
+
import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
|
|
13
|
+
import { Push, type PushSender } from "./push"
|
|
14
|
+
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
15
|
+
import { BusyError, Registry } from "./registry"
|
|
16
|
+
import { audioFormat, SpeechError } from "./speech/provider"
|
|
17
|
+
import { SpeechService } from "./speech/service"
|
|
18
|
+
import { RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type Notifier, type SpokeSocketData } from "./spoke-ws"
|
|
19
|
+
import { LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
20
|
+
|
|
21
|
+
export interface Speech {
|
|
22
|
+
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
23
|
+
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CentralOptions {
|
|
27
|
+
readonly config: CentralConfig
|
|
28
|
+
/** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
|
|
29
|
+
readonly webDir?: string
|
|
30
|
+
readonly web?: WebAssets
|
|
31
|
+
readonly version?: string
|
|
32
|
+
/** Overrides speech (default: the provider configured in the speech settings). */
|
|
33
|
+
readonly speech?: Speech
|
|
34
|
+
/** fetch for upstream speech providers and model lists (tests). */
|
|
35
|
+
readonly fetch?: typeof globalThis.fetch
|
|
36
|
+
/** Overrides the notifier (default: web push via `push`). */
|
|
37
|
+
readonly notifier?: Notifier
|
|
38
|
+
/** Overrides web-push delivery (tests; web-push itself needs https push services). */
|
|
39
|
+
readonly pushSender?: PushSender
|
|
40
|
+
/** Resolves push endpoint hosts at subscribe time (SSRF check); the system resolver by default. */
|
|
41
|
+
readonly resolveHost?: ResolveHost
|
|
42
|
+
/** Clock for sign-in expiry and the store (tests). */
|
|
43
|
+
readonly now?: () => number
|
|
44
|
+
readonly timing?: {
|
|
45
|
+
readonly helloTimeoutMs?: number
|
|
46
|
+
readonly requestTimeoutMs?: number
|
|
47
|
+
readonly turnGraceMs?: number
|
|
48
|
+
readonly heartbeatMs?: number
|
|
49
|
+
readonly staleMs?: number
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Central {
|
|
54
|
+
readonly url: string
|
|
55
|
+
readonly publicURL: string
|
|
56
|
+
readonly store: Store
|
|
57
|
+
readonly auth: Auth
|
|
58
|
+
readonly registry: Registry
|
|
59
|
+
readonly spokes: SpokeHub
|
|
60
|
+
readonly push: Push
|
|
61
|
+
readonly speechSettings: SpeechService
|
|
62
|
+
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
63
|
+
readonly routes: readonly RouteInfo[]
|
|
64
|
+
stop(): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Message = { type: string; [key: string]: unknown }
|
|
68
|
+
|
|
69
|
+
interface Listener {
|
|
70
|
+
readonly userID: string
|
|
71
|
+
readonly deviceID: string
|
|
72
|
+
send(message: Message): void
|
|
73
|
+
close(): void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path. */
|
|
77
|
+
export type Access = "public" | "user" | "admin" | "session" | "machine"
|
|
78
|
+
|
|
79
|
+
export interface RouteInfo {
|
|
80
|
+
readonly method: string
|
|
81
|
+
/** Express-style path, e.g. /api/sessions/:session/turn. */
|
|
82
|
+
readonly path: string
|
|
83
|
+
readonly access: Access
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Context {
|
|
87
|
+
readonly request: Request
|
|
88
|
+
readonly server: Server<SpokeSocketData>
|
|
89
|
+
readonly url: URL
|
|
90
|
+
readonly params: Record<string, string>
|
|
91
|
+
readonly device: DeviceRecord
|
|
92
|
+
readonly user: UserRecord
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type Handler = (context: Context) => Response | Promise<Response>
|
|
96
|
+
|
|
97
|
+
interface Route extends RouteInfo {
|
|
98
|
+
readonly handle: Handler
|
|
99
|
+
readonly pattern: RegExp
|
|
100
|
+
readonly keys: string[]
|
|
101
|
+
/** Speech routes refused (403 not_exposed) while Voice is off for the session; inspection and control are not. */
|
|
102
|
+
readonly exposed: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
106
|
+
|
|
107
|
+
export const COOKIE = "voice_device"
|
|
108
|
+
const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
|
|
109
|
+
const SSE_HEARTBEAT_MS = 20_000
|
|
110
|
+
const NO_OWNER = "No OpenCode process for this session is connected"
|
|
111
|
+
const NOT_EXPOSED = "Voice is not enabled for this session"
|
|
112
|
+
/** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
|
|
113
|
+
const PERMISSION_GONE = "This request was already answered or expired"
|
|
114
|
+
/** Longest directory and title accepted for a new session (as in the spoke protocol). */
|
|
115
|
+
const MAX_DIRECTORY_CHARS = 4096
|
|
116
|
+
const MAX_TITLE_CHARS = 200
|
|
117
|
+
/** Longest Herdr workspace ID accepted (as in the spoke protocol). */
|
|
118
|
+
const MAX_HERDR_WORKSPACE_CHARS = 100
|
|
119
|
+
/** View preferences: entries per list and characters per entry. */
|
|
120
|
+
const VIEW_PREF_MAX_ENTRIES = 500
|
|
121
|
+
const VIEW_PREF_MAX_CHARS = 4096
|
|
122
|
+
const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
|
|
123
|
+
/** Longest utterance accepted for conversation mode (characters, after trimming). */
|
|
124
|
+
export const MAX_UTTERANCE_CHARS = 4000
|
|
125
|
+
/** Longest typed prompt or steer accepted (characters, after trimming); a bigger one would not fit in a frame. */
|
|
126
|
+
export const MAX_PROMPT_CHARS = 100_000
|
|
127
|
+
/** The shell's answer when the hub has no built web app (e.g. the plugin installed from GitHub). */
|
|
128
|
+
const WEB_NOT_BUILT = [
|
|
129
|
+
"This hub has no built web app (web/dist).",
|
|
130
|
+
"",
|
|
131
|
+
"To run a hub, install it from npm (`opencode plugin add @huynle/hub`, or `bunx --package @huynle/hub hub-server`)",
|
|
132
|
+
"or use a release binary (https://github.com/huynle/hub/releases), which ship the web app.",
|
|
133
|
+
"In a source checkout, run `npm install && npm run build:web` first.",
|
|
134
|
+
].join("\n")
|
|
135
|
+
/** Preview text for POST /api/settings/speech/test. */
|
|
136
|
+
const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to you."
|
|
137
|
+
const SPEECH_TEST_MAX_CHARS = 300
|
|
138
|
+
const APP_ROUTES = /^\/(?:|pair|approve|new|settings|s\/[^/]+)$/
|
|
139
|
+
const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
|
|
140
|
+
/** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
|
|
141
|
+
const LOGIN_NEXT = /^\/(?:s\/[A-Za-z0-9_-]+(?:\?voice=1)?)?$/
|
|
142
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
143
|
+
".html": "text/html; charset=utf-8",
|
|
144
|
+
".js": "text/javascript; charset=utf-8",
|
|
145
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
146
|
+
".css": "text/css; charset=utf-8",
|
|
147
|
+
".json": "application/json",
|
|
148
|
+
".webmanifest": "application/manifest+json",
|
|
149
|
+
".svg": "image/svg+xml",
|
|
150
|
+
".png": "image/png",
|
|
151
|
+
".ico": "image/x-icon",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class HttpError extends Error {
|
|
155
|
+
constructor(
|
|
156
|
+
readonly status: number,
|
|
157
|
+
message: string,
|
|
158
|
+
/** Machine-readable reason, e.g. not_exposed. */
|
|
159
|
+
readonly code?: string,
|
|
160
|
+
) {
|
|
161
|
+
super(message)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const error = (status: number, message: string, code?: string) =>
|
|
166
|
+
Response.json({ error: message, ...(code ? { code } : {}) }, { status })
|
|
167
|
+
|
|
168
|
+
export function startCentral(options: CentralOptions): Central {
|
|
169
|
+
const { config } = options
|
|
170
|
+
const web: WebAssets = options.web ?? diskAssets(options.webDir ?? path.join(import.meta.dir, "..", "..", "web", "dist"))
|
|
171
|
+
const now = options.now ?? Date.now
|
|
172
|
+
const store = Store.open(config.stateDir, now, { mode: config.auth.mode })
|
|
173
|
+
// A machine's tenant is its owner (read live: ownership moves on migration).
|
|
174
|
+
const registry = new Registry(Date.now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
|
|
175
|
+
const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender })
|
|
176
|
+
const notifier = options.notifier ?? push
|
|
177
|
+
/** SSE subscribers, one user each. */
|
|
178
|
+
const listeners = new Set<Listener>()
|
|
179
|
+
/**
|
|
180
|
+
* Sends to SSE subscribers: catalog to everyone (each gets their own machines), `admins` messages to
|
|
181
|
+
* admins only, anything else only to `tenant` (the session's owner). Streams of users who were
|
|
182
|
+
* disabled are closed.
|
|
183
|
+
*/
|
|
184
|
+
const broadcast = (message: Message, audience: { tenant?: string; admins?: true } = {}) => {
|
|
185
|
+
for (const listener of [...listeners]) {
|
|
186
|
+
const user = store.user(listener.userID)
|
|
187
|
+
if (!user || user.disabled) {
|
|
188
|
+
listener.close()
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(user.id) })
|
|
192
|
+
else if (audience.admins) {
|
|
193
|
+
if (user.role === "admin") listener.send(message)
|
|
194
|
+
} else if (audience.tenant !== undefined && audience.tenant === user.id) listener.send(message)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const speechSettings = new SpeechService({
|
|
198
|
+
store,
|
|
199
|
+
defaults: {
|
|
200
|
+
apiKey: config.apiKey,
|
|
201
|
+
sttModel: config.sttModel,
|
|
202
|
+
ttsModel: config.ttsModel,
|
|
203
|
+
voice: config.voice,
|
|
204
|
+
...(config.speech ? { file: config.speech } : {}),
|
|
205
|
+
},
|
|
206
|
+
fetch: options.fetch,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Assigned right after the server starts listening; no request can arrive before that.
|
|
210
|
+
let publicURL = config.publicURL
|
|
211
|
+
let auth!: Auth
|
|
212
|
+
let spokes!: SpokeHub
|
|
213
|
+
/** At most one machine per hub process is approved without confirmation (see localAutoApprove). */
|
|
214
|
+
let autoApproved = false
|
|
215
|
+
|
|
216
|
+
const server = Bun.serve<SpokeSocketData>({
|
|
217
|
+
hostname: config.host,
|
|
218
|
+
port: config.port,
|
|
219
|
+
idleTimeout: 255,
|
|
220
|
+
maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, 1 << 20),
|
|
221
|
+
fetch: (request, server) => handle(request, server),
|
|
222
|
+
websocket: {
|
|
223
|
+
open: (ws) => spokes.websocket.open?.(ws),
|
|
224
|
+
message: (ws, raw) => spokes.websocket.message(ws, raw),
|
|
225
|
+
close: (ws, code, reason) => spokes.websocket.close?.(ws, code, reason),
|
|
226
|
+
pong: (ws, data) => spokes.websocket.pong?.(ws, data),
|
|
227
|
+
idleTimeout: 0,
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
const url = `http://${config.host.includes(":") ? `[${config.host}]` : config.host}:${server.port}`
|
|
231
|
+
if (config.port === 0 && publicURL.endsWith(":0")) publicURL = url
|
|
232
|
+
// A loopback public URL is the same site under localhost, 127.0.0.1 and [::1] (same scheme and port).
|
|
233
|
+
const origins = loopbackOrigins(publicURL)
|
|
234
|
+
const secure = publicURL.startsWith("https:")
|
|
235
|
+
|
|
236
|
+
auth = new Auth({ store, publicURL, now, mode: config.auth.mode })
|
|
237
|
+
// options.fetch is for speech providers only; sign-in always talks to the real issuer.
|
|
238
|
+
const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now }) : undefined
|
|
239
|
+
/** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
|
|
240
|
+
const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
|
|
241
|
+
const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
|
|
242
|
+
spokes = new SpokeHub({
|
|
243
|
+
store,
|
|
244
|
+
auth,
|
|
245
|
+
registry,
|
|
246
|
+
publicURL,
|
|
247
|
+
notifier,
|
|
248
|
+
broadcast: (message, tenant) => broadcast(message, { tenant }),
|
|
249
|
+
turnTimeoutMs: config.turnTimeoutMs,
|
|
250
|
+
...options.timing,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
/** The user's machines with their live processes and sessions. */
|
|
254
|
+
const machines = (userID: string) => {
|
|
255
|
+
const online = new Map(registry.catalog().map((m) => [m.id, m]))
|
|
256
|
+
return store
|
|
257
|
+
.machines()
|
|
258
|
+
.filter((m) => !m.revoked && m.ownerID === userID)
|
|
259
|
+
.map((m) => ({
|
|
260
|
+
id: m.id,
|
|
261
|
+
name: m.name,
|
|
262
|
+
created: m.created,
|
|
263
|
+
lastSeen: m.lastSeen,
|
|
264
|
+
online: online.has(m.id),
|
|
265
|
+
processes: online.get(m.id)?.processes ?? [],
|
|
266
|
+
...(online.get(m.id)?.terminals ? { terminals: online.get(m.id)!.terminals } : {}),
|
|
267
|
+
}))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
registry.subscribe((message, tenant) => broadcast({ ...message }, tenant !== undefined ? { tenant } : {}))
|
|
271
|
+
registry.subscribeAlerts((alert) => {
|
|
272
|
+
// Push goes to the session owner's devices only; an alert without an owner goes nowhere.
|
|
273
|
+
if (!alert.owner) return
|
|
274
|
+
const extra = alert.requestID ? { requestID: alert.requestID } : undefined
|
|
275
|
+
notifier.notify(alert.kind, alert.sessionID, alert.title, alert.detail, alert.text, alert.owner, extra).catch((cause) => {
|
|
276
|
+
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
/** GET /api/push/prefs: the device's toggles and quiet hours, the user's muted sessions, and the subscriptions held. */
|
|
281
|
+
const pushPrefsView = (deviceID: string, userID: string, endpoint?: string | null) => {
|
|
282
|
+
const mine = store.pushSubscriptions().filter((s) => s.deviceID === deviceID)
|
|
283
|
+
return Response.json(
|
|
284
|
+
{
|
|
285
|
+
prefs: store.pushPrefs(deviceID),
|
|
286
|
+
quietHours: store.quietHours(deviceID) ?? null,
|
|
287
|
+
muted: store.mutedSessions(userID),
|
|
288
|
+
subscriptions: mine.length,
|
|
289
|
+
...(endpoint ? { subscribed: mine.some((s) => s.endpoint === endpoint) } : {}),
|
|
290
|
+
},
|
|
291
|
+
{ headers: NO_STORE },
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const cookie = (token: string, maxAge = COOKIE_MAX_AGE) =>
|
|
296
|
+
`${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`
|
|
297
|
+
|
|
298
|
+
/** The device of the voice_device cookie and its user; undefined when unknown or the user is disabled. */
|
|
299
|
+
const deviceFor = (request: Request): { device: DeviceRecord; user: UserRecord } | undefined => {
|
|
300
|
+
const header = request.headers.get("cookie") ?? ""
|
|
301
|
+
for (const part of header.split(";")) {
|
|
302
|
+
const [name, ...rest] = part.trim().split("=")
|
|
303
|
+
if (name !== COOKIE) continue
|
|
304
|
+
const device = store.findDevice(rest.join("="))
|
|
305
|
+
if (!device) continue
|
|
306
|
+
const user = store.user(device.ownerID)
|
|
307
|
+
return user && !user.disabled ? { device, user } : undefined
|
|
308
|
+
}
|
|
309
|
+
return undefined
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const oidcCookie = (state: string, maxAge: number) =>
|
|
313
|
+
`${OIDC_COOKIE}=${state}; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=${maxAge}${secure ? "; Secure" : ""}`
|
|
314
|
+
|
|
315
|
+
async function authLogin(url: URL): Promise<Response> {
|
|
316
|
+
const { location, state } = await oidc!.login(url.searchParams.get("next"))
|
|
317
|
+
const headers = new Headers({ location, "cache-control": "no-store" })
|
|
318
|
+
headers.append("set-cookie", oidcCookie(state, 600))
|
|
319
|
+
return new Response(null, { status: 302, headers })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function authCallback(request: Request, url: URL): Promise<Response> {
|
|
323
|
+
if (config.auth.mode !== "oidc") throw new Error("oidc is not configured")
|
|
324
|
+
const settings = config.auth
|
|
325
|
+
const cleared = { "set-cookie": oidcCookie("", 0) }
|
|
326
|
+
const deny = (status: number, title: string, message: string) => authPage(status, title, message, cleared)
|
|
327
|
+
let result: Awaited<ReturnType<OidcClient["callback"]>>
|
|
328
|
+
try {
|
|
329
|
+
result = await oidc!.callback(url.searchParams, cookieValue(request.headers.get("cookie"), OIDC_COOKIE))
|
|
330
|
+
} catch (cause) {
|
|
331
|
+
if (!(cause instanceof OidcError)) throw cause
|
|
332
|
+
if (cause.audited) store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
|
|
333
|
+
else console.warn(`[hub] sign-in refused: ${cause.reason}`)
|
|
334
|
+
return deny(cause.status, "Sign-in failed", cause.message)
|
|
335
|
+
}
|
|
336
|
+
const { identity, next } = result
|
|
337
|
+
if (!emailAllowed(settings, identity.email)) {
|
|
338
|
+
store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
|
|
339
|
+
return deny(403, "Not allowed", `${identity.email} is not allowed to use this hub. Ask its admin to add you.`)
|
|
340
|
+
}
|
|
341
|
+
const role = settings.admins.includes(identity.email) ? "admin" : "user"
|
|
342
|
+
const existing = store.userByEmail(identity.email)
|
|
343
|
+
if (existing?.disabled) {
|
|
344
|
+
store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
|
|
345
|
+
return deny(403, "Account disabled", "Your account on this hub is disabled. Ask its admin.")
|
|
346
|
+
}
|
|
347
|
+
const user = store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
|
|
348
|
+
if (user.role === "admin" && store.migrateLocal(user.id)) broadcast({ type: "catalog" })
|
|
349
|
+
const headers = new Headers({ "cache-control": "no-store" })
|
|
350
|
+
headers.append("set-cookie", cleared["set-cookie"])
|
|
351
|
+
const current = deviceFor(request)
|
|
352
|
+
let deviceID: string
|
|
353
|
+
if (current && current.user.id === user.id) deviceID = current.device.id
|
|
354
|
+
else {
|
|
355
|
+
const { device, token } = store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
|
|
356
|
+
deviceID = device.id
|
|
357
|
+
headers.append("set-cookie", cookie(token))
|
|
358
|
+
}
|
|
359
|
+
store.audit({ actor: user.id, action: "signin", target: deviceID })
|
|
360
|
+
// Speech not usable yet (e.g. no key): land on the page's speech setup (the console opens Settings → Speech for #speech).
|
|
361
|
+
const setup = !next.includes("#") && speechSettings.view(speechScope(user.id)).ready.reason ? "#speech" : ""
|
|
362
|
+
headers.set("location", `${next}${setup}`)
|
|
363
|
+
return new Response(null, { status: 302, headers })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** CSRF guard for state-changing requests: Origin (or Referer) must be the public origin (any loopback name for a local hub). */
|
|
367
|
+
const sameOrigin = (request: Request): boolean => {
|
|
368
|
+
const origin = request.headers.get("origin")
|
|
369
|
+
if (origin) return origins.has(origin)
|
|
370
|
+
const referer = request.headers.get("referer")
|
|
371
|
+
if (!referer) return false
|
|
372
|
+
try {
|
|
373
|
+
return origins.has(new URL(referer).origin)
|
|
374
|
+
} catch {
|
|
375
|
+
return false
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
380
|
+
const url = new URL(request.url)
|
|
381
|
+
const { pathname } = url
|
|
382
|
+
const method = request.method
|
|
383
|
+
try {
|
|
384
|
+
if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
|
|
385
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
386
|
+
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections() })
|
|
387
|
+
}
|
|
388
|
+
if (method === "POST" && pathname === "/spoke/enroll") {
|
|
389
|
+
const start = auth.startEnrollment((await body(request)).machineName)
|
|
390
|
+
const auto = config.auth.mode === "pairing" && localAutoApprove({
|
|
391
|
+
host: config.host,
|
|
392
|
+
publicURL,
|
|
393
|
+
peer: server.requestIP(request)?.address,
|
|
394
|
+
forwarded: FORWARD_HEADERS.some((h) => request.headers.has(h)),
|
|
395
|
+
machines: store.machines().length,
|
|
396
|
+
devices: store.devices().length,
|
|
397
|
+
used: autoApproved,
|
|
398
|
+
})
|
|
399
|
+
if (auto && auth.approveEnrollment(start.userCode)) {
|
|
400
|
+
autoApproved = true
|
|
401
|
+
console.log(`[hub] local hub: approved the first machine (code ${start.userCode}) without confirmation; later machines need approval`)
|
|
402
|
+
return Response.json({ ...start, autoApproved: true } satisfies EnrollStart)
|
|
403
|
+
}
|
|
404
|
+
broadcast({ type: "enrollment" }, { admins: true })
|
|
405
|
+
return Response.json(start)
|
|
406
|
+
}
|
|
407
|
+
if (method === "POST" && pathname === "/spoke/enroll/poll") {
|
|
408
|
+
const poll = auth.pollEnrollment((await body(request)).deviceCode)
|
|
409
|
+
if (poll.status === "approved") broadcast({ type: "catalog" })
|
|
410
|
+
return Response.json(poll)
|
|
411
|
+
}
|
|
412
|
+
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
413
|
+
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
414
|
+
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
415
|
+
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
416
|
+
if (method === "GET") return staticFile(pathname)
|
|
417
|
+
return error(404, "Not found")
|
|
418
|
+
} catch (cause) {
|
|
419
|
+
return failure(cause)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function login(request: Request, url: URL): Response {
|
|
424
|
+
const next = url.searchParams.get("next") ?? "/"
|
|
425
|
+
if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
|
|
426
|
+
const ownerID = auth.redeemLogin(url.searchParams.get("code"))
|
|
427
|
+
if (!ownerID) {
|
|
428
|
+
return new Response("This sign-in link has expired or was already used. Run /hub again.", {
|
|
429
|
+
status: 401,
|
|
430
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
// Speech not usable yet (e.g. no key): land on the page's speech setup (the console opens Settings → Speech for #speech).
|
|
434
|
+
const setup = speechSettings.view(speechScope(ownerID)).ready.reason ? "#speech" : ""
|
|
435
|
+
const headers = new Headers({ location: `${next}${setup}`, "cache-control": "no-store" })
|
|
436
|
+
// A link signs in as the machine's owner: a browser signed in as someone else gets a new device.
|
|
437
|
+
if (deviceFor(request)?.user.id !== ownerID) {
|
|
438
|
+
const { device, token } = store.addDevice("Browser (sign-in link)", ownerID)
|
|
439
|
+
store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
|
|
440
|
+
headers.set("set-cookie", cookie(token))
|
|
441
|
+
}
|
|
442
|
+
return new Response(null, { status: 302, headers })
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------- JSON API: a route table with declared access, enforced here before any handler runs ----------
|
|
446
|
+
|
|
447
|
+
const ownMachine = (userID: string, machineID: string) =>
|
|
448
|
+
store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
|
|
449
|
+
|
|
450
|
+
const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
|
|
451
|
+
const keys: string[] = []
|
|
452
|
+
const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
|
|
453
|
+
return { method, path, access, handle, pattern, keys, exposed }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const routes: Route[] = [
|
|
457
|
+
// public
|
|
458
|
+
route("GET", "/api/auth", "public", () =>
|
|
459
|
+
Response.json({ mode: config.auth.mode, signIn: oidc ? "/auth/login" : null }, { headers: { "cache-control": "no-store" } }),
|
|
460
|
+
),
|
|
461
|
+
route("POST", "/api/pair", "public", async ({ request }) => {
|
|
462
|
+
const input = await body(request)
|
|
463
|
+
const paired = auth.pair(input.code, input.deviceName)
|
|
464
|
+
if (!paired) return error(401, "Invalid or expired pairing code")
|
|
465
|
+
store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
|
|
466
|
+
return Response.json({ device: deviceView(paired.device) }, { headers: { "set-cookie": cookie(paired.token) } })
|
|
467
|
+
}),
|
|
468
|
+
|
|
469
|
+
// any signed-in user, own data only
|
|
470
|
+
route("GET", "/api/me", "user", ({ device, user }) =>
|
|
471
|
+
Response.json({
|
|
472
|
+
device: deviceView(device),
|
|
473
|
+
user: userView(user),
|
|
474
|
+
auth: config.auth.mode,
|
|
475
|
+
publicURL,
|
|
476
|
+
version: options.version ?? "0.0.0",
|
|
477
|
+
}),
|
|
478
|
+
),
|
|
479
|
+
route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
|
|
480
|
+
route("GET", "/api/events", "user", ({ request, server, user, device }) => {
|
|
481
|
+
server.timeout(request, 0)
|
|
482
|
+
return events(request.signal, user.id, device.id)
|
|
483
|
+
}),
|
|
484
|
+
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
485
|
+
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
486
|
+
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
487
|
+
const prefs = store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
|
|
488
|
+
// The user's other devices only: never another user, and not the device that made the change.
|
|
489
|
+
for (const listener of [...listeners]) {
|
|
490
|
+
if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "prefs", prefs })
|
|
491
|
+
}
|
|
492
|
+
return Response.json({ prefs }, { headers: NO_STORE })
|
|
493
|
+
}),
|
|
494
|
+
route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
|
|
495
|
+
route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
|
|
496
|
+
route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
|
|
497
|
+
route("GET", "/api/devices", "user", ({ device, user }) =>
|
|
498
|
+
Response.json({
|
|
499
|
+
devices: store
|
|
500
|
+
.devices()
|
|
501
|
+
.filter((d) => d.ownerID === user.id)
|
|
502
|
+
.map((d) => ({ ...deviceView(d), current: d.id === device.id })),
|
|
503
|
+
}),
|
|
504
|
+
),
|
|
505
|
+
route("DELETE", "/api/devices/:id", "user", ({ params, user }) => {
|
|
506
|
+
const target = store.devices().find((d) => d.id === params.id && d.ownerID === user.id)
|
|
507
|
+
if (!target || !store.removeDevice(target.id)) return error(404, "No such device")
|
|
508
|
+
store.audit({ actor: user.id, action: "device.revoke", target: target.id })
|
|
509
|
+
return new Response(null, { status: 204 })
|
|
510
|
+
}),
|
|
511
|
+
route("POST", "/api/logout", "user", ({ device }) => {
|
|
512
|
+
store.removeDevice(device.id)
|
|
513
|
+
return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
|
|
514
|
+
}),
|
|
515
|
+
route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
|
|
516
|
+
route("POST", "/api/speak", "user", async ({ request, user }) => {
|
|
517
|
+
const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
|
|
518
|
+
if (!text) return error(400, "Nothing to speak")
|
|
519
|
+
return new Response(await speechFor(user.id).speak(text), { headers: { "content-type": "audio/mpeg" } })
|
|
520
|
+
}),
|
|
521
|
+
route("GET", "/api/push/key", "user", () => Response.json({ publicKey: push.publicKey() })),
|
|
522
|
+
route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
|
|
523
|
+
const input = await body(request)
|
|
524
|
+
const target = pushTarget(input.subscription)
|
|
525
|
+
if (!target) return error(400, "Invalid push subscription")
|
|
526
|
+
const problem = await pushEndpointProblem(target.endpoint, options.resolveHost ?? systemResolveHost)
|
|
527
|
+
if (problem) return error(400, `Invalid push subscription: ${problem}`)
|
|
528
|
+
// pushsubscriptionchange: the service worker sends the endpoint it replaces.
|
|
529
|
+
if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) store.removePushSubscription(device.id, input.oldEndpoint)
|
|
530
|
+
store.addPushSubscription(device.id, target)
|
|
531
|
+
const prefs = prefsInput(input.prefs)
|
|
532
|
+
return Response.json({ prefs: prefs ? store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
|
|
533
|
+
}),
|
|
534
|
+
route("POST", "/api/push/unsubscribe", "user", async ({ request, device }) => {
|
|
535
|
+
const endpoint = (await body(request)).endpoint
|
|
536
|
+
if (typeof endpoint !== "string" || !store.removePushSubscription(device.id, endpoint)) {
|
|
537
|
+
return error(404, "No such subscription on this device")
|
|
538
|
+
}
|
|
539
|
+
return new Response(null, { status: 204 })
|
|
540
|
+
}),
|
|
541
|
+
route("GET", "/api/push/prefs", "user", ({ device, user, url }) => pushPrefsView(device.id, user.id, url.searchParams.get("endpoint"))),
|
|
542
|
+
route("PUT", "/api/push/prefs", "user", async ({ request, device, user }) => {
|
|
543
|
+
const input = await body(request)
|
|
544
|
+
if ("quietHours" in input) {
|
|
545
|
+
const quiet = input.quietHours === null ? null : quietHoursInput(input.quietHours)
|
|
546
|
+
if (quiet === undefined) return error(400, "quietHours must be {start: \"HH:MM\", end: \"HH:MM\", timeZone} or null")
|
|
547
|
+
store.setQuietHours(device.id, quiet)
|
|
548
|
+
}
|
|
549
|
+
const prefs = prefsInput(input)
|
|
550
|
+
if (prefs) store.setPushPrefs(device.id, prefs)
|
|
551
|
+
return pushPrefsView(device.id, user.id)
|
|
552
|
+
}),
|
|
553
|
+
route("PUT", "/api/push/mute", "user", async ({ request, user }) => {
|
|
554
|
+
const input = await body(request)
|
|
555
|
+
const sessionID = input.sessionID
|
|
556
|
+
if (typeof sessionID !== "string" || !sessionID || sessionID.length > 200 || typeof input.muted !== "boolean") {
|
|
557
|
+
return error(400, "Expected {sessionID, muted}")
|
|
558
|
+
}
|
|
559
|
+
return Response.json({ muted: store.setSessionMuted(user.id, sessionID, input.muted) })
|
|
560
|
+
}),
|
|
561
|
+
route("POST", "/api/push/test", "user", async ({ device }) => {
|
|
562
|
+
const subscriptions = store.pushSubscriptions().filter((s) => s.deviceID === device.id).length
|
|
563
|
+
if (subscriptions === 0) return Response.json({ sent: 0, failed: [], subscriptions })
|
|
564
|
+
const result = await push.test(device.id)
|
|
565
|
+
if ("limited" in result) {
|
|
566
|
+
return Response.json({ error: "Wait a few seconds before sending another test" }, { status: 429, headers: { "retry-after": String(result.retryAfter) } })
|
|
567
|
+
}
|
|
568
|
+
return Response.json({ ...result, subscriptions: store.pushSubscriptions().filter((s) => s.deviceID === device.id).length })
|
|
569
|
+
}),
|
|
570
|
+
// The caller's own settings (pairing mode: the single owner's are the server default).
|
|
571
|
+
route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
|
|
572
|
+
// Raw JSON: anything but an object is refused by the validator.
|
|
573
|
+
route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
|
|
574
|
+
Response.json(speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
|
|
575
|
+
),
|
|
576
|
+
route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
|
|
577
|
+
const kind = url.searchParams.get("kind")
|
|
578
|
+
if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
|
|
579
|
+
return Response.json(await speechSettings.models(kind, speechScope(user.id)))
|
|
580
|
+
}),
|
|
581
|
+
route("POST", "/api/settings/speech/test", "user", async ({ request, user }) => {
|
|
582
|
+
const input = await body(request)
|
|
583
|
+
if (input.kind === "stt") return error(501, "Testing speech-to-text is not supported; record something instead")
|
|
584
|
+
if (input.kind !== "tts") return error(400, "Expected kind tts or stt")
|
|
585
|
+
const text = speakable(typeof input.text === "string" ? input.text : "", SPEECH_TEST_MAX_CHARS - 1) || SPEECH_TEST_TEXT
|
|
586
|
+
return new Response(await speechFor(user.id).speak(text), { headers: { "content-type": "audio/mpeg", "cache-control": "no-store" } })
|
|
587
|
+
}),
|
|
588
|
+
|
|
589
|
+
// sessions: the caller must own the session (else 404, like an unknown one)
|
|
590
|
+
route("GET", "/api/sessions/:session", "session", ({ params }) => {
|
|
591
|
+
const view = registry.session(params.session!)
|
|
592
|
+
return view ? Response.json(view) : error(404, "Not found")
|
|
593
|
+
}),
|
|
594
|
+
route("POST", "/api/sessions/:session/expose", "session", ({ request, params }) => expose(request, params.session!)),
|
|
595
|
+
route("POST", "/api/sessions/:session/presence", "session", async ({ request, params, device }) => {
|
|
596
|
+
const visible = (await body(request)).visible
|
|
597
|
+
if (typeof visible !== "boolean") return error(400, "Expected {visible: boolean}")
|
|
598
|
+
registry.setPresence(device.id, params.session!, visible)
|
|
599
|
+
return new Response(null, { status: 204 })
|
|
600
|
+
}),
|
|
601
|
+
route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params }) => turn(request, server, params.session!)),
|
|
602
|
+
route("POST", "/api/sessions/:session/utterance", "session", ({ request, params }) => utterance(request, params.session!), true),
|
|
603
|
+
route(
|
|
604
|
+
"POST",
|
|
605
|
+
"/api/sessions/:session/interrupt",
|
|
606
|
+
"session",
|
|
607
|
+
async ({ params }) => {
|
|
608
|
+
await spokes.request(ownerOf(params.session!), { op: "interrupt", sessionID: params.session! })
|
|
609
|
+
registry.cancelTurns(params.session!)
|
|
610
|
+
return new Response(null, { status: 204 })
|
|
611
|
+
},
|
|
612
|
+
),
|
|
613
|
+
route("POST", "/api/sessions/:session/stop", "session", async ({ params }) => {
|
|
614
|
+
const result = await spokes.request(ownerOf(params.session!), { op: "stop", sessionID: params.session! })
|
|
615
|
+
registry.cancelTurns(params.session!)
|
|
616
|
+
return Response.json(result, { headers: NO_STORE })
|
|
617
|
+
}),
|
|
618
|
+
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
|
|
619
|
+
route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
|
|
620
|
+
Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
|
|
621
|
+
route("GET", "/api/sessions/:session/model-prefs", "session", async ({ params, user }) => {
|
|
622
|
+
const prefs = store.modelPrefs(user.id, params.session!)
|
|
623
|
+
const defaults = await spokes.request(ownerOf(params.session!), {
|
|
624
|
+
op: "setModelPrefs",
|
|
625
|
+
sessionID: params.session!,
|
|
626
|
+
voice: prefs.voice ?? null,
|
|
627
|
+
worker: prefs.worker ?? null,
|
|
628
|
+
})
|
|
629
|
+
return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
|
|
630
|
+
}),
|
|
631
|
+
route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
|
|
632
|
+
const change = modelPrefsInput(await request.json())
|
|
633
|
+
await checkModelSelections(ownerOf(params.session!), params.session!, change)
|
|
634
|
+
const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
|
|
635
|
+
const prefs = store.setModelPrefs(user.id, params.session!, change)
|
|
636
|
+
return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
|
|
637
|
+
}),
|
|
638
|
+
route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
|
|
639
|
+
const limitRaw = url.searchParams.get("limit")
|
|
640
|
+
const limit = limitRaw === null ? undefined : Number(limitRaw)
|
|
641
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
|
|
642
|
+
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
643
|
+
if (cursor && cursor.length > 4096) return error(400, "cursor is too long")
|
|
644
|
+
return Response.json(await spokes.request(ownerOf(params.session!), { op: "timeline", sessionID: params.session!, ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) }), { headers: NO_STORE })
|
|
645
|
+
}),
|
|
646
|
+
route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
|
|
647
|
+
Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
|
|
648
|
+
route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params }) => timelineFile(params.session!, params.message!, params.index!)),
|
|
649
|
+
route(
|
|
650
|
+
"GET",
|
|
651
|
+
"/api/sessions/:session/context",
|
|
652
|
+
"session",
|
|
653
|
+
async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
|
|
654
|
+
),
|
|
655
|
+
route("POST", "/api/sessions/:session/permission", "session", ({ request, params }) => permission(request, params.session!)),
|
|
656
|
+
route("POST", "/api/sessions/:session/form", "session", ({ request, params }) => form(request, params.session!)),
|
|
657
|
+
route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
|
|
658
|
+
Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
|
|
659
|
+
route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
|
|
660
|
+
const file = url.searchParams.get("file")
|
|
661
|
+
if (!file || file.length > 4096) return error(400, "Expected ?file=<path>")
|
|
662
|
+
const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
|
|
663
|
+
return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
|
|
664
|
+
}),
|
|
665
|
+
route("POST", "/api/sessions/:session/read", "session", ({ params, user, device }) => {
|
|
666
|
+
const reads = store.markRead(user.id, params.session!, Date.now())
|
|
667
|
+
for (const listener of [...listeners]) {
|
|
668
|
+
if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "reads", reads })
|
|
669
|
+
}
|
|
670
|
+
return Response.json({ reads }, { headers: NO_STORE })
|
|
671
|
+
}),
|
|
672
|
+
|
|
673
|
+
// machines: the caller must own the machine
|
|
674
|
+
route("POST", "/api/machines/:machine/sessions", "machine", ({ request, params }) => createSession(request, params.machine!)),
|
|
675
|
+
route("DELETE", "/api/machines/:machine", "machine", ({ params, user }) => revokeMachine(params.machine!, user)),
|
|
676
|
+
|
|
677
|
+
// admins: users, machines, devices, audit, enrollments, the server speech default
|
|
678
|
+
route("GET", "/api/enrollments", "admin", () => Response.json({ enrollments: auth.pendingEnrollments() })),
|
|
679
|
+
route("GET", "/api/admin/users", "admin", () => Response.json(store.users().map(userRow))),
|
|
680
|
+
route("PUT", "/api/admin/users/:id", "admin", async ({ request, params, user }) => updateUser(params.id!, await body(request), user)),
|
|
681
|
+
route("GET", "/api/admin/machines", "admin", () => {
|
|
682
|
+
const online = new Set(registry.catalog().map((m) => m.id))
|
|
683
|
+
const emails = new Map(store.users().map((u) => [u.id, u.email]))
|
|
684
|
+
return Response.json(
|
|
685
|
+
store
|
|
686
|
+
.machines()
|
|
687
|
+
.filter((m) => !m.revoked)
|
|
688
|
+
.map((m) => ({
|
|
689
|
+
id: m.id,
|
|
690
|
+
name: m.name,
|
|
691
|
+
created: m.created,
|
|
692
|
+
lastSeen: m.lastSeen,
|
|
693
|
+
online: online.has(m.id),
|
|
694
|
+
ownerID: m.ownerID,
|
|
695
|
+
...(emails.get(m.ownerID) ? { ownerEmail: emails.get(m.ownerID) } : {}),
|
|
696
|
+
})),
|
|
697
|
+
)
|
|
698
|
+
}),
|
|
699
|
+
route("DELETE", "/api/admin/machines/:id", "admin", ({ params, user }) => revokeMachine(params.id!, user)),
|
|
700
|
+
route("DELETE", "/api/admin/devices/:id", "admin", ({ params, user }) => {
|
|
701
|
+
if (!store.removeDevice(params.id!)) return error(404, "No such device")
|
|
702
|
+
store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
|
|
703
|
+
return new Response(null, { status: 204 })
|
|
704
|
+
}),
|
|
705
|
+
route("GET", "/api/admin/audit", "admin", () => Response.json(store.auditLog())),
|
|
706
|
+
route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
|
|
707
|
+
route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
|
|
708
|
+
const view = speechSettings.update(await request.json())
|
|
709
|
+
store.audit({ actor: user.id, action: "speech.default" })
|
|
710
|
+
return Response.json(view, { headers: NO_STORE })
|
|
711
|
+
}),
|
|
712
|
+
]
|
|
713
|
+
|
|
714
|
+
function findRoute(method: string, pathname: string): { route: Route; params: Record<string, string> } | undefined {
|
|
715
|
+
for (const candidate of routes) {
|
|
716
|
+
if (candidate.method !== method) continue
|
|
717
|
+
const match = candidate.pattern.exec(pathname)
|
|
718
|
+
if (!match) continue
|
|
719
|
+
const params: Record<string, string> = {}
|
|
720
|
+
candidate.keys.forEach((key, i) => (params[key] = param(match[i + 1]!)))
|
|
721
|
+
return { route: candidate, params }
|
|
722
|
+
}
|
|
723
|
+
return undefined
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function api(request: Request, server: Server<SpokeSocketData>, url: URL): Promise<Response> {
|
|
727
|
+
const method = request.method
|
|
728
|
+
if (method !== "GET" && method !== "HEAD" && !sameOrigin(request)) {
|
|
729
|
+
return error(403, "Cross-origin request refused")
|
|
730
|
+
}
|
|
731
|
+
const found = findRoute(method, url.pathname)
|
|
732
|
+
if (found?.route.access === "public") {
|
|
733
|
+
// Public handlers never read device or user.
|
|
734
|
+
return await found.route.handle({ request, server, url, params: found.params } as Context)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const resolved = deviceFor(request)
|
|
738
|
+
if (!resolved) return error(401, "This device is not paired")
|
|
739
|
+
const { device, user } = resolved
|
|
740
|
+
store.touchDevice(device.id)
|
|
741
|
+
store.touchUser(user.id)
|
|
742
|
+
if (!found) return error(404, "Not found")
|
|
743
|
+
|
|
744
|
+
const { route: matched, params } = found
|
|
745
|
+
switch (matched.access) {
|
|
746
|
+
case "admin":
|
|
747
|
+
if (user.role !== "admin") return error(403, "Admins only")
|
|
748
|
+
break
|
|
749
|
+
case "session":
|
|
750
|
+
// Foreign and unknown sessions answer alike, so IDs don't leak.
|
|
751
|
+
if (!registry.owns(user.id, params.session!)) return error(404, "Not found")
|
|
752
|
+
// Sessions listed for discovery only: nothing but enabling voice (and presence) reaches them.
|
|
753
|
+
if (matched.exposed && registry.exposed(params.session!) === false) return error(403, NOT_EXPOSED, "not_exposed")
|
|
754
|
+
break
|
|
755
|
+
case "machine":
|
|
756
|
+
if (!ownMachine(user.id, params.machine!)) return error(404, "No such machine")
|
|
757
|
+
break
|
|
758
|
+
}
|
|
759
|
+
return await matched.handle({ request, server, url, params, device, user })
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Response {
|
|
763
|
+
const machineName = auth.decide(code, status, user.id)
|
|
764
|
+
if (machineName === undefined) return error(404, "No pending enrollment with that code")
|
|
765
|
+
store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
|
|
766
|
+
broadcast({ type: "enrollment" }, { admins: true })
|
|
767
|
+
return new Response(null, { status: 204 })
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function revokeMachine(id: string, user: UserRecord): Response {
|
|
771
|
+
if (!store.revokeMachine(id)) return error(404, "No such machine")
|
|
772
|
+
store.audit({ actor: user.id, action: "machine.revoke", target: id })
|
|
773
|
+
spokes.closeMachine(id, "revoked")
|
|
774
|
+
broadcast({ type: "catalog" })
|
|
775
|
+
return new Response(null, { status: 204 })
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function userRow(u: UserRecord) {
|
|
779
|
+
return {
|
|
780
|
+
id: u.id,
|
|
781
|
+
...(u.email ? { email: u.email } : {}),
|
|
782
|
+
...(u.name ? { name: u.name } : {}),
|
|
783
|
+
role: u.role,
|
|
784
|
+
disabled: u.disabled === true,
|
|
785
|
+
created: u.created,
|
|
786
|
+
lastSeen: u.lastSeen,
|
|
787
|
+
machines: store.machines().filter((m) => m.ownerID === u.id && !m.revoked).length,
|
|
788
|
+
devices: store.devices().filter((d) => d.ownerID === u.id).length,
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** PUT /api/admin/users/:id {role?, disabled?}: the last active admin can be neither demoted nor disabled. */
|
|
793
|
+
function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Response {
|
|
794
|
+
const target = store.user(id)
|
|
795
|
+
if (!target) return error(404, "No such user")
|
|
796
|
+
const { role, disabled } = input
|
|
797
|
+
if (role !== undefined && role !== "admin" && role !== "user") return error(400, 'role must be "admin" or "user"')
|
|
798
|
+
if (disabled !== undefined && typeof disabled !== "boolean") return error(400, "disabled must be true or false")
|
|
799
|
+
if (role === undefined && disabled === undefined) return error(400, "Expected {role?, disabled?}")
|
|
800
|
+
const activeAdmin = (u: UserRecord) => u.role === "admin" && !u.disabled
|
|
801
|
+
const after: UserRecord = { ...target, ...(role ? { role: role as UserRecord["role"] } : {}), ...(disabled !== undefined ? { disabled } : {}) }
|
|
802
|
+
if (activeAdmin(target) && !activeAdmin(after) && store.users().filter(activeAdmin).length <= 1) {
|
|
803
|
+
return error(409, "Cannot demote or disable the last active admin. Make someone else an admin first.")
|
|
804
|
+
}
|
|
805
|
+
if (role && role !== target.role) store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
|
|
806
|
+
if (disabled !== undefined && disabled !== (target.disabled === true)) {
|
|
807
|
+
store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
|
|
808
|
+
}
|
|
809
|
+
store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
|
|
810
|
+
if (disabled === true) {
|
|
811
|
+
// Their machines go offline at once; their browsers get 401 from now on and lose their event streams.
|
|
812
|
+
for (const machine of store.machines()) if (machine.ownerID === id) spokes.closeMachine(machine.id, "revoked")
|
|
813
|
+
broadcast({ type: "catalog" })
|
|
814
|
+
}
|
|
815
|
+
return Response.json(userRow(store.user(id)!))
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function turn(request: Request, server: Server<SpokeSocketData>, sessionID: string): Promise<Response> {
|
|
819
|
+
const input = await body(request)
|
|
820
|
+
const raw = input.text
|
|
821
|
+
const text = typeof raw === "string" ? raw.trim() : ""
|
|
822
|
+
if (input.images !== undefined) {
|
|
823
|
+
const problem = promptImagesProblem(input.images)
|
|
824
|
+
if (problem) return error(400, problem)
|
|
825
|
+
}
|
|
826
|
+
// Only the validated fields go on; an empty list is the same as none.
|
|
827
|
+
const images = Array.isArray(input.images) && input.images.length
|
|
828
|
+
? (input.images as PromptImage[]).map(({ mime, data, name }) => ({ mime, data, ...(name ? { name } : {}) }))
|
|
829
|
+
: undefined
|
|
830
|
+
if (!text && !images) return error(400, "Empty prompt")
|
|
831
|
+
if (text.length > MAX_PROMPT_CHARS) return error(400, `Prompt longer than ${MAX_PROMPT_CHARS} characters`)
|
|
832
|
+
const attached = images ? { images } : {}
|
|
833
|
+
const connID = ownerOf(sessionID)
|
|
834
|
+
// While anything in the tree runs, typed input redirects the current work instead of queueing.
|
|
835
|
+
if (registry.running(sessionID)) {
|
|
836
|
+
const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text, ...attached })
|
|
837
|
+
return Response.json({ mode: "steer", inboxID }, { status: 202 })
|
|
838
|
+
}
|
|
839
|
+
// A stop or interrupt releases the lock and answers this request at once, even if the spoke never does.
|
|
840
|
+
let cancel = () => {}
|
|
841
|
+
const stopped = new Promise<TurnResult>((resolve) => {
|
|
842
|
+
cancel = () => resolve({ outcome: "interrupted", message: "Stopped", reply: "" })
|
|
843
|
+
})
|
|
844
|
+
const release = registry.beginTurn(sessionID, () => cancel())
|
|
845
|
+
server.timeout(request, 0)
|
|
846
|
+
try {
|
|
847
|
+
const voiceStyle = registry.exposed(sessionID) === true
|
|
848
|
+
return Response.json(await Promise.race([spokes.request(connID, { op: "turn", sessionID, text, voiceStyle, ...attached }), stopped]))
|
|
849
|
+
} finally {
|
|
850
|
+
release()
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. */
|
|
855
|
+
async function timelineFile(sessionID: string, messageID: string, rawIndex: string): Promise<Response> {
|
|
856
|
+
const index = /^\d{1,3}$/.test(rawIndex) ? Number(rawIndex) : NaN
|
|
857
|
+
if (!Number.isInteger(index) || messageID.length > 300) return error(400, "Expected an attachment index 0–999")
|
|
858
|
+
const { file, tooLarge } = await spokes.request(ownerOf(sessionID), { op: "timelineFile", sessionID, messageID, index })
|
|
859
|
+
if (tooLarge) return error(413, "This image is too large to show here")
|
|
860
|
+
// Only image types that are safe to render inline are served (never SVG or HTML).
|
|
861
|
+
if (!file || typeof file.data !== "string" || !PROMPT_IMAGE_MIMES.includes(file.mime)) return error(404, "No such image")
|
|
862
|
+
const bytes = Buffer.from(file.data, "base64")
|
|
863
|
+
return new Response(bytes, {
|
|
864
|
+
headers: { "content-type": file.mime, "content-length": String(bytes.length), "cache-control": "private, no-store", "x-content-type-options": "nosniff" },
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
|
|
870
|
+
* the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
|
|
871
|
+
*/
|
|
872
|
+
async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): Promise<void> {
|
|
873
|
+
const selections = [change.voice, change.worker].filter((m): m is ModelSelection => !!m)
|
|
874
|
+
if (selections.length === 0) return
|
|
875
|
+
let models: readonly AvailableModel[]
|
|
876
|
+
try {
|
|
877
|
+
models = (await spokes.request(connID, { op: "models", sessionID })).models
|
|
878
|
+
} catch (cause) {
|
|
879
|
+
if (cause instanceof HttpError) throw cause
|
|
880
|
+
throw new HttpError(502, `Could not fetch the session's model catalog: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
881
|
+
}
|
|
882
|
+
for (const m of selections) {
|
|
883
|
+
const known = models.find((model) => model.providerID === m.providerID && model.modelID === m.modelID)
|
|
884
|
+
if (!known || (m.variant !== undefined && m.variant !== "default" && !known.variants.includes(m.variant))) {
|
|
885
|
+
throw new HttpError(400, `Unknown model ${m.providerID}/${m.modelID}${m.variant ? `#${m.variant}` : ""} for this session`)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Connection that owns the session, or a 409. */
|
|
891
|
+
function ownerOf(sessionID: string): string {
|
|
892
|
+
const connID = registry.owner(sessionID)
|
|
893
|
+
if (!connID) throw new HttpError(409, NO_OWNER)
|
|
894
|
+
return connID
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
|
|
898
|
+
async function utterance(request: Request, sessionID: string): Promise<Response> {
|
|
899
|
+
const raw = (await body(request)).text
|
|
900
|
+
const text = typeof raw === "string" ? raw.trim() : ""
|
|
901
|
+
if (!text) return error(400, "Empty utterance")
|
|
902
|
+
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
903
|
+
const { inboxID } = await spokes.request(ownerOf(sessionID), { op: "steer", sessionID, text })
|
|
904
|
+
registry.trackUtterance(sessionID, inboxID)
|
|
905
|
+
return Response.json({ inboxID }, { status: 202 })
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function conversation(request: Request, sessionID: string): Promise<Response> {
|
|
909
|
+
const on = (await body(request)).on
|
|
910
|
+
if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
|
|
911
|
+
// Turning off always clears the hub's state, so a later reconnect does not switch it back on.
|
|
912
|
+
if (!on) registry.setConversation(sessionID, false)
|
|
913
|
+
const connID = ownerOf(sessionID)
|
|
914
|
+
const { agent } = await spokes.request(connID, { op: "conversation", sessionID, on })
|
|
915
|
+
if (on) registry.setConversation(sessionID, true, connID)
|
|
916
|
+
return Response.json({ on, agent })
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Enables or disables voice for a session, through a process that reported it (service first). */
|
|
920
|
+
async function expose(request: Request, sessionID: string): Promise<Response> {
|
|
921
|
+
const on = (await body(request)).on
|
|
922
|
+
if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
|
|
923
|
+
const connID = registry.exposeTarget(sessionID)
|
|
924
|
+
if (!connID) return error(404, "Not found")
|
|
925
|
+
const { exposed } = await spokes.request(connID, { op: "expose", sessionID, on })
|
|
926
|
+
return Response.json({ exposed })
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Creates a session in a workspace the machine's background service reported (or a Herdr pane folder);
|
|
931
|
+
* with herdrWorkspace, the spoke also opens it in a new tab of that Herdr workspace.
|
|
932
|
+
*/
|
|
933
|
+
async function createSession(request: Request, machineID: string): Promise<Response> {
|
|
934
|
+
const input = await body(request)
|
|
935
|
+
const { directory, title, herdrWorkspace } = input
|
|
936
|
+
if (typeof directory !== "string" || !directory.startsWith("/") || directory.length > MAX_DIRECTORY_CHARS) {
|
|
937
|
+
return error(400, "Expected {directory: absolute path, title?: string}")
|
|
938
|
+
}
|
|
939
|
+
if (title !== undefined && (typeof title !== "string" || title.length > MAX_TITLE_CHARS)) {
|
|
940
|
+
return error(400, `Title must be a string of at most ${MAX_TITLE_CHARS} characters`)
|
|
941
|
+
}
|
|
942
|
+
if (herdrWorkspace !== undefined && (typeof herdrWorkspace !== "string" || !herdrWorkspace || herdrWorkspace.length > MAX_HERDR_WORKSPACE_CHARS)) {
|
|
943
|
+
return error(400, `herdrWorkspace must be a Herdr workspace ID of at most ${MAX_HERDR_WORKSPACE_CHARS} characters`)
|
|
944
|
+
}
|
|
945
|
+
if (!store.machines().some((m) => m.id === machineID && !m.revoked)) return error(404, "No such machine")
|
|
946
|
+
const connID = registry.serviceFor(machineID)
|
|
947
|
+
if (!connID) return error(409, "The background service of this machine is not connected")
|
|
948
|
+
if (!registry.reportsWorkspace(connID, directory)) return error(400, "unknown workspace")
|
|
949
|
+
const trimmed = typeof title === "string" ? title.trim() : ""
|
|
950
|
+
const result = (await spokes.request(connID, {
|
|
951
|
+
op: "create",
|
|
952
|
+
directory: path.posix.resolve(directory),
|
|
953
|
+
...(trimmed ? { title: trimmed } : {}),
|
|
954
|
+
...(typeof herdrWorkspace === "string" ? { herdrWorkspace } : {}),
|
|
955
|
+
})) as { sessionID: string; openedIn?: unknown; warning?: unknown }
|
|
956
|
+
const openedIn = herdrWorkspace !== undefined && result.openedIn === "herdr" ? ("herdr" as const) : undefined
|
|
957
|
+
// Herdr failed (or an older spoke ignored it): the session exists anyway.
|
|
958
|
+
const warning =
|
|
959
|
+
herdrWorkspace === undefined || openedIn
|
|
960
|
+
? undefined
|
|
961
|
+
: typeof result.warning === "string" && result.warning
|
|
962
|
+
? result.warning.slice(0, 500)
|
|
963
|
+
: "The session was created but could not be opened in Herdr"
|
|
964
|
+
return Response.json({ sessionID: result.sessionID, ...(openedIn ? { openedIn } : {}), ...(warning ? { warning } : {}) }, { status: 201 })
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function permission(request: Request, rootID: string): Promise<Response> {
|
|
968
|
+
const input = await body(request)
|
|
969
|
+
const decision = input.decision as PermissionDecision
|
|
970
|
+
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
971
|
+
const targets = registry.permissionTargets(rootID, input.requestID)
|
|
972
|
+
if (!targets.length) return error(409, PERMISSION_GONE, "gone")
|
|
973
|
+
// Try every process that saw the request: only the one (and the location) running the session holds it.
|
|
974
|
+
let failure: unknown
|
|
975
|
+
for (const target of targets) {
|
|
976
|
+
try {
|
|
977
|
+
const result = await spokes.request(target.connID, { op: "permission", sessionID: target.sessionID, requestID: input.requestID, decision })
|
|
978
|
+
if (!result?.gone) return new Response(null, { status: 204 })
|
|
979
|
+
} catch (cause) {
|
|
980
|
+
// Older spokes pass OpenCode's "Permission request not found" through as an error.
|
|
981
|
+
if (!/not found/i.test(cause instanceof Error ? cause.message : String(cause))) failure ??= cause
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (failure) throw failure
|
|
985
|
+
registry.dropPermission(input.requestID)
|
|
986
|
+
return error(409, PERMISSION_GONE, "gone")
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
|
|
990
|
+
async function form(request: Request, rootID: string): Promise<Response> {
|
|
991
|
+
const input = await body(request)
|
|
992
|
+
if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
|
|
993
|
+
const cancel = input.cancel === true
|
|
994
|
+
if (cancel ? input.answer !== undefined : input.cancel !== undefined) return error(400, "Send either an answer or cancel: true")
|
|
995
|
+
if (!cancel) {
|
|
996
|
+
const problem = formAnswerProblem(input.answer)
|
|
997
|
+
if (problem) return error(400, problem)
|
|
998
|
+
}
|
|
999
|
+
const targets = registry.formTargets(rootID, input.formID)
|
|
1000
|
+
if (!targets.length) return error(409, PERMISSION_GONE, "gone")
|
|
1001
|
+
let failure: unknown
|
|
1002
|
+
for (const target of targets) {
|
|
1003
|
+
const req = cancel
|
|
1004
|
+
? { op: "form" as const, sessionID: target.sessionID, formID: input.formID, cancel: true as const }
|
|
1005
|
+
: { op: "form" as const, sessionID: target.sessionID, formID: input.formID, answer: input.answer as FormAnswer }
|
|
1006
|
+
try {
|
|
1007
|
+
const result = await spokes.request(target.connID, req)
|
|
1008
|
+
if (!result?.gone) return new Response(null, { status: 204 })
|
|
1009
|
+
} catch (cause) {
|
|
1010
|
+
failure ??= cause
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (failure) throw failure
|
|
1014
|
+
registry.dropForm(input.formID)
|
|
1015
|
+
return error(409, PERMISSION_GONE, "gone")
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function transcribe(request: Request, speech: Speech): Promise<Response> {
|
|
1019
|
+
const format = audioFormat(request.headers.get("content-type"))
|
|
1020
|
+
if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
|
|
1021
|
+
if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
|
|
1022
|
+
return error(413, "Recording too large")
|
|
1023
|
+
}
|
|
1024
|
+
const audio = new Uint8Array(await request.arrayBuffer())
|
|
1025
|
+
if (audio.length > config.maxAudioBytes) return error(413, "Recording too large")
|
|
1026
|
+
if (audio.length === 0) return error(400, "Empty recording")
|
|
1027
|
+
return Response.json({ text: await speech.transcribe(audio, format) })
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function events(signal: AbortSignal, userID: string, deviceID: string): Response {
|
|
1031
|
+
const encoder = new TextEncoder()
|
|
1032
|
+
let cleanup = () => {}
|
|
1033
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1034
|
+
start(controller) {
|
|
1035
|
+
const write = (chunk: string) => {
|
|
1036
|
+
try {
|
|
1037
|
+
controller.enqueue(encoder.encode(chunk))
|
|
1038
|
+
} catch {
|
|
1039
|
+
cleanup()
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const listener: Listener = {
|
|
1043
|
+
userID,
|
|
1044
|
+
deviceID,
|
|
1045
|
+
send: (message) => write(`data: ${JSON.stringify(message)}\n\n`),
|
|
1046
|
+
close: () => {
|
|
1047
|
+
cleanup()
|
|
1048
|
+
try {
|
|
1049
|
+
controller.close()
|
|
1050
|
+
} catch {}
|
|
1051
|
+
},
|
|
1052
|
+
}
|
|
1053
|
+
listener.send({ type: "catalog", machines: machines(userID) })
|
|
1054
|
+
listeners.add(listener)
|
|
1055
|
+
const heartbeat = setInterval(() => write(": ping\n\n"), SSE_HEARTBEAT_MS)
|
|
1056
|
+
cleanup = () => {
|
|
1057
|
+
clearInterval(heartbeat)
|
|
1058
|
+
listeners.delete(listener)
|
|
1059
|
+
}
|
|
1060
|
+
signal.addEventListener("abort", () => cleanup(), { once: true })
|
|
1061
|
+
},
|
|
1062
|
+
cancel() {
|
|
1063
|
+
cleanup()
|
|
1064
|
+
},
|
|
1065
|
+
})
|
|
1066
|
+
return new Response(stream, {
|
|
1067
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
|
|
1068
|
+
})
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function staticFile(pathname: string): Response {
|
|
1072
|
+
const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
|
|
1073
|
+
const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
|
|
1074
|
+
const bytes = name && type ? web.read(name) : undefined
|
|
1075
|
+
if (name === "index.html" && !bytes) {
|
|
1076
|
+
return new Response(WEB_NOT_BUILT, { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
1077
|
+
}
|
|
1078
|
+
if (!type || !bytes) return error(404, "Not found")
|
|
1079
|
+
const headers = new Headers({ "content-type": type })
|
|
1080
|
+
if (pathname === "/sw.js") {
|
|
1081
|
+
headers.set("cache-control", "no-cache")
|
|
1082
|
+
headers.set("service-worker-allowed", "/")
|
|
1083
|
+
} else if (type.startsWith("text/html")) {
|
|
1084
|
+
headers.set("cache-control", "no-cache")
|
|
1085
|
+
}
|
|
1086
|
+
return new Response(bytes, { headers })
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return {
|
|
1090
|
+
url,
|
|
1091
|
+
publicURL,
|
|
1092
|
+
store,
|
|
1093
|
+
auth,
|
|
1094
|
+
registry,
|
|
1095
|
+
spokes,
|
|
1096
|
+
push,
|
|
1097
|
+
speechSettings,
|
|
1098
|
+
routes: routes.map(({ method, path, access }) => ({ method, path, access })),
|
|
1099
|
+
async stop() {
|
|
1100
|
+
spokes.stop()
|
|
1101
|
+
for (const listener of [...listeners]) listeners.delete(listener)
|
|
1102
|
+
// Bun 1.3.5 quirk: once the server has closed a WebSocket itself, stop(true)'s promise never
|
|
1103
|
+
// resolves even though the listener is closed. Don't wait on it forever.
|
|
1104
|
+
await Promise.race([server.stop(true), Bun.sleep(250)])
|
|
1105
|
+
},
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function userView(user: UserRecord) {
|
|
1110
|
+
return { id: user.id, ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), role: user.role }
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function deviceView(device: DeviceRecord) {
|
|
1114
|
+
return { id: device.id, name: device.name, created: device.created, lastSeen: device.lastSeen }
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const PUSH_KEY = /^[A-Za-z0-9_-]{1,256}={0,2}$/
|
|
1118
|
+
|
|
1119
|
+
/** A browser PushSubscription (toJSON shape) with an https endpoint, or undefined. */
|
|
1120
|
+
function pushTarget(value: unknown): { endpoint: string; keys: { p256dh: string; auth: string } } | undefined {
|
|
1121
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
1122
|
+
const { endpoint, keys } = value as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } }
|
|
1123
|
+
if (typeof endpoint !== "string" || endpoint.length > 2048 || !endpoint.startsWith("https://")) return undefined
|
|
1124
|
+
try {
|
|
1125
|
+
new URL(endpoint)
|
|
1126
|
+
} catch {
|
|
1127
|
+
return undefined
|
|
1128
|
+
}
|
|
1129
|
+
const p256dh = keys?.p256dh
|
|
1130
|
+
const auth = keys?.auth
|
|
1131
|
+
if (typeof p256dh !== "string" || typeof auth !== "string" || !PUSH_KEY.test(p256dh) || !PUSH_KEY.test(auth)) {
|
|
1132
|
+
return undefined
|
|
1133
|
+
}
|
|
1134
|
+
return { endpoint, keys: { p256dh, auth } }
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Boolean pref fields (and 0.5.0's `blocked`) from untrusted input; undefined when nothing applies. */
|
|
1138
|
+
function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | undefined {
|
|
1139
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
1140
|
+
const prefs: Partial<PushPrefs> & LegacyPushPrefs = {}
|
|
1141
|
+
for (const key of [...PUSH_PREF_KEYS, "blocked"] as const) {
|
|
1142
|
+
const v = (value as Record<string, unknown>)[key]
|
|
1143
|
+
if (typeof v === "boolean") prefs[key] = v
|
|
1144
|
+
}
|
|
1145
|
+
return Object.keys(prefs).length ? prefs : undefined
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
|
|
1149
|
+
function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
1150
|
+
const invalid = () =>
|
|
1151
|
+
new HttpError(400, `Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} strings of at most ${VIEW_PREF_MAX_CHARS} characters`)
|
|
1152
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
|
|
1153
|
+
const record = value as Record<string, unknown>
|
|
1154
|
+
if (Object.keys(record).some((key) => !(VIEW_PREF_KEYS as readonly string[]).includes(key))) throw invalid()
|
|
1155
|
+
const out: Partial<ViewPrefs> = {}
|
|
1156
|
+
for (const key of VIEW_PREF_KEYS) {
|
|
1157
|
+
const list = record[key]
|
|
1158
|
+
if (list === undefined) continue
|
|
1159
|
+
if (!Array.isArray(list) || list.length > VIEW_PREF_MAX_ENTRIES) throw invalid()
|
|
1160
|
+
if (!list.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= VIEW_PREF_MAX_CHARS)) throw invalid()
|
|
1161
|
+
out[key] = [...new Set(list as string[])]
|
|
1162
|
+
}
|
|
1163
|
+
if (Object.keys(out).length === 0) throw invalid()
|
|
1164
|
+
return out
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const MODEL_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
1168
|
+
function modelPrefsInput(value: unknown): { voice?: ModelSelection | null; worker?: ModelSelection | null } {
|
|
1169
|
+
const invalid = () => new HttpError(400, "Expected Voice/Worker model selections")
|
|
1170
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
|
|
1171
|
+
const record = value as Record<string, unknown>
|
|
1172
|
+
if (Object.keys(record).some((key) => key !== "voice" && key !== "worker")) throw invalid()
|
|
1173
|
+
const out: { voice?: ModelSelection | null; worker?: ModelSelection | null } = {}
|
|
1174
|
+
for (const key of ["voice", "worker"] as const) {
|
|
1175
|
+
const item = record[key]
|
|
1176
|
+
if (item === undefined) continue
|
|
1177
|
+
if (item === null) { out[key] = null; continue }
|
|
1178
|
+
if (typeof item !== "object" || Array.isArray(item)) throw invalid()
|
|
1179
|
+
const m = item as Record<string, unknown>
|
|
1180
|
+
if (typeof m.providerID !== "string" || !MODEL_ID.test(m.providerID) || typeof m.modelID !== "string" || !MODEL_ID.test(m.modelID) || (m.variant !== undefined && (typeof m.variant !== "string" || !MODEL_ID.test(m.variant)))) throw invalid()
|
|
1181
|
+
out[key] = { providerID: m.providerID, modelID: m.modelID, ...(typeof m.variant === "string" ? { variant: m.variant } : {}) }
|
|
1182
|
+
}
|
|
1183
|
+
if (!("voice" in out) && !("worker" in out)) throw invalid()
|
|
1184
|
+
return out
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function param(raw: string): string {
|
|
1188
|
+
try {
|
|
1189
|
+
return decodeURIComponent(raw)
|
|
1190
|
+
} catch {
|
|
1191
|
+
throw new HttpError(400, "Bad path")
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
1196
|
+
const value = (await request.json()) as unknown
|
|
1197
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function failure(cause: unknown): Response {
|
|
1201
|
+
if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code)
|
|
1202
|
+
if (cause instanceof BusyError) return error(409, cause.message)
|
|
1203
|
+
if (cause instanceof RateLimitError) return error(429, cause.message)
|
|
1204
|
+
if (cause instanceof ValidationError) return error(400, cause.message)
|
|
1205
|
+
if (cause instanceof SyntaxError) return error(400, "Invalid JSON")
|
|
1206
|
+
if (cause instanceof SpeechError) return error(502, cause.message)
|
|
1207
|
+
if (cause instanceof SpokeError) return error(502, cause.message)
|
|
1208
|
+
if (cause instanceof RequestTooLargeError) return error(413, cause.message)
|
|
1209
|
+
if (cause instanceof OidcError) return authPage(cause.status, "Sign-in failed", cause.message)
|
|
1210
|
+
if (cause instanceof RequestTimeoutError) return error(504, cause.message)
|
|
1211
|
+
console.error("[hub] request failed:", cause)
|
|
1212
|
+
return error(500, cause instanceof Error ? cause.message : String(cause))
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export interface LocalAutoApproveInput {
|
|
1216
|
+
/** Listen address of the hub. */
|
|
1217
|
+
readonly host: string
|
|
1218
|
+
readonly publicURL: string
|
|
1219
|
+
/** Peer address of the request (server.requestIP). */
|
|
1220
|
+
readonly peer: string | undefined
|
|
1221
|
+
/** The request carries proxy forwarding headers. */
|
|
1222
|
+
readonly forwarded: boolean
|
|
1223
|
+
/** Machines ever enrolled (revoked ones included) and devices paired. */
|
|
1224
|
+
readonly machines: number
|
|
1225
|
+
readonly devices: number
|
|
1226
|
+
/** This hub process already auto-approved one enrollment. */
|
|
1227
|
+
readonly used: boolean
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* The very first machine may enroll without approval on a purely local hub: it listens on loopback
|
|
1232
|
+
* only, its public URL is loopback, the request comes straight from loopback (no proxy headers), and
|
|
1233
|
+
* nothing was ever enrolled or paired. Loopback is never trusted for anything else.
|
|
1234
|
+
*/
|
|
1235
|
+
export function localAutoApprove(input: LocalAutoApproveInput): boolean {
|
|
1236
|
+
return (
|
|
1237
|
+
isLoopbackHost(input.host) &&
|
|
1238
|
+
isLoopbackURL(input.publicURL) &&
|
|
1239
|
+
isLoopbackAddress(input.peer) &&
|
|
1240
|
+
!input.forwarded &&
|
|
1241
|
+
input.machines === 0 &&
|
|
1242
|
+
input.devices === 0 &&
|
|
1243
|
+
!input.used
|
|
1244
|
+
)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const FORWARD_HEADERS = ["forwarded", "x-forwarded-for", "x-forwarded-host", "x-real-ip"]
|