@tiphareth/dsh-hardssh 0.1.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,892 @@
1
+ /**
2
+ * The /api/dsh-ssh route family: host CRUD, exec, cluster, SFTP transfer
3
+ * (NDJSON progress stream for uploads, binary stream for downloads), remote
4
+ * listing, tunnels, and the WebSocket PTY terminal upgrade. Every route
5
+ * carries a loopback-only trust fence (plus browser same-origin markers) —
6
+ * these endpoints execute commands on remote servers, so LAN-exposed dsh web
7
+ * deployments must not serve them.
8
+ */
9
+
10
+ import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
11
+ import { unlink } from 'node:fs/promises'
12
+ import type { IncomingMessage, ServerResponse } from 'node:http'
13
+ import { basename, join } from 'node:path'
14
+ import { tmpdir } from 'node:os'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { WebSocket, WebSocketServer } from 'ws'
17
+ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
+ import type { SshEngine, ShellSession } from './engine.ts'
19
+ import { SSH_API, type HostPayload, type ApiErrorBody, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
20
+ import type { HostStore } from './store.ts'
21
+ import { HostKeyMismatchError, HostKeyUnknownError, type KnownHostsStore } from './known-hosts.ts'
22
+ import { NeedsPasswordError } from './engine.ts'
23
+
24
+ /** Cap on JSON request bodies (host entries and exec payloads are small). */
25
+ const MAX_JSON_BODY_BYTES = 64 * 1024
26
+
27
+ /** Cap on declared upload bodies (staged to disk before SFTP). */
28
+ export const DEFAULT_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
29
+
30
+ /**
31
+ * One noServer WebSocket server for terminal upgrades: the browser half uses
32
+ * a standards-compliant WebSocket, so the host must speak real RFC 6455
33
+ * frames (the webserver hands us the raw upgraded socket).
34
+ */
35
+ const terminalWss = new WebSocketServer({ noServer: true })
36
+
37
+ /** Pause the shell when the socket's send buffer exceeds this… */
38
+ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
39
+
40
+ /** …and resume once it drains below this. */
41
+ const BACKPRESSURE_LOW_WATER = 512 * 1024
42
+
43
+ /** Result of staging one upload body under a hard byte limit. */
44
+ type StageUploadResult =
45
+ | { ok: true; receivedBytes: number }
46
+ | { ok: false; reason: 'too-large' | 'aborted' | 'request-error' | 'write-error'; receivedBytes: number; error: Error }
47
+
48
+ function toError(error: unknown): Error {
49
+ return error instanceof Error ? error : new Error(String(error))
50
+ }
51
+
52
+ /** Idempotent staging-file cleanup (ENOENT is fine; other errors surface). */
53
+ async function removeStagingFile(path: string): Promise<void> {
54
+ try {
55
+ await unlink(path)
56
+ } catch (error) {
57
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return
58
+ throw error
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Stage a request body to `tmpPath` while enforcing a hard cap on the bytes
64
+ * ACTUALLY received (`Content-Length` is only a fast-reject hint — chunked
65
+ * and under-declared bodies must be caught here). On every failure the sink
66
+ * is destroyed, its 'close' waited for (Windows needs the handle released
67
+ * before unlink), and the partial file removed. Resolves only after the
68
+ * write stream finished, so a success means the body is fully on disk.
69
+ */
70
+ function stageUploadBody(req: IncomingMessage, tmpPath: string, limitBytes: number): Promise<StageUploadResult> {
71
+ return new Promise((resolve) => {
72
+ const sink = createWriteStream(tmpPath, { flags: 'wx' })
73
+
74
+ let receivedBytes = 0
75
+ let terminal = false
76
+ let result: StageUploadResult | undefined
77
+
78
+ const detachRequestListeners = (): void => {
79
+ req.off('data', onData)
80
+ req.off('end', onEnd)
81
+ req.off('aborted', onAborted)
82
+ req.off('error', onRequestError)
83
+ }
84
+
85
+ const resolveFailureAfterClose = (failure: Exclude<StageUploadResult, { ok: true }>): void => {
86
+ if (terminal) return
87
+ terminal = true
88
+ result = failure
89
+ detachRequestListeners()
90
+ // Stop consuming body bytes; the socket is torn down after the 413.
91
+ req.pause()
92
+ if (!sink.destroyed) sink.destroy()
93
+ }
94
+
95
+ const onData = (chunk: Buffer | string): void => {
96
+ if (terminal) return
97
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
98
+ receivedBytes += buffer.length
99
+ if (receivedBytes > limitBytes) {
100
+ // The over-limit chunk is NOT written, so the temp file never
101
+ // exceeds the cap.
102
+ resolveFailureAfterClose({ ok: false, reason: 'too-large', receivedBytes, error: new Error('upload body too large') })
103
+ return
104
+ }
105
+ // Respect filesystem backpressure instead of buffering unbounded data.
106
+ if (!sink.write(buffer)) {
107
+ req.pause()
108
+ sink.once('drain', () => { if (!terminal) req.resume() })
109
+ }
110
+ }
111
+
112
+ const onEnd = (): void => {
113
+ if (terminal) return
114
+ detachRequestListeners()
115
+ sink.end()
116
+ }
117
+
118
+ const onAborted = (): void => {
119
+ resolveFailureAfterClose({ ok: false, reason: 'aborted', receivedBytes, error: new Error('upload aborted by the client') })
120
+ }
121
+
122
+ const onRequestError = (error: Error): void => {
123
+ resolveFailureAfterClose({ ok: false, reason: 'request-error', receivedBytes, error: toError(error) })
124
+ }
125
+
126
+ sink.on('error', (error) => {
127
+ resolveFailureAfterClose({ ok: false, reason: 'write-error', receivedBytes, error: toError(error) })
128
+ })
129
+
130
+ sink.on('finish', () => {
131
+ if (terminal) return
132
+ terminal = true
133
+ detachRequestListeners()
134
+ resolve({ ok: true, receivedBytes })
135
+ })
136
+
137
+ sink.on('close', () => {
138
+ if (result === undefined) return
139
+ const failure = result
140
+ result = undefined
141
+ void removeStagingFile(tmpPath).catch(() => undefined).then(() => resolve(failure))
142
+ })
143
+
144
+ req.on('data', onData)
145
+ req.once('end', onEnd)
146
+ req.once('aborted', onAborted)
147
+ req.once('error', onRequestError)
148
+ })
149
+ }
150
+
151
+ /** 413 + connection: close so the sender cannot keep streaming into the socket. */
152
+ function rejectUploadTooLarge(req: IncomingMessage, res: ServerResponse): void {
153
+ const payload = JSON.stringify({ error: 'upload body too large' })
154
+ res.writeHead(413, {
155
+ 'content-type': 'application/json; charset=utf-8',
156
+ 'content-length': String(Buffer.byteLength(payload)),
157
+ 'connection': 'close',
158
+ 'referrer-policy': 'no-referrer',
159
+ })
160
+ res.end(payload, () => {
161
+ if (!req.socket.destroyed) req.socket.destroy()
162
+ })
163
+ }
164
+
165
+ /** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
166
+ function isLoopbackRequest(request: IncomingMessage): boolean {
167
+ const address = request.socket.remoteAddress
168
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
169
+ const host = request.headers.host
170
+ if (typeof host !== 'string') return false
171
+ let hostUrl: URL
172
+ try {
173
+ hostUrl = new URL(`http://${host}`)
174
+ } catch {
175
+ return false
176
+ }
177
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
178
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
179
+ const origin = request.headers.origin
180
+ if (origin === undefined) return true
181
+ try {
182
+ return new URL(origin).host === hostUrl.host
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ /** One JSON response. */
189
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
190
+ const payload = JSON.stringify(body)
191
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
192
+ res.end(payload)
193
+ }
194
+
195
+ /** Map a thrown error to a structured ApiErrorBody (host-key errors carry
196
+ * machine codes + fingerprints for the GUI's confirm dialog). */
197
+ function errorBody(error: unknown): ApiErrorBody {
198
+ if (error instanceof HostKeyUnknownError) {
199
+ return { error: error.message, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
200
+ }
201
+ if (error instanceof HostKeyMismatchError) {
202
+ return { error: error.message, code: 'HOST_KEY_MISMATCH', hostKeyFingerprint: error.actual, hostKeyMismatch: { expected: error.expected, actual: error.actual } }
203
+ }
204
+ if (error instanceof NeedsPasswordError) {
205
+ return { error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret }
206
+ }
207
+ return { error: error instanceof Error ? error.message : String(error) }
208
+ }
209
+
210
+ /** Read a JSON request body (undefined when too large or unparseable). */
211
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
212
+ const chunks: Buffer[] = []
213
+ let size = 0
214
+ for await (const chunk of req) {
215
+ const buffer = chunk as Buffer
216
+ size += buffer.length
217
+ if (size > MAX_JSON_BODY_BYTES) return undefined
218
+ chunks.push(buffer)
219
+ }
220
+ try {
221
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
222
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
223
+ } catch {
224
+ return undefined
225
+ }
226
+ }
227
+
228
+ /** URL query helper (first value, decoded). */
229
+ function queryParam(url: URL, name: string): string | undefined {
230
+ const value = url.searchParams.get(name)
231
+ return value === null ? undefined : value
232
+ }
233
+
234
+ /** Route family dependencies. */
235
+ export interface SshRoutesDeps {
236
+ /** The host store (CRUD; vault-backed for secrets). */
237
+ store: import('./store.ts').SecureHostStore
238
+ /** The engine (ops). */
239
+ engine: SshEngine
240
+ /** Host-key trust store (TOFU); absent → host-key verification disabled. */
241
+ knownHosts?: KnownHostsStore
242
+ /** Credential vault (optional; enables /api/dsh-ssh/vault endpoints). */
243
+ vault?: import('./vault.ts').Vault
244
+ /** SSH-bound workspace ledger (host-delete reference check). */
245
+ ledger?: import('../ledger.ts').SshWorkspaceLedger
246
+ /** Temp dir for upload/download staging (tests inject a sandbox). */
247
+ stagingDir?: string
248
+ /** Hard cap on ACTUAL upload bytes accepted (chunked requests included).
249
+ * Defaults to 4 GiB; tests inject a small value. */
250
+ uploadLimitBytes?: number
251
+ /** Called after a host PATCH/DELETE persisted, so derived caches (e.g. the
252
+ * remote environment cache) can drop that alias. */
253
+ onHostInvalidated?: (alias: string) => void
254
+ }
255
+
256
+ /**
257
+ * Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
258
+ * @param deps - store, engine, staging dir.
259
+ * @returns routes and the upgrade route.
260
+ */
261
+ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
262
+ const { store, engine } = deps
263
+ const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
264
+ const uploadLimitBytes = deps.uploadLimitBytes ?? DEFAULT_MAX_UPLOAD_BYTES
265
+ if (!Number.isSafeInteger(uploadLimitBytes) || uploadLimitBytes < 0) {
266
+ throw new RangeError('uploadLimitBytes must be a non-negative safe integer')
267
+ }
268
+ // The upload route stages request bodies here; it must exist before the
269
+ // first request (a missing dir would hang the first upload forever).
270
+ mkdirSync(staging, { recursive: true })
271
+
272
+ /** Guard helper: fence + method check. */
273
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
274
+ if (!isLoopbackRequest(req)) {
275
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
276
+ return false
277
+ }
278
+ if (req.method !== method) {
279
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
280
+ return false
281
+ }
282
+ return true
283
+ }
284
+
285
+ const routes: WebRoute[] = [
286
+ // ------------------------------------------------------------ hosts
287
+ {
288
+ kind: 'exact',
289
+ path: SSH_API.hosts,
290
+ handler: async (req, res) => {
291
+ // One handler per path (the webserver keyed route registry rejects
292
+ // duplicate (kind, path)); dispatch by HTTP method here.
293
+ const method = req.method ?? 'GET'
294
+ if (!isLoopbackRequest(req)) {
295
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
296
+ return
297
+ }
298
+ const url = new URL(req.url ?? '/', 'http://localhost')
299
+ if (method === 'GET') {
300
+ writeJson(res, 200, { hosts: engine.list(queryParam(url, 'query')) })
301
+ return
302
+ }
303
+ if (method === 'POST') {
304
+ const body = await readJsonBody(req)
305
+ if (body === undefined) {
306
+ writeJson(res, 400, { error: 'invalid JSON body' })
307
+ return
308
+ }
309
+ try {
310
+ const entry = await store.create(body as unknown as HostPayload)
311
+ writeJson(res, 201, { host: store.summarize(entry) })
312
+ } catch (error) {
313
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
314
+ }
315
+ return
316
+ }
317
+ if (method !== 'PATCH' && method !== 'DELETE') {
318
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
319
+ return
320
+ }
321
+ const alias = queryParam(url, 'alias')
322
+ if (alias === undefined || alias === '') {
323
+ writeJson(res, 400, { error: 'alias query parameter is required' })
324
+ return
325
+ }
326
+ if (method === 'PATCH') {
327
+ const body = await readJsonBody(req)
328
+ if (body === undefined) {
329
+ writeJson(res, 400, { error: 'invalid JSON body' })
330
+ return
331
+ }
332
+ try {
333
+ const entry = await store.update(alias, body as unknown as Partial<HostPayload>)
334
+ // Persist first. A validation/save failure must leave all
335
+ // existing connections untouched. drain lets in-flight
336
+ // operations finish while generation invalidation guarantees
337
+ // subsequent acquisitions cannot reuse the old config's
338
+ // connection (or publish one built from it).
339
+ engine.invalidate(alias, { includeDependents: true, mode: 'drain' })
340
+ deps.onHostInvalidated?.(alias)
341
+ writeJson(res, 200, { host: store.summarize(entry) })
342
+ } catch (error) {
343
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
344
+ }
345
+ return
346
+ }
347
+ if (method === 'DELETE') {
348
+ // Reference guard: a host still backing SSH-bound workspaces must
349
+ // not be deleted (the workspaces would silently lose their
350
+ // transport). Return 409 with the referencing workspace titles.
351
+ if (deps.ledger !== undefined) {
352
+ const referenced = (await deps.ledger.list()).filter(record => record.alias === alias)
353
+ if (referenced.length > 0) {
354
+ writeJson(res, 409, {
355
+ error: `alias '${alias}' is still referenced by ${referenced.length} SSH workspace(s) (${referenced.map(r => r.title).join('、')}) — delete those workspaces first`,
356
+ code: 'HOST_IN_USE',
357
+ workspaces: referenced.map(record => ({ id: record.id, title: record.title })),
358
+ })
359
+ return
360
+ }
361
+ }
362
+ try {
363
+ // Delete first so a missing alias or failed store write does not
364
+ // disturb a live tunnel/connection.
365
+ await store.delete(alias)
366
+ // Tunnels own leases: release them before forcing the remaining
367
+ // pooled transport so records/sockets clean up normally.
368
+ engine.stopAllTunnels(alias)
369
+ engine.invalidate(alias, { mode: 'force' })
370
+ deps.onHostInvalidated?.(alias)
371
+ writeJson(res, 200, { ok: true })
372
+ } catch (error) {
373
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
374
+ }
375
+ return
376
+ }
377
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
378
+ },
379
+ },
380
+ {
381
+ kind: 'exact',
382
+ path: SSH_API.importSshConfig,
383
+ handler: async (req, res) => {
384
+ if (!guard(req, res, 'POST')) return
385
+ try {
386
+ writeJson(res, 200, { result: store.importFromSshConfig() })
387
+ } catch (error) {
388
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
389
+ }
390
+ },
391
+ },
392
+ // -------------------------------------------------------- known-hosts
393
+ {
394
+ kind: 'exact',
395
+ path: SSH_API.knownHosts,
396
+ handler: async (req, res) => {
397
+ if (!isLoopbackRequest(req)) {
398
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
399
+ return
400
+ }
401
+ const method = req.method ?? 'GET'
402
+ const url = new URL(req.url ?? '/', 'http://localhost')
403
+ const knownHosts = deps.knownHosts
404
+ if (knownHosts === undefined) {
405
+ writeJson(res, 404, { error: 'host-key trust store is not enabled' })
406
+ return
407
+ }
408
+ if (method === 'GET') {
409
+ const alias = queryParam(url, 'alias')
410
+ const records = knownHosts.list()
411
+ .filter(record => alias === undefined || alias === '' || record.alias === alias)
412
+ .map(record => ({
413
+ alias: record.alias,
414
+ host: record.host,
415
+ port: record.port,
416
+ keyType: record.keyType,
417
+ fingerprint: record.fingerprint,
418
+ status: record.status,
419
+ firstSeenAt: record.firstSeenAt,
420
+ confirmedAt: record.confirmedAt,
421
+ } satisfies import('./protocol.ts').KnownHostView))
422
+ writeJson(res, 200, { records })
423
+ return
424
+ }
425
+ if (method !== 'POST') {
426
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
427
+ return
428
+ }
429
+ const body = await readJsonBody(req)
430
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
431
+ const action = typeof body?.action === 'string' ? body.action : ''
432
+ if (alias === '' || (action !== 'trust' && action !== 'forget')) {
433
+ writeJson(res, 400, { error: 'alias and action (trust|forget) are required' })
434
+ return
435
+ }
436
+ try {
437
+ if (action === 'trust') knownHosts.trust(alias)
438
+ else knownHosts.forget(alias)
439
+ writeJson(res, 200, { ok: true })
440
+ } catch (error) {
441
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
442
+ }
443
+ },
444
+ },
445
+ // ------------------------------------------------------------- vault
446
+ {
447
+ kind: 'exact',
448
+ path: SSH_API.vault,
449
+ handler: async (req, res) => {
450
+ if (!isLoopbackRequest(req)) {
451
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
452
+ return
453
+ }
454
+ const vault = deps.vault
455
+ if (vault === undefined) {
456
+ writeJson(res, 404, { error: 'credential vault is not enabled' })
457
+ return
458
+ }
459
+ const method = req.method ?? 'GET'
460
+ if (method === 'GET') {
461
+ writeJson(res, 200, { status: vault.status() })
462
+ return
463
+ }
464
+ if (method !== 'POST') {
465
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
466
+ return
467
+ }
468
+ const body = await readJsonBody(req)
469
+ const action = typeof body?.action === 'string' ? body.action : ''
470
+ try {
471
+ if (action === 'status') {
472
+ writeJson(res, 200, { status: vault.status() })
473
+ return
474
+ }
475
+ if (action === 'unlock') {
476
+ const password = typeof body?.password === 'string' ? body.password : ''
477
+ await vault.unlock(password)
478
+ writeJson(res, 200, { ok: true, status: vault.status() })
479
+ return
480
+ }
481
+ if (action === 'rekey') {
482
+ const password = typeof body?.password === 'string' ? body.password : ''
483
+ await vault.rekey(password)
484
+ writeJson(res, 200, { ok: true, status: vault.status() })
485
+ return
486
+ }
487
+ writeJson(res, 400, { error: 'action must be status|unlock|rekey' })
488
+ } catch (error) {
489
+ // Vault errors carry stable codes (VAULT_LOCKED / VAULT_AUTH /
490
+ // VAULT_LOCKOUT) + retry info; surface the message + code.
491
+ const record = error as { code?: unknown }
492
+ writeJson(res, 401, {
493
+ error: error instanceof Error ? error.message : String(error),
494
+ code: typeof record.code === 'string' ? record.code : undefined,
495
+ })
496
+ }
497
+ },
498
+ },
499
+ // --------------------------------------------------- session-secret
500
+ {
501
+ kind: 'exact',
502
+ path: SSH_API.sessionSecret,
503
+ handler: async (req, res) => {
504
+ if (!isLoopbackRequest(req)) {
505
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
506
+ return
507
+ }
508
+ if ((req.method ?? '') !== 'POST') {
509
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
510
+ return
511
+ }
512
+ const body = await readJsonBody(req)
513
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
514
+ const password = typeof body?.password === 'string' ? body.password : undefined
515
+ const passphrase = typeof body?.passphrase === 'string' ? body.passphrase : undefined
516
+ if (alias === '' || (password === undefined && passphrase === undefined)) {
517
+ writeJson(res, 400, { error: 'alias and password|passphrase are required' })
518
+ return
519
+ }
520
+ // Memory-only: never persisted; consumed by connectChain for the
521
+ // pooled connection's lifetime (idle 30 min auto-disconnect).
522
+ engine.setSessionPassword(alias, { password, passphrase })
523
+ writeJson(res, 200, { ok: true })
524
+ },
525
+ },
526
+ // --------------------------------------------------- connections
527
+ {
528
+ kind: 'exact',
529
+ path: SSH_API.connections,
530
+ handler: async (req, res) => {
531
+ if (!isLoopbackRequest(req)) {
532
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
533
+ return
534
+ }
535
+ if ((req.method ?? '') !== 'GET') {
536
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
537
+ return
538
+ }
539
+ writeJson(res, 200, { connected: engine.connectedAliases() })
540
+ },
541
+ },
542
+ // ------------------------------------------------------------ ops
543
+ {
544
+ kind: 'exact',
545
+ path: SSH_API.test,
546
+ handler: async (req, res) => {
547
+ if (!guard(req, res, 'POST')) return
548
+ const body = await readJsonBody(req)
549
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
550
+ if (alias === '') {
551
+ writeJson(res, 400, { error: 'alias is required' })
552
+ return
553
+ }
554
+ try {
555
+ writeJson(res, 200, { result: await engine.test(alias) })
556
+ } catch (error) {
557
+ if (error instanceof NeedsPasswordError) {
558
+ // Not a failure: the connection needs a credential the user
559
+ // hasn't entered this session yet. Return a structured prompt
560
+ // (200) so the GUI can show the password dialog and retry.
561
+ writeJson(res, 200, {
562
+ result: { ok: false, error: error.message, code: 'NEEDS_PASSWORD', secret: error.secret },
563
+ })
564
+ return
565
+ }
566
+ writeJson(res, 500, errorBody(error))
567
+ }
568
+ },
569
+ },
570
+ {
571
+ kind: 'exact',
572
+ path: SSH_API.exec,
573
+ handler: async (req, res) => {
574
+ if (!guard(req, res, 'POST')) return
575
+ const body = await readJsonBody(req)
576
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
577
+ const command = typeof body?.command === 'string' ? body.command : ''
578
+ if (alias === '' || command === '') {
579
+ writeJson(res, 400, { error: 'alias and command are required' })
580
+ return
581
+ }
582
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
583
+ try {
584
+ writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) })
585
+ } catch (error) {
586
+ writeJson(res, 500, errorBody(error))
587
+ }
588
+ },
589
+ },
590
+ {
591
+ kind: 'exact',
592
+ path: SSH_API.cluster,
593
+ handler: async (req, res) => {
594
+ if (!guard(req, res, 'POST')) return
595
+ const body = await readJsonBody(req)
596
+ const command = typeof body?.command === 'string' ? body.command : ''
597
+ if (command === '') {
598
+ writeJson(res, 400, { error: 'command is required' })
599
+ return
600
+ }
601
+ const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
602
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
603
+ const environment = typeof body?.environment === 'string' ? body.environment : undefined
604
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
605
+ const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
606
+ try {
607
+ writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
608
+ } catch (error) {
609
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
610
+ }
611
+ },
612
+ },
613
+ // ------------------------------------------------------------- ls
614
+ {
615
+ kind: 'exact',
616
+ path: SSH_API.ls,
617
+ handler: async (req, res) => {
618
+ if (!guard(req, res, 'GET')) return
619
+ const url = new URL(req.url ?? '/', 'http://localhost')
620
+ const alias = queryParam(url, 'alias')
621
+ const path = queryParam(url, 'path') ?? '/'
622
+ if (alias === undefined || alias === '') {
623
+ writeJson(res, 400, { error: 'alias query parameter is required' })
624
+ return
625
+ }
626
+ try {
627
+ writeJson(res, 200, { entries: await engine.ls(alias, path) })
628
+ } catch (error) {
629
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
630
+ }
631
+ },
632
+ },
633
+ // --------------------------------------------------------- tunnel
634
+ {
635
+ kind: 'exact',
636
+ path: SSH_API.tunnel,
637
+ handler: async (req, res) => {
638
+ if (!guard(req, res, 'POST')) return
639
+ const body = await readJsonBody(req)
640
+ const action = typeof body?.action === 'string' ? body.action : ''
641
+ if (action === 'list') {
642
+ writeJson(res, 200, { tunnels: engine.listTunnels() })
643
+ return
644
+ }
645
+ if (action === 'start') {
646
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
647
+ const remotePort = typeof body?.remotePort === 'number' ? body.remotePort : undefined
648
+ if (alias === '' || remotePort === undefined) {
649
+ writeJson(res, 400, { error: 'alias and remotePort are required' })
650
+ return
651
+ }
652
+ try {
653
+ const tunnel = await engine.startTunnel(alias, {
654
+ remotePort,
655
+ remoteHost: typeof body?.remoteHost === 'string' && body.remoteHost !== '' ? body.remoteHost : undefined,
656
+ localPort: typeof body?.localPort === 'number' ? body.localPort : undefined,
657
+ })
658
+ writeJson(res, 200, { tunnel })
659
+ } catch (error) {
660
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
661
+ }
662
+ return
663
+ }
664
+ if (action === 'stop') {
665
+ const id = typeof body?.tunnelId === 'string' ? body.tunnelId : ''
666
+ if (id === '') {
667
+ writeJson(res, 400, { error: 'tunnelId is required' })
668
+ return
669
+ }
670
+ writeJson(res, 200, { ok: engine.stopTunnel(id) })
671
+ return
672
+ }
673
+ if (action === 'stop-all') {
674
+ const alias = typeof body?.alias === 'string' ? body.alias : undefined
675
+ writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === '' ? undefined : alias) })
676
+ return
677
+ }
678
+ writeJson(res, 400, { error: `unknown action '${action}'` })
679
+ },
680
+ },
681
+ // --------------------------------------------------------- upload
682
+ {
683
+ kind: 'exact',
684
+ path: SSH_API.upload,
685
+ handler: async (req, res) => {
686
+ if (!guard(req, res, 'POST')) return
687
+ const url = new URL(req.url ?? '/', 'http://localhost')
688
+ const alias = queryParam(url, 'alias')
689
+ const remotePath = queryParam(url, 'remotePath')
690
+ if (alias === undefined || remotePath === undefined) {
691
+ writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
692
+ return
693
+ }
694
+
695
+ // Content-Length is only a fast-reject path; never trust it as proof
696
+ // the actual body is within the limit (chunked / under-declared).
697
+ const contentLengthHeader = req.headers['content-length']
698
+ const declaredBytes = typeof contentLengthHeader === 'string' ? Number(contentLengthHeader) : Number.NaN
699
+ if (Number.isFinite(declaredBytes) && declaredBytes > uploadLimitBytes) {
700
+ writeJson(res, 413, { error: 'upload body too large' })
701
+ return
702
+ }
703
+
704
+ const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
705
+
706
+ // Stage and validate the WHOLE body before starting the NDJSON 200 —
707
+ // over-limit bodies get a real HTTP 413, not a failure frame inside
708
+ // an already-started 200 stream.
709
+ const staged = await stageUploadBody(req, tmp, uploadLimitBytes)
710
+
711
+ if (!staged.ok) {
712
+ if (staged.reason === 'too-large') {
713
+ if (!res.headersSent) {
714
+ rejectUploadTooLarge(req, res)
715
+ } else {
716
+ res.destroy()
717
+ }
718
+ return
719
+ }
720
+ if (!res.headersSent) {
721
+ writeJson(res, staged.reason === 'aborted' ? 400 : 500, { error: staged.error.message })
722
+ } else {
723
+ res.destroy(staged.error)
724
+ }
725
+ return
726
+ }
727
+
728
+ res.writeHead(200, {
729
+ 'content-type': 'application/x-ndjson; charset=utf-8',
730
+ 'cache-control': 'no-cache',
731
+ 'referrer-policy': 'no-referrer',
732
+ })
733
+
734
+ let responseClosed = false
735
+ const emit = (line: unknown): void => {
736
+ if (responseClosed || res.destroyed || res.writableEnded) return
737
+ try { res.write(`${JSON.stringify(line)}\n`) } catch { responseClosed = true }
738
+ }
739
+ const onResponseClose = (): void => { responseClosed = true }
740
+ res.once('close', onResponseClose)
741
+
742
+ try {
743
+ emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: staged.receivedBytes, percent: 0 } })
744
+ const outcome = await engine.upload(alias, tmp, remotePath, false, progress => emit({ type: 'progress', progress }))
745
+ emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
746
+ } catch (error) {
747
+ emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
748
+ } finally {
749
+ // engine.upload() has settled (read handle released) — safe to unlink.
750
+ await removeStagingFile(tmp).catch(() => undefined)
751
+ res.off('close', onResponseClose)
752
+ if (!responseClosed && !res.destroyed && !res.writableEnded) {
753
+ try { res.end() } catch { /* client gone */ }
754
+ }
755
+ }
756
+ },
757
+ },
758
+ // ------------------------------------------------------- download
759
+ {
760
+ kind: 'exact',
761
+ path: SSH_API.download,
762
+ handler: async (req, res) => {
763
+ if (!guard(req, res, 'GET')) return
764
+ const url = new URL(req.url ?? '/', 'http://localhost')
765
+ const alias = queryParam(url, 'alias')
766
+ const remotePath = queryParam(url, 'remotePath')
767
+ if (alias === undefined || remotePath === undefined) {
768
+ writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
769
+ return
770
+ }
771
+ const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
772
+ try {
773
+ const outcome = await engine.download(alias, remotePath, tmp)
774
+ res.writeHead(200, {
775
+ 'content-type': 'application/octet-stream',
776
+ 'content-length': String(outcome.bytes),
777
+ 'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
778
+ 'referrer-policy': 'no-referrer',
779
+ })
780
+ await new Promise<void>((resolve, reject) => {
781
+ const source = createReadStream(tmp)
782
+ source.on('error', reject)
783
+ res.on('error', reject)
784
+ source.pipe(res)
785
+ source.on('end', resolve)
786
+ })
787
+ } catch (error) {
788
+ if (!res.headersSent) {
789
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
790
+ } else {
791
+ // Mid-stream failure after headers: destroy so the browser does
792
+ // not hang waiting for the promised content-length bytes.
793
+ res.destroy()
794
+ }
795
+ } finally {
796
+ await unlink(tmp).catch(() => undefined)
797
+ }
798
+ },
799
+ },
800
+ ]
801
+
802
+ // ---------------------------------------------- terminal (upgrade)
803
+ const upgrade: WebUpgradeRoute = {
804
+ path: SSH_API.terminal,
805
+ handler: (req, socket, head) => {
806
+ if (!isLoopbackRequest(req)) {
807
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
808
+ socket.destroy()
809
+ return
810
+ }
811
+ const url = new URL(req.url ?? '/', 'http://localhost')
812
+ const alias = queryParam(url, 'alias')
813
+ if (alias === undefined) {
814
+ socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
815
+ socket.destroy()
816
+ return
817
+ }
818
+ const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
819
+ const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
820
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
821
+ let session: ShellSession | undefined
822
+ let closed = false
823
+ let paused = false
824
+ // Resume the shell once the socket's send buffer drains below the
825
+ // low-water mark (transport backpressure).
826
+ const resume = (): void => {
827
+ if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
828
+ paused = false
829
+ session?.resume()
830
+ }
831
+ }
832
+ const sendFrame = (frame: TerminalServerFrame): void => {
833
+ if (closed || ws.readyState !== WebSocket.OPEN) return
834
+ ws.send(JSON.stringify(frame), resume)
835
+ if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
836
+ paused = true
837
+ session?.pause()
838
+ }
839
+ }
840
+ const closeSession = (): void => {
841
+ const opened = session
842
+ session = undefined
843
+ if (opened !== undefined) opened.close()
844
+ }
845
+ engine.openShell(alias, {
846
+ cols: Number.isFinite(cols) ? cols : 80,
847
+ rows: Number.isFinite(rows) ? rows : 24,
848
+ }).then((opened) => {
849
+ if (ws.readyState !== WebSocket.OPEN) {
850
+ opened.close()
851
+ return
852
+ }
853
+ session = opened
854
+ sendFrame({ type: 'ready', alias })
855
+ opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
856
+ opened.onExit = (code, error) => {
857
+ sendFrame({ type: 'exit', code, error })
858
+ closed = true
859
+ try { ws.close(1000) } catch { /* already closed */ }
860
+ }
861
+ }).catch((error) => {
862
+ sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
863
+ closed = true
864
+ try { ws.close(1000) } catch { /* already closed */ }
865
+ })
866
+ ws.on('message', (data) => {
867
+ let frame: TerminalClientFrame
868
+ try {
869
+ frame = JSON.parse(String(data)) as TerminalClientFrame
870
+ } catch {
871
+ return
872
+ }
873
+ if (frame.type === 'input') {
874
+ session?.send(frame.data)
875
+ } else if (frame.type === 'resize') {
876
+ session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
877
+ }
878
+ })
879
+ ws.on('close', () => {
880
+ closed = true
881
+ closeSession()
882
+ })
883
+ ws.on('error', () => {
884
+ closed = true
885
+ closeSession()
886
+ })
887
+ })
888
+ },
889
+ }
890
+
891
+ return { routes, upgrade }
892
+ }