dsh-tiddlywiki 0.19.2 → 0.19.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "TiddlyWiki 5 as the DSH persistent knowledge base: fifteen tiddlywiki_* agent tools (filtered search/recent/tags/batch/rewrite/trash/lint + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a right-side wiki tab, a per-session summary tab, a quick-note card with draft auto-save behind a single knowledge FAB, an optional clip bookmarklet that also saves selected images as attachments, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.19.2",
4
+ "version": "0.19.3",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -85,7 +85,7 @@
85
85
  "smoke:client": "node scripts/smoke-client.mjs",
86
86
  "selftest": "node scripts/selftest.mjs",
87
87
  "verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs",
88
- "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs",
88
+ "verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs",
89
89
  "verify:e2e": "node scripts/verify-auth.mjs && node scripts/verify-tools.mjs && node scripts/verify-git-resolve.mjs && node scripts/verify-resilience.mjs",
90
90
  "verify:large": "node scripts/verify-large-wiki.mjs",
91
91
  "verify": "npm run verify:static && npm run verify:unit && npm run verify:e2e",
@@ -267,13 +267,13 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
267
267
  const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
268
268
  checkField('ui.sendToAgent.enabled', '启用「发送给 Agent」(TW 笔记 → DSH 会话注入)', sendToAgent.enabled !== false)
269
269
  textField('ui.sendToAgent.endpoint', 'TW 端请求基址(空=自动取当前 DSH origin)', typeof sendToAgent.endpoint === 'string' ? sendToAgent.endpoint : '')
270
- textField('ui.sendToAgent.token', '共享 token(非空时路由校验 x-send-to-agent-token 头)', typeof sendToAgent.token === 'string' ? sendToAgent.token : '')
270
+ textField('ui.sendToAgent.token', '共享 token(非空时路由校验 x-send-to-agent-token 头;已设置时显示为 ********,原样保存=不改,清空=删除)', typeof sendToAgent.token === 'string' ? sendToAgent.token : '')
271
271
  const allArticles = (ui.allArticles ?? {}) as Record<string, unknown>
272
272
  numField('ui.allArticles.pageSize', '「所有文章」每页条数', typeof allArticles.pageSize === 'number' ? allArticles.pageSize : 10)
273
273
  const bridge = (config.bridge ?? {}) as Record<string, unknown>
274
274
  checkField('bridge.enabled', '启用「本地剪藏桥」(书签小工具后端监听 127.0.0.1 端口,保存后立即生效)', bridge.enabled === true)
275
275
  numField('bridge.port', '剪藏桥端口(改端口需重启 dsh web 生效)', typeof bridge.port === 'number' && bridge.port > 0 ? bridge.port : 8618)
276
- textField('bridge.token', '剪藏桥 token(非空时校验书签的 x-clip-token 头;强烈建议设置)', typeof bridge.token === 'string' ? bridge.token : '')
276
+ textField('bridge.token', '剪藏桥 token(非空时校验书签的 x-clip-token 头;强烈建议设置。已设置时显示为 ********,原样保存=不改,清空=删除)', typeof bridge.token === 'string' ? bridge.token : '')
277
277
  textField('bridge.tag', '剪藏笔记默认 tag', typeof bridge.tag === 'string' && bridge.tag.trim().length > 0 ? bridge.tag.trim() : 'clip')
278
278
  // 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
279
279
  // 注意:uiLanguage 目前只影响 TW 侧语言,客户端插件文案(FAB/快速笔记/侧边栏/本页)
package/src/host/admin.ts CHANGED
@@ -24,7 +24,7 @@ import type { TiddlyWebClient } from './tw-api.ts'
24
24
  import type { WikiServer } from './wiki.ts'
25
25
  import { ROUTE_PREFIX, redactLogLines, redactRemoteUrl, type WebServerFace } from './routes.ts'
26
26
  import { CONFIG_TIDDLER, type ConfigStore, type PluginConfigShape } from './config.ts'
