@riceawa/dsh-lan-gateway 0.6.0 → 0.6.2

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,48 @@ 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
+ * The card's password badge state.
65
+ *
66
+ * Three states, not two: a host that predates the password route does not
67
+ * report `passwordSet` at all, and `undefined` must read as "unknown" rather
68
+ * than "not set" — a client refreshed against a running old host would
69
+ * otherwise announce that a gateway which is demonstrably running (it cannot
70
+ * start without a credential) has no password.
71
+ */
72
+ export type PasswordStatus = 'set' | 'unset' | 'unknown'
73
+
74
+ /**
75
+ * Classify the host's `passwordSet` for the badge.
76
+ * @param passwordSet - the snapshot's flag, or `undefined` when absent.
77
+ * @returns `set` / `unset` / `unknown` (absent field).
78
+ */
79
+ export function passwordStatus(passwordSet: boolean | undefined): PasswordStatus {
80
+ if (passwordSet === undefined) return 'unknown'
81
+ return passwordSet ? 'set' : 'unset'
82
+ }
83
+
84
+ /**
85
+ * Judge a password draft the way the host's password route will, so a draft the
86
+ * card enables is never answered with a 400. The length bound is the shared
87
+ * {@link MIN_PASSWORD_LENGTH}; the confirmation is a UI concern and is checked
88
+ * here rather than server-side (the host is told one password, and storing only
89
+ * what was typed twice is the browser's job).
90
+ * @param password - the new password draft.
91
+ * @param confirm - the confirmation draft.
92
+ * @returns the reason it cannot be submitted, or `null` when it can.
93
+ */
94
+ export function passwordProblem(password: string, confirm: string): PasswordProblem | null {
95
+ if (password.length < MIN_PASSWORD_LENGTH) return 'tooShort'
96
+ if (password !== confirm) return 'mismatch'
97
+ return null
55
98
  }
56
99
 
57
100
  /* ------------------------------------------------------------------ */
