@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/icon.svg +3 -0
  5. package/lib/client.js +396 -132
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +604 -262
  8. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  9. package/lib/types/client/FenceNotice.js +0 -3
  10. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +3 -2
  13. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +14 -9
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +13 -4
  17. package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +14 -8
  20. package/lib/types/client/UpdateEntry.js +2 -2
  21. package/lib/types/client/UpdatePanel.js +2 -2
  22. package/lib/types/client/deep-link.js +2 -2
  23. package/lib/types/client/index.d.ts +16 -8
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +56 -32
  26. package/lib/types/client/locales.d.ts +0 -2
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +6 -8
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  30. package/lib/types/client/mobile-adapt.js +143 -28
  31. package/lib/types/client/pair-api.d.ts +32 -3
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +44 -6
  34. package/lib/types/client/plugin-card-seat.d.ts +104 -0
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  36. package/lib/types/client/plugin-card-seat.js +143 -0
  37. package/lib/types/client/remote-channel.d.ts.map +1 -1
  38. package/lib/types/client/remote-channel.js +18 -5
  39. package/lib/types/client/settings-form.d.ts +17 -17
  40. package/lib/types/client/settings-form.d.ts.map +1 -1
  41. package/lib/types/client/settings-form.js +30 -27
  42. package/lib/types/console-output.d.ts +41 -0
  43. package/lib/types/console-output.d.ts.map +1 -0
  44. package/lib/types/console-output.js +89 -0
  45. package/lib/types/http.d.ts +18 -1
  46. package/lib/types/http.d.ts.map +1 -1
  47. package/lib/types/http.js +22 -1
  48. package/lib/types/index.d.ts +19 -11
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/index.js +91 -76
  51. package/lib/types/inner-auth.d.ts +3 -2
  52. package/lib/types/inner-auth.d.ts.map +1 -1
  53. package/lib/types/inner-auth.js +3 -2
  54. package/lib/types/pair-grant.d.ts +66 -0
  55. package/lib/types/pair-grant.d.ts.map +1 -0
  56. package/lib/types/pair-grant.js +95 -0
  57. package/lib/types/pairing.d.ts +44 -12
  58. package/lib/types/pairing.d.ts.map +1 -1
  59. package/lib/types/pairing.js +69 -20
  60. package/lib/types/pairing.persist.test.js +26 -0
  61. package/lib/types/posture.d.ts +11 -3
  62. package/lib/types/posture.d.ts.map +1 -1
  63. package/lib/types/posture.js +8 -3
  64. package/lib/types/remote-api.d.ts +10 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +34 -34
  67. package/lib/types/remote-channel-boot.d.ts +6 -1
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +16 -8
  70. package/lib/types/remote-channel-rules.d.ts +14 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +8 -0
  73. package/lib/types/remote-methods.d.ts +1 -1
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +1 -0
  76. package/lib/types/routes.d.ts +47 -5
  77. package/lib/types/routes.d.ts.map +1 -1
  78. package/lib/types/routes.js +160 -58
  79. package/lib/types/tunnel.d.ts +21 -6
  80. package/lib/types/tunnel.d.ts.map +1 -1
  81. package/lib/types/tunnel.js +58 -11
  82. package/lib/types/update.d.ts +4 -0
  83. package/lib/types/update.d.ts.map +1 -1
  84. package/lib/types/update.js +74 -33
  85. package/package.json +23 -20
  86. package/src/client/FenceNotice.tsx +0 -2
  87. package/src/client/PluginSettingsCard.tsx +7 -2
  88. package/src/client/RemoteEntry.tsx +14 -9
  89. package/src/client/RemotePanel.tsx +17 -7
  90. package/src/client/RemoteSettingsCard.tsx +16 -9
  91. package/src/client/UpdateEntry.tsx +2 -2
  92. package/src/client/UpdatePanel.tsx +3 -3
  93. package/src/client/deep-link.ts +2 -2
  94. package/src/client/index.ts +70 -40
  95. package/src/client/locales.ts +6 -8
  96. package/src/client/mobile-adapt.ts +136 -25
  97. package/src/client/pair-api.ts +47 -7
  98. package/src/client/plugin-card-seat.ts +192 -0
  99. package/src/client/remote-channel.ts +19 -4
  100. package/src/client/remote.module.css +38 -0
  101. package/src/client/settings-form.ts +34 -31
  102. package/src/console-output.ts +97 -0
  103. package/src/http.ts +23 -1
  104. package/src/index.ts +102 -79
  105. package/src/inner-auth.ts +3 -2
  106. package/src/pair-grant.ts +131 -0
  107. package/src/pairing.persist.test.ts +36 -0
  108. package/src/pairing.ts +75 -24
  109. package/src/posture.ts +12 -4
  110. package/src/remote-api.ts +34 -41
  111. package/src/remote-channel-boot.ts +16 -8
  112. package/src/remote-channel-rules.ts +17 -0
  113. package/src/remote-methods.ts +1 -0
  114. package/src/routes.ts +165 -57
  115. package/src/tunnel.ts +56 -11
  116. package/src/update.ts +74 -29
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
36
36
  import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