27
- import { readBody, json, rejectCrossSiteWrite, rejectNonRead } from './http.ts'
27
+ import { readBody, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead } from './http.ts'
28
28
  import { waitForFileWrite, needsRestartAfterSeeds, flushPendingWrites } from './seeds.ts'
29
29
  import { RENDER_PLUGIN_FILE } from './seed-render.ts'
30
30
  import { GitFace } from './git.ts'
@@ -85,21 +85,28 @@ export async function readWikiInfo(wikiPath: string): Promise<WikiInfo> {
85
85
  }
86
86
  throw new Error(`读取 tiddlywiki.info 失败:${err instanceof Error ? err.message : String(err)}`)
87
87
  }
88
- let parsed: Partial<WikiInfo>
88
+ let parsed: unknown
89
89
  try {
90
- parsed = JSON.parse(raw) as Partial<WikiInfo>
90
+ parsed = JSON.parse(raw)
91
91
  } catch {
92
92
  throw new Error('tiddlywiki.info 不是合法 JSON;请先修复该文件再保存设置(已保留原文件)')
93
93
  }
94
+ // A top-level `null`/array/primitive is NOT a valid tiddlywiki.info; the old
95
+ // code spread it and crashed with "Cannot read properties of null" (a
96
+ // confusing 500 instead of the intended readable error).
97
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
98
+ throw new Error('tiddlywiki.info 顶层不是对象;请先修复该文件再保存设置(已保留原文件)')
99
+ }
100
+ const obj = parsed as Partial<WikiInfo>
94
101
  return {
95
102
  // Spread FIRST, then the normalized fields: the old order let a literal
96
103
  // `"plugins": null` (or a non-array) overwrite the `[]` default and crash
97
104
  // every consumer with `.includes is not a function`.
98
- ...parsed,
99
- description: parsed.description,
100
- plugins: Array.isArray(parsed.plugins) ? parsed.plugins : [],
101
- themes: Array.isArray(parsed.themes) ? parsed.themes : [],
102
- languages: Array.isArray(parsed.languages) ? parsed.languages : [],
105
+ ...obj,
106
+ description: obj.description,
107
+ plugins: Array.isArray(obj.plugins) ? obj.plugins : [],
108
+ themes: Array.isArray(obj.themes) ? obj.themes : [],
109
+ languages: Array.isArray(obj.languages) ? obj.languages : [],
103
110
  }
104
111
  }
105
112
 
@@ -297,6 +304,79 @@ export interface AdminDeps {
297
304
  }
298
305
  }
299
306
 
