@riceawa/dsh-lan-gateway 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
20
20
  import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
21
21
  import {
22
22
  FIELDS,
23
+ MIN_PASSWORD_LENGTH,
23
24
  TRISTATE_OPTIONS,
24
25
  formatValue,
25
26
  parseValue,
@@ -52,6 +53,27 @@ interface RouteState {
52
53
  port: number
53
54
  tls: string
54
55
  lastError: string | null
56
+ /** Whether a login credential exists. Never the credential itself. */
57
+ passwordSet?: boolean
58
+ }
59
+
60
+ /** Why a password draft cannot be submitted yet. */
61
+ export type PasswordProblem = 'tooShort' | 'mismatch'
62
+
63
+ /**
64
+ * Judge a password draft the way the host's password route will, so a draft the
65
+ * card enables is never answered with a 400. The length bound is the shared
66
+ * {@link MIN_PASSWORD_LENGTH}; the confirmation is a UI concern and is checked
67
+ * here rather than server-side (the host is told one password, and storing only
68
+ * what was typed twice is the browser's job).
69
+ * @param password - the new password draft.
70
+ * @param confirm - the confirmation draft.
71
+ * @returns the reason it cannot be submitted, or `null` when it can.
72
+ */
73
+ export function passwordProblem(password: string, confirm: string): PasswordProblem | null {
74
+ if (password.length < MIN_PASSWORD_LENGTH) return 'tooShort'
75
+ if (password !== confirm) return 'mismatch'
76
+ return null
55
77
  }
56
78
 
57
79
  /* ------------------------------------------------------------------ */
@@ -74,6 +96,19 @@ interface Labels {
74
96
  stopped: string
75
97
  tls: string
76
98
  lastError: string
99
+ passwordSection: string
100
+ passwordHint: string
101
+ passwordNew: string
102
+ passwordConfirm: string
103
+ passwordSet: string
104
+ passwordUnset: string
105
+ passwordRequired: string
106
+ passwordChange: string
107
+ passwordChanged: string
108
+ passwordFailed: string
109
+ passwordStaleHost: string
110
+ passwordTooShort: string
111
+ passwordMismatch: string
77
112
  [key: `field.${string}`]: string
78
113
  [key: `hint.${string}`]: string
79
114
  [key: `opt.${string}`]: string
@@ -96,6 +131,19 @@ const LABELS: Record<'zh' | 'en', Labels> = {
96
131
  stopped: '已停止',
97
132
  tls: 'TLS',
98
133
  lastError: '上次错误',
134
+ passwordSection: '登录密码',
135
+ passwordHint: '当前密码不会显示(也读不出来)。输入两次新密码后直接覆盖原密码;改密会递增会话代次,所有已登录会话与已建立的 WebSocket 立即失效。仅在从本机 loopback 打开 dsh web 时可改。',
136
+ passwordNew: '新密码(至少 8 位)',
137
+ passwordConfirm: '再次输入新密码',
138
+ passwordSet: '已设置',
139
+ passwordUnset: '未设置',
140
+ passwordRequired: '未设置密码时网关拒绝启动。',
141
+ passwordChange: '修改密码',
142
+ passwordChanged: '密码已更新:旧密码立即失效,所有已登录会话已作废。',
143
+ passwordFailed: '修改密码失败,请重试。',
144
+ passwordStaleHost: '宿主端没有响应这个接口:可能还没重启 dsh web 加载新版本。重启后再试;重启前仍可用 lan_gateway 工具改密。',
145
+ passwordTooShort: '密码至少 8 位。',
146
+ passwordMismatch: '两次输入不一致。',
99
147
  'field.enabled': '启用网关',
100
148
  'hint.enabled': '启动时监听 0.0.0.0 网关端口',
101
149
  'field.gatewayPort': '网关端口',
@@ -146,6 +194,19 @@ const LABELS: Record<'zh' | 'en', Labels> = {
146
194
  stopped: 'Stopped',
147
195
  tls: 'TLS',
148
196
  lastError: 'Last error',
197
+ passwordSection: 'Login password',
198
+ passwordHint: 'The current password is never shown (it cannot be read back). Enter a new one twice to overwrite it; changing it advances the session epoch, so every signed-in session and live WebSocket is invalidated at once. Only changeable where dsh web runs on loopback.',
199
+ passwordNew: 'New password (min 8 chars)',
200
+ passwordConfirm: 'Repeat new password',
201
+ passwordSet: 'Set',
202
+ passwordUnset: 'Not set',
203
+ passwordRequired: 'The gateway refuses to start without a password.',
204
+ passwordChange: 'Change password',
205
+ passwordChanged: 'Password updated: the old one no longer works and every signed-in session was revoked.',
206
+ passwordFailed: 'The password change failed — retry.',
207
+ passwordStaleHost: 'The host did not answer this endpoint — it may still be running the previous build. Restart dsh web and retry; the lan_gateway tool can change the password meanwhile.',
208
+ passwordTooShort: 'At least 8 characters.',
209
+ passwordMismatch: 'The two entries do not match.',
149
210
  'field.enabled': 'Enable gateway',
150
211
  'hint.enabled': 'Listen on the gateway port at boot',
151
212
  'field.gatewayPort': 'Gateway port',
@@ -218,6 +279,14 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
218
279
  const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
219
280
  const [saving, setSaving] = useState(false)
220
281
  const [failed, setFailed] = useState<string | null>(null)
282
+ // The password section is its own submit path: a password is not a settings
283
+ // key, so it must not ride the config patch (nor light up the card's "未保存"
284
+ // badge for every keystroke in a password box).
285
+ const [password, setPasswordDraft] = useState('')
286
+ const [passwordConfirm, setPasswordConfirmDraft] = useState('')
287
+ const [passwordBusy, setPasswordBusy] = useState(false)
288
+ const [passwordError, setPasswordError] = useState<string | null>(null)
289
+ const [passwordNotice, setPasswordNotice] = useState<string | null>(null)
221
290
 
222
291
  useEffect(() => {
223
292
  let cancelled = false
@@ -290,6 +359,64 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
290
359
  })
291
360
 
292
361
  const dirty = Object.keys(drafts).length > 0
362
+ const passwordDraftProblem = passwordProblem(password, passwordConfirm)
363
+ const passwordTyping = password !== '' || passwordConfirm !== ''
364
+
365
+ /** Adopt the host's post-write snapshot, keeping the last known values. */
366
+ const adopt = (body: Partial<RouteState>): void => {
367
+ if (body.config === undefined) return
368
+ setRoute({
369
+ config: body.config,
370
+ running: body.running ?? false,
371
+ port: body.port ?? 0,
372
+ tls: body.tls ?? '',
373
+ lastError: body.lastError ?? null,
374
+ passwordSet: body.passwordSet === true,
375
+ })
376
+ }
377
+
378
+ /**
379
+ * Overwrite the login password. Deliberately writes only to
380
+ * `/lan-gateway/password`: the credential is not part of the config patch, so
381
+ * a change here can never disturb an unsaved settings draft, and the old
382
+ * password is neither sent nor requested.
383
+ */
384
+ const changePassword = async (): Promise<void> => {
385
+ if (passwordBusy || passwordProblem(password, passwordConfirm) !== null) return
386
+ setPasswordBusy(true)
387
+ setPasswordError(null)
388
+ setPasswordNotice(null)
389
+ try {
390
+ const response = await fetch('/lan-gateway/password', {
391
+ method: 'POST',
392
+ headers: { 'content-type': 'application/json' },
393
+ body: JSON.stringify({ password }),
394
+ })
395
+ // A host that predates this route answers the SPA fallback (HTML, 200),
396
+ // not a 404 — so "not our JSON" is the signal, and it must not read as a
397
+ // wrong password or a network failure.
398
+ const body = await response.json().catch(() => null) as (Partial<RouteState> & { error?: string }) | null
399
+ if (!response.ok) {
400
+ setPasswordError(body?.error ?? (response.status === 404 ? t.passwordStaleHost : `HTTP ${response.status}`))
401
+ return
402
+ }
403
+ if (body === null) {
404
+ setPasswordError(t.passwordStaleHost)
405
+ return
406
+ }
407
+ // Clear the boxes the moment the write lands: the new credential is now
408
+ // the stored one, and leaving it on screen is the one thing a password
409
+ // field should not do.
410
+ setPasswordDraft('')
411
+ setPasswordConfirmDraft('')
412
+ setPasswordNotice(t.passwordChanged)
413
+ adopt(body)
414
+ } catch {
415
+ setPasswordError(t.passwordFailed)
416
+ } finally {
417
+ setPasswordBusy(false)
418
+ }
419
+ }
293
420
 
294
421
  const save = async (): Promise<void> => {
295
422
  if (!dirty || saving || invalid()) return
@@ -318,15 +445,7 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
318
445
  setFailed(body.error ?? `HTTP ${response.status}`)
319
446
  return
320
447
  }
321
- if (body.config !== undefined) {
322
- setRoute({
323
- config: body.config,
324
- running: body.running ?? false,
325
- port: body.port ?? 0,
326
- tls: body.tls ?? '',
327
- lastError: body.lastError ?? null,
328
- })
329
- }
448
+ adopt(body)
330
449
  setDrafts({})
331
450
  } catch {
332
451
  setFailed(t.saveFailed)
@@ -440,6 +559,69 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
440
559
  ? (
441
560
  <div style={styles.body}>
442
561
  {route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
562
+ <div style={styles.section}>
563
+ <div style={styles.sectionHead}>
564
+ <span style={styles.label}>{t.passwordSection}</span>
565
+ <span style={route.passwordSet === true ? styles.badge : styles.badgeAlert}>
566
+ {route.passwordSet === true ? t.passwordSet : t.passwordUnset}
567
+ </span>
568
+ </div>
569
+ <p style={styles.hint}>{t.passwordHint}</p>
570
+ {route.passwordSet === true ? null : <p style={styles.error}>{t.passwordRequired}</p>}
571
+ <div style={styles.passwordRow}>
572
+ <input
573
+ id="lan-gw-password"
574
+ type="password"
575
+ autoComplete="new-password"
576
+ aria-label={t.passwordNew}
577
+ style={passwordInput}
578
+ placeholder={t.passwordNew}
579
+ value={password}
580
+ disabled={passwordBusy}
581
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
582
+ setPasswordDraft(e.target.value)
583
+ setPasswordError(null)
584
+ setPasswordNotice(null)
585
+ }}
586
+ />
587
+ <input
588
+ id="lan-gw-password-confirm"
589
+ type="password"
590
+ autoComplete="new-password"
591
+ aria-label={t.passwordConfirm}
592
+ style={passwordInput}
593
+ placeholder={t.passwordConfirm}
594
+ value={passwordConfirm}
595
+ disabled={passwordBusy}
596
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
597
+ setPasswordConfirmDraft(e.target.value)
598
+ setPasswordError(null)
599
+ setPasswordNotice(null)
600
+ }}
601
+ />
602
+ </div>
603
+ <div style={styles.passwordFoot}>
604
+ {passwordError !== null
605
+ ? <p style={styles.error} role="alert">{passwordError}</p>
606
+ : passwordNotice !== null
607
+ ? <p style={styles.notice} role="status">{passwordNotice}</p>
608
+ : passwordTyping && passwordDraftProblem !== null
609
+ ? (
610
+ <p style={styles.error} role="status">
611
+ {passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
612
+ </p>
613
+ )
614
+ : null}
615
+ <button
616
+ type="button"
617
+ style={styles.save}
618
+ disabled={passwordBusy || passwordDraftProblem !== null}
619
+ onClick={() => { void changePassword() }}
620
+ >
621
+ {passwordBusy ? t.saving : t.passwordChange}
622
+ </button>
623
+ </div>
624
+ </div>
443
625
  {FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
444
626
  <div style={styles.footer}>
445
627
  {failed ? <p style={styles.error} role="status">{failed}</p> : null}
@@ -563,6 +745,47 @@ const styles: Record<string, React.CSSProperties> = {
563
745
  display: 'flex',
564
746
  flexDirection: 'column',
565
747
  },
748
+ // The password block sits inside the card body, ahead of the config fields:
749
+ // it is the setting an operator comes here for, and it owns its own submit
750
+ // button (a password is not part of the settings patch), so it is separated
751
+ // by a rule rather than merged into the field list.
752
+ section: {
753
+ display: 'flex',
754
+ flexDirection: 'column',
755
+ gap: '8px',
756
+ padding: '14px 0',
757
+ borderBottom: `1px solid ${L.border}`,
758
+ },
759
+ sectionHead: { display: 'flex', alignItems: 'center', gap: '8px' },
760
+ badge: {
761
+ borderRadius: '999px',
762
+ padding: '1px 8px',
763
+ fontSize: '11px',
764
+ lineHeight: '17px',
765
+ fontWeight: 500,
766
+ whiteSpace: 'nowrap',
767
+ background: L.badgeBg,
768
+ color: L.labelSecondary,
769
+ },
770
+ badgeAlert: {
771
+ borderRadius: '999px',
772
+ padding: '1px 8px',
773
+ fontSize: '11px',
774
+ lineHeight: '17px',
775
+ fontWeight: 500,
776
+ whiteSpace: 'nowrap',
777
+ background: L.badgeBg,
778
+ color: L.error,
779
+ },
780
+ notice: { flex: 1, minWidth: 0, margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.labelSecondary },
781
+ passwordRow: { display: 'flex', flexWrap: 'wrap', gap: '8px' },
782
+ passwordFoot: {
783
+ display: 'flex',
784
+ alignItems: 'center',
785
+ justifyContent: 'flex-end',
786
+ gap: '8px',
787
+ marginTop: '2px',
788
+ },
566
789
  field: {
567
790
  display: 'flex',
568
791
  flexDirection: 'column',
@@ -628,3 +851,6 @@ const styles: Record<string, React.CSSProperties> = {
628
851
  cursor: 'pointer',
629
852
  },
630
853
  }
854
+
855
+ /** The shared input style plus the growth two side-by-side password boxes need. */
856
+ const passwordInput: React.CSSProperties = { ...styles.input, flex: '1 1 160px' }
@@ -9,6 +9,11 @@
9
9
  * side and unknown on the other, which is what let the card silently rewrite
10
10
  * keys it never showed.
11
11
  *
12
+ * It also carries `MIN_PASSWORD_LENGTH`: the login password is deliberately not
13
+ * a field here (it is a secret in `state.json`, not a settings key), but its
14
+ * length bound is a contract the card's form and the host's password route
15
+ * must not disagree about.
16
+ *
12
17
  * Because both halves import it, this module must stay dependency-free and
13
18
  * side-effect-free: no schemastery (the host schema remains the validating
14
19
  * authority, not this table), no node built-ins, no DOM, no I/O.
@@ -43,6 +48,14 @@ export interface LanGatewaySettings {
43
48
  /** A settings key this table knows how to edit. */
44
49
  export type ConfigFieldKey = keyof LanGatewaySettings
45
50
 
51
+ /**
52
+ * Minimum login-password length, in characters. The credential is not a config
53
+ * field (it lives in `state.json`, never in the schema), but the bound belongs
54
+ * here all the same: the card's password form and the host's password route
55
+ * both read it, so a draft the card accepts is never answered with a 400.
56
+ */
57
+ export const MIN_PASSWORD_LENGTH = 8
58
+
46
59
  /** How a field is rendered and parsed. */
47
60
  export type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
48
61
 
package/src/index.ts CHANGED
@@ -31,9 +31,12 @@
31
31
  * Every tunable is also exposed as the `lan-gateway` user-settings namespace
32
32
  * (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
33
33
  * port, CIDRs, auth, and TLS live; the running listener restarts on change.
34
- * The card reads/writes through the loopback-only `/lan-gateway/config` route;
35
- * remote browsers get a 403 from the gateway for that prefix and manage the
36
- * gateway through the `lan_gateway` tool instead.
34
+ * The card reads/writes through the loopback-only `/lan-gateway/config` route
35
+ * and changes the login password through the loopback-only
36
+ * `/lan-gateway/password` route (the credential is a secret in `state.json`,
37
+ * never a settings key, so it cannot ride a config patch); remote browsers get
38
+ * a 403 from the gateway for that whole prefix and manage the gateway through
39
+ * the `lan_gateway` tool instead.
37
40
  *
38
41
  * Disabled by default in the bundle patch (safe): the listener opens only
39
42
  * after `lan_gateway enable` or `enabled: true`.
@@ -53,6 +56,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader'
53
56
  import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
54
57
  import {
55
58
  CONFIG_FIELD_KEYS,
59
+ MIN_PASSWORD_LENGTH,
56
60
  OPTIONAL_CONFIG_KEYS,
57
61
  } from './config-fields.ts'
58
62
  import { LanGateway } from './gateway.ts'
@@ -677,6 +681,50 @@ export function apply(ctx: Context, config: ConfigRefs): void {
677
681
  manualOverride = enabled
678
682
  }
679
683
 
684
+ /**
685
+ * Apply a login-password change and reconcile the listener. One
686
+ * implementation behind both surfaces the operator has — the `lan_gateway`
687
+ * tool's `set-password` command and the Settings card's
688
+ * `/lan-gateway/password` route — so the epoch bump, the listener stop on a
689
+ * clear, and the first-password reconcile cannot diverge between them.
690
+ *
691
+ * The credential itself is never read back out: `state` holds a scrypt hash
692
+ * and salt, and neither is returned to a caller.
693
+ */
694
+ const applyPassword = async (password: string | undefined): Promise<ToolResult> => {
695
+ if (password !== undefined && password.length > 0 && password.length < MIN_PASSWORD_LENGTH) {
696
+ return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` }
697
+ }
698
+ const setting = password !== undefined && password.length > 0
699
+ const hadPassword = state.password !== undefined
700
+ state = await setPassword(state, setting ? password : undefined)
701
+ saveState(state)
702
+ gateway?.setState(state)
703
+ if (!setting) {
704
+ // Clearing the credential must not leave an open gateway serving
705
+ // sessions the old password authorized: stop the listener. A password
706
+ // is required to run, so a later enable fails closed.
707
+ await setRunIntent(false)
708
+ return enqueue('password cleared', async () => {
709
+ if (gateway !== undefined) await stopGateway()
710
+ lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
711
+ }).then(() => ({
712
+ ok: true,
713
+ message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
714
+ }))
715
+ }
716
+ // The first password turns a dormant "enabled but unpassworded" intent
717
+ // into a startable one, so reconcile: the listener was refused a moment
718
+ // ago for a reason that no longer holds. A later password change needs no
719
+ // reconcile (the listener is already running or already refused for some
720
+ // other reason), and reconciling anyway would be harmless but noisy.
721
+ if (!hadPassword) await syncGateway('password set')
722
+ return {
723
+ ok: true,
724
+ message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
725
+ }
726
+ }
727
+
680
728
  // The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
681
729
  // it by *entry id*: the `lan-gateway` settings namespace this plugin used to
682
730
  // register (and the scope handle it wrote through) no longer exist. The entry
@@ -735,28 +783,35 @@ export function apply(ctx: Context, config: ConfigRefs): void {
735
783
  void syncGateway('connection attach')
736
784
  })
737
785
 
738
- // The Settings → Plugins card reads and writes through this loopback-only
739
- // JSON route (ModLens-style: the browser never touches the settings seam
786
+ // The Settings → Plugins card reads and writes through these loopback-only
787
+ // JSON routes (ModLens-style: the browser never touches the settings seam
740
788
  // directly, so the card has no service dependencies to resolve). The gateway
741
- // refuses to relay this prefix, so only the native loopback listener can
789
+ // refuses to relay this whole prefix, so only the native loopback listener can
742
790
  // reach it — a genuine local user, or a local process that could already read
743
791
  // ~/.dsh.
744
- const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
745
- const snapshot = (): Record<string, unknown> => {
746
- const cfg = effective()
747
- return {
748
- config: cfg,
749
- running: gateway !== undefined,
750
- port: cfg.gatewayPort,
751
- tls: tlsStatusLine(cfg),
752
- upstreamSessionAvailable,
753
- lastError: lastError ?? null,
754
- }
755
- }
756
- const send = (status: number, body: unknown): void => {
757
- res.writeHead(status, { 'content-type': 'application/json' })
758
- res.end(JSON.stringify(body))
792
+ const sendJson = (res: ServerResponse, status: number, body: unknown): void => {
793
+ res.writeHead(status, { 'content-type': 'application/json' })
794
+ res.end(JSON.stringify(body))
795
+ }
796
+ /**
797
+ * What both card routes report. `passwordSet` is a boolean on purpose: the
798
+ * card must be able to say whether a credential exists without any part of
799
+ * that credential — not even its length — ever leaving this process.
800
+ */
801
+ const snapshot = (): Record<string, unknown> => {
802
+ const cfg = effective()
803
+ return {
804
+ config: cfg,
805
+ running: gateway !== undefined,
806
+ port: cfg.gatewayPort,
807
+ tls: tlsStatusLine(cfg),
808
+ upstreamSessionAvailable,
809
+ passwordSet: state.password !== undefined,
810
+ lastError: lastError ?? null,
759
811
  }
812
+ }
813
+ const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
814
+ const send = (status: number, body: unknown): void => sendJson(res, status, body)
760
815
  if (!isTrustedConfigRequest(req)) {
761
816
  send(403, { error: 'request refused: this route answers same-origin loopback requests only' })
762
817
  return
@@ -840,6 +895,58 @@ export function apply(ctx: Context, config: ConfigRefs): void {
840
895
  'dsh-lan-gateway: config route',
841
896
  )
842
897
 
898
+ // The card's password control, on its own loopback-only route. It cannot ride
899
+ // the config patch: the credential is a scrypt hash in `state.json`, not a
900
+ // settings key, and a key that reached `--dump-config` would be exactly the
901
+ // leak the state file exists to prevent. Same fence as the config route, so
902
+ // remote browsers (which the gateway answers 403 for this whole prefix) keep
903
+ // using the `lan_gateway` tool.
904
+ //
905
+ // This surface only *sets* a password. Clearing stops the listener by design,
906
+ // and a card button that silently takes remote access down is a footgun; the
907
+ // tool's `set-password` with an empty password remains the way to clear.
908
+ const passwordRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
909
+ if (!isTrustedConfigRequest(req)) {
910
+ sendJson(res, 403, { error: 'request refused: this route answers same-origin loopback requests only' })
911
+ return
912
+ }
913
+ if (req.method !== 'POST') {
914
+ sendJson(res, 405, { error: 'method not allowed' })
915
+ return
916
+ }
917
+ const body = await readBody(req, 4 * 1024, res)
918
+ if (body === undefined) return // response already sent (413/400)
919
+ let submitted: unknown
920
+ try {
921
+ submitted = JSON.parse(body)
922
+ } catch {
923
+ sendJson(res, 400, { error: 'invalid JSON body' })
924
+ return
925
+ }
926
+ const password = typeof submitted === 'object' && submitted !== null && !Array.isArray(submitted)
927
+ ? (submitted as Record<string, unknown>)['password']
928
+ : undefined
929
+ if (typeof password !== 'string' || password.length === 0) {
930
+ sendJson(res, 400, {
931
+ error: `password must be a non-empty string of at least ${MIN_PASSWORD_LENGTH} characters; `
932
+ + 'this route only sets one — use the lan_gateway tool to clear it',
933
+ })
934
+ return
935
+ }
936
+ const result = await applyPassword(password)
937
+ if (!result.ok) {
938
+ sendJson(res, 400, { error: result.message })
939
+ return
940
+ }
941
+ // The snapshot flips `passwordSet` for the card's status badge. Nothing here
942
+ // carries the password, its hash, or its length back to the browser.
943
+ sendJson(res, 200, snapshot())
944
+ }
945
+ ctx.effect(
946
+ () => ctx.webServer.register({ kind: 'exact', path: '/lan-gateway/password', handler: passwordRouteHandler }),
947
+ 'dsh-lan-gateway: password route',
948
+ )
949
+
843
950
  const controller: GatewayController = {
844
951
  status(): ToolResult {
845
952
  const cfg = desiredConfig()
@@ -875,39 +982,7 @@ export function apply(ctx: Context, config: ConfigRefs): void {
875
982
  await syncGateway('tool disable')
876
983
  return { ok: true, message: 'Gateway disabled.' }
877
984
  },
878
- async setPassword(password: string | undefined): Promise<ToolResult> {
879
- if (password !== undefined && password.length > 0 && password.length < 8) {
880
- return { ok: false, message: 'Password must be at least 8 characters.' }
881
- }
882
- const setting = password !== undefined && password.length > 0
883
- const hadPassword = state.password !== undefined
884
- state = await setPassword(state, setting ? password : undefined)
885
- saveState(state)
886
- gateway?.setState(state)
887
- if (!setting) {
888
- // Clearing the credential must not leave an open gateway serving
889
- // sessions the old password authorized: stop the listener. A password
890
- // is required to run, so a later enable fails closed.
891
- await setRunIntent(false)
892
- return enqueue('password cleared', async () => {
893
- if (gateway !== undefined) await stopGateway()
894
- lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
895
- }).then(() => ({
896
- ok: true,
897
- message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
898
- }))
899
- }
900
- // The first password turns a dormant "enabled but unpassworded" intent
901
- // into a startable one, so reconcile: the listener was refused a moment
902
- // ago for a reason that no longer holds. A later password change needs no
903
- // reconcile (the listener is already running or already refused for some
904
- // other reason), and reconciling anyway would be harmless but noisy.
905
- if (!hadPassword) await syncGateway('password set')
906
- return {
907
- ok: true,
908
- message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
909
- }
910
- },
985
+ setPassword: applyPassword,
911
986
  rotateSecret(): ToolResult {
912
987
  const next: GatewayState = {
913
988
  cookieSecret: randomBytes(32).toString('base64'),