@riceawa/dsh-lan-gateway 0.5.5 → 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.
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`.
@@ -45,10 +48,15 @@ import type { Context } from '@deepseek-ai/cordis'
45
48
  import { randomBytes } from 'node:crypto'
46
49
  import type { IncomingMessage, ServerResponse } from 'http'
47
50
  import z from '@deepseek-ai/schemastery'
48
- import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
51
+ // Type-only: the `settings` service (0.1.7 addresses a write by profile entry
52
+ // id) and the Loader's `fiber.entry`, which is where this plugin reads its own
53
+ // entry id from.
54
+ import type SettingsService from '@deepseek-ai/dsh-settings'
55
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
49
56
  import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
50
57
  import {
51
58
  CONFIG_FIELD_KEYS,
59
+ MIN_PASSWORD_LENGTH,
52
60
  OPTIONAL_CONFIG_KEYS,
53
61
  } from './config-fields.ts'
54
62
  import { LanGateway } from './gateway.ts'
@@ -209,35 +217,112 @@ export interface Config {
209
217
  secureCookies?: boolean
210
218
  }
211
219
 
220
+ /** A `.volatile()` config field as the Loader hands it to `apply`. */
221
+ export interface ConfigRef<T> {
222
+ /** The current value; updated in place when the profile entry is written. */
223
+ get(): T
224
+ }
225
+
226
+ /**
227
+ * The config object `apply` receives: dsh 0.1.7 hands every `.volatile()` field
228
+ * over as a reference rather than a value, so the plugin reads its live config
229
+ * through `readConfig`. A field declared optional and left unset resolves to
230
+ * `undefined` instead of a reference, hence the union inside `ConfigRef`.
231
+ */
232
+ export type ConfigRefs = { [K in keyof Required<Config>]: ConfigRef<Config[K]> }
233
+
212
234
  /**
213
- * The `lan-gateway` user-settings namespace, mirroring the composition schema.
214
- * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
215
- * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
216
- * shape works against both that release line and the older branded one.
235
+ * Schemastery configuration validated by the Loader.
236
+ *
237
+ * Every field is `.volatile()`, which is what lets the Settings service write
238
+ * it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
239
+ * other path (`not volatile`). The mark also changes the runtime shape — a
240
+ * volatile field arrives as a reference (see `ConfigRefs`), never as the plain
241
+ * value the rest of this file expects — so read it through `readConfig`.
217
242
  */
218
- const NS = 'lan-gateway'
219
-
220
- /** Schemastery configuration validated by the Loader. */
221
- export const Config: z<Config> = z.object({
222
- enabled: z.boolean().default(false),
223
- gatewayPort: z.natural().min(1).max(65535).default(3081),
224
- dshTargetPort: z.natural().min(1).max(65535),
225
- lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
226
- lanPasswordless: z.boolean().default(false),
227
- authRequired: z.boolean().default(true),
228
- cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
229
- cookieName: z.string().default('dsh_gw_auth'),
230
- tlsEnabled: z.boolean().default(false),
231
- tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed'),
232
- tlsCertPath: z.string(),
233
- tlsKeyPath: z.string(),
234
- tlsSelfSignedHosts: z.string().default('localhost'),
235
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
236
- allowInsecurePlaintext: z.boolean().default(false),
237
- trustedTerminator: z.string(),
238
- secureCookies: z.boolean(),
243
+ export const Config = z.object({
244
+ enabled: z.boolean().default(false).volatile(),
245
+ gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
246
+ dshTargetPort: z.natural().min(1).max(65535).volatile(),
247
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
248
+ lanPasswordless: z.boolean().default(false).volatile(),
249
+ authRequired: z.boolean().default(true).volatile(),
250
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
251
+ cookieName: z.string().default('dsh_gw_auth').volatile(),
252
+ tlsEnabled: z.boolean().default(false).volatile(),
253
+ tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed').volatile(),
254
+ tlsCertPath: z.string().volatile(),
255
+ tlsKeyPath: z.string().volatile(),
256
+ tlsSelfSignedHosts: z.string().default('localhost').volatile(),
257
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
258
+ allowInsecurePlaintext: z.boolean().default(false).volatile(),
259
+ trustedTerminator: z.string().volatile(),
260
+ secureCookies: z.boolean().volatile(),
239
261
  })
240
262
 
263
+ /**
264
+ * Unwrap the config references into the plain values every other function in
265
+ * this file reads. Called on each access rather than once, because a settings
266
+ * write updates the references in place.
267
+ * @param refs - the config object handed to `apply`.
268
+ * @returns one detached plain snapshot.
269
+ */
270
+ export function readConfig(refs: ConfigRefs): Config {
271
+ const dshTargetPort = refs.dshTargetPort?.get()
272
+ const authRequired = refs.authRequired?.get()
273
+ const tlsCertPath = refs.tlsCertPath?.get()
274
+ const tlsKeyPath = refs.tlsKeyPath?.get()
275
+ const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get()
276
+ const trustedTerminator = refs.trustedTerminator?.get()
277
+ const secureCookies = refs.secureCookies?.get()
278
+ return {
279
+ enabled: refs.enabled.get(),
280
+ gatewayPort: refs.gatewayPort.get(),
281
+ lanCidrs: [...refs.lanCidrs.get()],
282
+ lanPasswordless: refs.lanPasswordless.get(),
283
+ cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
284
+ cookieName: refs.cookieName.get(),
285
+ tlsEnabled: refs.tlsEnabled.get(),
286
+ tlsMode: refs.tlsMode.get(),
287
+ tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
288
+ allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
289
+ // `exactOptionalPropertyTypes` is on, so an absent optional key must stay
290
+ // absent rather than be assigned `undefined`. Every field here has a schema
291
+ // default or is genuinely optional, so an absent reference means unset.
292
+ ...(dshTargetPort !== undefined ? { dshTargetPort } : {}),
293
+ ...(authRequired !== undefined ? { authRequired } : {}),
294
+ ...(tlsCertPath !== undefined ? { tlsCertPath } : {}),
295
+ ...(tlsKeyPath !== undefined ? { tlsKeyPath } : {}),
296
+ ...(tlsSelfSignedHosts !== undefined ? { tlsSelfSignedHosts } : {}),
297
+ ...(trustedTerminator !== undefined ? { trustedTerminator } : {}),
298
+ ...(secureCookies !== undefined ? { secureCookies } : {}),
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Build the reference-shaped config `apply` receives, exactly as the Loader
304
+ * builds it. Exported for tests that drive `apply` directly.
305
+ * @param raw - a config object; missing fields take their schema defaults.
306
+ * @returns one reference per volatile field.
307
+ */
308
+ export function configRefs(raw: object): ConfigRefs {
309
+ return Config(raw) as unknown as ConfigRefs
310
+ }
311
+
312
+ /**
313
+ * Validate a raw config object the way the Loader does, and unwrap it.
314
+ *
315
+ * `Config` marks every field volatile, so a validation hands the values back as
316
+ * references (typed deeply-readonly by schemastery); this returns the plain
317
+ * shape the rest of the file reads. Used to judge a config the Settings card is
318
+ * about to save, before it is persisted.
319
+ * @param raw - a config object; missing fields take their schema defaults.
320
+ * @returns the validated plain config.
321
+ */
322
+ export function validateConfig(raw: object): Config {
323
+ return readConfig(configRefs(raw))
324
+ }
325
+
241
326
  /** Facts the fail-closed start guard needs to judge a config. */
242
327
  export interface StartFacts {
243
328
  /** Whether the dsh base enforces browser-session auth (auto-detected). */
@@ -432,7 +517,7 @@ export function buildConfigPatch(submitted: Record<string, unknown>): {
432
517
  return { patch, clear, unknown }
433
518
  }
434
519
 
435
- export function apply(ctx: Context, config: Config): void {
520
+ export function apply(ctx: Context, config: ConfigRefs): void {
436
521
  let state = loadState()
437
522
  let gateway: LanGateway | undefined
438
523
  let startedWith: string | undefined
@@ -455,18 +540,20 @@ export function apply(ctx: Context, config: Config): void {
455
540
  let connectionGeneration = 0
456
541
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
457
542
  let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
458
- /** The authoritative config: settings section when attached, else composition. */
459
- let configSource: () => Config = () => config
460
- /** Whether writes go to the settings section rather than staying in memory. */
543
+ /** Whether the settings service is attached, so writes reach the profile entry. */
461
544
  let settingsAttached = false
462
- /** The settings scope for the `lan-gateway` namespace, while one is attached. */
463
- let settingsScope: SettingsScope<Config> | undefined
464
545
  /**
465
- * The settings provider, for the one write a scope cannot express: a section
466
- * key must be *removed* to re-inherit the composition layer, and only the
467
- * provider's path-addressed `mutate` can unset one.
546
+ * This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
547
+ * the *entry id* — the `lan-gateway` namespace this plugin used to register
548
+ * with is gone along with `settingsScope`.
549
+ */
550
+ let settingsEntryId: string | undefined
551
+ /**
552
+ * The settings service, for the one write a merge patch cannot express: a key
553
+ * must be *removed* to re-inherit the composition layer, and only its
554
+ * path-addressed `mutate` can unset one.
468
555
  */
469
- let settingsProvider: SettingsProvider | undefined
556
+ let settingsProvider: SettingsService | undefined
470
557
  /**
471
558
  * One queue for every lifecycle side effect. Settings changes, tool commands,
472
559
  * credential changes, TLS regeneration and plugin disposal all land here, so
@@ -476,7 +563,7 @@ export function apply(ctx: Context, config: Config): void {
476
563
  /** Set by the dispose hook; a start that completes after it must undo itself. */
477
564
  let disposed = false
478
565
 
479
- const effective = (): Config => configSource()
566
+ const effective = (): Config => readConfig(config)
480
567
 
481
568
  /** Queue one lifecycle action behind every action already running. */
482
569
  const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
@@ -557,8 +644,8 @@ export function apply(ctx: Context, config: Config): void {
557
644
  const cfg = effective()
558
645
  // Without a settings service there is nowhere to record the tool's intent,
559
646
  // so it lives in memory as an override on the composition entry. With one
560
- // attached, `enabled` already carries it and an override would shadow the
561
- // card — the defect this replaces.
647
+ // attached, the profile entry's `enabled` already carries it and an override
648
+ // would shadow the card — the defect this replaces.
562
649
  if (settingsAttached) return cfg
563
650
  return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
564
651
  }
@@ -580,39 +667,85 @@ export function apply(ctx: Context, config: Config): void {
580
667
  })
581
668
  }
582
669
 
583
- /** Record the run intent where it will survive: the settings section, or memory. */
670
+ /** Record the run intent where it will survive: the profile entry, or memory. */
584
671
  const setRunIntent = async (enabled: boolean): Promise<void> => {
585
- if (settingsAttached && settingsScope !== undefined) {
586
- // The same field the Settings card writes. A merge patch, so nothing else
587
- // in the user's section is disturbed.
588
- await settingsScope.update({ enabled })
589
- // The section's watcher queues the reconcile; it observes committed
590
- // changes, so waiting on it here would deadlock behind this same write.
672
+ if (settingsAttached && settingsProvider !== undefined && settingsEntryId !== undefined) {
673
+ // The same field the Settings card writes, in this plugin's own profile
674
+ // entry. A merge patch, so nothing else in the entry is disturbed.
675
+ await settingsProvider.update(settingsEntryId, { enabled })
676
+ // The write updates the config references in place and the loader then
677
+ // emits `loader/volatile-update`, which queues the reconcile; waiting on
678
+ // that here would deadlock behind this same write.
591
679
  return
592
680
  }
593
681
  manualOverride = enabled
594
682
  }
595
683
 
596
- // The tunables also live in the `lan-gateway` settings section: while the
597
- // settings service exists, the section (composition base + user overrides)
598
- // is the authoritative config, and every committed change re-syncs the
599
- // listener — so the Settings → Plugins page adjusts the gateway live.
600
- // Registered directly (not via installSection) so the scope handle is
601
- // available to the /lan-gateway/config route for writes.
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
+
728
+ // The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
729
+ // it by *entry id*: the `lan-gateway` settings namespace this plugin used to
730
+ // register (and the scope handle it wrote through) no longer exist. The entry
731
+ // id is the Loader's, so it comes from the fiber; without a Loader there is no
732
+ // entry to write and the composition value stands alone.
602
733
  ctx.inject(['settings'], (sctx) => {
603
- const scope = sctx.settings.register(NS, Config, { base: config })
604
- settingsScope = scope
734
+ const entryId = ctx.fiber.entry?.options.id
735
+ if (entryId === undefined) return
736
+ settingsEntryId = entryId
605
737
  settingsProvider = sctx.settings
606
738
  settingsAttached = true
607
- configSource = () => scope.get()
608
- sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
739
+ // This plugin ships its own card, so it owns its page policy.
740
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
741
+ // A committed write updates the volatile references in place (no remount)
742
+ // and emits this, so the listener follows the new config.
743
+ sctx.effect(() => ctx.on('loader/volatile-update', () => { void syncGateway('settings change') }))
609
744
  sctx.effect(() => () => {
610
- // The settings provider went away (disposal / provider reload): fall back
611
- // to the composition entry so the plugin keeps working as composed, and
612
- // reconcile so the listener follows the config that is now authoritative
613
- // instead of staying on the section's last value.
614
- configSource = () => config
615
- settingsScope = undefined
745
+ // The settings service went away (disposal / provider reload): the
746
+ // composition entry is authoritative again, and the tool's intent falls
747
+ // back to memory. Reconcile so the listener follows it.
748
+ settingsEntryId = undefined
616
749
  settingsProvider = undefined
617
750
  settingsAttached = false
618
751
  void syncGateway('settings detach')
@@ -650,28 +783,35 @@ export function apply(ctx: Context, config: Config): void {
650
783
  void syncGateway('connection attach')
651
784
  })
652
785
 
653
- // The Settings → Plugins card reads and writes through this loopback-only
654
- // 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
655
788
  // directly, so the card has no service dependencies to resolve). The gateway
656
- // 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
657
790
  // reach it — a genuine local user, or a local process that could already read
658
791
  // ~/.dsh.
659
- const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
660
- const snapshot = (): Record<string, unknown> => {
661
- const cfg = effective()
662
- return {
663
- config: cfg,
664
- running: gateway !== undefined,
665
- port: cfg.gatewayPort,
666
- tls: tlsStatusLine(cfg),
667
- upstreamSessionAvailable,
668
- lastError: lastError ?? null,
669
- }
670
- }
671
- const send = (status: number, body: unknown): void => {
672
- res.writeHead(status, { 'content-type': 'application/json' })
673
- 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,
674
811
  }
812
+ }
813
+ const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
814
+ const send = (status: number, body: unknown): void => sendJson(res, status, body)
675
815
  if (!isTrustedConfigRequest(req)) {
676
816
  send(403, { error: 'request refused: this route answers same-origin loopback requests only' })
677
817
  return
@@ -697,7 +837,12 @@ export function apply(ctx: Context, config: Config): void {
697
837
  send(400, { error: 'body must be a config object' })
698
838
  return
699
839
  }
700
- if (settingsProvider === undefined) {
840
+ // A settings write is addressed by this plugin's profile entry id; without
841
+ // one (no Loader, or no settings service) the composition value is all there
842
+ // is, and the operator edits the profile patch instead.
843
+ const settings = settingsProvider
844
+ const entryId = settingsEntryId
845
+ if (settings === undefined || entryId === undefined) {
701
846
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
702
847
  return
703
848
  }
@@ -708,7 +853,7 @@ export function apply(ctx: Context, config: Config): void {
708
853
  // Validate the candidate the patch would produce — schema defaults included,
709
854
  // exactly as the listener will resolve it — so the save fails closed on an
710
855
  // unusable combination instead of persisting it.
711
- const candidate = Config({ ...effective(), ...patch })
856
+ const candidate = validateConfig({ ...effective(), ...patch })
712
857
  // A structural problem (legacy authRequired:false, lanPasswordless without
713
858
  // a session-capable base) is invalid however it is reached; a start
714
859
  // condition (plaintext without TLS/terminator/opt-in) only blocks a save
@@ -730,7 +875,7 @@ export function apply(ctx: Context, config: Config): void {
730
875
  // key so it re-inherits the composition layer. Storing null instead would
731
876
  // leave a null where the config expects a string, and `!== undefined`
732
877
  // tests elsewhere would then read that null as a declared value.
733
- if (ops.length > 0) await settingsProvider.mutate(NS, ops)
878
+ if (ops.length > 0) await settings.mutate(entryId, ops)
734
879
  // The write commits through the section's watcher; reconcile explicitly so
735
880
  // the response reports a settled listener rather than a mid-restart one.
736
881
  await syncGateway('config route save')
@@ -750,6 +895,58 @@ export function apply(ctx: Context, config: Config): void {
750
895
  'dsh-lan-gateway: config route',
751
896
  )
752
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
+
753
950
  const controller: GatewayController = {
754
951
  status(): ToolResult {
755
952
  const cfg = desiredConfig()
@@ -785,39 +982,7 @@ export function apply(ctx: Context, config: Config): void {
785
982
  await syncGateway('tool disable')
786
983
  return { ok: true, message: 'Gateway disabled.' }
787
984
  },
788
- async setPassword(password: string | undefined): Promise<ToolResult> {
789
- if (password !== undefined && password.length > 0 && password.length < 8) {
790
- return { ok: false, message: 'Password must be at least 8 characters.' }
791
- }
792
- const setting = password !== undefined && password.length > 0
793
- const hadPassword = state.password !== undefined
794
- state = await setPassword(state, setting ? password : undefined)
795
- saveState(state)
796
- gateway?.setState(state)
797
- if (!setting) {
798
- // Clearing the credential must not leave an open gateway serving
799
- // sessions the old password authorized: stop the listener. A password
800
- // is required to run, so a later enable fails closed.
801
- await setRunIntent(false)
802
- return enqueue('password cleared', async () => {
803
- if (gateway !== undefined) await stopGateway()
804
- lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
805
- }).then(() => ({
806
- ok: true,
807
- message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
808
- }))
809
- }
810
- // The first password turns a dormant "enabled but unpassworded" intent
811
- // into a startable one, so reconcile: the listener was refused a moment
812
- // ago for a reason that no longer holds. A later password change needs no
813
- // reconcile (the listener is already running or already refused for some
814
- // other reason), and reconciling anyway would be harmless but noisy.
815
- if (!hadPassword) await syncGateway('password set')
816
- return {
817
- ok: true,
818
- message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
819
- }
820
- },
985
+ setPassword: applyPassword,
821
986
  rotateSecret(): ToolResult {
822
987
  const next: GatewayState = {
823
988
  cookieSecret: randomBytes(32).toString('base64'),