307
+ /**
308
+ * Sentinel returned instead of a stored secret (`bridge.token` /
309
+ * `ui.sendToAgent.token`) by `GET /admin/state` and by `POST /admin/config`'s
310
+ * echo. `/admin/*` is an unauthenticated surface (only CSRF-hardened), and the
311
+ * config tiddler legitimately carries shared tokens — handing them to any
312
+ * caller of the loopback/LAN-reachable GUI is a real leak (v0.19.3). The
313
+ * settings page renders the sentinel verbatim; posting it back is a no-op
314
+ * (`stripMaskedSecrets` drops it), so the real value never leaves the host.
315
+ */
316
+ export const MASKED_SECRET = '********'
317
+
318
+ /** Mask secrets (and credentials inside the git remote URL) for an HTTP caller. */
319
+ export function maskConfigSecrets(config: PluginConfigShape): PluginConfigShape {
320
+ const bridge = (config.bridge ?? {}) as Record<string, unknown>
321
+ const ui = (config.ui ?? {}) as Record<string, unknown>
322
+ const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
323
+ const git = (config.git ?? {}) as Record<string, unknown>
324
+ const maskToken = (value: unknown): string => (typeof value === 'string' && value.length > 0 ? MASKED_SECRET : '')
325
+ return {
326
+ ...config,
327
+ git: { ...git, remote: typeof git.remote === 'string' ? redactRemoteUrl(git.remote) : git.remote },
328
+ bridge: {
329
+ ...bridge,
330
+ token: maskToken(bridge.token),
331
+ tokenSet: typeof bridge.token === 'string' && bridge.token.length > 0,
332
+ },
333
+ ui: {
334
+ ...ui,
335
+ sendToAgent: {
336
+ ...sendToAgent,
337
+ token: maskToken(sendToAgent.token),
338
+ tokenSet: typeof sendToAgent.token === 'string' && sendToAgent.token.length > 0,
339
+ },
340
+ },
341
+ } as PluginConfigShape
342
+ }
343
+
344
+ /**
345
+ * Drop the masked placeholders from an incoming config patch so saving the
346
+ * settings page never overwrites a stored secret with `********` (or the
347
+ * redacted git remote with `https://***@…`). `bridge.token`/`ui.sendToAgent.token`
348
+ * are cleared only when the caller actually sends an empty string.
349
+ */
350
+ export function stripMaskedSecrets<T extends Record<string, unknown>>(patch: T, current: PluginConfigShape): T {
351
+ const copy = { ...patch } as Record<string, unknown>
352
+ const cleanToken = (container: unknown): unknown => {
353
+ if (typeof container !== 'object' || container === null) return container
354
+ const obj = { ...(container as Record<string, unknown>) }
355
+ if (obj.token === MASKED_SECRET) delete obj.token
356
+ delete obj.tokenSet
357
+ return obj
358
+ }
359
+ if (copy.bridge !== undefined) copy.bridge = cleanToken(copy.bridge)
360
+ if (copy.ui !== undefined) {
361
+ const ui = typeof copy.ui === 'object' && copy.ui !== null
362
+ ? { ...(copy.ui as Record<string, unknown>) }
363
+ : copy.ui
364
+ if (typeof ui === 'object' && ui !== null && (ui as Record<string, unknown>).sendToAgent !== undefined) {
365
+ ;(ui as Record<string, unknown>).sendToAgent = cleanToken((ui as Record<string, unknown>).sendToAgent)
366
+ }
367
+ copy.ui = ui
368
+ }
369
+ if (copy.git !== undefined && typeof copy.git === 'object' && copy.git !== null) {
370
+ const git = { ...(copy.git as Record<string, unknown>) }
371
+ const storedRemote = typeof current.git?.remote === 'string' ? current.git.remote : ''
372
+ if (typeof git.remote === 'string' && storedRemote.length > 0 && git.remote === redactRemoteUrl(storedRemote)) {
373
+ delete git.remote
374
+ }
375
+ copy.git = git
376
+ }
377
+ return copy as T
378
+ }
379
+
300
380
  export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: AdminDeps): () => void {
301
381
  const handleState = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
302
382
  try {
@@ -317,7 +397,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
317
397
  server: { ...view, logs: redactLogLines(view.logs) },
318
398
  info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] },
319
399
  catalog,
320
- config: deps.config.get(),
400
+ config: maskConfigSecrets(deps.config.get()),
321
401
  git,
322
402
  })
323
403
  } catch (err) {
@@ -343,6 +423,10 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
343
423
  const catalog = await bundledCatalog(deps.twRoot())
344
424
  const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name))
345
425
  const knownLangs = new Set(catalog.languages.map((c) => c.name))
426
+ /** Snapshot for the no-op guard below (JSON compare is enough here). */
427
+ const beforeInfo = JSON.stringify(info)
428
+ /** Set only when the request carried a themes array with a resolvable active pick. */
429
+ let activatedTheme: string | undefined
346
430
  const applyList = (field: 'plugins' | 'themes', raw: unknown): string[] => {
347
431
  if (!Array.isArray(raw)) return info[field]
348
432
  const next: string[] = []
@@ -367,45 +451,58 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
367
451
  }
368
452
  return next
369
453
  }