37
37
  import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
38
38
  import { createInnerAuth } from './inner-auth.ts'
39
+ import { withIdentityEncoding } from './http.ts'
39
40
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
40
41
  import { PublicBaseKeeper } from './public-base.ts'
41
42
  import {
@@ -84,9 +85,12 @@ export const name = 'remote-web-ui'
84
85
  export const inject = ['webServer', 'typertGateway', 'connection']
85
86
 
86
87
  /**
87
- * Settings namespace of the remote-control capability — the section the web
88
- * settings surface edits. Spelled here rather than imported: the browser
89
- * half spells the same value and must not depend on a Host package.
88
+ * Settings namespace of the remote-control capability. Under the 0.1.7
89
+ * settings model the namespace IS the Host profile entry id, so this is also
90
+ * the id the browser half asks `ctx.configForms` for (the family binder
91
+ * resolves the same value to the owning entry). Spelled here rather than
92
+ * imported: the browser half spells the same value and must not depend on a
93
+ * Host package.
90
94
  */
91
95
  export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = 'remote-web-ui' as SettingsNamespace
92
96
 
@@ -100,22 +104,28 @@ export interface Config {
100
104
  maxDevices?: number
101
105
  /**
102
106
  * Idle sessions older than this (ms) are deleted from memory and disk.
103
- * Default is 7 days; a leftover cookie no longer authorizes after expiry.
107
+ * Default is 30 days; a leftover cookie no longer authorizes after expiry.
104
108
  */
105
109
  idleExpireMs?: number
106
110
  /** Cookie name carrying the paired device id. */
107
111
  cookieName?: string
108
112
  /**
109
113
  * When true (default), a desktop Web GUI opened at a non-loopback origin
110
- * rides the gated `/remote/api` channel and must carry a live paired-device
111
- * cookie — the QR is the only way into remote desktop, and stop()/revoke()
112
- * cut the /remote channel and the pairing cookie off immediately. Scope
114
+ * rides the gated `/remote/api` channel, whose requests must carry a live
115
+ * paired-device cookie — the QR is the only way into remote desktop, and
116
+ * stop()/revoke() cut the channel and the pairing cookie off immediately.
117
+ * This policy selects the transport; it never loosens the channel, whose
118
+ * gate is unconditional (issue #1665). Scope
113
119
  * note for this cohort: direct /api is governed by the harness fence +
114
120
  * browser-auth cookie (the api/gate seam has no emitter on 0.1.2-alpha.2),
115
121
  * so a harness browser credential a device has already redeemed is not
116
122
  * invalidated by stop() — see the README security model. Set false to keep
117
123
  * the desktop on plain `/api` (only useful when that origin is already
118
- * trusted for `/api`).
124
+ * trusted for `/api`). Turning the policy off does NOT open the channel:
125
+ * the /remote proxy attaches the process's own browser-auth credential only
126
+ * when the request itself presents a live paired-device credential, so an
127
+ * unpaired LAN/tunnel caller is forwarded without it and the inner route
128
+ * answers 401.
119
129
  */
120
130
  requirePairingForLan?: boolean
121
131
  /**
@@ -220,6 +230,13 @@ export const Config: z<Config> = z.object({
220
230
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
221
231
  const SWEEP_INTERVAL_MS = 10_000
222
232
 
233
+ /**
234
+ * How long one update-status probe answer is reused. The probe fans out one
235
+ * registry GET per family package plus a GitHub call, and the sidebar update
236
+ * entry asks for the status on every GUI page load.
237
+ */
238
+ const UPDATE_STATUS_TTL_MS = 60_000
239
+
223
240
  /**
224
241
  * Fully resolved config: every field non-optional except `publicBaseUrl`,
225
242
  * which legitimately resolves to `undefined` when unset (the schema keeps it
@@ -237,9 +254,8 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | '
237
254
 
238
255
  /**
239
256
  * The single mapping from resolved plugin config to the pairing service
240
- * config. Both the constructed service and every live settings sync reuse
241
- * it, so no field can be silently dropped when the web settings surface
242
- * pushes a new value into the running service.
257
+ * config. Both the constructed service and every later apply of this row
258
+ * reuse it, so no field can be silently dropped.
243
259
  */
244
260
  export function pairingConfigOf(resolved: Pick<
245
261
  ResolvedConfig,
@@ -305,30 +321,12 @@ function applyImpl(ctx: Context, config?: Config): void {
305
321
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
306
322
  enabled: config?.enabled ?? DEFAULTS.enabled,
307
323
  }
308
- // The live source the pairing service and the gate read: the settings
309
- // section once the web settings surface is served, the composition entry
310
- // otherwise (installSection swaps it when the namespace registers).
311
- let current: () => Config = () => config ?? {}
312
- const resolve = (): ResolvedConfig => {
313
- const value = current()
314
- return {
315
- tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
316
- offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
317
- maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
318
- idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
319
- cookieName: value.cookieName ?? DEFAULTS.cookieName,
320
- requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
321
- publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
322
- trustedHosts: value.trustedHosts,
323
- devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
324
- autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
325
- tunnelToken: value.tunnelToken,
326
- relay: value.relay ?? DEFAULTS.relay,
327
- lanBind: value.lanBind,
328
- profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
329
- enabled: value.enabled ?? DEFAULTS.enabled,
330
- }
331
- }
324
+ // The effective configuration this activation runs on: under the 0.1.7
325
+ // settings model the plugin's own Config IS its settings document, so the
326
+ // Host serves the settings page from this schema and reloads the row after a
327
+ // save — a live edit re-enters apply() with the new value instead of being
328
+ // pushed into a running instance.
329
+ const resolve = (): ResolvedConfig => resolved
332
330
  const service = new PairingService(pairingConfigOf(resolved))
333
331
 
334
332
  // ── auto tunnel ─────────────────────────────────────────────────────────
@@ -442,13 +440,14 @@ function applyImpl(ctx: Context, config?: Config): void {
442
440
  service.setLanBases(lanBases)
443
441
  }
444
442
 
445
- // Push a committed settings section into the service and gate. The service
443
+ // Push the effective configuration into the service and gate. The service
446
444
  // config object is read per operation (token mint, touch, sweep), and the
447
- // gate re-reads its fence flag per request, so a live edit takes effect
448
- // without a restart. When `enabled` turns off, the pairing routes and
449
- // sweep timer are dropped and all device/token state is revoked, but the
450
- // gate listener stays mounted so a LAN-exposed /api stays behind pairing
451
- // (now vetoing every non-loopback request) instead of opening the fence.
445
+ // gate re-reads its fence flag per request, so a saved edit takes effect
446
+ // without a restart (the Host reloads this row, and apply re-runs). When
447
+ // `enabled` is off, the pairing routes and sweep timer are dropped and all
448
+ // device/token state is revoked, but the gate listener stays mounted so a
449
+ // LAN-exposed /api stays behind pairing (now vetoing every non-loopback
450
+ // request) instead of opening the fence.
452
451
  let disposeRoutes: (() => void) | undefined
453
452
  let disposeSweep: (() => void) | undefined
454
453
  // ── remote update ────────────────────────────────────────────────────────
@@ -478,16 +477,32 @@ function applyImpl(ctx: Context, config?: Config): void {
478
477
  releaseNotesCache.set(version, { at: Date.now(), notes })
479
478
  return notes
480
479
  }
481
- const updateRoutes = makeUpdateRoutes({
482
- // Control endpoints are host-surface only: a LAN/phone origin must never
483
- // trigger a real install on this machine.
484
- fence: request => isTrustedApiRequest(request, []),
485
- check: () => checkUpdates({
480
+ // The status probe fans out one registry GET per family package (plus a
481
+ // GitHub release-notes call), and the sidebar update entry asks for it on
482
+ // every GUI page load — so the answer is memoized briefly and concurrent
483
+ // callers share one probe. A completed update invalidates it.
484
+ let updateStatusCache: { at: number; value: Awaited<ReturnType<typeof checkUpdates>> } | undefined
485
+ let updateStatusInFlight: Promise<Awaited<ReturnType<typeof checkUpdates>>> | undefined
486
+ const checkStatusCached = (): Promise<Awaited<ReturnType<typeof checkUpdates>>> => {
487
+ const cached = updateStatusCache
488
+ if (cached !== undefined && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value)
489
+ if (updateStatusInFlight !== undefined) return updateStatusInFlight
490
+ updateStatusInFlight = checkUpdates({
486
491
  anchorManifestPath: resolveAnchorPath(),
487
492
  resolve: hostResolve,
488
493
  fetchLatest: name => fetchLatestVersion(name, fetch),
489
494
  fetchReleaseNotes: fetchReleaseNotesCached,
490
- }),
495
+ }).then((value) => {
496
+ updateStatusCache = { at: Date.now(), value }
497
+ return value
498
+ }).finally(() => { updateStatusInFlight = undefined })
499
+ return updateStatusInFlight
500
+ }
501
+ const updateRoutes = makeUpdateRoutes({
502
+ // Control endpoints are host-surface only: a LAN/phone origin must never
503
+ // trigger a real install on this machine.
504
+ fence: request => isTrustedApiRequest(request, []),
505
+ check: () => checkStatusCached(),
491
506
  run: async (): Promise<UpdateRunResult> => {
492
507
  const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
493
508
  if ('error' in target) {
@@ -504,7 +519,7 @@ function applyImpl(ctx: Context, config?: Config): void {
504
519
  // 11 minimumReleaseAge gate can silently keep the installed versions
505
520
  // (same-day releases), which a plain exit-0 check would report as
506
521
  // success — the user then restarts and nothing changed.
507
- return runUpdateVerified({
522
+ const result = await runUpdateVerified({
508
523
  run: { profileDir: target.profileDir, packages: target.packages },
509
524
  check: {
510
525
  anchorManifestPath: resolveAnchorPath(),
@@ -513,6 +528,9 @@ function applyImpl(ctx: Context, config?: Config): void {
513
528
  fetchReleaseNotes: fetchReleaseNotesCached,
514
529
  },
515
530
  })
531
+ // The install moved (or failed) the versions the cached status reported.
532
+ updateStatusCache = undefined
533
+ return result
516
534
  },
517
535
  })
518
536
  // LAN-bind facts for the settings card, re-read per request so a hot
@@ -580,10 +598,16 @@ function applyImpl(ctx: Context, config?: Config): void {
580
598
  if (appShellCache !== undefined && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html
581
599
  const cookie = await innerAuth.ready()
582
600
  try {
583
- const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, {
601
+ const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, withIdentityEncoding({
584
602
  headers: cookie !== undefined ? { cookie } : undefined,
585
- })
586
- if (!response.ok) return undefined
603
+ }))
604
+ if (!response.ok) {
605
+ // A stale credential (secret rotation, 30-day TTL) must not wedge every
606
+ // later landing: drop it so the next attempt re-redeems, exactly as the
607
+ // proxy path does on an upstream 401.
608
+ if (response.status === 401 || response.status === 403) innerAuth.invalidate()
609
+ return undefined
610
+ }
587
611
  const html = await response.text()
588
612
  appShellCache = { at: Date.now(), html }
589
613
  return html
@@ -618,15 +642,15 @@ function applyImpl(ctx: Context, config?: Config): void {
618
642
  return list
619
643
  },
620
644
  }),
621
- // The remote desktop channel: policy-gated `/remote` prefix that
622
- // re-issues fenced paths to loopback (see remote-api.ts). The live
623
- // requirePairingForLan is re-read per request, same as the gate listener
624
- // and routes above, so a stale client rewrite on an open-LAN deployment
625
- // proxies instead of 403ing.
645
+ // The remote desktop channel: a paired-credential-gated `/remote` prefix
646
+ // that re-issues fenced paths to loopback (see remote-api.ts). The gate is
647
+ // unconditional — requirePairingForLan governs the plain /api surface and
648
+ // the desktop's client-side rewrite only; it is never an authorization
649
+ // input here, because this channel attaches the process's own browser
650
+ // credential to everything it forwards (issue #1665).
626
651
  ...makeRemoteApiRoutes({
627
652
  service,
628
653
  port: ctx.webServer.port,
629
- requirePairingForLan: () => resolve().requirePairingForLan,
630
654
  auth: innerAuth,
631
655
  }),
632
656
  ...updateRoutes,
@@ -634,7 +658,6 @@ function applyImpl(ctx: Context, config?: Config): void {
634
658
  const upgrades = makeRemoteApiUpgradeRoutes({
635
659
  service,
636
660
  port: ctx.webServer.port,
637
- requirePairingForLan: () => resolve().requirePairingForLan,
638
661
  auth: innerAuth,
639
662
  })
640
663
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
@@ -718,6 +741,9 @@ function applyImpl(ctx: Context, config?: Config): void {
718
741
  console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
719
742
  }
720
743
 
744
+ // Apply the effective configuration to the running surfaces once per
745
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
746
+ // plan, the pairing routes, the presence sweep, and the posture probe.
721
747
  const sync = (): void => {
722
748
  const value = resolve()
723
749
  service.config = pairingConfigOf(value)
@@ -805,6 +831,10 @@ function applyImpl(ctx: Context, config?: Config): void {
805
831
  ? plan.targetUrl
806
832
  : { kind: 'quick', targetUrl: plan.targetUrl, originHostHeader })
807
833
  } else if (plan.mode === 'named') {
834
+ // The hostname is fixed and known before the process runs, so publish it
835
+ // now: a named tunnel that fails to start must not leave the previous
836
+ // mode's ephemeral host as the QR and fence base.
837
+ publicBase.markRunning(plan.publicUrl)
808
838
  tunnel.start({ kind: 'named', token: plan.token, publicUrl: plan.publicUrl })
809
839
  } else {
810
840
  tunnel.stop()
@@ -824,7 +854,17 @@ function applyImpl(ctx: Context, config?: Config): void {
824
854
  }
825
855
  }
826
856
  const enabled = value.enabled
827
- if (!enabled) service.stop()
857
+ if (!enabled) {
858
+ service.stop()
859
+ // The tunnel branch above follows the plan, not the master switch, so an
860
+ // explicit "off" must also drop the public ingress it started: otherwise
861
+ // the cloudflared child keeps a live public URL (and the relay keeps its
862
+ // stable row) while the panel says remote control is stopped. The relay
863
+ // row is deliberately kept so re-enabling reuses the same origin.
864
+ tunnel.stop()
865
+ disposeRelayRegistrar()
866
+ publicBase.reset()
867
+ }
828
868
  if (disposeRoutes === undefined && enabled) {
829
869
  disposeRoutes = ctx.effect(
830
870
  () => {
@@ -877,25 +917,8 @@ function applyImpl(ctx: Context, config?: Config): void {
877
917
  table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
878
918
  }), 'remote-web-ui: remote channel boot patch')
879
919
 
880
- ctx.inject(['settings'], (settingsCtx) => {
881
- try {
882
- if (typeof settingsCtx.settings?.installSection === 'function') {
883
- settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
884
- setSource: (source) => {
885
- current = source
886
- sync()
887
- },
888
- onChange: sync,
889
- })
890
- } else if (typeof settingsCtx.settings?.register === 'function') {
891
- const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
892
- current = () => scope?.get?.() ?? (config ?? {})
893
- scope?.watch?.(() => { sync() })
894
- sync()
895
- }
896
- } catch {
897
- // Defensive fallback against settings registration differences
898
- }
899
- })
920
+ // No settings registration: the Host derives this plugin's settings page
921
+ // from the exported `Config` schema and reloads the row after a save, so
922
+ // every write re-enters apply() with the new config (see `resolve`).
900
923
  sync()
901
924
  }
package/src/inner-auth.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * therefore redeems its own launch token (the same one-time exchange every
8
8
  * paired device's first navigation performs) and attaches the resulting
9
9
  * cookie to inner requests. The credential is only ever exercised behind the
10
- * pairing gate: while requirePairingForLan is on, every proxied call already
11
- * carried a live paired-device cookie before this module is consulted.
10
+ * pairing gate: the `/remote` channel always requires a live paired-device
11
+ * credential before this module is consulted, regardless of the
12
+ * requirePairingForLan LAN policy (issue #1665).
12
13
  */
13
14
 
14
15
  /** Browser-auth credential the proxy attaches to loopback-bound requests. */
@@ -0,0 +1,131 @@
1
+ /**
2
+ * One-time pairing grants for the /pair-app landing.
3
+ *
4
+ * The QR entry (`/pair-accept?pair=<token>`) used to hand the freshly minted
5
+ * device id to the app landing in its own URL (`/pair-app?device=<id>`). A
6
+ * device id is a live session credential: the channel gate authorizes every
7
+ * gated request by it and the cookie it mirrors lives a year, so a URL carrying
8
+ * it leaks that credential into browser history, the address bar, and every log
9
+ * on the path. The landing is reached with a one-time grant instead — 256 bits
10
+ * of entropy, a short TTL, and single-consume semantics — and the device id
11
+ * reaches the client only inside the server-rendered shell (the capture script
12
+ * the landing patches in).
13
+ *
14
+ * Consume semantics follow the reference implementation's one-time host
15
+ * capability (zcode `packages/server/src/hostCapability.ts`): the record is
16
+ * deleted on EVERY consume attempt — success, expiry, or replay — so a captured
17
+ * grant is worthless after its first use and a replayed one can never resolve,
18
+ * and only a first use inside the TTL yields a device.
19
+ *
20
+ * @module @linxin666/dsh-remote-web-ui/pair-grant
21
+ */
22
+
23
+ import { randomBytes } from 'node:crypto'
24
+
25
+ /**
26
+ * Default grant lifetime. The grant only has to survive one redirect chain
27
+ * from the accept page to the app landing, tunnel round trips included, so it
28
+ * is far shorter than the device session it stands in for.
29
+ */
30
+ export const DEFAULT_PAIR_GRANT_TTL_MS = 60_000
31
+
32
+ /**
33
+ * Cap on live grants. The accept page is rate-limited per source, but the
34
+ * table is still caller-driven, so it stays bounded (oldest evicted first)
35
+ * exactly like the device table.
36
+ */
37
+ export const MAX_LIVE_GRANTS = 256
38
+
39
+ /** Clock/entropy injection for tests. */
40
+ export interface PairGrantStoreOptions {
41
+ /** Grant lifetime in ms (default {@link DEFAULT_PAIR_GRANT_TTL_MS}). */
42
+ ttlMs?: number
43
+ /** Clock seam (default Date.now). */
44
+ now?: () => number
45
+ /** Entropy seam (default 32 random bytes, base64url). */
46
+ createGrant?: () => string
47
+ }
48
+
49
+ /** One issued grant and its absolute expiry (both non-secret to the issuer). */
50
+ export interface PairGrantRecord {
51
+ /** The one-time secret carried in the /pair-app URL. */
52
+ grant: string
53
+ /** Absolute expiry (ms epoch); a consume past this resolves to nothing. */
54
+ expiresAt: number
55
+ }
56
+
57
+ /** The grant table surface the routes consume. */
58
+ export interface PairGrantStore {
59
+ /** Mint one grant bound to a device session. */
60
+ issue(deviceId: string): PairGrantRecord
61
+ /** Redeem a grant once, returning its device id (undefined when unusable). */
62
+ consume(grant: string | undefined): string | undefined
63
+ /** Liveness check that does not spend the grant (the fence fallback). */
64
+ peek(grant: string | undefined): string | undefined
65
+ /** Live grant count (tests/diagnostics). */
66
+ readonly size: number
67
+ }
68
+
69
+ /** One live grant. */
70
+ interface GrantEntry {
71
+ deviceId: string
72
+ expiresAt: number
73
+ }
74
+
75
+ /**
76
+ * Build the grant table. Pure TypeScript with injected clock/randomness so the
77
+ * whole one-time semantics are unit-testable without a server.
78
+ */
79
+ export function createPairGrantStore(options: PairGrantStoreOptions = {}): PairGrantStore {
80
+ const ttlMs = options.ttlMs ?? DEFAULT_PAIR_GRANT_TTL_MS
81
+ const now = options.now ?? ((): number => Date.now())
82
+ const createGrant = options.createGrant ?? ((): string => randomBytes(32).toString('base64url'))
83
+ const entries = new Map<string, GrantEntry>()
84
+
85
+ const purgeExpired = (at: number): void => {
86
+ for (const [grant, entry] of entries) {
87
+ if (entry.expiresAt <= at) entries.delete(grant)
88
+ }
89
+ }
90
+
91
+ return {
92
+ issue(deviceId: string): PairGrantRecord {
93
+ const issuedAt = now()
94
+ purgeExpired(issuedAt)
95
+ while (entries.size >= MAX_LIVE_GRANTS) {
96
+ const oldest = entries.keys().next().value
97
+ if (oldest === undefined) break
98
+ entries.delete(oldest)
99
+ }
100
+ const grant = createGrant()
101
+ const expiresAt = issuedAt + ttlMs
102
+ entries.set(grant, { deviceId, expiresAt })
103
+ return { grant, expiresAt }
104
+ },
105
+ consume(grant: string | undefined): string | undefined {
106
+ if (grant === undefined || grant === '') return undefined
107
+ const consumedAt = now()
108
+ const entry = entries.get(grant)
109
+ // Delete before validating: a replayed grant must never resolve, and an
110
+ // expired one must not linger in the table.
111
+ entries.delete(grant)
112
+ purgeExpired(consumedAt)
113
+ if (entry === undefined || entry.expiresAt <= consumedAt) return undefined
114
+ return entry.deviceId
115
+ },
116
+ peek(grant: string | undefined): string | undefined {
117
+ if (grant === undefined || grant === '') return undefined
118
+ const at = now()
119
+ const entry = entries.get(grant)
120
+ if (entry === undefined) return undefined
121
+ if (entry.expiresAt <= at) {
122
+ entries.delete(grant)
123
+ return undefined
124
+ }
125
+ return entry.deviceId
126
+ },
127
+ get size(): number {
128
+ return entries.size
129
+ },
130
+ }
131
+ }
@@ -201,4 +201,40 @@ describe('PairingService device persistence', () => {
201
201
  const deviceId = result.ok ? result.deviceId : ''
202
202
  expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone')
203
203
  })
204
+
205
+ it('operator is not left with a revocation that a restart silently undoes', () => {
206
+ // Given a device store that cannot be written (its parent path is a file)
207
+ // while a stale, readable store sits beside it.
208
+ const blocker = join(dir, 'blocker')
209
+ writeFileSync(blocker, 'not a directory')
210
+ const file = join(blocker, 'devices.json')
211
+ const removed: string[] = []
212
+ const service = new PairingService(
213
+ { ...BASE_CONFIG, devicesFile: file },
214
+ makeClock(),
215
+ { removeFile: (path) => { removed.push(path) } },
216
+ )
217
+ // When the operator stops remote access and the write cannot be made durable.
218
+ service.stop()
219
+ // Then the stale store is handed to the removal step, so a restart cannot
220
+ // restore the revoked sessions from disk.
221
+ expect(removed).toEqual([file])
222
+ })
223
+
224
+ it('operator keeps the device store when a revocation persist succeeds', () => {
225
+ // Given a writable device store with one paired device.
226
+ const file = join(dir, 'devices.json')
227
+ const removed: string[] = []
228
+ const service = new PairingService(
229
+ { ...BASE_CONFIG, devicesFile: file },
230
+ makeClock(),
231
+ { removeFile: (path) => { removed.push(path) } },
232
+ )
233
+ const deviceId = pairDevice(service)
234
+ // When the device is revoked and the write lands.
235
+ expect(service.revoke(deviceId)).toBe(true)
236
+ // Then the store stays (with the row gone) and nothing is removed.
237
+ expect(removed).toEqual([])
238
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({})
239
+ })
204
240
  })