@huynle/hub 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/central/http.ts
CHANGED
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
// Hub: HTTP routes (web UI, JSON API, SSE, enrollment) and the spoke WebSocket.
|
|
2
|
-
// Phones authenticate with the `voice_device` cookie
|
|
2
|
+
// Phones authenticate with the `voice_device` cookie (or, in native apps, the same secret as a bearer device
|
|
3
|
+
// token; see ./agent/actor.ts); spokes with machine bearer tokens. Source IPs
|
|
3
4
|
// (including loopback) are never trusted.
|
|
4
5
|
import type { Server } from "bun"
|
|
5
6
|
import { diskAssets, type WebAssets } from "./assets"
|
|
6
7
|
import path from "node:path"
|
|
7
|
-
import {
|
|
8
|
+
import { promisify } from "node:util"
|
|
9
|
+
import { brotliCompress, constants as zlib, gzip } from "node:zlib"
|
|
10
|
+
import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
|
|
8
11
|
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
9
12
|
import { speakable } from "../speakable"
|
|
10
13
|
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
11
14
|
import { emailAllowed, type CentralConfig } from "./config"
|
|
12
15
|
import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
|
|
13
|
-
import { Push, type PushSender } from "./push"
|
|
16
|
+
import { isNotificationKind, Push, type PushSender } from "./push"
|
|
14
17
|
import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
|
|
15
|
-
import {
|
|
18
|
+
import { Webhooks } from "./agent/webhooks"
|
|
19
|
+
import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
|
|
20
|
+
import { hubBackends, type Backends } from "./backend"
|
|
21
|
+
import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
|
|
22
|
+
import { ActingSwitch } from "./agent/acting-switch"
|
|
23
|
+
import { AgentAudit } from "./agent/audit"
|
|
24
|
+
import { Grants } from "./agent/grants"
|
|
25
|
+
import { Decisions, type Decision } from "./agent/decisions"
|
|
26
|
+
import type { Escalate } from "./agent/acting"
|
|
27
|
+
import { Escalations } from "./agent/escalation"
|
|
28
|
+
import { GrantRequests } from "./agent/grant-requests"
|
|
29
|
+
import { partyFromActor } from "./agent/envelope"
|
|
30
|
+
import { Delivery } from "./agent/delivery"
|
|
31
|
+
import { AgentError, type AgentErrorCode } from "./agent/errors"
|
|
32
|
+
import type { Launch } from "./agent/launch-store"
|
|
33
|
+
import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
|
|
34
|
+
import { hubDirectory } from "./agent/directory-routes"
|
|
35
|
+
import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
|
|
36
|
+
import { SessionTokens } from "./agent/session-tokens"
|
|
37
|
+
import { SessionTokenPush } from "./session-token-push"
|
|
38
|
+
import { TokenKeys } from "./token-keys"
|
|
39
|
+
import { EventLog } from "./events-log"
|
|
40
|
+
import { BusyError, Registry, type RegistryMessage } from "./registry"
|
|
41
|
+
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
16
42
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
17
43
|
import { SpeechService } from "./speech/service"
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
44
|
+
import { NodeRegistry } from "./nodes"
|
|
45
|
+
import { ConnDirectory } from "./cluster/directory"
|
|
46
|
+
import { PodRpc } from "./cluster/rpc"
|
|
47
|
+
import { ClusterView } from "./cluster/view"
|
|
48
|
+
import { LeaderLoop, NoLeaderError } from "./cluster/leader"
|
|
49
|
+
import { ClusterRecommendations } from "./cluster/recommend"
|
|
50
|
+
import { LiveFanout, type LiveEvent } from "./cluster/live"
|
|
51
|
+
import { randomBytes, randomUUID } from "node:crypto"
|
|
52
|
+
import { LaunchEngine } from "./agent/launch-engine"
|
|
53
|
+
import { LaunchTokens } from "./agent/launch-tokens"
|
|
54
|
+
import { LaunchFeed, launchInfo } from "./agent/launch-routes"
|
|
55
|
+
import type { WatchRegistry } from "./agent/watch"
|
|
56
|
+
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
57
|
+
import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
|
|
20
58
|
|
|
21
59
|
export interface Speech {
|
|
22
60
|
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
23
61
|
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
62
|
+
/** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
|
|
63
|
+
speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
|
|
24
64
|
}
|
|
25
65
|
|
|
26
66
|
export interface CentralOptions {
|
|
@@ -37,16 +77,36 @@ export interface CentralOptions {
|
|
|
37
77
|
readonly notifier?: Notifier
|
|
38
78
|
/** Overrides web-push delivery (tests; web-push itself needs https push services). */
|
|
39
79
|
readonly pushSender?: PushSender
|
|
40
|
-
/** Resolves push endpoint
|
|
80
|
+
/** Resolves push endpoint and webhook hosts (SSRF checks); the system resolver by default. */
|
|
41
81
|
readonly resolveHost?: ResolveHost
|
|
42
|
-
/** Clock for sign-in expiry and the
|
|
82
|
+
/** Clock for sign-in expiry, the store, the registry and the backends (tests). */
|
|
43
83
|
readonly now?: () => number
|
|
84
|
+
/** Shared state for new code (default: in memory, see ./backend). */
|
|
85
|
+
readonly backends?: Backends
|
|
86
|
+
/** More bearer verifiers and /api/agent routes, after the built-in ones (tests). */
|
|
87
|
+
readonly agent?: { readonly verifiers?: readonly BearerVerifier[]; readonly routes?: readonly AgentRoute[] }
|
|
44
88
|
readonly timing?: {
|
|
45
89
|
readonly helloTimeoutMs?: number
|
|
46
90
|
readonly requestTimeoutMs?: number
|
|
47
91
|
readonly turnGraceMs?: number
|
|
92
|
+
/** `Prefer: respond-async` turns: how long the hub waits for a quick result before answering 202. */
|
|
93
|
+
readonly turnAcceptMs?: number
|
|
48
94
|
readonly heartbeatMs?: number
|
|
49
95
|
readonly staleMs?: number
|
|
96
|
+
/** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
|
|
97
|
+
readonly nodeTimeoutsMs?: SpokeHubOptions["nodeTimeoutsMs"]
|
|
98
|
+
/** Cluster mode: how often each pod re-checks its sockets' machines and owners (see RECHECK_MS). */
|
|
99
|
+
readonly recheckMs?: number
|
|
100
|
+
/** Cluster mode: the cluster view's heartbeat and slice TTL (see VIEW_HEARTBEAT_MS, VIEW_TTL_MS). */
|
|
101
|
+
readonly viewHeartbeatMs?: number
|
|
102
|
+
readonly viewTtlMs?: number
|
|
103
|
+
/** Leader leases of the timed jobs (see LEADER_TTL_MS). */
|
|
104
|
+
readonly leaderTtlMs?: number
|
|
105
|
+
/** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
|
|
106
|
+
readonly loopTickMs?: number
|
|
107
|
+
/** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
|
|
108
|
+
readonly drainTimeoutMs?: number
|
|
109
|
+
readonly drainRetryAfterMs?: number
|
|
50
110
|
}
|
|
51
111
|
}
|
|
52
112
|
|
|
@@ -56,11 +116,36 @@ export interface Central {
|
|
|
56
116
|
readonly store: Store
|
|
57
117
|
readonly auth: Auth
|
|
58
118
|
readonly registry: Registry
|
|
119
|
+
/** Connected `hub node` daemons (never in the registry). */
|
|
120
|
+
readonly nodes: NodeRegistry
|
|
59
121
|
readonly spokes: SpokeHub
|
|
60
122
|
readonly push: Push
|
|
61
123
|
readonly speechSettings: SpeechService
|
|
124
|
+
/** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
|
|
125
|
+
readonly backends: Backends
|
|
126
|
+
/** Agent messages: store and delivery to sessions (the message routes use it). */
|
|
127
|
+
readonly delivery: Delivery
|
|
128
|
+
/** Decisions (ask @me): answered by the user on /api/decisions. */
|
|
129
|
+
readonly decisions: Decisions
|
|
130
|
+
/** Kill switches (per user and hub-wide) and hub.json agents.never, for the acting verbs. */
|
|
131
|
+
readonly acting: ActingSwitch
|
|
132
|
+
/** Launches (design §6): accept, list, stop; placement onto nodes and each launch's run to its end. */
|
|
133
|
+
readonly launches: LaunchEngine
|
|
134
|
+
/** Every launch change, per user (SSE 'launch' events and the agent watch subscribe; webhooks hang off it). */
|
|
135
|
+
readonly launchFeed: LaunchFeed
|
|
136
|
+
/** Launch-bound (hlt1) tokens: what a sandboxed harness connects back to /spoke/ws with (phase 5). */
|
|
137
|
+
readonly launchTokens: LaunchTokens
|
|
138
|
+
/** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
|
|
139
|
+
readonly webhooks: Webhooks
|
|
62
140
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
63
141
|
readonly routes: readonly RouteInfo[]
|
|
142
|
+
/**
|
|
143
|
+
* Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
|
|
144
|
+
* nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
|
|
145
|
+
* are released, cluster view slices withdrawn, SSE streams end with a `retry:` hint, and it resolves when requests
|
|
146
|
+
* in flight are done (at most timeoutMs; the rest fail as offline). Call stop() after it. Repeated calls share one.
|
|
147
|
+
*/
|
|
148
|
+
drain(options?: DrainOptions): Promise<void>
|
|
64
149
|
stop(): Promise<void>
|
|
65
150
|
}
|
|
66
151
|
|
|
@@ -69,12 +154,25 @@ type Message = { type: string; [key: string]: unknown }
|
|
|
69
154
|
interface Listener {
|
|
70
155
|
readonly userID: string
|
|
71
156
|
readonly deviceID: string
|
|
72
|
-
|
|
157
|
+
/** Leaves out streamed text and reasoning (except of `session`'s tree). */
|
|
158
|
+
readonly lite: boolean
|
|
159
|
+
/** The session the page shows: its tree's text and reasoning stream even when lite. */
|
|
160
|
+
readonly session?: string
|
|
161
|
+
/** Sends a message; `id` is its event id (EventSource's lastEventId). */
|
|
162
|
+
send(message: Message, id?: string): void
|
|
73
163
|
close(): void
|
|
164
|
+
/** Draining: tells the browser to reconnect after retryMs (`retry:`), then ends the stream. */
|
|
165
|
+
end(retryMs: number): void
|
|
74
166
|
}
|
|
75
167
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
168
|
+
/** Messages a lite stream leaves out (unless they are of the listener's session tree). */
|
|
169
|
+
const STREAMED = new Set(["delta", "reasoning"])
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
|
|
173
|
+
* "agent": an /api/agent route (src/central/agent), checked per credential kind there.
|
|
174
|
+
*/
|
|
175
|
+
export type Access = "public" | "user" | "admin" | "session" | "machine" | "agent"
|
|
78
176
|
|
|
79
177
|
export interface RouteInfo {
|
|
80
178
|
readonly method: string
|
|
@@ -107,7 +205,23 @@ const NO_STORE = { "cache-control": "no-store" }
|
|
|
107
205
|
export const COOKIE = "voice_device"
|
|
108
206
|
const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
|
|
109
207
|
const SSE_HEARTBEAT_MS = 20_000
|
|
208
|
+
/** Draining: the `retry:` an ending SSE stream sends (the browser reconnects, through the load balancer, after it). */
|
|
209
|
+
const SSE_RETRY_MS = 2_000
|
|
210
|
+
const SSE_RETRY = `retry: ${SSE_RETRY_MS}\n\n`
|
|
211
|
+
/** /readyz in cluster mode: how long the database and Valkey get to answer. */
|
|
212
|
+
const READY_CHECK_MS = 2_000
|
|
213
|
+
/** Cluster mode: how much longer than the turn timeout a turn's cluster-wide lease lasts (it is released at its end). */
|
|
214
|
+
const TURN_LEASE_SLACK_MS = 120_000
|
|
215
|
+
/** Cluster mode: how long a stop sent to the launches leader may take (it waits for the node's collect and cleanup). */
|
|
216
|
+
const LAUNCH_STOP_TIMEOUT_MS = 180_000
|
|
217
|
+
/**
|
|
218
|
+
* `POST …/turn` with `Prefer: respond-async`: a turn that has not settled after this long is answered 202 and
|
|
219
|
+
* finishes in the background (result as an SSE `turn` event). Long enough for the quick refusals (busy, unknown
|
|
220
|
+
* session, spoke error), short enough that no proxy cuts the request (Traefik's default readTimeout is 60 s).
|
|
221
|
+
*/
|
|
222
|
+
export const TURN_ACCEPT_MS = 1500
|
|
110
223
|
const NO_OWNER = "No OpenCode process for this session is connected"
|
|
224
|
+
const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
|
|
111
225
|
const NOT_EXPOSED = "Voice is not enabled for this session"
|
|
112
226
|
/** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
|
|
113
227
|
const PERMISSION_GONE = "This request was already answered or expired"
|
|
@@ -118,6 +232,8 @@ const MAX_TITLE_CHARS = 200
|
|
|
118
232
|
const MAX_HERDR_WORKSPACE_CHARS = 100
|
|
119
233
|
/** View preferences: entries per list and characters per entry. */
|
|
120
234
|
const VIEW_PREF_MAX_ENTRIES = 500
|
|
235
|
+
/** hiddenSessions holds archived sessions (Archive in the console), which add up over time. */
|
|
236
|
+
const VIEW_PREF_MAX_ARCHIVED = 5000
|
|
121
237
|
const VIEW_PREF_MAX_CHARS = 4096
|
|
122
238
|
const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
|
|
123
239
|
/** Longest utterance accepted for conversation mode (characters, after trimming). */
|
|
@@ -149,6 +265,29 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
149
265
|
".svg": "image/svg+xml",
|
|
150
266
|
".png": "image/png",
|
|
151
267
|
".ico": "image/x-icon",
|
|
268
|
+
// Voice mode's on-device speech detection (Silero VAD through onnxruntime-web)
|
|
269
|
+
".wasm": "application/wasm",
|
|
270
|
+
".onnx": "application/octet-stream",
|
|
271
|
+
}
|
|
272
|
+
/** Vite's content-hashed build output: a changed file gets a new name, so it can be cached for good. */
|
|
273
|
+
const HASHED_ASSET = /^assets\//
|
|
274
|
+
const IMMUTABLE = "public, max-age=31536000, immutable"
|
|
275
|
+
/** Hashed assets sent compressed when the browser accepts it (onnxruntime's wasm: 12 MB → 2.3 MB with Brotli). */
|
|
276
|
+
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".wasm", ".json", ".svg"])
|
|
277
|
+
const MIN_COMPRESS_BYTES = 1024
|
|
278
|
+
const brotli = promisify(brotliCompress)
|
|
279
|
+
const gzipAsync = promisify(gzip)
|
|
280
|
+
|
|
281
|
+
/** "br" or "gzip" when the Accept-Encoding header allows it (q=0 refuses), else undefined. */
|
|
282
|
+
function pickEncoding(header: string | null): "br" | "gzip" | undefined {
|
|
283
|
+
const accepted = new Map<string, number>()
|
|
284
|
+
for (const part of (header ?? "").toLowerCase().split(",")) {
|
|
285
|
+
const [name, ...params] = part.trim().split(";")
|
|
286
|
+
const q = params.map((p) => /^\s*q=([\d.]+)\s*$/.exec(p)?.[1]).find(Boolean)
|
|
287
|
+
if (name) accepted.set(name.trim(), q === undefined ? 1 : Number(q))
|
|
288
|
+
}
|
|
289
|
+
for (const encoding of ["br", "gzip"] as const) if ((accepted.get(encoding) ?? 0) > 0) return encoding
|
|
290
|
+
return undefined
|
|
152
291
|
}
|
|
153
292
|
|
|
154
293
|
class HttpError extends Error {
|
|
@@ -162,38 +301,111 @@ class HttpError extends Error {
|
|
|
162
301
|
}
|
|
163
302
|
}
|
|
164
303
|
|
|
304
|
+
/** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
|
|
305
|
+
const unsupportedError = (lacking: Pick<Unsupported, "feature" | "harness">) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
|
|
306
|
+
|
|
165
307
|
const error = (status: number, message: string, code?: string) =>
|
|
166
308
|
Response.json({ error: message, ...(code ? { code } : {}) }, { status })
|
|
167
309
|
|
|
168
310
|
export function startCentral(options: CentralOptions): Central {
|
|
169
311
|
const { config } = options
|
|
312
|
+
const heartbeatMs = options.timing?.heartbeatMs ?? SSE_HEARTBEAT_MS
|
|
170
313
|
const web: WebAssets = options.web ?? diskAssets(options.webDir ?? path.join(import.meta.dir, "..", "..", "web", "dist"))
|
|
171
314
|
const now = options.now ?? Date.now
|
|
172
|
-
|
|
315
|
+
/** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
|
|
316
|
+
const ownBackends = !options.backends
|
|
317
|
+
const backends = options.backends ?? hubBackends(config, { now })
|
|
318
|
+
// Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
|
|
319
|
+
// migration runs on start). Otherwise state.json in the state dir.
|
|
320
|
+
const store = config.cluster
|
|
321
|
+
? Store.shared({ state: backends.state, bus: backends.bus, now, instanceID: `${config.cluster.podID}-${randomUUID().slice(0, 8)}` })
|
|
322
|
+
: Store.open(config.stateDir, now, { mode: config.auth.mode })
|
|
173
323
|
// A machine's tenant is its owner (read live: ownership moves on migration).
|
|
174
|
-
const registry = new Registry(
|
|
175
|
-
const
|
|
324
|
+
const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
|
|
325
|
+
const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
|
|
326
|
+
const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
|
|
176
327
|
const notifier = options.notifier ?? push
|
|
177
328
|
/** SSE subscribers, one user each. */
|
|
178
329
|
const listeners = new Set<Listener>()
|
|
330
|
+
/** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
|
|
331
|
+
const eventLog = new EventLog()
|
|
332
|
+
/** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
|
|
333
|
+
const wants = (listener: Listener, message: Message): boolean => {
|
|
334
|
+
if (!listener.lite || !STREAMED.has(message.type)) return true
|
|
335
|
+
if (!listener.session) return false
|
|
336
|
+
const root = registry.status(listener.session)?.[0]?.id ?? listener.session
|
|
337
|
+
return message.sessionID === listener.session || message.rootID === root || message.sessionID === root
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
|
|
341
|
+
* EventLog first (except streamed text), so consoles on any pod get them and resume by Last-Event-ID on any pod.
|
|
342
|
+
*/
|
|
343
|
+
const live = config.cluster ? new LiveFanout({ bus: backends.bus, events: backends.events, podID: config.cluster.podID }) : undefined
|
|
344
|
+
/** Sends a message to this process's streams of a user (except `exceptDevice`'s); a catalog is built per user. */
|
|
345
|
+
const deliver = (userID: string, message: Message, id?: string, exceptDevice?: string) => {
|
|
346
|
+
for (const listener of [...listeners]) {
|
|
347
|
+
if (listener.userID !== userID || listener.deviceID === exceptDevice) continue
|
|
348
|
+
if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(userID) }, id)
|
|
349
|
+
else if (wants(listener, message)) listener.send(message, id)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** Logs a message for one user and sends it to their streams (except `exceptDevice`'s). */
|
|
353
|
+
const toUser = (userID: string, message: Message, exceptDevice?: string) => {
|
|
354
|
+
if (live) {
|
|
355
|
+
// A catalog is this pod's own view (its registry covers the cluster): never logged, never sent to other pods.
|
|
356
|
+
if (message.type === "catalog") deliver(userID, message)
|
|
357
|
+
// Failures are logged by the fan-out.
|
|
358
|
+
else live.send(userID, message, exceptDevice).catch(() => {})
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
// A catalog is logged as a marker: a replay sends one fresh catalog for any number of them.
|
|
362
|
+
const id = eventLog.append(userID, message.type === "catalog" ? { type: "catalog" } : message, exceptDevice)
|
|
363
|
+
deliver(userID, message, id, exceptDevice)
|
|
364
|
+
}
|
|
365
|
+
/** Closes the streams of users who were disabled or removed. */
|
|
366
|
+
const closeDisabled = () => {
|
|
367
|
+
for (const listener of [...listeners]) {
|
|
368
|
+
const user = store.user(listener.userID)
|
|
369
|
+
if (!user || user.disabled) listener.close()
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Users that may receive an `audience` message (cluster mode: whether or not they stream on this pod). */
|
|
373
|
+
const audienceUsers = (audience: { tenant?: string; admins?: true }): string[] => {
|
|
374
|
+
if (audience.admins) return store.users().filter((u) => u.role === "admin" && !u.disabled).map((u) => u.id)
|
|
375
|
+
if (audience.tenant === undefined) return []
|
|
376
|
+
const user = store.user(audience.tenant)
|
|
377
|
+
return user && !user.disabled ? [user.id] : []
|
|
378
|
+
}
|
|
179
379
|
/**
|
|
180
380
|
* Sends to SSE subscribers: catalog to everyone (each gets their own machines), `admins` messages to
|
|
181
381
|
* admins only, anything else only to `tenant` (the session's owner). Streams of users who were
|
|
182
382
|
* disabled are closed.
|
|
183
383
|
*/
|
|
184
384
|
const broadcast = (message: Message, audience: { tenant?: string; admins?: true } = {}) => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
385
|
+
closeDisabled()
|
|
386
|
+
if (live) {
|
|
387
|
+
// Stored machines changed: every pod sends its streams a fresh catalog (catalogHere).
|
|
388
|
+
if (message.type === "catalog") live.publishCatalog().catch((cause) => console.error(`[hub] catalog announcement failed: ${(cause as Error).message}`))
|
|
389
|
+
else for (const userID of audienceUsers(audience)) toUser(userID, message)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const users = new Set([...eventLog.users(), ...[...listeners].map((l) => l.userID)])
|
|
393
|
+
for (const userID of users) {
|
|
394
|
+
const user = store.user(userID)
|
|
395
|
+
if (!user || user.disabled) continue
|
|
396
|
+
if (message.type === "catalog") toUser(userID, message)
|
|
192
397
|
else if (audience.admins) {
|
|
193
|
-
if (user.role === "admin")
|
|
194
|
-
} else if (audience.tenant !== undefined && audience.tenant ===
|
|
398
|
+
if (user.role === "admin") toUser(userID, message)
|
|
399
|
+
} else if (audience.tenant !== undefined && audience.tenant === userID) toUser(userID, message)
|
|
195
400
|
}
|
|
196
401
|
}
|
|
402
|
+
/** Cluster mode: a fresh catalog to this pod's streams (the registry's cluster-wide view changed). */
|
|
403
|
+
const catalogHere = () => {
|
|
404
|
+
closeDisabled()
|
|
405
|
+
for (const userID of new Set([...listeners].map((l) => l.userID))) toUser(userID, { type: "catalog" })
|
|
406
|
+
}
|
|
407
|
+
live?.onCatalog(catalogHere)
|
|
408
|
+
live?.start().catch((cause) => console.error(`[hub] live fan-out did not start: ${(cause as Error).message}`))
|
|
197
409
|
const speechSettings = new SpeechService({
|
|
198
410
|
store,
|
|
199
411
|
defaults: {
|
|
@@ -210,6 +422,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
210
422
|
let publicURL = config.publicURL
|
|
211
423
|
let auth!: Auth
|
|
212
424
|
let spokes!: SpokeHub
|
|
425
|
+
/** Set by drain() (design §10): /readyz fails and new SSE streams are sent away. */
|
|
426
|
+
let draining: Promise<void> | undefined
|
|
427
|
+
/** Aborted by drain(): the agent watch streams end with a `retry:` hint. */
|
|
428
|
+
const drainSignal = new AbortController()
|
|
213
429
|
/** At most one machine per hub process is approved without confirmation (see localAutoApprove). */
|
|
214
430
|
let autoApproved = false
|
|
215
431
|
|
|
@@ -233,22 +449,62 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
233
449
|
const origins = loopbackOrigins(publicURL)
|
|
234
450
|
const secure = publicURL.startsWith("https:")
|
|
235
451
|
|
|
236
|
-
auth = new Auth({ store, publicURL, now, mode: config.auth.mode })
|
|
452
|
+
auth = new Auth({ store, publicURL, now, mode: config.auth.mode, kv: backends.kv, counters: backends.counters })
|
|
237
453
|
// 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
|
|
454
|
+
const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
|
|
239
455
|
/** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
|
|
240
456
|
const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
|
|
241
457
|
const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
|
|
458
|
+
const tokenKeys = TokenKeys.open(config.stateDir)
|
|
459
|
+
// Launch-bound tokens (phase 5): checked against the launch engine's store (created below; read only at use).
|
|
460
|
+
const launchTokens: LaunchTokens = new LaunchTokens({ keys: tokenKeys, store, launches: { launch: (userID, launchID) => launches.store.get(userID, launchID) }, publicURL, now })
|
|
242
461
|
spokes = new SpokeHub({
|
|
243
462
|
store,
|
|
244
463
|
auth,
|
|
245
464
|
registry,
|
|
465
|
+
nodes,
|
|
246
466
|
publicURL,
|
|
247
467
|
notifier,
|
|
248
468
|
broadcast: (message, tenant) => broadcast(message, { tenant }),
|
|
249
469
|
turnTimeoutMs: config.turnTimeoutMs,
|
|
470
|
+
launchTokens,
|
|
471
|
+
// Cluster mode: connections are found through the shared directory and reached over pod RPC (design §9).
|
|
472
|
+
...(config.cluster
|
|
473
|
+
? { cluster: { directory: new ConnDirectory({ kv: backends.kv, podID: config.cluster.podID }), rpc: new PodRpc({ bus: backends.bus, podID: config.cluster.podID }) } }
|
|
474
|
+
: {}),
|
|
250
475
|
...options.timing,
|
|
251
476
|
})
|
|
477
|
+
spokes.ready().catch((cause) => console.error(`[hub] cluster routing did not start: ${(cause as Error).message}`))
|
|
478
|
+
// Cluster mode: this pod publishes its connections (spokes and nodes) per user and mirrors the other pods', so the
|
|
479
|
+
// registries' reads cover the whole cluster (design §9).
|
|
480
|
+
const view = config.cluster
|
|
481
|
+
? new ClusterView({
|
|
482
|
+
catalog: backends.catalog,
|
|
483
|
+
bus: backends.bus,
|
|
484
|
+
podID: config.cluster.podID,
|
|
485
|
+
registry,
|
|
486
|
+
nodes,
|
|
487
|
+
users: async () => {
|
|
488
|
+
await store.ready()
|
|
489
|
+
return store.users().map((u) => u.id)
|
|
490
|
+
},
|
|
491
|
+
...(options.timing?.viewHeartbeatMs !== undefined ? { heartbeatMs: options.timing.viewHeartbeatMs } : {}),
|
|
492
|
+
...(options.timing?.viewTtlMs !== undefined ? { ttlMs: options.timing.viewTtlMs } : {}),
|
|
493
|
+
})
|
|
494
|
+
: undefined
|
|
495
|
+
view?.start().catch((cause) => console.error(`[hub] cluster view did not start: ${(cause as Error).message}`))
|
|
496
|
+
// Session tokens (design §1.3): minted for the sessions spokes report, pushed on their sessions frames.
|
|
497
|
+
const sessionTokens = new SessionTokens({ keys: tokenKeys, store, sessions: registry, publicURL, now })
|
|
498
|
+
const tokenPush = new SessionTokenPush({
|
|
499
|
+
registry,
|
|
500
|
+
tokens: sessionTokens,
|
|
501
|
+
send: (connID, req) => spokes.request(connID, req),
|
|
502
|
+
hubURL: publicURL,
|
|
503
|
+
now,
|
|
504
|
+
log: (message) => console.error(message),
|
|
505
|
+
})
|
|
506
|
+
registry.onSessions((connID) => void tokenPush.sessions(connID))
|
|
507
|
+
tokenPush.start()
|
|
252
508
|
|
|
253
509
|
/** The user's machines with their live processes and sessions. */
|
|
254
510
|
const machines = (userID: string) => {
|
|
@@ -267,12 +523,20 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
267
523
|
}))
|
|
268
524
|
}
|
|
269
525
|
|
|
270
|
-
|
|
526
|
+
// Cluster mode: a catalog change stays on this pod (each pod's registry emits its own for remote slices); the rest
|
|
527
|
+
// goes to the tenant's streams on every pod.
|
|
528
|
+
registry.subscribe((message, tenant) => (live && message.type === "catalog" ? catalogHere() : broadcast({ ...message }, tenant !== undefined ? { tenant } : {})))
|
|
271
529
|
registry.subscribeAlerts((alert) => {
|
|
272
530
|
// Push goes to the session owner's devices only; an alert without an owner goes nowhere.
|
|
273
531
|
if (!alert.owner) return
|
|
274
|
-
|
|
275
|
-
|
|
532
|
+
// Activity updates are collected and throttled per device by the push layer.
|
|
533
|
+
if (alert.kind === "activity") return push.activity(alert)
|
|
534
|
+
const extra = {
|
|
535
|
+
...(alert.requestID ? { requestID: alert.requestID } : {}),
|
|
536
|
+
...(alert.subject ? { subject: alert.subject } : {}),
|
|
537
|
+
...(alert.key ? { key: alert.key } : {}),
|
|
538
|
+
}
|
|
539
|
+
notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
|
|
276
540
|
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
|
277
541
|
})
|
|
278
542
|
})
|
|
@@ -284,6 +548,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
284
548
|
{
|
|
285
549
|
prefs: store.pushPrefs(deviceID),
|
|
286
550
|
quietHours: store.quietHours(deviceID) ?? null,
|
|
551
|
+
activityMinutes: store.activityMinutes(deviceID),
|
|
287
552
|
muted: store.mutedSessions(userID),
|
|
288
553
|
subscriptions: mine.length,
|
|
289
554
|
...(endpoint ? { subscribed: mine.some((s) => s.endpoint === endpoint) } : {}),
|
|
@@ -296,12 +561,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
296
561
|
`${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`
|
|
297
562
|
|
|
298
563
|
/** The device of the voice_device cookie and its user; undefined when unknown or the user is disabled. */
|
|
299
|
-
|
|
564
|
+
/** The device of a token (cookie value or device token), its owner re-read too (a shared store's security re-check). */
|
|
565
|
+
const deviceOfToken = async (token: string): Promise<DeviceRecord | undefined> => {
|
|
566
|
+
const device = await store.findDevice(token)
|
|
567
|
+
if (device) await store.recheck({ users: [device.ownerID] })
|
|
568
|
+
return device
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const deviceFor = async (request: Request): Promise<{ device: DeviceRecord; user: UserRecord } | undefined> => {
|
|
300
572
|
const header = request.headers.get("cookie") ?? ""
|
|
301
573
|
for (const part of header.split(";")) {
|
|
302
574
|
const [name, ...rest] = part.trim().split("=")
|
|
303
575
|
if (name !== COOKIE) continue
|
|
304
|
-
const device =
|
|
576
|
+
const device = await deviceOfToken(rest.join("="))
|
|
305
577
|
if (!device) continue
|
|
306
578
|
const user = store.user(device.ownerID)
|
|
307
579
|
return user && !user.disabled ? { device, user } : undefined
|
|
@@ -329,34 +601,34 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
329
601
|
result = await oidc!.callback(url.searchParams, cookieValue(request.headers.get("cookie"), OIDC_COOKIE))
|
|
330
602
|
} catch (cause) {
|
|
331
603
|
if (!(cause instanceof OidcError)) throw cause
|
|
332
|
-
if (cause.audited) store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
|
|
604
|
+
if (cause.audited) await store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
|
|
333
605
|
else console.warn(`[hub] sign-in refused: ${cause.reason}`)
|
|
334
606
|
return deny(cause.status, "Sign-in failed", cause.message)
|
|
335
607
|
}
|
|
336
608
|
const { identity, next } = result
|
|
337
609
|
if (!emailAllowed(settings, identity.email)) {
|
|
338
|
-
store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
|
|
610
|
+
await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
|
|
339
611
|
return deny(403, "Not allowed", `${identity.email} is not allowed to use this hub. Ask its admin to add you.`)
|
|
340
612
|
}
|
|
341
613
|
const role = settings.admins.includes(identity.email) ? "admin" : "user"
|
|
342
614
|
const existing = store.userByEmail(identity.email)
|
|
343
615
|
if (existing?.disabled) {
|
|
344
|
-
store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
|
|
616
|
+
await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
|
|
345
617
|
return deny(403, "Account disabled", "Your account on this hub is disabled. Ask its admin.")
|
|
346
618
|
}
|
|
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" })
|
|
619
|
+
const user = await store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
|
|
620
|
+
if (user.role === "admin" && await store.migrateLocal(user.id)) broadcast({ type: "catalog" })
|
|
349
621
|
const headers = new Headers({ "cache-control": "no-store" })
|
|
350
622
|
headers.append("set-cookie", cleared["set-cookie"])
|
|
351
|
-
const current = deviceFor(request)
|
|
623
|
+
const current = await deviceFor(request)
|
|
352
624
|
let deviceID: string
|
|
353
625
|
if (current && current.user.id === user.id) deviceID = current.device.id
|
|
354
626
|
else {
|
|
355
|
-
const { device, token } = store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
|
|
627
|
+
const { device, token } = await store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
|
|
356
628
|
deviceID = device.id
|
|
357
629
|
headers.append("set-cookie", cookie(token))
|
|
358
630
|
}
|
|
359
|
-
store.audit({ actor: user.id, action: "signin", target: deviceID })
|
|
631
|
+
await store.audit({ actor: user.id, action: "signin", target: deviceID })
|
|
360
632
|
// Speech not usable yet (e.g. no key): land on the page's speech setup (the console opens Settings → Speech for #speech).
|
|
361
633
|
const setup = !next.includes("#") && speechSettings.view(speechScope(user.id)).ready.reason ? "#speech" : ""
|
|
362
634
|
headers.set("location", `${next}${setup}`)
|
|
@@ -376,17 +648,299 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
376
648
|
}
|
|
377
649
|
}
|
|
378
650
|
|
|
651
|
+
/** A push to the user about one session (titled by it; "" opens the console), whatever device shows it. */
|
|
652
|
+
const pushUser = (kind: "decision" | "message", userID: string, sessionID: string, key: string, decisionID?: string) => {
|
|
653
|
+
const title = (sessionID && registry.status(sessionID)?.find((s) => s.id === sessionID)?.title) || "Hub"
|
|
654
|
+
notifier.notify(kind, sessionID, title, undefined, userID, { key, ...(decisionID ? { decisionID } : {}) }).catch((cause) => {
|
|
655
|
+
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const directory = hubDirectory({ registry, store, now })
|
|
660
|
+
/** This process as owner of leases (leader loops, flushes, turns): `<pod>:<pid>-<random>`. */
|
|
661
|
+
const leaseOwner = `${config.cluster?.podID ?? "hub"}:${process.pid}-${randomBytes(4).toString("hex")}`
|
|
662
|
+
/** Every stored user (the leader loops look at all of them). */
|
|
663
|
+
const allUsers = async () => {
|
|
664
|
+
await store.ready()
|
|
665
|
+
return store.users().map((u) => u.id)
|
|
666
|
+
}
|
|
667
|
+
const loopTickMs = options.timing?.loopTickMs
|
|
668
|
+
// Timed jobs run on one pod at a time, under a fenced leader lease (design §9; a single hub always leads).
|
|
669
|
+
const leaders: LeaderLoop[] = []
|
|
670
|
+
const leader = (name: string, handlers: Pick<ConstructorParameters<typeof LeaderLoop>[0], "onElected" | "onDeposed" | "onKick" | "onRequest">) => {
|
|
671
|
+
const loop = new LeaderLoop({
|
|
672
|
+
locks: backends.locks,
|
|
673
|
+
bus: backends.bus,
|
|
674
|
+
name,
|
|
675
|
+
owner: leaseOwner,
|
|
676
|
+
...(options.timing?.leaderTtlMs !== undefined ? { ttlMs: options.timing.leaderTtlMs } : {}),
|
|
677
|
+
...handlers,
|
|
678
|
+
})
|
|
679
|
+
leaders.push(loop)
|
|
680
|
+
return loop
|
|
681
|
+
}
|
|
682
|
+
// Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
|
|
683
|
+
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
684
|
+
const delivery = new Delivery({
|
|
685
|
+
state: backends.state,
|
|
686
|
+
counters: backends.counters,
|
|
687
|
+
registry,
|
|
688
|
+
directory,
|
|
689
|
+
send: (connID, req) => spokes.request(connID, req),
|
|
690
|
+
now,
|
|
691
|
+
describe: (userID, sessionID) => {
|
|
692
|
+
try {
|
|
693
|
+
const entry = directory.resolve(userID, sessionID).sessions[0]
|
|
694
|
+
const machine = entry?.machineID ? store.machines().find((m) => m.id === entry.machineID)?.name : undefined
|
|
695
|
+
return { ...(entry?.name ? { name: entry.name } : {}), ...(machine ? { machine } : {}) }
|
|
696
|
+
} catch {
|
|
697
|
+
return {}
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
// Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
|
|
701
|
+
alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
|
|
702
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
703
|
+
// Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
|
|
704
|
+
...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
|
|
705
|
+
})
|
|
706
|
+
/** Message events (watch, waiters), shared by the agent routes and the console's Decision answers. */
|
|
707
|
+
const feed = new MessageFeed()
|
|
708
|
+
// Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
|
|
709
|
+
if (config.cluster) feed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] message feed did not attach: ${(cause as Error).message}`))
|
|
710
|
+
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
711
|
+
const decisions = new Decisions({
|
|
712
|
+
state: backends.state,
|
|
713
|
+
delivery,
|
|
714
|
+
now,
|
|
715
|
+
users: () => store.users().map((u) => u.id),
|
|
716
|
+
notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
|
|
717
|
+
// The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
|
|
718
|
+
changed: (userID, decision) => broadcast({ type: "decision", decision }, { tenant: userID }),
|
|
719
|
+
// Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user
|
|
720
|
+
// (both are set up below).
|
|
721
|
+
act: (userID, decision, choice, by) => (decision.action?.kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)),
|
|
722
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
723
|
+
})
|
|
724
|
+
leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
|
|
725
|
+
/** The user prompted the session: its message hop chain starts over (never fails the prompt). */
|
|
726
|
+
const humanTurn = async (sessionID: string): Promise<void> => {
|
|
727
|
+
const tenant = registry.sessionTenant(sessionID)
|
|
728
|
+
if (tenant === undefined) return
|
|
729
|
+
await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
|
|
730
|
+
}
|
|
731
|
+
registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
|
|
732
|
+
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
733
|
+
/** Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. */
|
|
734
|
+
const actions = createActions({
|
|
735
|
+
registry,
|
|
736
|
+
spokes,
|
|
737
|
+
humanTurn,
|
|
738
|
+
kv: backends.kv,
|
|
739
|
+
// Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
|
|
740
|
+
...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
|
|
741
|
+
})
|
|
742
|
+
leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
|
|
743
|
+
// Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
|
|
744
|
+
// service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
|
|
745
|
+
// console streams get it as a 'launch' event.
|
|
746
|
+
const launchFeed = new LaunchFeed()
|
|
747
|
+
// Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
|
|
748
|
+
// launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
|
|
749
|
+
if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
|
|
750
|
+
launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
|
|
751
|
+
const launches = new LaunchEngine({
|
|
752
|
+
state: backends.state,
|
|
753
|
+
counters: backends.counters,
|
|
754
|
+
locks: backends.locks,
|
|
755
|
+
owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
|
|
756
|
+
...(config.launchCaps ? { caps: config.launchCaps } : {}),
|
|
757
|
+
nodes,
|
|
758
|
+
nodeOps: spokes,
|
|
759
|
+
registry,
|
|
760
|
+
spokes,
|
|
761
|
+
directory,
|
|
762
|
+
delivery,
|
|
763
|
+
decisions,
|
|
764
|
+
actions,
|
|
765
|
+
// openshell launches: the node starts the sandbox's harness with an hlt1 token and the hub's public URL.
|
|
766
|
+
launchTokens,
|
|
767
|
+
hubURL: publicURL,
|
|
768
|
+
users: () => store.users().map((u) => u.id),
|
|
769
|
+
now,
|
|
770
|
+
onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
|
|
771
|
+
// Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
|
|
772
|
+
...(config.cluster
|
|
773
|
+
? {
|
|
774
|
+
supervisor: {
|
|
775
|
+
kick: (userID: string) => void launchLeader.kick({ userID }).catch((cause) => console.error(`[hub] launch kick failed: ${(cause as Error).message}`)),
|
|
776
|
+
stop: async (userID: string, id: string, reason: string) => {
|
|
777
|
+
let reply: { launch?: Launch; error?: { code: AgentErrorCode; message: string } }
|
|
778
|
+
try {
|
|
779
|
+
reply = await launchLeader.request({ op: "stop", userID, id, reason }, { timeoutMs: LAUNCH_STOP_TIMEOUT_MS })
|
|
780
|
+
} catch (cause) {
|
|
781
|
+
if (cause instanceof NoLeaderError) return undefined
|
|
782
|
+
throw cause
|
|
783
|
+
}
|
|
784
|
+
if (reply.error) throw new AgentError(reply.error.code, reply.error.message)
|
|
785
|
+
return reply.launch
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
}
|
|
789
|
+
: {}),
|
|
790
|
+
})
|
|
791
|
+
// A launch-bound connection lives only while its launch is provisioning or running: it is closed (and refused from
|
|
792
|
+
// then on) when the launch ends. Not on queued: that would refuse the sandbox's spoke before it ever connects.
|
|
793
|
+
launchFeed.subscribe((_userID, launch) => {
|
|
794
|
+
if (launch.state === "ended") spokes.closeLaunch(launch.id)
|
|
795
|
+
}, { origin: true })
|
|
796
|
+
const launchLeader = leader("launches", {
|
|
797
|
+
onElected: () => launches.start().catch((cause) => console.error(`[hub] launches did not start: ${(cause as Error).message}`)),
|
|
798
|
+
onDeposed: () => launches.shutdown(),
|
|
799
|
+
onKick: (message) => {
|
|
800
|
+
const userID = (message as { userID?: unknown } | undefined)?.userID
|
|
801
|
+
if (typeof userID === "string") launches.kick(userID)
|
|
802
|
+
},
|
|
803
|
+
onRequest: async (message) => {
|
|
804
|
+
const { op, userID, id, reason } = (message ?? {}) as { op?: unknown; userID?: unknown; id?: unknown; reason?: unknown }
|
|
805
|
+
if (op !== "stop" || typeof userID !== "string" || typeof id !== "string" || typeof reason !== "string") throw new Error("Invalid launch supervisor request")
|
|
806
|
+
try {
|
|
807
|
+
return { launch: await launches.stop(userID, id, reason) }
|
|
808
|
+
} catch (cause) {
|
|
809
|
+
if (cause instanceof AgentError) return { error: { code: cause.code, message: cause.message } }
|
|
810
|
+
throw cause
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
})
|
|
814
|
+
// Webhooks (P4-T10): launch changes go to the owner's matching registrations through the durable outbox; URLs
|
|
815
|
+
// are checked again at every send; private targets only with hub.json agents.webhooks.allowPrivate.
|
|
816
|
+
const webhooks = new Webhooks({
|
|
817
|
+
state: backends.state,
|
|
818
|
+
locks: backends.locks,
|
|
819
|
+
now,
|
|
820
|
+
source: publicURL,
|
|
821
|
+
users: () => store.users().map((u) => u.id),
|
|
822
|
+
resolve: options.resolveHost ?? systemResolveHost,
|
|
823
|
+
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
824
|
+
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
|
|
825
|
+
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
826
|
+
})
|
|
827
|
+
launchFeed.subscribe((userID, launch) => {
|
|
828
|
+
void webhooks
|
|
829
|
+
.onLaunch(userID, launch, launchInfo(launch))
|
|
830
|
+
// Queued here, sent by the webhooks leader (kicked when that is another pod).
|
|
831
|
+
.then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))
|
|
832
|
+
.catch((cause) => console.error(`[hub] webhook queue failed: ${(cause as Error).message}`))
|
|
833
|
+
}, { origin: true })
|
|
834
|
+
const webhookLeader = leader("webhooks", {
|
|
835
|
+
onElected: () => webhooks.start(),
|
|
836
|
+
onDeposed: () => webhooks.stop(),
|
|
837
|
+
onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
|
|
838
|
+
})
|
|
839
|
+
for (const loop of leaders) void loop.start()
|
|
840
|
+
|
|
841
|
+
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
842
|
+
// Every stored agent message also goes to the user's console streams (live transcript rows).
|
|
843
|
+
feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
|
|
844
|
+
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }) })
|
|
845
|
+
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now })
|
|
846
|
+
const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
|
|
847
|
+
/** An agent as envelopes name it (a session by its directory name). */
|
|
848
|
+
const describeActor = (userID: string, actor: AgentActor) => {
|
|
849
|
+
if (actor.kind !== "session") return partyFromActor(actor)
|
|
850
|
+
try {
|
|
851
|
+
const name = directory.resolve(userID, actor.sessionID).sessions[0]?.name
|
|
852
|
+
return partyFromActor(actor, name ? { name } : {})
|
|
853
|
+
} catch {
|
|
854
|
+
return partyFromActor(actor)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// needs_user escalations (P3-T8): one Decision per (holder, request); answered as the user; closed when the
|
|
858
|
+
// request goes elsewhere.
|
|
859
|
+
// Cluster mode: a recommendation on a request another pod holds goes to that pod.
|
|
860
|
+
const recommendations = config.cluster ? new ClusterRecommendations({ bus: backends.bus, podID: config.cluster.podID, registry }) : undefined
|
|
861
|
+
recommendations?.start().catch((cause) => console.error(`[hub] cluster recommendations did not start: ${(cause as Error).message}`))
|
|
862
|
+
const escalations: Escalations = new Escalations({
|
|
863
|
+
state: backends.state,
|
|
864
|
+
counters: backends.counters,
|
|
865
|
+
decisions,
|
|
866
|
+
registry: {
|
|
867
|
+
sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
|
|
868
|
+
recommend: (requestID, recommendation) => (recommendations ?? registry).recommend(requestID, recommendation),
|
|
869
|
+
},
|
|
870
|
+
actions,
|
|
871
|
+
audit,
|
|
872
|
+
now,
|
|
873
|
+
describe: describeActor,
|
|
874
|
+
published: (userID, message) => feed.publish(userID, message),
|
|
875
|
+
})
|
|
876
|
+
// Grant requests (P3-T9): an agent asks; the user's tap creates the grant as the user.
|
|
877
|
+
const grantRequests: GrantRequests = new GrantRequests({
|
|
878
|
+
state: backends.state,
|
|
879
|
+
counters: backends.counters,
|
|
880
|
+
decisions,
|
|
881
|
+
grants,
|
|
882
|
+
directory,
|
|
883
|
+
audit,
|
|
884
|
+
now,
|
|
885
|
+
describe: describeActor,
|
|
886
|
+
published: (userID, message) => feed.publish(userID, message),
|
|
887
|
+
})
|
|
888
|
+
registry.onPermissionClosed((requestID, _sessionID, tenant) => {
|
|
889
|
+
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
890
|
+
})
|
|
891
|
+
const escalate: Escalate = (escalation) => escalations.escalate(escalation)
|
|
892
|
+
/** Cluster mode: what GET /api/agent/watch reads; status of sessions on any pod comes from the user's live events. */
|
|
893
|
+
const watchRegistry = live
|
|
894
|
+
? (userID: string): WatchRegistry => ({
|
|
895
|
+
catalog: () => registry.catalog(),
|
|
896
|
+
sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
|
|
897
|
+
subscribe: (listener) => {
|
|
898
|
+
const offCatalog = registry.subscribe((message) => {
|
|
899
|
+
if (message.type === "catalog") listener(message)
|
|
900
|
+
})
|
|
901
|
+
const subscription = live.subscribe(userID, (event) => {
|
|
902
|
+
if (event.message.type === "status") listener(event.message as unknown as RegistryMessage, userID)
|
|
903
|
+
})
|
|
904
|
+
return () => {
|
|
905
|
+
offCatalog()
|
|
906
|
+
subscription.close()
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
})
|
|
910
|
+
: undefined
|
|
911
|
+
const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, launchFeed, webhooks, maxPromptChars: MAX_PROMPT_CHARS, ...(watchRegistry ? { watchRegistry } : {}) }
|
|
912
|
+
const resolveActor = createResolver({
|
|
913
|
+
cookieName: COOKIE,
|
|
914
|
+
findDevice: (token) => deviceOfToken(token),
|
|
915
|
+
user: (id) => store.user(id),
|
|
916
|
+
verifiers: [...agentVerifiers(agentDeps), ...(options.agent?.verifiers ?? [])],
|
|
917
|
+
})
|
|
918
|
+
const agent = agentApi({
|
|
919
|
+
resolve: resolveActor,
|
|
920
|
+
cookieName: COOKIE,
|
|
921
|
+
sameOrigin,
|
|
922
|
+
touch: (user, device) => {
|
|
923
|
+
// Never rejects (lastSeen only).
|
|
924
|
+
if (device) void store.touchDevice(device.id)
|
|
925
|
+
void store.touchUser(user.id)
|
|
926
|
+
},
|
|
927
|
+
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
928
|
+
})
|
|
929
|
+
|
|
379
930
|
async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
|
|
380
931
|
const url = new URL(request.url)
|
|
381
932
|
const { pathname } = url
|
|
382
933
|
const method = request.method
|
|
383
934
|
try {
|
|
935
|
+
if (method === "GET" && pathname === "/readyz") return readyz()
|
|
384
936
|
if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
|
|
385
937
|
if (method === "GET" && pathname === "/healthz") {
|
|
386
|
-
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections() })
|
|
938
|
+
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
|
|
387
939
|
}
|
|
940
|
+
// A shared (cluster) store loads its records on first use; the file store is ready at once.
|
|
941
|
+
await store.ready()
|
|
388
942
|
if (method === "POST" && pathname === "/spoke/enroll") {
|
|
389
|
-
const start = auth.startEnrollment((await body(request)).machineName)
|
|
943
|
+
const start = await auth.startEnrollment((await body(request)).machineName)
|
|
390
944
|
const auto = config.auth.mode === "pairing" && localAutoApprove({
|
|
391
945
|
host: config.host,
|
|
392
946
|
publicURL,
|
|
@@ -396,7 +950,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
396
950
|
devices: store.devices().length,
|
|
397
951
|
used: autoApproved,
|
|
398
952
|
})
|
|
399
|
-
if (auto && auth.approveEnrollment(start.userCode)) {
|
|
953
|
+
if (auto && (await auth.approveEnrollment(start.userCode))) {
|
|
400
954
|
autoApproved = true
|
|
401
955
|
console.log(`[hub] local hub: approved the first machine (code ${start.userCode}) without confirmation; later machines need approval`)
|
|
402
956
|
return Response.json({ ...start, autoApproved: true } satisfies EnrollStart)
|
|
@@ -405,25 +959,29 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
405
959
|
return Response.json(start)
|
|
406
960
|
}
|
|
407
961
|
if (method === "POST" && pathname === "/spoke/enroll/poll") {
|
|
408
|
-
const poll = auth.pollEnrollment((await body(request)).deviceCode)
|
|
962
|
+
const poll = await auth.pollEnrollment((await body(request)).deviceCode)
|
|
409
963
|
if (poll.status === "approved") broadcast({ type: "catalog" })
|
|
410
964
|
return Response.json(poll)
|
|
411
965
|
}
|
|
966
|
+
if (method === "GET" && pathname === "/.well-known/hub") {
|
|
967
|
+
return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
|
|
968
|
+
}
|
|
412
969
|
if (method === "GET" && pathname === "/login") return login(request, url)
|
|
413
970
|
if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
|
|
414
971
|
if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
|
|
972
|
+
if (isAgentPath(pathname)) return await agent.handle(request, url)
|
|
415
973
|
if (pathname.startsWith("/api/")) return await api(request, server, url)
|
|
416
|
-
if (method === "GET") return staticFile(pathname)
|
|
974
|
+
if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
|
|
417
975
|
return error(404, "Not found")
|
|
418
976
|
} catch (cause) {
|
|
419
977
|
return failure(cause)
|
|
420
978
|
}
|
|
421
979
|
}
|
|
422
980
|
|
|
423
|
-
function login(request: Request, url: URL): Response {
|
|
981
|
+
async function login(request: Request, url: URL): Promise<Response> {
|
|
424
982
|
const next = url.searchParams.get("next") ?? "/"
|
|
425
983
|
if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
|
|
426
|
-
const ownerID = auth.redeemLogin(url.searchParams.get("code"))
|
|
984
|
+
const ownerID = await auth.redeemLogin(url.searchParams.get("code"))
|
|
427
985
|
if (!ownerID) {
|
|
428
986
|
return new Response("This sign-in link has expired or was already used. Run /hub again.", {
|
|
429
987
|
status: 401,
|
|
@@ -434,9 +992,9 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
434
992
|
const setup = speechSettings.view(speechScope(ownerID)).ready.reason ? "#speech" : ""
|
|
435
993
|
const headers = new Headers({ location: `${next}${setup}`, "cache-control": "no-store" })
|
|
436
994
|
// 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" })
|
|
995
|
+
if ((await deviceFor(request))?.user.id !== ownerID) {
|
|
996
|
+
const { device, token } = await store.addDevice("Browser (sign-in link)", ownerID)
|
|
997
|
+
await store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
|
|
440
998
|
headers.set("set-cookie", cookie(token))
|
|
441
999
|
}
|
|
442
1000
|
return new Response(null, { status: 302, headers })
|
|
@@ -447,6 +1005,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
447
1005
|
const ownMachine = (userID: string, machineID: string) =>
|
|
448
1006
|
store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
|
|
449
1007
|
|
|
1008
|
+
/** Session routes that also answer for the user's offline sessions the directory knows (hub-side data only). */
|
|
1009
|
+
const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages"])
|
|
1010
|
+
/** No spoke reports the session (for anyone) and the user's directory has a record of it (not deleted). */
|
|
1011
|
+
const knownOffline = (userID: string, sessionID: string) =>
|
|
1012
|
+
registry.sessionTenant(sessionID) === undefined && (store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt === undefined)
|
|
450
1013
|
const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
|
|
451
1014
|
const keys: string[] = []
|
|
452
1015
|
const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
|
|
@@ -460,11 +1023,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
460
1023
|
),
|
|
461
1024
|
route("POST", "/api/pair", "public", async ({ request }) => {
|
|
462
1025
|
const input = await body(request)
|
|
463
|
-
const paired = auth.pair(input.code, input.deviceName)
|
|
1026
|
+
const paired = await auth.pair(input.code, input.deviceName)
|
|
464
1027
|
if (!paired) return error(401, "Invalid or expired pairing code")
|
|
465
|
-
store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
|
|
1028
|
+
await store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
|
|
466
1029
|
return Response.json({ device: deviceView(paired.device) }, { headers: { "set-cookie": cookie(paired.token) } })
|
|
467
1030
|
}),
|
|
1031
|
+
// A native app trades a link code for a device token (kept by the app, sent as Authorization: Bearer): no cookie.
|
|
1032
|
+
route("POST", "/api/devices/redeem", "public", async ({ request }) => {
|
|
1033
|
+
const input = await body(request)
|
|
1034
|
+
const linked = await auth.redeemLink(input.code, input.deviceName, input.publicKey)
|
|
1035
|
+
if (!linked) return error(401, "Invalid or expired link code")
|
|
1036
|
+
await store.audit({ actor: linked.device.ownerID, action: "device.pair", target: linked.device.id, detail: "app link" })
|
|
1037
|
+
return Response.json({ device: deviceView(linked.device), token: linked.token }, { headers: NO_STORE })
|
|
1038
|
+
}),
|
|
468
1039
|
|
|
469
1040
|
// any signed-in user, own data only
|
|
470
1041
|
route("GET", "/api/me", "user", ({ device, user }) =>
|
|
@@ -477,18 +1048,21 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
477
1048
|
}),
|
|
478
1049
|
),
|
|
479
1050
|
route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
|
|
480
|
-
route("GET", "/api/events", "user", ({ request, server, user, device }) => {
|
|
1051
|
+
route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
|
|
481
1052
|
server.timeout(request, 0)
|
|
482
|
-
|
|
1053
|
+
const session = url.searchParams.get("session")
|
|
1054
|
+
return events(request.signal, user.id, device.id, {
|
|
1055
|
+
lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
|
|
1056
|
+
lite: url.searchParams.get("lite") === "1",
|
|
1057
|
+
...(session && session.length <= 300 && registry.owns(user.id, session) ? { session } : {}),
|
|
1058
|
+
})
|
|
483
1059
|
}),
|
|
484
1060
|
// The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
|
|
485
1061
|
route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
|
|
486
1062
|
route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
|
|
487
|
-
const prefs = store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
|
|
1063
|
+
const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
|
|
488
1064
|
// The user's other devices only: never another user, and not the device that made the change.
|
|
489
|
-
|
|
490
|
-
if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "prefs", prefs })
|
|
491
|
-
}
|
|
1065
|
+
toUser(user.id, { type: "prefs", prefs }, device.id)
|
|
492
1066
|
return Response.json({ prefs }, { headers: NO_STORE })
|
|
493
1067
|
}),
|
|
494
1068
|
route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
|
|
@@ -502,23 +1076,33 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
502
1076
|
.map((d) => ({ ...deviceView(d), current: d.id === device.id })),
|
|
503
1077
|
}),
|
|
504
1078
|
),
|
|
505
|
-
|
|
1079
|
+
// A link code for a native app, shown as a QR code / deep link in Settings → Devices → Add app.
|
|
1080
|
+
route("POST", "/api/devices/link", "user", async ({ user }) => {
|
|
1081
|
+
const { code, expiresAt } = auth.issueLinkCode(user.id)
|
|
1082
|
+
await store.audit({ actor: user.id, action: "device.link" })
|
|
1083
|
+
const url = `${APP_SCHEME}://link?hub=${encodeURIComponent(publicURL)}&code=${code}`
|
|
1084
|
+
return Response.json({ code, expiresAt, hub: publicURL, url }, { headers: NO_STORE })
|
|
1085
|
+
}),
|
|
1086
|
+
route("DELETE", "/api/devices/:id", "user", async ({ params, user }) => {
|
|
506
1087
|
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 })
|
|
1088
|
+
if (!target || !await store.removeDevice(target.id)) return error(404, "No such device")
|
|
1089
|
+
await store.audit({ actor: user.id, action: "device.revoke", target: target.id })
|
|
509
1090
|
return new Response(null, { status: 204 })
|
|
510
1091
|
}),
|
|
511
|
-
route("POST", "/api/logout", "user", ({ device }) => {
|
|
512
|
-
store.removeDevice(device.id)
|
|
1092
|
+
route("POST", "/api/logout", "user", async ({ device }) => {
|
|
1093
|
+
await store.removeDevice(device.id)
|
|
513
1094
|
return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
|
|
514
1095
|
}),
|
|
515
1096
|
route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
|
|
516
1097
|
route("POST", "/api/speak", "user", async ({ request, user }) => {
|
|
517
1098
|
const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
|
|
518
1099
|
if (!text) return error(400, "Nothing to speak")
|
|
519
|
-
|
|
1100
|
+
const speech = speechFor(user.id)
|
|
1101
|
+
// Passed through as the provider sends it, so the phone can start playing before synthesis ends.
|
|
1102
|
+
const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
|
|
1103
|
+
return new Response(audio, { headers: { "content-type": "audio/mpeg" } })
|
|
520
1104
|
}),
|
|
521
|
-
route("GET", "/api/push/key", "user", () => Response.json({ publicKey: push.publicKey() })),
|
|
1105
|
+
route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
|
|
522
1106
|
route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
|
|
523
1107
|
const input = await body(request)
|
|
524
1108
|
const target = pushTarget(input.subscription)
|
|
@@ -526,14 +1110,14 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
526
1110
|
const problem = await pushEndpointProblem(target.endpoint, options.resolveHost ?? systemResolveHost)
|
|
527
1111
|
if (problem) return error(400, `Invalid push subscription: ${problem}`)
|
|
528
1112
|
// 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)
|
|
1113
|
+
if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) await store.removePushSubscription(device.id, input.oldEndpoint)
|
|
1114
|
+
await store.addPushSubscription(device.id, target)
|
|
531
1115
|
const prefs = prefsInput(input.prefs)
|
|
532
|
-
return Response.json({ prefs: prefs ? store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
|
|
1116
|
+
return Response.json({ prefs: prefs ? await store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
|
|
533
1117
|
}),
|
|
534
1118
|
route("POST", "/api/push/unsubscribe", "user", async ({ request, device }) => {
|
|
535
1119
|
const endpoint = (await body(request)).endpoint
|
|
536
|
-
if (typeof endpoint !== "string" || !store.removePushSubscription(device.id, endpoint)) {
|
|
1120
|
+
if (typeof endpoint !== "string" || !await store.removePushSubscription(device.id, endpoint)) {
|
|
537
1121
|
return error(404, "No such subscription on this device")
|
|
538
1122
|
}
|
|
539
1123
|
return new Response(null, { status: 204 })
|
|
@@ -544,10 +1128,17 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
544
1128
|
if ("quietHours" in input) {
|
|
545
1129
|
const quiet = input.quietHours === null ? null : quietHoursInput(input.quietHours)
|
|
546
1130
|
if (quiet === undefined) return error(400, "quietHours must be {start: \"HH:MM\", end: \"HH:MM\", timeZone} or null")
|
|
547
|
-
store.setQuietHours(device.id, quiet)
|
|
1131
|
+
await store.setQuietHours(device.id, quiet)
|
|
1132
|
+
}
|
|
1133
|
+
if ("activityMinutes" in input) {
|
|
1134
|
+
const minutes = input.activityMinutes
|
|
1135
|
+
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < ACTIVITY_INTERVAL_MIN || minutes > ACTIVITY_INTERVAL_MAX) {
|
|
1136
|
+
return error(400, `activityMinutes must be a whole number of minutes from ${ACTIVITY_INTERVAL_MIN} to ${ACTIVITY_INTERVAL_MAX}`)
|
|
1137
|
+
}
|
|
1138
|
+
await store.setActivityMinutes(device.id, minutes)
|
|
548
1139
|
}
|
|
549
1140
|
const prefs = prefsInput(input)
|
|
550
|
-
if (prefs) store.setPushPrefs(device.id, prefs)
|
|
1141
|
+
if (prefs) await store.setPushPrefs(device.id, prefs)
|
|
551
1142
|
return pushPrefsView(device.id, user.id)
|
|
552
1143
|
}),
|
|
553
1144
|
route("PUT", "/api/push/mute", "user", async ({ request, user }) => {
|
|
@@ -556,12 +1147,15 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
556
1147
|
if (typeof sessionID !== "string" || !sessionID || sessionID.length > 200 || typeof input.muted !== "boolean") {
|
|
557
1148
|
return error(400, "Expected {sessionID, muted}")
|
|
558
1149
|
}
|
|
559
|
-
return Response.json({ muted: store.setSessionMuted(user.id, sessionID, input.muted) })
|
|
1150
|
+
return Response.json({ muted: await store.setSessionMuted(user.id, sessionID, input.muted) })
|
|
560
1151
|
}),
|
|
561
|
-
route("POST", "/api/push/test", "user", async ({ device }) => {
|
|
1152
|
+
route("POST", "/api/push/test", "user", async ({ request, device }) => {
|
|
1153
|
+
// The body is optional (older clients send none): {kind?} picks what the test looks like.
|
|
1154
|
+
const kind = (await body(request).catch(() => ({}) as Record<string, unknown>)).kind
|
|
1155
|
+
if (kind !== undefined && !isNotificationKind(kind)) return error(400, `kind must be one of ${PUSH_PREF_KEYS.join(", ")}`)
|
|
562
1156
|
const subscriptions = store.pushSubscriptions().filter((s) => s.deviceID === device.id).length
|
|
563
1157
|
if (subscriptions === 0) return Response.json({ sent: 0, failed: [], subscriptions })
|
|
564
|
-
const result = await push.test(device.id)
|
|
1158
|
+
const result = await push.test(device.id, kind)
|
|
565
1159
|
if ("limited" in result) {
|
|
566
1160
|
return Response.json({ error: "Wait a few seconds before sending another test" }, { status: 429, headers: { "retry-after": String(result.retryAfter) } })
|
|
567
1161
|
}
|
|
@@ -571,7 +1165,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
571
1165
|
route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
|
|
572
1166
|
// Raw JSON: anything but an object is refused by the validator.
|
|
573
1167
|
route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
|
|
574
|
-
Response.json(speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
|
|
1168
|
+
Response.json(await speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
|
|
575
1169
|
),
|
|
576
1170
|
route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
|
|
577
1171
|
const kind = url.searchParams.get("kind")
|
|
@@ -605,16 +1199,18 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
605
1199
|
"/api/sessions/:session/interrupt",
|
|
606
1200
|
"session",
|
|
607
1201
|
async ({ params }) => {
|
|
608
|
-
await spokes.request(ownerOf(params.session
|
|
1202
|
+
await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
|
|
609
1203
|
registry.cancelTurns(params.session!)
|
|
610
1204
|
return new Response(null, { status: 204 })
|
|
611
1205
|
},
|
|
612
1206
|
),
|
|
613
1207
|
route("POST", "/api/sessions/:session/stop", "session", async ({ params }) => {
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
1208
|
+
const stopped = await actions.stop(params.session!)
|
|
1209
|
+
if (stopped.outcome === "offline") throw new HttpError(409, NO_OWNER)
|
|
1210
|
+
if (stopped.outcome === "unsupported") throw unsupportedError(stopped)
|
|
1211
|
+
return Response.json(stopped.result, { headers: NO_STORE })
|
|
617
1212
|
}),
|
|
1213
|
+
route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
|
|
618
1214
|
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
|
|
619
1215
|
route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
|
|
620
1216
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
|
|
@@ -632,7 +1228,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
632
1228
|
const change = modelPrefsInput(await request.json())
|
|
633
1229
|
await checkModelSelections(ownerOf(params.session!), params.session!, change)
|
|
634
1230
|
const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
|
|
635
|
-
const prefs = store.setModelPrefs(user.id, params.session!, change)
|
|
1231
|
+
const prefs = await store.setModelPrefs(user.id, params.session!, change)
|
|
636
1232
|
return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
|
|
637
1233
|
}),
|
|
638
1234
|
route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
|
|
@@ -641,7 +1237,35 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
641
1237
|
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
|
|
642
1238
|
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
643
1239
|
if (cursor && cursor.length > 4096) return error(400, "cursor is too long")
|
|
644
|
-
|
|
1240
|
+
// Newest-first mode: order=desc for the newest page, before=<older cursor> for the page before it.
|
|
1241
|
+
const order = url.searchParams.get("order") ?? undefined
|
|
1242
|
+
if (order !== undefined && order !== "asc" && order !== "desc") return error(400, "order must be asc or desc")
|
|
1243
|
+
const before = url.searchParams.get("before") || undefined
|
|
1244
|
+
if (before && before.length > 4096) return error(400, "before is too long")
|
|
1245
|
+
if (cursor && (before || order === "desc")) return error(400, "cursor cannot be combined with before or order=desc")
|
|
1246
|
+
try {
|
|
1247
|
+
const page = await spokes.request(ownerOf(params.session!), {
|
|
1248
|
+
op: "timeline",
|
|
1249
|
+
sessionID: params.session!,
|
|
1250
|
+
...(limit ? { limit } : {}),
|
|
1251
|
+
...(cursor ? { cursor } : {}),
|
|
1252
|
+
...(order ? { order } : {}),
|
|
1253
|
+
...(before ? { before } : {}),
|
|
1254
|
+
})
|
|
1255
|
+
return Response.json(page, { headers: NO_STORE })
|
|
1256
|
+
} catch (cause) {
|
|
1257
|
+
// The row the cursor names is gone (history reverted or compacted): the console starts over.
|
|
1258
|
+
if (cause instanceof SpokeError && cause.message.includes("Invalid timeline cursor")) return error(400, "Invalid timeline cursor")
|
|
1259
|
+
throw cause
|
|
1260
|
+
}
|
|
1261
|
+
}),
|
|
1262
|
+
// Agent messages the session sent or received (newest first), merged into the timeline by `at` (+ watch events).
|
|
1263
|
+
route("GET", "/api/sessions/:session/messages", "session", async ({ params, url, user }) => {
|
|
1264
|
+
const raw = url.searchParams.get("limit")
|
|
1265
|
+
const limit = raw === null ? undefined : Number(raw)
|
|
1266
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
|
|
1267
|
+
const messages = await delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit })
|
|
1268
|
+
return Response.json({ messages }, { headers: NO_STORE })
|
|
645
1269
|
}),
|
|
646
1270
|
route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
|
|
647
1271
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
|
|
@@ -662,11 +1286,9 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
662
1286
|
const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
|
|
663
1287
|
return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
|
|
664
1288
|
}),
|
|
665
|
-
route("POST", "/api/sessions/:session/read", "session", ({ params, user, device }) => {
|
|
666
|
-
const reads = store.markRead(user.id, params.session!, Date.now())
|
|
667
|
-
|
|
668
|
-
if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "reads", reads })
|
|
669
|
-
}
|
|
1289
|
+
route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
|
|
1290
|
+
const reads = await store.markRead(user.id, params.session!, Date.now())
|
|
1291
|
+
toUser(user.id, { type: "reads", reads }, device.id)
|
|
670
1292
|
return Response.json({ reads }, { headers: NO_STORE })
|
|
671
1293
|
}),
|
|
672
1294
|
|
|
@@ -675,7 +1297,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
675
1297
|
route("DELETE", "/api/machines/:machine", "machine", ({ params, user }) => revokeMachine(params.machine!, user)),
|
|
676
1298
|
|
|
677
1299
|
// admins: users, machines, devices, audit, enrollments, the server speech default
|
|
678
|
-
route("GET", "/api/enrollments", "admin", () => Response.json({ enrollments: auth.pendingEnrollments() })),
|
|
1300
|
+
route("GET", "/api/enrollments", "admin", async () => Response.json({ enrollments: await auth.pendingEnrollments() })),
|
|
679
1301
|
route("GET", "/api/admin/users", "admin", () => Response.json(store.users().map(userRow))),
|
|
680
1302
|
route("PUT", "/api/admin/users/:id", "admin", async ({ request, params, user }) => updateUser(params.id!, await body(request), user)),
|
|
681
1303
|
route("GET", "/api/admin/machines", "admin", () => {
|
|
@@ -697,16 +1319,49 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
697
1319
|
)
|
|
698
1320
|
}),
|
|
699
1321
|
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! })
|
|
1322
|
+
route("DELETE", "/api/admin/devices/:id", "admin", async ({ params, user }) => {
|
|
1323
|
+
if (!await store.removeDevice(params.id!)) return error(404, "No such device")
|
|
1324
|
+
await store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
|
|
703
1325
|
return new Response(null, { status: 204 })
|
|
704
1326
|
}),
|
|
705
1327
|
route("GET", "/api/admin/audit", "admin", () => Response.json(store.auditLog())),
|
|
1328
|
+
// The hub-wide agent kill switch: pauses every agent action for every user (reading and messaging still work).
|
|
1329
|
+
route("GET", "/api/admin/agents/acting", "admin", () => Response.json({ enabled: !store.actingPaused() }, { headers: NO_STORE })),
|
|
1330
|
+
route("PUT", "/api/admin/agents/acting", "admin", async ({ request, user }) => {
|
|
1331
|
+
const enabled = (await body(request)).enabled
|
|
1332
|
+
if (typeof enabled !== "boolean") return error(400, "enabled must be true or false")
|
|
1333
|
+
await store.setActingPaused(!enabled)
|
|
1334
|
+
await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
|
|
1335
|
+
return Response.json({ enabled }, { headers: NO_STORE })
|
|
1336
|
+
}),
|
|
1337
|
+
// Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
|
|
1338
|
+
route("GET", "/api/decisions", "user", async ({ url, user }) => {
|
|
1339
|
+
try {
|
|
1340
|
+
return Response.json({ decisions: await decisions.list(user.id, { ...optionalState(url) }) }, { headers: NO_STORE })
|
|
1341
|
+
} catch (cause) {
|
|
1342
|
+
return decisionFailure(cause)
|
|
1343
|
+
}
|
|
1344
|
+
}),
|
|
1345
|
+
route("GET", "/api/decisions/:id", "user", async ({ params, user }) => {
|
|
1346
|
+
const decision = await decisions.get(user.id, params.id!)
|
|
1347
|
+
return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
|
|
1348
|
+
}),
|
|
1349
|
+
route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
|
|
1350
|
+
const input = await body(request)
|
|
1351
|
+
const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
|
|
1352
|
+
try {
|
|
1353
|
+
const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
|
|
1354
|
+
if (answered.reply) feed.publish(user.id, answered.reply)
|
|
1355
|
+
await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answered.decision.answer?.option ?? "text" })
|
|
1356
|
+
return Response.json(answered, { headers: NO_STORE })
|
|
1357
|
+
} catch (cause) {
|
|
1358
|
+
return decisionFailure(cause)
|
|
1359
|
+
}
|
|
1360
|
+
}),
|
|
706
1361
|
route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
|
|
707
1362
|
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" })
|
|
1363
|
+
const view = await speechSettings.update(await request.json())
|
|
1364
|
+
await store.audit({ actor: user.id, action: "speech.default" })
|
|
710
1365
|
return Response.json(view, { headers: NO_STORE })
|
|
711
1366
|
}),
|
|
712
1367
|
]
|
|
@@ -725,7 +1380,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
725
1380
|
|
|
726
1381
|
async function api(request: Request, server: Server<SpokeSocketData>, url: URL): Promise<Response> {
|
|
727
1382
|
const method = request.method
|
|
728
|
-
|
|
1383
|
+
const mode = await resolveActor.mode(request)
|
|
1384
|
+
if (mode === "mixed") return error(400, "Send the device cookie or a bearer token, not both", "invalid")
|
|
1385
|
+
// Bearer requests (device tokens of native apps) are not CSRF-prone; cookie writes must be same-origin (a
|
|
1386
|
+
// foreign bearer next to the cookie, e.g. from an auth proxy, is ignored: still a cookie request).
|
|
1387
|
+
if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !sameOrigin(request) && !credentialFree(request, mode, url)) {
|
|
729
1388
|
return error(403, "Cross-origin request refused")
|
|
730
1389
|
}
|
|
731
1390
|
const found = findRoute(method, url.pathname)
|
|
@@ -734,11 +1393,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
734
1393
|
return await found.route.handle({ request, server, url, params: found.params } as Context)
|
|
735
1394
|
}
|
|
736
1395
|
|
|
737
|
-
const resolved =
|
|
738
|
-
if (!resolved)
|
|
1396
|
+
const resolved = await resolveActor(request)
|
|
1397
|
+
if (!resolved.ok) {
|
|
1398
|
+
if (resolved.reason === "expired") return error(401, "Token expired", "token_expired")
|
|
1399
|
+
return resolved.bearer ? error(401, "Invalid or revoked token", "unauthorized") : error(401, "This device is not paired")
|
|
1400
|
+
}
|
|
1401
|
+
// Console routes are for the user's devices (cookie or device token) only, never session or app tokens.
|
|
1402
|
+
if (resolved.actor.kind !== "user" || !resolved.device) return error(403, "This token cannot be used here", "forbidden")
|
|
739
1403
|
const { device, user } = resolved
|
|
740
|
-
store.touchDevice(device.id)
|
|
741
|
-
store.touchUser(user.id)
|
|
1404
|
+
void store.touchDevice(device.id)
|
|
1405
|
+
void store.touchUser(user.id)
|
|
742
1406
|
if (!found) return error(404, "Not found")
|
|
743
1407
|
|
|
744
1408
|
const { route: matched, params } = found
|
|
@@ -748,7 +1412,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
748
1412
|
break
|
|
749
1413
|
case "session":
|
|
750
1414
|
// Foreign and unknown sessions answer alike, so IDs don't leak.
|
|
751
|
-
if (!registry.owns(user.id, params.session!)) return error(404, "Not found")
|
|
1415
|
+
if (!registry.owns(user.id, params.session!) && !(OFFLINE_SESSION_ROUTES.has(matched.path) && knownOffline(user.id, params.session!))) return error(404, "Not found")
|
|
752
1416
|
// Sessions listed for discovery only: nothing but enabling voice (and presence) reaches them.
|
|
753
1417
|
if (matched.exposed && registry.exposed(params.session!) === false) return error(403, NOT_EXPOSED, "not_exposed")
|
|
754
1418
|
break
|
|
@@ -759,17 +1423,18 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
759
1423
|
return await matched.handle({ request, server, url, params, device, user })
|
|
760
1424
|
}
|
|
761
1425
|
|
|
762
|
-
function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Response {
|
|
763
|
-
const machineName = auth.decide(code, status, user.id)
|
|
1426
|
+
async function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Promise<Response> {
|
|
1427
|
+
const machineName = await auth.decide(code, status, user.id)
|
|
764
1428
|
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 })
|
|
1429
|
+
await store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
|
|
766
1430
|
broadcast({ type: "enrollment" }, { admins: true })
|
|
767
1431
|
return new Response(null, { status: 204 })
|
|
768
1432
|
}
|
|
769
1433
|
|
|
770
|
-
function revokeMachine(id: string, user: UserRecord): Response {
|
|
771
|
-
if (!store.revokeMachine(id)) return error(404, "No such machine")
|
|
772
|
-
store.
|
|
1434
|
+
async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
|
|
1435
|
+
if (!await store.revokeMachine(id)) return error(404, "No such machine")
|
|
1436
|
+
await store.bumpMachineEpoch(id)
|
|
1437
|
+
await store.audit({ actor: user.id, action: "machine.revoke", target: id })
|
|
773
1438
|
spokes.closeMachine(id, "revoked")
|
|
774
1439
|
broadcast({ type: "catalog" })
|
|
775
1440
|
return new Response(null, { status: 204 })
|
|
@@ -790,7 +1455,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
790
1455
|
}
|
|
791
1456
|
|
|
792
1457
|
/** 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 {
|
|
1458
|
+
async function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Promise<Response> {
|
|
794
1459
|
const target = store.user(id)
|
|
795
1460
|
if (!target) return error(404, "No such user")
|
|
796
1461
|
const { role, disabled } = input
|
|
@@ -802,12 +1467,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
802
1467
|
if (activeAdmin(target) && !activeAdmin(after) && store.users().filter(activeAdmin).length <= 1) {
|
|
803
1468
|
return error(409, "Cannot demote or disable the last active admin. Make someone else an admin first.")
|
|
804
1469
|
}
|
|
805
|
-
if (role && role !== target.role) store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
|
|
1470
|
+
if (role && role !== target.role) await store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
|
|
806
1471
|
if (disabled !== undefined && disabled !== (target.disabled === true)) {
|
|
807
|
-
store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
|
|
1472
|
+
await store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
|
|
808
1473
|
}
|
|
809
|
-
store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
|
|
1474
|
+
await store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
|
|
810
1475
|
if (disabled === true) {
|
|
1476
|
+
await store.bumpUserEpoch(id)
|
|
811
1477
|
// Their machines go offline at once; their browsers get 401 from now on and lose their event streams.
|
|
812
1478
|
for (const machine of store.machines()) if (machine.ownerID === id) spokes.closeMachine(machine.id, "revoked")
|
|
813
1479
|
broadcast({ type: "catalog" })
|
|
@@ -829,26 +1495,30 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
829
1495
|
: undefined
|
|
830
1496
|
if (!text && !images) return error(400, "Empty prompt")
|
|
831
1497
|
if (text.length > MAX_PROMPT_CHARS) return error(400, `Prompt longer than ${MAX_PROMPT_CHARS} characters`)
|
|
832
|
-
|
|
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())
|
|
1498
|
+
// A turn may run for minutes: this request never times out (a stop or interrupt still answers it at once).
|
|
845
1499
|
server.timeout(request, 0)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
|
|
1500
|
+
const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
1501
|
+
if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
|
|
1502
|
+
if (started.outcome === "unsupported") throw unsupportedError(started)
|
|
1503
|
+
if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
|
|
1504
|
+
const result = started.result
|
|
1505
|
+
if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
|
|
1506
|
+
// Asynchronous: answer quick results (and refusals) as usual; a turn still running is accepted (202) and its
|
|
1507
|
+
// result goes to the owner's consoles as an SSE `turn` event (the reply itself streams as deltas meanwhile).
|
|
1508
|
+
const early = await Promise.race([result.then((value) => ({ value }), (cause: unknown) => ({ cause })), sleep(options.timing?.turnAcceptMs ?? TURN_ACCEPT_MS)])
|
|
1509
|
+
if (early) {
|
|
1510
|
+
if ("cause" in early) throw early.cause
|
|
1511
|
+
return Response.json(early.value)
|
|
851
1512
|
}
|
|
1513
|
+
const tenant = registry.sessionTenant(sessionID)
|
|
1514
|
+
void result.then(
|
|
1515
|
+
(value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
|
|
1516
|
+
(cause: unknown) => {
|
|
1517
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
1518
|
+
broadcast({ type: "turn", sessionID, ...(cause instanceof RequestTimeoutError ? { outcome: "timeout" } : { outcome: "failed", message }), reply: "" }, { tenant })
|
|
1519
|
+
},
|
|
1520
|
+
)
|
|
1521
|
+
return Response.json({ mode: "turn" }, { status: 202, headers: { "preference-applied": "respond-async" } })
|
|
852
1522
|
}
|
|
853
1523
|
|
|
854
1524
|
/** GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. */
|
|
@@ -887,10 +1557,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
887
1557
|
}
|
|
888
1558
|
}
|
|
889
1559
|
|
|
890
|
-
/** Connection that owns the session, or a 409
|
|
891
|
-
function ownerOf(sessionID: string): string {
|
|
1560
|
+
/** Connection that owns the session, or a 409; also a 409 (code unsupported) when it lacks the core op `feature`. */
|
|
1561
|
+
function ownerOf(sessionID: string, feature?: CoreOp): string {
|
|
892
1562
|
const connID = registry.owner(sessionID)
|
|
893
1563
|
if (!connID) throw new HttpError(409, NO_OWNER)
|
|
1564
|
+
if (feature && !registry.supports(connID, feature)) throw unsupportedError({ feature, harness: registry.harnessOf(connID) ?? "unknown" })
|
|
894
1565
|
return connID
|
|
895
1566
|
}
|
|
896
1567
|
|
|
@@ -900,11 +1571,61 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
900
1571
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
901
1572
|
if (!text) return error(400, "Empty utterance")
|
|
902
1573
|
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
903
|
-
const
|
|
1574
|
+
const connID = ownerOf(sessionID, "steer")
|
|
1575
|
+
await humanTurn(sessionID)
|
|
1576
|
+
const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
|
|
904
1577
|
registry.trackUtterance(sessionID, inboxID)
|
|
905
1578
|
return Response.json({ inboxID }, { status: 202 })
|
|
906
1579
|
}
|
|
907
1580
|
|
|
1581
|
+
/** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
|
|
1582
|
+
async function rename(request: Request, sessionID: string): Promise<Response> {
|
|
1583
|
+
const json = await body(request)
|
|
1584
|
+
if (json.auto !== undefined) {
|
|
1585
|
+
if (json.auto !== true) return error(400, "Expected {auto: true} or {title}")
|
|
1586
|
+
return autoName(sessionID)
|
|
1587
|
+
}
|
|
1588
|
+
const raw = json.title
|
|
1589
|
+
const title = typeof raw === "string" ? raw.trim() : ""
|
|
1590
|
+
if (!title) return error(400, "Expected {title}: a non-empty title")
|
|
1591
|
+
if (title.length > MAX_TITLE_CHARS) return error(400, `Title must be at most ${MAX_TITLE_CHARS} characters`)
|
|
1592
|
+
const targets = registry.sessionTargets(sessionID)
|
|
1593
|
+
if (!targets.length) throw new HttpError(409, NO_OWNER)
|
|
1594
|
+
let failure: unknown
|
|
1595
|
+
for (const connID of targets) {
|
|
1596
|
+
try {
|
|
1597
|
+
const result = await spokes.request(connID, { op: "rename", sessionID, title })
|
|
1598
|
+
return Response.json({ title: typeof result?.title === "string" ? result.title : title }, { headers: NO_STORE })
|
|
1599
|
+
} catch (cause) {
|
|
1600
|
+
failure ??= cause
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
throw failure
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* Has OpenCode regenerate the title with its title agent, through the processes reporting the session that
|
|
1608
|
+
* support it (older spokes drop the op): 200 {title} or {title, unchanged}, 202 {pending} while generating.
|
|
1609
|
+
*/
|
|
1610
|
+
async function autoName(sessionID: string): Promise<Response> {
|
|
1611
|
+
const reporters = registry.sessionTargets(sessionID)
|
|
1612
|
+
if (!reporters.length) throw new HttpError(409, NO_OWNER)
|
|
1613
|
+
const targets = reporters.filter((connID) => registry.supports(connID, "autoName"))
|
|
1614
|
+
if (!targets.length) throw new HttpError(409, AUTO_NAME_OUTDATED, "plugin_outdated")
|
|
1615
|
+
let failure: unknown
|
|
1616
|
+
for (const connID of targets) {
|
|
1617
|
+
try {
|
|
1618
|
+
const result = await spokes.request(connID, { op: "autoName", sessionID })
|
|
1619
|
+
if (result?.pending) return Response.json({ pending: true }, { status: 202, headers: NO_STORE })
|
|
1620
|
+
if (typeof result?.title !== "string") throw new SpokeError("OpenCode did not report a title")
|
|
1621
|
+
return Response.json({ title: result.title, ...(result.unchanged ? { unchanged: true } : {}) }, { headers: NO_STORE })
|
|
1622
|
+
} catch (cause) {
|
|
1623
|
+
failure ??= cause
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
throw failure
|
|
1627
|
+
}
|
|
1628
|
+
|
|
908
1629
|
async function conversation(request: Request, sessionID: string): Promise<Response> {
|
|
909
1630
|
const on = (await body(request)).on
|
|
910
1631
|
if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
|
|
@@ -968,22 +1689,9 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
968
1689
|
const input = await body(request)
|
|
969
1690
|
const decision = input.decision as PermissionDecision
|
|
970
1691
|
if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
|
|
971
|
-
const
|
|
972
|
-
if (
|
|
973
|
-
|
|
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")
|
|
1692
|
+
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
1693
|
+
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
1694
|
+
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
987
1695
|
}
|
|
988
1696
|
|
|
989
1697
|
/** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
|
|
@@ -996,23 +1704,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
996
1704
|
const problem = formAnswerProblem(input.answer)
|
|
997
1705
|
if (problem) return error(400, problem)
|
|
998
1706
|
}
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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")
|
|
1707
|
+
const answered = await actions.answerForm(
|
|
1708
|
+
cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
|
|
1709
|
+
)
|
|
1710
|
+
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
1711
|
+
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
1016
1712
|
}
|
|
1017
1713
|
|
|
1018
1714
|
async function transcribe(request: Request, speech: Speech): Promise<Response> {
|
|
@@ -1027,7 +1723,14 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1027
1723
|
return Response.json({ text: await speech.transcribe(audio, format) })
|
|
1028
1724
|
}
|
|
1029
1725
|
|
|
1030
|
-
|
|
1726
|
+
/**
|
|
1727
|
+
* The live stream. Every message has an id; with `lastID` (Last-Event-ID or ?last=) the hub replays what came
|
|
1728
|
+
* after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
|
|
1729
|
+
* over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
|
|
1730
|
+
*/
|
|
1731
|
+
function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; session?: string }): Response {
|
|
1732
|
+
// Draining: come back (to another pod) shortly.
|
|
1733
|
+
if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
|
|
1031
1734
|
const encoder = new TextEncoder()
|
|
1032
1735
|
let cleanup = () => {}
|
|
1033
1736
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -1042,17 +1745,38 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1042
1745
|
const listener: Listener = {
|
|
1043
1746
|
userID,
|
|
1044
1747
|
deviceID,
|
|
1045
|
-
|
|
1748
|
+
lite: options.lite,
|
|
1749
|
+
...(options.session ? { session: options.session } : {}),
|
|
1750
|
+
send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
|
|
1046
1751
|
close: () => {
|
|
1047
1752
|
cleanup()
|
|
1048
1753
|
try {
|
|
1049
1754
|
controller.close()
|
|
1050
1755
|
} catch {}
|
|
1051
1756
|
},
|
|
1757
|
+
end: (retryMs) => {
|
|
1758
|
+
write(`retry: ${retryMs}\n\n`)
|
|
1759
|
+
listener.close()
|
|
1760
|
+
},
|
|
1761
|
+
}
|
|
1762
|
+
if (live) return clusterStream(listener, write)
|
|
1763
|
+
eventLog.open(userID)
|
|
1764
|
+
const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
|
|
1765
|
+
if (missed) {
|
|
1766
|
+
let catalog = false
|
|
1767
|
+
for (const { id, message, exceptDevice } of missed) {
|
|
1768
|
+
if (exceptDevice === deviceID) continue
|
|
1769
|
+
if (message.type === "catalog") catalog = true
|
|
1770
|
+
else if (wants(listener, message)) listener.send(message, id)
|
|
1771
|
+
}
|
|
1772
|
+
if (catalog) listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
|
|
1773
|
+
} else {
|
|
1774
|
+
listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
|
|
1775
|
+
if (options.lastID) listener.send({ type: "resync" }, eventLog.latest(userID))
|
|
1052
1776
|
}
|
|
1053
|
-
listener.send({ type: "catalog", machines: machines(userID) })
|
|
1054
1777
|
listeners.add(listener)
|
|
1055
|
-
|
|
1778
|
+
// A named event (not a comment) so the page sees it and can tell a live stream from a dead one.
|
|
1779
|
+
const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
|
|
1056
1780
|
cleanup = () => {
|
|
1057
1781
|
clearInterval(heartbeat)
|
|
1058
1782
|
listeners.delete(listener)
|
|
@@ -1063,12 +1787,104 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1063
1787
|
cleanup()
|
|
1064
1788
|
},
|
|
1065
1789
|
})
|
|
1790
|
+
/**
|
|
1791
|
+
* Cluster mode: the user's live events from every pod; the replay comes from the shared EventLog. Events that
|
|
1792
|
+
* arrive while it is read wait (in order) and then go out, except those the replay already sent.
|
|
1793
|
+
*/
|
|
1794
|
+
async function clusterStream(listener: Listener, write: (chunk: string) => void): Promise<void> {
|
|
1795
|
+
let pending: LiveEvent[] | undefined = []
|
|
1796
|
+
const replayed = new Set<string>()
|
|
1797
|
+
const forward = (event: LiveEvent) => {
|
|
1798
|
+
if (event.exceptDevice === deviceID || (event.id !== undefined && replayed.has(event.id))) return
|
|
1799
|
+
if (wants(listener, event.message)) listener.send(event.message, event.id)
|
|
1800
|
+
}
|
|
1801
|
+
const subscription = live!.subscribe(userID, (event) => (pending ? pending.push(event) : forward(event)))
|
|
1802
|
+
listeners.add(listener)
|
|
1803
|
+
const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
|
|
1804
|
+
cleanup = () => {
|
|
1805
|
+
clearInterval(heartbeat)
|
|
1806
|
+
listeners.delete(listener)
|
|
1807
|
+
subscription.close()
|
|
1808
|
+
}
|
|
1809
|
+
signal.addEventListener("abort", () => cleanup(), { once: true })
|
|
1810
|
+
try {
|
|
1811
|
+
await subscription.ready
|
|
1812
|
+
const missed = options.lastID ? await backends.events.since(userID, options.lastID) : undefined
|
|
1813
|
+
if (missed && "events" in missed) {
|
|
1814
|
+
for (const { id, message, exceptDevice } of missed.events) {
|
|
1815
|
+
replayed.add(id)
|
|
1816
|
+
if (exceptDevice !== deviceID && wants(listener, message)) listener.send(message, id)
|
|
1817
|
+
}
|
|
1818
|
+
// Catalogs are not logged in a cluster: a resumed stream always gets the current one.
|
|
1819
|
+
listener.send({ type: "catalog", machines: machines(userID) })
|
|
1820
|
+
} else {
|
|
1821
|
+
const latest = await backends.events.latest(userID)
|
|
1822
|
+
listener.send({ type: "catalog", machines: machines(userID) }, latest)
|
|
1823
|
+
if (options.lastID) listener.send({ type: "resync" }, latest)
|
|
1824
|
+
}
|
|
1825
|
+
} catch (cause) {
|
|
1826
|
+
console.error(`[hub] live stream did not start: ${(cause as Error).message}`)
|
|
1827
|
+
listener.close()
|
|
1828
|
+
return
|
|
1829
|
+
}
|
|
1830
|
+
const waiting = pending
|
|
1831
|
+
pending = undefined
|
|
1832
|
+
for (const event of waiting) forward(event)
|
|
1833
|
+
}
|
|
1066
1834
|
return new Response(stream, {
|
|
1067
1835
|
headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
|
|
1068
1836
|
})
|
|
1069
1837
|
}
|
|
1070
1838
|
|
|
1071
|
-
|
|
1839
|
+
/** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
|
|
1840
|
+
async function readyz(): Promise<Response> {
|
|
1841
|
+
if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
|
|
1842
|
+
if (config.cluster) {
|
|
1843
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1844
|
+
const late = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error("timed out")), READY_CHECK_MS)))
|
|
1845
|
+
try {
|
|
1846
|
+
await Promise.race([Promise.all([backends.state.ping?.(), backends.kv.ping?.()]), late])
|
|
1847
|
+
} catch {
|
|
1848
|
+
// A public endpoint: no backend detail (hosts, users) in the answer.
|
|
1849
|
+
return Response.json({ ok: false, error: "database or Valkey not reachable" }, { status: 503, headers: NO_STORE })
|
|
1850
|
+
} finally {
|
|
1851
|
+
clearTimeout(timer)
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
return Response.json({ ok: true }, { headers: NO_STORE })
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
async function runDrain(drainOptions: DrainOptions): Promise<void> {
|
|
1858
|
+
const timeoutMs = drainOptions.timeoutMs ?? options.timing?.drainTimeoutMs ?? DRAIN_TIMEOUT_MS
|
|
1859
|
+
const retryAfterMs = drainOptions.retryAfterMs ?? options.timing?.drainRetryAfterMs ?? DRAIN_RETRY_AFTER_MS
|
|
1860
|
+
// Refuse new WebSockets and requests; bye each connection once its requests in flight are answered.
|
|
1861
|
+
const connections = spokes.drain({ timeoutMs, retryAfterMs })
|
|
1862
|
+
// Step down at once (releasing the leases), so another pod takes the timed jobs over.
|
|
1863
|
+
await Promise.allSettled(leaders.map((loop) => loop.stop()))
|
|
1864
|
+
// Withdraw this pod's slices: the other pods stop showing its connections (the new pod republishes them).
|
|
1865
|
+
await view?.stop().catch((cause) => console.error(`[hub] cluster view not withdrawn: ${(cause as Error).message}`))
|
|
1866
|
+
for (const listener of [...listeners]) listener.end(SSE_RETRY_MS)
|
|
1867
|
+
drainSignal.abort()
|
|
1868
|
+
await connections
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** Compressed copies of hashed assets, made once per file and encoding (they never change). */
|
|
1872
|
+
const compressed = new Map<string, Promise<Uint8Array>>()
|
|
1873
|
+
function compress(name: string, bytes: Uint8Array, encoding: "br" | "gzip"): Promise<Uint8Array> {
|
|
1874
|
+
const key = `${encoding} ${name}`
|
|
1875
|
+
let done = compressed.get(key)
|
|
1876
|
+
if (!done) {
|
|
1877
|
+
done =
|
|
1878
|
+
encoding === "br"
|
|
1879
|
+
? brotli(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 6, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } })
|
|
1880
|
+
: gzipAsync(bytes, { level: 9 })
|
|
1881
|
+
done.catch(() => compressed.delete(key))
|
|
1882
|
+
compressed.set(key, done)
|
|
1883
|
+
}
|
|
1884
|
+
return done
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
|
|
1072
1888
|
const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
|
|
1073
1889
|
const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
|
|
1074
1890
|
const bytes = name && type ? web.read(name) : undefined
|
|
@@ -1082,6 +1898,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1082
1898
|
headers.set("service-worker-allowed", "/")
|
|
1083
1899
|
} else if (type.startsWith("text/html")) {
|
|
1084
1900
|
headers.set("cache-control", "no-cache")
|
|
1901
|
+
} else if (HASHED_ASSET.test(name)) {
|
|
1902
|
+
headers.set("cache-control", IMMUTABLE)
|
|
1903
|
+
if (COMPRESSIBLE.has(path.extname(name).toLowerCase()) && bytes.length >= MIN_COMPRESS_BYTES) {
|
|
1904
|
+
headers.set("vary", "Accept-Encoding")
|
|
1905
|
+
const encoding = pickEncoding(acceptEncoding)
|
|
1906
|
+
if (encoding) {
|
|
1907
|
+
headers.set("content-encoding", encoding)
|
|
1908
|
+
return new Response(new Uint8Array(await compress(name, bytes, encoding)), { headers })
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1085
1911
|
}
|
|
1086
1912
|
return new Response(bytes, { headers })
|
|
1087
1913
|
}
|
|
@@ -1092,20 +1918,85 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1092
1918
|
store,
|
|
1093
1919
|
auth,
|
|
1094
1920
|
registry,
|
|
1921
|
+
nodes,
|
|
1095
1922
|
spokes,
|
|
1096
1923
|
push,
|
|
1097
1924
|
speechSettings,
|
|
1098
|
-
|
|
1925
|
+
backends,
|
|
1926
|
+
delivery,
|
|
1927
|
+
decisions,
|
|
1928
|
+
acting,
|
|
1929
|
+
launches,
|
|
1930
|
+
launchFeed,
|
|
1931
|
+
launchTokens,
|
|
1932
|
+
webhooks,
|
|
1933
|
+
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
1934
|
+
drain(drainOptions: DrainOptions = {}) {
|
|
1935
|
+
draining ??= runDrain(drainOptions)
|
|
1936
|
+
return draining
|
|
1937
|
+
},
|
|
1099
1938
|
async stop() {
|
|
1100
|
-
|
|
1939
|
+
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
1940
|
+
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
1941
|
+
launches.shutdown()
|
|
1942
|
+
webhooks.stop()
|
|
1943
|
+
tokenPush.stop()
|
|
1944
|
+
delivery.stop()
|
|
1945
|
+
decisions.stop()
|
|
1946
|
+
await spokes.stop()
|
|
1947
|
+
await view?.stop().catch((cause) => console.error(`[hub] cluster view not stopped: ${(cause as Error).message}`))
|
|
1101
1948
|
for (const listener of [...listeners]) listeners.delete(listener)
|
|
1949
|
+
await Promise.all([live?.stop(), feed.detach(), launchFeed.detach()]).catch((cause) => console.error(`[hub] live fan-out not stopped: ${(cause as Error).message}`))
|
|
1102
1950
|
// Bun 1.3.5 quirk: once the server has closed a WebSocket itself, stop(true)'s promise never
|
|
1103
1951
|
// resolves even though the listener is closed. Don't wait on it forever.
|
|
1104
1952
|
await Promise.race([server.stop(true), Bun.sleep(250)])
|
|
1953
|
+
await backends.state.flush?.().catch((cause) => console.error(`[hub] agent state not saved: ${(cause as Error).message}`))
|
|
1954
|
+
await store.close()
|
|
1955
|
+
if (ownBackends) await backends.close?.().catch((cause) => console.error(`[hub] backends not closed: ${(cause as Error).message}`))
|
|
1105
1956
|
},
|
|
1106
1957
|
}
|
|
1107
1958
|
}
|
|
1108
1959
|
|
|
1960
|
+
/** The state filter of GET /api/decisions (throws invalid). */
|
|
1961
|
+
const optionalState = (url: URL): { state?: Decision["state"] } => {
|
|
1962
|
+
const state = decisionStateQuery(url.searchParams.get("state"))
|
|
1963
|
+
return state ? { state } : {}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** {option?, text?} of a Decision answer (types only; Decisions validates). */
|
|
1967
|
+
function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
|
|
1968
|
+
for (const key of ["option", "text"] as const) {
|
|
1969
|
+
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
1970
|
+
}
|
|
1971
|
+
return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
|
|
1975
|
+
function decisionFailure(cause: unknown): Response {
|
|
1976
|
+
if (!(cause instanceof AgentError)) throw cause
|
|
1977
|
+
const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423 }
|
|
1978
|
+
return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/** Deep-link scheme of the native apps: `hubapp://link?hub=<public URL>&code=<link code>`. */
|
|
1982
|
+
export const APP_SCHEME = "hubapp"
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Public writes a native app makes with no ambient credential at all (no cookie, no bearer, no Origin or
|
|
1986
|
+
* Referer): not CSRF-prone, the single-use code in the body is the credential. Every other write keeps the
|
|
1987
|
+
* same-origin check, and so does any request carrying a cookie or a browser Origin.
|
|
1988
|
+
*/
|
|
1989
|
+
const CREDENTIAL_FREE = new Set(["POST /api/devices/redeem"])
|
|
1990
|
+
|
|
1991
|
+
function credentialFree(request: Request, mode: CredentialMode, url: URL): boolean {
|
|
1992
|
+
return (
|
|
1993
|
+
mode === "none" &&
|
|
1994
|
+
!request.headers.has("origin") &&
|
|
1995
|
+
!request.headers.has("referer") &&
|
|
1996
|
+
CREDENTIAL_FREE.has(`${request.method} ${url.pathname}`)
|
|
1997
|
+
)
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1109
2000
|
function userView(user: UserRecord) {
|
|
1110
2001
|
return { id: user.id, ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), role: user.role }
|
|
1111
2002
|
}
|
|
@@ -1134,11 +2025,11 @@ function pushTarget(value: unknown): { endpoint: string; keys: { p256dh: string;
|
|
|
1134
2025
|
return { endpoint, keys: { p256dh, auth } }
|
|
1135
2026
|
}
|
|
1136
2027
|
|
|
1137
|
-
/** Boolean pref fields (and
|
|
2028
|
+
/** Boolean pref fields (and the legacy `blocked` and `finished`) from untrusted input; undefined when nothing applies. */
|
|
1138
2029
|
function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | undefined {
|
|
1139
2030
|
if (typeof value !== "object" || value === null) return undefined
|
|
1140
2031
|
const prefs: Partial<PushPrefs> & LegacyPushPrefs = {}
|
|
1141
|
-
for (const key of [...PUSH_PREF_KEYS, "blocked"] as const) {
|
|
2032
|
+
for (const key of [...PUSH_PREF_KEYS, "blocked", "finished"] as const) {
|
|
1142
2033
|
const v = (value as Record<string, unknown>)[key]
|
|
1143
2034
|
if (typeof v === "boolean") prefs[key] = v
|
|
1144
2035
|
}
|
|
@@ -1148,7 +2039,10 @@ function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | un
|
|
|
1148
2039
|
/** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
|
|
1149
2040
|
function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
1150
2041
|
const invalid = () =>
|
|
1151
|
-
new HttpError(
|
|
2042
|
+
new HttpError(
|
|
2043
|
+
400,
|
|
2044
|
+
`Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} (hiddenSessions: ${VIEW_PREF_MAX_ARCHIVED}) strings of at most ${VIEW_PREF_MAX_CHARS} characters`,
|
|
2045
|
+
)
|
|
1152
2046
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
|
|
1153
2047
|
const record = value as Record<string, unknown>
|
|
1154
2048
|
if (Object.keys(record).some((key) => !(VIEW_PREF_KEYS as readonly string[]).includes(key))) throw invalid()
|
|
@@ -1156,7 +2050,7 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
|
1156
2050
|
for (const key of VIEW_PREF_KEYS) {
|
|
1157
2051
|
const list = record[key]
|
|
1158
2052
|
if (list === undefined) continue
|
|
1159
|
-
if (!Array.isArray(list) || list.length > VIEW_PREF_MAX_ENTRIES) throw invalid()
|
|
2053
|
+
if (!Array.isArray(list) || list.length > (key === "hiddenSessions" ? VIEW_PREF_MAX_ARCHIVED : VIEW_PREF_MAX_ENTRIES)) throw invalid()
|
|
1160
2054
|
if (!list.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= VIEW_PREF_MAX_CHARS)) throw invalid()
|
|
1161
2055
|
out[key] = [...new Set(list as string[])]
|
|
1162
2056
|
}
|
|
@@ -1192,6 +2086,8 @@ function param(raw: string): string {
|
|
|
1192
2086
|
}
|
|
1193
2087
|
}
|
|
1194
2088
|
|
|
2089
|
+
const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
|
|
2090
|
+
|
|
1195
2091
|
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
1196
2092
|
const value = (await request.json()) as unknown
|
|
1197
2093
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|