370
- info.plugins = applyList('plugins', body.plugins)
371
- // Activate the chosen theme: load its full dependency chain AND set
372
- // `$:/theme` so the browser's themeManager actually applies it (the
373
- // `themes` array alone only makes the plugin available).
374
- let activatedTheme: string | undefined
375
- if (Array.isArray(body.themes)) {
376
- const themeDeps: Record<string, string[]> = {}
377
- for (const theme of catalog.themes) {
378
- if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents
379
- }
380
- let selected = applyList('themes', body.themes)
381
- // Explicit active-theme pick (new two-layer UI): validate and auto-add
382
- // it to the loaded set so its dependency closure is loaded. Without an
383
- // explicit pick, activate the deepest loaded overlay (old single-radio).
384
- const explicitActive = typeof body.themeActive === 'string' && body.themeActive.length > 0
385
- if (explicitActive) {
386
- const activeName = body.themeActive as string
387
- if (known.has(activeName) || info.themes.includes(activeName)) {
388
- if (!selected.includes(activeName)) selected.push(activeName)
389
- activatedTheme = activeName
454
+ // Validation + in-memory mutation only: a rejected name must NOT touch
455
+ // tiddlywiki.info (400 straight out), while a failure of the write/restart
456
+ // below is an internal error (500) — the old code reported both as 400.
457
+ try {
458
+ info.plugins = applyList('plugins', body.plugins)
459
+ // Activate the chosen theme: load its full dependency chain AND set
460
+ // `$:/theme` so the browser's themeManager actually applies it (the
461
+ // `themes` array alone only makes the plugin available).
462
+ if (Array.isArray(body.themes)) {
463
+ const themeDeps: Record<string, string[]> = {}
464
+ for (const theme of catalog.themes) {
465
+ if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents
390
466
  }
467
+ const selected = applyList('themes', body.themes)
468
+ // Explicit active-theme pick (new two-layer UI): validate and auto-add
469
+ // it to the loaded set so its dependency closure is loaded. Without an
470
+ // explicit pick, activate the deepest loaded overlay (old single-radio).
471
+ const explicitActive = typeof body.themeActive === 'string' && body.themeActive.length > 0
472
+ if (explicitActive) {
473
+ const activeName = body.themeActive as string
474
+ if (known.has(activeName) || info.themes.includes(activeName)) {
475
+ if (!selected.includes(activeName)) selected.push(activeName)
476
+ activatedTheme = activeName
477
+ }
478
+ }
479
+ info.themes = normalizeThemes(selected, themeDeps)
480
+ if (activatedTheme === undefined && info.themes.length > 0) {
481
+ activatedTheme = info.themes[info.themes.length - 1]
482
+ }
483
+ } else {
484
+ info.themes = applyList('themes', body.themes)
391
485
  }
392
- info.themes = normalizeThemes(selected, themeDeps)
393
- if (activatedTheme === undefined && info.themes.length > 0) {
394
- activatedTheme = info.themes[info.themes.length - 1]
395
- }
396
- } else {
397
- info.themes = applyList('themes', body.themes)
486
+ if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages)
487
+ } catch (err) {
488
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
489
+ return
490
+ }
491
+ // NO-OP GUARD (v0.19.3): a body that changes nothing (e.g. `{}`, or the
492
+ // same lists re-sent) used to rewrite tiddlywiki.info + restart TW — a
493
+ // visible TW interruption for a request that asked for nothing.
494
+ const changed = JSON.stringify(info) !== beforeInfo
495
+ if (changed) {
496
+ await writeWikiInfo(wikiPath, info)
497
+ await deps.server.restart()
398
498
  }
399
- if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages)
400
- await writeWikiInfo(wikiPath, info)
401
- await deps.server.restart()
402
499
  // Activate the chosen theme tiddler (mirrors TW's own Control Panel).
403
- if (activatedTheme !== undefined) {
500
+ if (Array.isArray(body.themes) && activatedTheme !== undefined) {
404
501
  const client = deps.getClient()
405
502
  if (client !== undefined) {
406
503
  await client
407
504
  .put({ title: '$:/theme', text: `$:/themes/${activatedTheme}`, type: 'text/vnd.tiddlywiki', tags: [] })
408
- .catch(() => undefined)
505
+ .catch((err) => { console.warn('[dsh-tiddlywiki] activating theme failed:', err) })
409
506
  }
410
507
  }
411
508
  // After a languages change, pin the active language tiddler: first
@@ -416,7 +513,8 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
416
513
  if (client !== undefined) {
417
514
  const langs = info.languages ?? []
418
515
  const active = langs.length > 0 ? `$:/languages/${langs[0]}` : '$:/languages/en-GB'
419
- await client.put({ title: '$:/language', text: active, type: 'text/plain', tags: [] }).catch(() => undefined)
516
+ await client.put({ title: '$:/language', text: active, type: 'text/plain', tags: [] })
517
+ .catch((err) => { console.warn('[dsh-tiddlywiki] pinning $:/language failed:', err) })
420
518
  // Keep the startup auto-apply hint (config uiLanguage) consistent with
421
519
  // the active language, so a later dsh-web restart doesn't re-enable
422
520
  // a language the user just disabled here.
@@ -426,9 +524,9 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
426
524
  }
427
525
  }
428
526
  }
