@riceawa/dsh-lan-gateway 0.5.5 → 0.6.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.
package/src/index.ts CHANGED
@@ -45,7 +45,11 @@ import type { Context } from '@deepseek-ai/cordis'
45
45
  import { randomBytes } from 'node:crypto'
46
46
  import type { IncomingMessage, ServerResponse } from 'http'
47
47
  import z from '@deepseek-ai/schemastery'
48
- import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
48
+ // Type-only: the `settings` service (0.1.7 addresses a write by profile entry
49
+ // id) and the Loader's `fiber.entry`, which is where this plugin reads its own
50
+ // entry id from.
51
+ import type SettingsService from '@deepseek-ai/dsh-settings'
52
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
49
53
  import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
50
54
  import {
51
55
  CONFIG_FIELD_KEYS,
@@ -209,35 +213,112 @@ export interface Config {
209
213
  secureCookies?: boolean
210
214
  }
211
215
 
216
+ /** A `.volatile()` config field as the Loader hands it to `apply`. */
217
+ export interface ConfigRef<T> {
218
+ /** The current value; updated in place when the profile entry is written. */
219
+ get(): T
220
+ }
221
+
222
+ /**
223
+ * The config object `apply` receives: dsh 0.1.7 hands every `.volatile()` field
224
+ * over as a reference rather than a value, so the plugin reads its live config
225
+ * through `readConfig`. A field declared optional and left unset resolves to
226
+ * `undefined` instead of a reference, hence the union inside `ConfigRef`.
227
+ */
228
+ export type ConfigRefs = { [K in keyof Required<Config>]: ConfigRef<Config[K]> }
229
+
212
230
  /**
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.
231
+ * Schemastery configuration validated by the Loader.
232
+ *
233
+ * Every field is `.volatile()`, which is what lets the Settings service write
234
+ * it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
235
+ * other path (`not volatile`). The mark also changes the runtime shape — a
236
+ * volatile field arrives as a reference (see `ConfigRefs`), never as the plain
237
+ * value the rest of this file expects — so read it through `readConfig`.
217
238
  */
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(),
239
+ export const Config = z.object({
240
+ enabled: z.boolean().default(false).volatile(),
241
+ gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
242
+ dshTargetPort: z.natural().min(1).max(65535).volatile(),
243
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
244
+ lanPasswordless: z.boolean().default(false).volatile(),
245
+ authRequired: z.boolean().default(true).volatile(),
246
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
247
+ cookieName: z.string().default('dsh_gw_auth').volatile(),
248
+ tlsEnabled: z.boolean().default(false).volatile(),
249
+ tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed').volatile(),
250
+ tlsCertPath: z.string().volatile(),
251
+ tlsKeyPath: z.string().volatile(),
252
+ tlsSelfSignedHosts: z.string().default('localhost').volatile(),
253
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
254
+ allowInsecurePlaintext: z.boolean().default(false).volatile(),
255
+ trustedTerminator: z.string().volatile(),
256
+ secureCookies: z.boolean().volatile(),
239
257
  })
240
258
 
259
+ /**
260
+ * Unwrap the config references into the plain values every other function in
261
+ * this file reads. Called on each access rather than once, because a settings
262
+ * write updates the references in place.
263
+ * @param refs - the config object handed to `apply`.
264
+ * @returns one detached plain snapshot.
265
+ */
266
+ export function readConfig(refs: ConfigRefs): Config {
267
+ const dshTargetPort = refs.dshTargetPort?.get()
268
+ const authRequired = refs.authRequired?.get()
269
+ const tlsCertPath = refs.tlsCertPath?.get()
270
+ const tlsKeyPath = refs.tlsKeyPath?.get()
271
+ const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get()
272
+ const trustedTerminator = refs.trustedTerminator?.get()
273
+ const secureCookies = refs.secureCookies?.get()
274
+ return {
275
+ enabled: refs.enabled.get(),
276
+ gatewayPort: refs.gatewayPort.get(),
277
+ lanCidrs: [...refs.lanCidrs.get()],
278
+ lanPasswordless: refs.lanPasswordless.get(),
279
+ cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
280
+ cookieName: refs.cookieName.get(),
281
+ tlsEnabled: refs.tlsEnabled.get(),
282
+ tlsMode: refs.tlsMode.get(),
283
+ tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
284
+ allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
285
+ // `exactOptionalPropertyTypes` is on, so an absent optional key must stay
286
+ // absent rather than be assigned `undefined`. Every field here has a schema
287
+ // default or is genuinely optional, so an absent reference means unset.
288
+ ...(dshTargetPort !== undefined ? { dshTargetPort } : {}),
289
+ ...(authRequired !== undefined ? { authRequired } : {}),
290
+ ...(tlsCertPath !== undefined ? { tlsCertPath } : {}),
291
+ ...(tlsKeyPath !== undefined ? { tlsKeyPath } : {}),
292
+ ...(tlsSelfSignedHosts !== undefined ? { tlsSelfSignedHosts } : {}),
293
+ ...(trustedTerminator !== undefined ? { trustedTerminator } : {}),
294
+ ...(secureCookies !== undefined ? { secureCookies } : {}),
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Build the reference-shaped config `apply` receives, exactly as the Loader
300
+ * builds it. Exported for tests that drive `apply` directly.
301
+ * @param raw - a config object; missing fields take their schema defaults.
302
+ * @returns one reference per volatile field.
303
+ */
304
+ export function configRefs(raw: object): ConfigRefs {
305
+ return Config(raw) as unknown as ConfigRefs
306
+ }
307
+
308
+ /**
309
+ * Validate a raw config object the way the Loader does, and unwrap it.
310
+ *
311
+ * `Config` marks every field volatile, so a validation hands the values back as
312
+ * references (typed deeply-readonly by schemastery); this returns the plain
313
+ * shape the rest of the file reads. Used to judge a config the Settings card is
314
+ * about to save, before it is persisted.
315
+ * @param raw - a config object; missing fields take their schema defaults.
316
+ * @returns the validated plain config.
317
+ */
318
+ export function validateConfig(raw: object): Config {
319
+ return readConfig(configRefs(raw))
320
+ }
321
+
241
322
  /** Facts the fail-closed start guard needs to judge a config. */
242
323
  export interface StartFacts {
243
324
  /** Whether the dsh base enforces browser-session auth (auto-detected). */
@@ -432,7 +513,7 @@ export function buildConfigPatch(submitted: Record<string, unknown>): {
432
513
  return { patch, clear, unknown }
433
514
  }
434
515
 
435
- export function apply(ctx: Context, config: Config): void {
516
+ export function apply(ctx: Context, config: ConfigRefs): void {
436
517
  let state = loadState()
437
518
  let gateway: LanGateway | undefined
438
519
  let startedWith: string | undefined
@@ -455,18 +536,20 @@ export function apply(ctx: Context, config: Config): void {
455
536
  let connectionGeneration = 0
456
537
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
457
538
  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. */
539
+ /** Whether the settings service is attached, so writes reach the profile entry. */
461
540
  let settingsAttached = false
462
- /** The settings scope for the `lan-gateway` namespace, while one is attached. */
463
- let settingsScope: SettingsScope<Config> | undefined
464
541
  /**
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.
542
+ * This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
543
+ * the *entry id* — the `lan-gateway` namespace this plugin used to register
544
+ * with is gone along with `settingsScope`.
545
+ */
546
+ let settingsEntryId: string | undefined
547
+ /**
548
+ * The settings service, for the one write a merge patch cannot express: a key
549
+ * must be *removed* to re-inherit the composition layer, and only its
550
+ * path-addressed `mutate` can unset one.
468
551
  */
469
- let settingsProvider: SettingsProvider | undefined
552
+ let settingsProvider: SettingsService | undefined
470
553
  /**
471
554
  * One queue for every lifecycle side effect. Settings changes, tool commands,
472
555
  * credential changes, TLS regeneration and plugin disposal all land here, so
@@ -476,7 +559,7 @@ export function apply(ctx: Context, config: Config): void {
476
559
  /** Set by the dispose hook; a start that completes after it must undo itself. */
477
560
  let disposed = false
478
561
 
479
- const effective = (): Config => configSource()
562
+ const effective = (): Config => readConfig(config)
480
563
 
481
564
  /** Queue one lifecycle action behind every action already running. */
482
565
  const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
@@ -557,8 +640,8 @@ export function apply(ctx: Context, config: Config): void {
557
640
  const cfg = effective()
558
641
  // Without a settings service there is nowhere to record the tool's intent,
559
642
  // 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.
643
+ // attached, the profile entry's `enabled` already carries it and an override
644
+ // would shadow the card — the defect this replaces.
562
645
  if (settingsAttached) return cfg
563
646
  return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
564
647
  }
@@ -580,39 +663,41 @@ export function apply(ctx: Context, config: Config): void {
580
663
  })
581
664
  }
582
665
 
583
- /** Record the run intent where it will survive: the settings section, or memory. */
666
+ /** Record the run intent where it will survive: the profile entry, or memory. */
584
667
  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.
668
+ if (settingsAttached && settingsProvider !== undefined && settingsEntryId !== undefined) {
669
+ // The same field the Settings card writes, in this plugin's own profile
670
+ // entry. A merge patch, so nothing else in the entry is disturbed.
671
+ await settingsProvider.update(settingsEntryId, { enabled })
672
+ // The write updates the config references in place and the loader then
673
+ // emits `loader/volatile-update`, which queues the reconcile; waiting on
674
+ // that here would deadlock behind this same write.
591
675
  return
592
676
  }
593
677
  manualOverride = enabled
594
678
  }
595
679
 
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.
680
+ // The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
681
+ // it by *entry id*: the `lan-gateway` settings namespace this plugin used to
682
+ // register (and the scope handle it wrote through) no longer exist. The entry
683
+ // id is the Loader's, so it comes from the fiber; without a Loader there is no
684
+ // entry to write and the composition value stands alone.
602
685
  ctx.inject(['settings'], (sctx) => {
603
- const scope = sctx.settings.register(NS, Config, { base: config })
604
- settingsScope = scope
686
+ const entryId = ctx.fiber.entry?.options.id
687
+ if (entryId === undefined) return
688
+ settingsEntryId = entryId
605
689
  settingsProvider = sctx.settings
606
690
  settingsAttached = true
607
- configSource = () => scope.get()
608
- sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
691
+ // This plugin ships its own card, so it owns its page policy.
692
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
693
+ // A committed write updates the volatile references in place (no remount)
694
+ // and emits this, so the listener follows the new config.
695
+ sctx.effect(() => ctx.on('loader/volatile-update', () => { void syncGateway('settings change') }))
609
696
  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
697
+ // The settings service went away (disposal / provider reload): the
698
+ // composition entry is authoritative again, and the tool's intent falls
699
+ // back to memory. Reconcile so the listener follows it.
700
+ settingsEntryId = undefined
616
701
  settingsProvider = undefined
617
702
  settingsAttached = false
618
703
  void syncGateway('settings detach')
@@ -697,7 +782,12 @@ export function apply(ctx: Context, config: Config): void {
697
782
  send(400, { error: 'body must be a config object' })
698
783
  return
699
784
  }
700
- if (settingsProvider === undefined) {
785
+ // A settings write is addressed by this plugin's profile entry id; without
786
+ // one (no Loader, or no settings service) the composition value is all there
787
+ // is, and the operator edits the profile patch instead.
788
+ const settings = settingsProvider
789
+ const entryId = settingsEntryId
790
+ if (settings === undefined || entryId === undefined) {
701
791
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
702
792
  return
703
793
  }
@@ -708,7 +798,7 @@ export function apply(ctx: Context, config: Config): void {
708
798
  // Validate the candidate the patch would produce — schema defaults included,
709
799
  // exactly as the listener will resolve it — so the save fails closed on an
710
800
  // unusable combination instead of persisting it.
711
- const candidate = Config({ ...effective(), ...patch })
801
+ const candidate = validateConfig({ ...effective(), ...patch })
712
802
  // A structural problem (legacy authRequired:false, lanPasswordless without
713
803
  // a session-capable base) is invalid however it is reached; a start
714
804
  // condition (plaintext without TLS/terminator/opt-in) only blocks a save
@@ -730,7 +820,7 @@ export function apply(ctx: Context, config: Config): void {
730
820
  // key so it re-inherits the composition layer. Storing null instead would
731
821
  // leave a null where the config expects a string, and `!== undefined`
732
822
  // tests elsewhere would then read that null as a declared value.
733
- if (ops.length > 0) await settingsProvider.mutate(NS, ops)
823
+ if (ops.length > 0) await settings.mutate(entryId, ops)
734
824
  // The write commits through the section's watcher; reconcile explicitly so
735
825
  // the response reports a settled listener rather than a mid-restart one.
736
826
  await syncGateway('config route save')