@@ -74,6 +117,21 @@ interface Labels {
74
117
  stopped: string
75
118
  tls: string
76
119
  lastError: string
120
+ passwordSection: string
121
+ passwordHint: string
122
+ passwordNew: string
123
+ passwordConfirm: string
124
+ passwordSet: string
125
+ passwordUnset: string
126
+ passwordUnknown: string
127
+ passwordRequired: string
128
+ passwordHostStale: string
129
+ passwordChange: string
130
+ passwordChanged: string
131
+ passwordFailed: string
132
+ passwordStaleHost: string
133
+ passwordTooShort: string
134
+ passwordMismatch: string
77
135
  [key: `field.${string}`]: string
78
136
  [key: `hint.${string}`]: string
79
137
  [key: `opt.${string}`]: string
@@ -96,6 +154,21 @@ const LABELS: Record<'zh' | 'en', Labels> = {
96
154
  stopped: '已停止',
97
155
  tls: 'TLS',
98
156
  lastError: '上次错误',
157
+ passwordSection: '登录密码',
158
+ passwordHint: '当前密码不会显示(也读不出来)。输入两次新密码后直接覆盖原密码;改密会递增会话代次,所有已登录会话与已建立的 WebSocket 立即失效。仅在从本机 loopback 打开 dsh web 时可改。',
159
+ passwordNew: '新密码(至少 8 位)',
160
+ passwordConfirm: '再次输入新密码',
161
+ passwordSet: '已设置',
162
+ passwordUnset: '未设置',
163
+ passwordUnknown: '状态未知(宿主端较旧)',
164
+ passwordRequired: '未设置密码时网关拒绝启动。',
165
+ passwordHostStale: '宿主端没有报告密码状态,说明 dsh web 还在运行旧版本:重启后这里才会显示「已设置 / 未设置」,这个改密表单也才会生效。',
166
+ passwordChange: '修改密码',
167
+ passwordChanged: '密码已更新:旧密码立即失效,所有已登录会话已作废。',
168
+ passwordFailed: '修改密码失败,请重试。',
169
+ passwordStaleHost: '宿主端没有响应这个接口:可能还没重启 dsh web 加载新版本。重启后再试;重启前仍可用 lan_gateway 工具改密。',
170
+ passwordTooShort: '密码至少 8 位。',
171
+ passwordMismatch: '两次输入不一致。',
99
172
  'field.enabled': '启用网关',
100
173
  'hint.enabled': '启动时监听 0.0.0.0 网关端口',
101
174
  'field.gatewayPort': '网关端口',
@@ -146,6 +219,21 @@ const LABELS: Record<'zh' | 'en', Labels> = {
146
219
  stopped: 'Stopped',
147
220
  tls: 'TLS',
148
221
  lastError: 'Last error',
222
+ passwordSection: 'Login password',
223
+ 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.',
224
+ passwordNew: 'New password (min 8 chars)',
225
+ passwordConfirm: 'Repeat new password',
226
+ passwordSet: 'Set',
227
+ passwordUnset: 'Not set',
228
+ passwordUnknown: 'Unknown (older host)',
229
+ passwordRequired: 'The gateway refuses to start without a password.',
230
+ passwordHostStale: 'The host did not report a password state, so dsh web is still running the previous build: restart it to get Set / Not set here and to make this form take effect.',
231
+ passwordChange: 'Change password',
232
+ passwordChanged: 'Password updated: the old one no longer works and every signed-in session was revoked.',
233
+ passwordFailed: 'The password change failed — retry.',
234
+ 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.',
235
+ passwordTooShort: 'At least 8 characters.',
236
+ passwordMismatch: 'The two entries do not match.',
149
237
  'field.enabled': 'Enable gateway',
150
238
  'hint.enabled': 'Listen on the gateway port at boot',
151
239
  'field.gatewayPort': 'Gateway port',
@@ -218,6 +306,14 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
218
306
  const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
219
307
  const [saving, setSaving] = useState(false)
220
308
  const [failed, setFailed] = useState<string | null>(null)
309
+ // The password section is its own submit path: a password is not a settings
310
+ // key, so it must not ride the config patch (nor light up the card's "未保存"
311
+ // badge for every keystroke in a password box).
312
+ const [password, setPasswordDraft] = useState('')
313
+ const [passwordConfirm, setPasswordConfirmDraft] = useState('')
314
+ const [passwordBusy, setPasswordBusy] = useState(false)
315
+ const [passwordError, setPasswordError] = useState<string | null>(null)
316
+ const [passwordNotice, setPasswordNotice] = useState<string | null>(null)
221
317
 
222
318
  useEffect(() => {
223
319
  let cancelled = false
@@ -290,6 +386,68 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
290
386
  })
291
387
 
292
388
  const dirty = Object.keys(drafts).length > 0
389
+ const passwordDraftProblem = passwordProblem(password, passwordConfirm)
390
+ const passwordTyping = password !== '' || passwordConfirm !== ''
391
+ const passwordBadge = passwordStatus(route.passwordSet)
392
+
393
+ /** Adopt the host's post-write snapshot, keeping the last known values. */
394
+ const adopt = (body: Partial<RouteState>): void => {
395
+ if (body.config === undefined) return
396
+ setRoute({
397
+ config: body.config,
398
+ running: body.running ?? false,
399
+ port: body.port ?? 0,
400
+ tls: body.tls ?? '',
401
+ lastError: body.lastError ?? null,
402
+ // Absent is a state of its own (an older host does not report the field),
403
+ // so it must not be coerced into `false`: that is what made a running,
404
+ // password-protected gateway render as "not set".
405
+ ...(body.passwordSet !== undefined ? { passwordSet: body.passwordSet } : {}),
406
+ })
407
+ }
408
+
409
+ /**
410
+ * Overwrite the login password. Deliberately writes only to
411
+ * `/lan-gateway/password`: the credential is not part of the config patch, so
412
+ * a change here can never disturb an unsaved settings draft, and the old
413
+ * password is neither sent nor requested.
414
+ */
415
+ const changePassword = async (): Promise<void> => {
416
+ if (passwordBusy || passwordProblem(password, passwordConfirm) !== null) return
417
+ setPasswordBusy(true)
418
+ setPasswordError(null)
419
+ setPasswordNotice(null)
420
+ try {
421
+ const response = await fetch('/lan-gateway/password', {
422
+ method: 'POST',
423
+ headers: { 'content-type': 'application/json' },
424
+ body: JSON.stringify({ password }),
425
+ })
426
+ // A host that predates this route answers the SPA fallback (HTML, 200),
427
+ // not a 404 — so "not our JSON" is the signal, and it must not read as a
428
+ // wrong password or a network failure.
429
+ const body = await response.json().catch(() => null) as (Partial<RouteState> & { error?: string }) | null
430
+ if (!response.ok) {
431
+ setPasswordError(body?.error ?? (response.status === 404 ? t.passwordStaleHost : `HTTP ${response.status}`))
432
+ return
433
+ }
434
+ if (body === null) {
435
+ setPasswordError(t.passwordStaleHost)
436
+ return
437
+ }
438
+ // Clear the boxes the moment the write lands: the new credential is now
439
+ // the stored one, and leaving it on screen is the one thing a password
440
+ // field should not do.
441
+ setPasswordDraft('')
442
+ setPasswordConfirmDraft('')
443
+ setPasswordNotice(t.passwordChanged)
444
+ adopt(body)
445
+ } catch {
446
+ setPasswordError(t.passwordFailed)
447
+ } finally {
448
+ setPasswordBusy(false)
449
+ }
450
+ }
293
451
 
294
452
  const save = async (): Promise<void> => {
295
453
  if (!dirty || saving || invalid()) return
@@ -318,15 +476,7 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
318
476
  setFailed(body.error ?? `HTTP ${response.status}`)
319
477
  return
320
478
  }
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
- }
479
+ adopt(body)
330
480
  setDrafts({})
331
481
  } catch {
332
482
  setFailed(t.saveFailed)
@@ -440,6 +590,72 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
440
590
  ? (
441
591
  <div style={styles.body}>
442
592
  {route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
593
+ <div style={styles.section}>
594
+ <div style={styles.sectionHead}>
595
+ <span style={styles.label}>{t.passwordSection}</span>
596
+ <span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
597
+ {passwordBadge === 'set'
598
+ ? t.passwordSet
599
+ : passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
600
+ </span>
601
+ </div>
602
+ <p style={styles.hint}>{t.passwordHint}</p>
603
+ {passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
604
+ {passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
605
+ <div style={styles.passwordRow}>
606
+ <input
607
+ id="lan-gw-password"
608
+ type="password"
609
+ autoComplete="new-password"
610
+ aria-label={t.passwordNew}
611
+ style={passwordInput}
612
+ placeholder={t.passwordNew}
613
+ value={password}
614
+ disabled={passwordBusy}
615
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
616
+ setPasswordDraft(e.target.value)
617
+ setPasswordError(null)
618
+ setPasswordNotice(null)
619
+ }}
620
+ />
621
+ <input
622
+ id="lan-gw-password-confirm"
623
+ type="password"
624
+ autoComplete="new-password"
625
+ aria-label={t.passwordConfirm}
626
+ style={passwordInput}
627
+ placeholder={t.passwordConfirm}
628
+ value={passwordConfirm}
629
+ disabled={passwordBusy}
630
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
631
+ setPasswordConfirmDraft(e.target.value)
632
+ setPasswordError(null)
633
+ setPasswordNotice(null)
634
+ }}
635
+ />
636
+ </div>
637
+ <div style={styles.passwordFoot}>
638
+ {passwordError !== null
639
+ ? <p style={styles.error} role="alert">{passwordError}</p>
640
+ : passwordNotice !== null
641
+ ? <p style={styles.notice} role="status">{passwordNotice}</p>
642
+ : passwordTyping && passwordDraftProblem !== null
643
+ ? (
644
+ <p style={styles.error} role="status">
645
+ {passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
646
+ </p>
647
+ )
648
+ : null}
649
+ <button
650
+ type="button"
651
+ style={styles.save}
652
+ disabled={passwordBusy || passwordDraftProblem !== null}
653
+ onClick={() => { void changePassword() }}
654
+ >
655
+ {passwordBusy ? t.saving : t.passwordChange}
656
+ </button>
657
+ </div>
658
+ </div>
443
659
  {FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
444
660
  <div style={styles.footer}>
445
661
  {failed ? <p style={styles.error} role="status">{failed}</p> : null}
@@ -563,6 +779,47 @@ const styles: Record<string, React.CSSProperties> = {
563
779
  display: 'flex',
564
780
  flexDirection: 'column',
565
781
  },
782
+ // The password block sits inside the card body, ahead of the config fields:
783
+ // it is the setting an operator comes here for, and it owns its own submit
784
+ // button (a password is not part of the settings patch), so it is separated
785
+ // by a rule rather than merged into the field list.
786
+ section: {
787
+ display: 'flex',
788
+ flexDirection: 'column',
789
+ gap: '8px',
790
+ padding: '14px 0',
791
+ borderBottom: `1px solid ${L.border}`,
792
+ },
793
+ sectionHead: { display: 'flex', alignItems: 'center', gap: '8px' },
794
+ badge: {
795
+ borderRadius: '999px',
796
+ padding: '1px 8px',
797
+ fontSize: '11px',
798
+ lineHeight: '17px',
799
+ fontWeight: 500,
800
+ whiteSpace: 'nowrap',
801
+ background: L.badgeBg,
802
+ color: L.labelSecondary,
803
+ },
804
+ badgeAlert: {
805
+ borderRadius: '999px',
806
+ padding: '1px 8px',
807
+ fontSize: '11px',
808
+ lineHeight: '17px',
809
+ fontWeight: 500,
810
+ whiteSpace: 'nowrap',
811
+ background: L.badgeBg,
812
+ color: L.error,
813
+ },
814
+ notice: { flex: 1, minWidth: 0, margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.labelSecondary },
815
+ passwordRow: { display: 'flex', flexWrap: 'wrap', gap: '8px' },
816
+ passwordFoot: {
817
+ display: 'flex',
818
+ alignItems: 'center',
819
+ justifyContent: 'flex-end',
820
+ gap: '8px',
821
+ marginTop: '2px',
822
+ },
566
823
  field: {
567
824
  display: 'flex',
568
825
  flexDirection: 'column',
@@ -628,3 +885,6 @@ const styles: Record<string, React.CSSProperties> = {
628
885
  cursor: 'pointer',
629
886
  },
630
887
  }
888
+
889
+ /** The shared input style plus the growth two side-by-side password boxes need. */
890
+ 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'),