429
- json(res, { ok: true, info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] } })
527
+ json(res, { ok: true, changed, info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] } })
430
528
  } catch (err) {
431
- json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
529
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
432
530
  }
433
531
  }
434
532
 
@@ -441,8 +539,10 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
441
539
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
442
540
  return
443
541
  }
444
- await deps.config.set(client, body)
445
- json(res, { ok: true, config: deps.config.get() })
542
+ // Never persist the masked placeholders the page read back from /state
543
+ // (v0.19.3): saving an untouched form must not clobber a real token.
544
+ await deps.config.set(client, stripMaskedSecrets(body as Record<string, unknown>, deps.config.get()) as PluginConfigShape)
545
+ json(res, { ok: true, config: maskConfigSecrets(deps.config.get()) })
446
546
  } catch (err) {
447
547
  json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
448
548
  }
@@ -552,14 +652,15 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
552
652
  }
553
653
  }
554
654
 
655
+ // Same rejection safety net as routes.ts (v0.19.3).
555
656
  const disposers = [
556
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/state`, handler: (req, res) => { void handleState(req, res) } }),
557
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/info`, handler: (req, res) => { void handleInfo(req, res) } }),
558
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/config`, handler: (req, res) => { void handleConfig(req, res) } }),
559
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
560
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds`, handler: (req, res) => { void handleSeeds(req, res) } }),
561
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/run`, handler: (req, res) => { void handleSeedsRun(req, res) } }),
562
- ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/remove`, handler: (req, res) => { void handleSeedsRemove(req, res) } }),
657
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/state`, handler: guardHandler(handleState) }),
658
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/info`, handler: guardHandler(handleInfo) }),
659
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/config`, handler: guardHandler(handleConfig) }),
660
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/restart`, handler: guardHandler(handleRestart) }),
661
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds`, handler: guardHandler(handleSeeds) }),
662
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/run`, handler: guardHandler(handleSeedsRun) }),
663
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/remove`, handler: guardHandler(handleSeedsRemove) }),
563
664
  ]
564
665
  return () => {
565
666
  for (const dispose of disposers) dispose()
@@ -137,8 +137,14 @@ const PRIVATE_V4_PATTERNS = [
137
137
  /^169\.254\./, // link-local (cloud metadata 169.254.169.254)
138
138
  /^172\.(1[6-9]|2\d|3[01])\./, // private
139
139
  /^192\.0\.0\./, // IETF protocol assignments
140
+ /^192\.0\.2\./, // TEST-NET-1
141
+ /^192\.88\.99\./, // 6to4 relay anycast
140
142
  /^192\.168\./, // private
141
- /^198\.1[89]\./, // benchmarking
143
+ /^198\.1[89]\./, // benchmarking 198.18/15
144
+ /^198\.51\.100\./, // TEST-NET-2
145
+ /^203\.0\.113\./, // TEST-NET-3
146
+ /^22[4-9]\./, /^23\d\./, // multicast 224/4
147
+ /^24\d\./, /^25[0-5]\./, // reserved 240/4 (incl. 255.255.255.255 broadcast)
142
148
  ]
143
149
 
144
150
  /** Parse a dotted-quad IPv4 into 4 bytes, or null. */
@@ -659,6 +665,13 @@ export class ClipBridge {
659
665
  }
660
666
  const onListening = (): void => {
661
667
  server.removeListener('error', onError)
668
+ // Replace the bind-time handler with a PERMANENT one (v0.19.3): without
669
+ // any 'error' listener a later server error (e.g. EMFILE/ECONNRESET on a
670
+ // accept) is an unhandled 'error' event → Node throws → the whole dsh
671
+ // web process dies. Log and keep serving.
672
+ server.on('error', (err: Error) => {
673
+ this.deps.log?.(`server error after listen: ${err.message}`)
674
+ })
662
675
  const addr = server.address() as AddressInfo
663
676
  this.boundPort = addr.port
664
677
  resolve()
@@ -675,8 +688,22 @@ export class ClipBridge {
675
688
  this.server = undefined
676
689
  this.boundPort = 0
677
690
  if (server === undefined) return Promise.resolve()
691
+ // `server.close()` only resolves once every open connection ends, and the
692
+ // bookmarklet's keep-alive socket can hold it open forever — the effect
693
+ // disposer (plugin unload) would then hang (v0.19.3). Force-close the
694
+ // connections and cap the wait.
678
695
  return new Promise<void>((resolve) => {
679
- server.close(() => resolve())
696
+ let done = false
697
+ const finish = (): void => {
698
+ if (done) return
699
+ done = true
700
+ resolve()
701
+ }
702
+ try {
703
+ ;(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.()
704
+ } catch { /* not available on this Node */ }
705
+ server.close(() => finish())
706
+ setTimeout(finish, 1_000).unref?.()
680
707
  })
681
708
  }
682
709
 
package/src/host/http.ts CHANGED
@@ -75,6 +75,41 @@ export function json(res: ServerResponse, payload: unknown, status = 200): void
75
75
  res.end(JSON.stringify(payload))
76
76
  }
77
77
 
78
+ /**
79
+ * Map a thrown error to an HTTP status. Oversized bodies are a client problem
80
+ * (413), everything else is ours (500) — the routes used to answer 500 for
81
+ * `/note` and `/admin/*` but 413 for `/upload` for the identical condition.
82
+ */
83
+ export function errorStatus(err: unknown): number {
84
+ const message = err instanceof Error ? err.message : String(err)
85
+ return /body too large/i.test(message) ? 413 : 500
86
+ }
87
+
88
+ /** The route-handler shape the registries take. */
89
+ export type RouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
90
+
91
+ /**
92
+ * Wrap an async route handler so a rejected promise can never become an
93
+ * unhandled rejection (v0.19.3).
94
+ *
95
+ * WHY: every registration used to be `void handleX(req, res)`, and the host
96
+ * installs no `unhandledRejection` handler — Node's default policy then EXITS
97
+ * the whole dsh web process, while the client request hangs with no response.
98
+ * A handler that throws (including a sync throw before its own try block, e.g.
99
+ * `new URL(req.url)` outside the try in the proxies) now always ends in a
100
+ * response: 413/500 JSON, or a bare `end()` when headers are already sent.
101
+ */
102
+ export function guardHandler(handler: RouteHandler): (req: IncomingMessage, res: ServerResponse) => void {
103
+ return (req, res) => {
104
+ void handler(req, res).catch((err) => {
105
+ try {
106
+ if (!res.headersSent) json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
107
+ else res.end()
108
+ } catch { /* response already gone */ }
109
+ })
110
+ }
111
+ }
112
+
78
113
  /**
79
114
  * True when a NON-GET/HEAD/OPTIONS request is a cross-site (CSRF) request.
80
115
  *