@riceawa/dsh-lan-gateway 0.5.4 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@riceawa/dsh-lan-gateway",
3
3
  "description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
4
- "version": "0.5.4",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -42,22 +42,34 @@
42
42
  }
43
43
  },
44
44
  "peerDependencies": {
45
- "@deepseek-ai/cordis": "^4.0.2",
46
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1 || ^0.1.5-rc.2",
47
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1 || ^0.1.5-rc.2",
48
- "@deepseek-ai/schemastery": "^3.18.2"
45
+ "@deepseek-ai/cordis": "^4.0.4",
46
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
47
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1",
48
+ "@deepseek-ai/schemastery": "^3.18.4"
49
49
  },
50
50
  "devDependencies": {
51
- "@deepseek-ai/cordis": "4.0.2",
52
- "@deepseek-ai/dsh-brand": "0.1.5-rc.2",
51
+ "@deepseek-ai/cordis": "4.0.4",
52
+ "@deepseek-ai/cordis-plugin-loader": "1.0.5",
53
+ "@deepseek-ai/cosmokit": "1.8.5",
54
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.2",
58
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
53
63
  "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
54
- "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
55
- "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
56
- "@deepseek-ai/dsh-scope": "0.1.5-rc.2",
57
- "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
58
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
59
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
60
- "@deepseek-ai/schemastery": "3.18.2",
64
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
68
+ "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
69
+ "@deepseek-ai/dsh-settings": "0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
72
+ "@deepseek-ai/schemastery": "3.18.4",
61
73
  "@types/node": "^22.0.0",
62
74
  "@types/react": "~18.3.1",
63
75
  "react": "^18.2.0",
package/src/auth.ts CHANGED
@@ -77,6 +77,13 @@ function normalizeAddress(raw: string): string {
77
77
 
78
78
  /**
79
79
  * Classify a source address string into one of the three trust tiers.
80
+ *
81
+ * The input is a *socket* address — `req.socket.remoteAddress`, unwrapped from
82
+ * its `::ffff:` mapping — which is a different domain from the URL hostname
83
+ * `isLoopbackHost` in `request-policy.ts` judges. The two agree on the common
84
+ * inputs but are not interchangeable: this one never sees `[::1]`, and that one
85
+ * never sees a mapped form. Both spans are documented where each lives.
86
+ *
80
87
  * @param remoteAddress - the raw value of `req.socket.remoteAddress`.
81
88
  * @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
82
89
  * @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
@@ -191,20 +198,6 @@ export function verifySession(
191
198
  }
192
199
  }
193
200
 
194
- /**
195
- * Whether a cookie value is a valid, unexpired session signed with `secret`
196
- * and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
197
- * epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
198
- */
199
- export function verifyCookie(
200
- secret: string,
201
- value: string | undefined,
202
- now: number,
203
- epoch: number = 0,
204
- ): boolean {
205
- return verifySession(secret, value, now, epoch) !== undefined
206
- }
207
-
208
201
  /**
209
202
  * Whether a browser Origin header names the same authority (hostname:port) as
210
203
  * a request Host header. Both sides run through WHATWG URL parsing so case and
@@ -7,10 +7,9 @@
7
7
  * installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
8
8
  * module scope. With TLS enabled the origin is secure and the shim is a
9
9
  * no-op.
10
- * 2. Settings card: registers the LAN gateway card into the official
11
- * Settings → Plugins page (`settings.plugin.item` slot), editing the
12
- * `lan-gateway` settings namespace so port, CIDRs, auth, and TLS are
13
- * adjustable from the GUI.
10
+ * 2. Settings card: registers the LAN gateway card into the official Plugins
11
+ * page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
12
+ * from the GUI.
14
13
  */
15
14
 
16
15
  /** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
@@ -62,12 +61,25 @@ export function installRandomUuidShim(): boolean {
62
61
  installRandomUuidShim()
63
62
 
64
63
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
65
- import { LanGatewayCard } from './lan-gateway-card.tsx'
64
+ // Type-only, and never bundled: the Plugins page's slot contract (`plugins.item`)
65
+ // and the settings domain's `configForms` service both resolve from the web
66
+ // shell's frozen module table. Importing them is what subjects the registration
67
+ // below to the platform's own contract instead of a local copy that drifts the
68
+ // next time upstream renames a slot.
69
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
70
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
71
+ import { LanGatewayCard, cardTitle } from './lan-gateway-card.tsx'
66
72
 
67
73
  export const name = 'dsh-lan-gateway'
68
74
 
69
- /** Only the slots service: the card itself is self-loading (ModLens-style). */
70
- export const inject = ['slots']
75
+ /**
76
+ * The profile entry id this plugin's bundle patch composes it under, and the
77
+ * settings namespace dsh ≥ 0.1.7 addresses every write by.
78
+ */
79
+ const ENTRY_ID = 'dsh-lan-gateway'
80
+
81
+ /** The slots service the card rides, and the settings mirror the gate reads. */
82
+ export const inject = ['slots', 'configForms']
71
83
 
72
84
  /**
73
85
  * Mount the settings card and the UUID shim.
@@ -76,28 +88,26 @@ export const inject = ['slots']
76
88
  export function apply(ctx: ClientContext): void {
77
89
  installRandomUuidShim()
78
90
 
79
- // The card rides the official Plugins → Configurable tab. Like ModLens, it
80
- // registers with no inject face and fetches its own loopback config route,
81
- // so it has no settings/locale/connection service dependencies.
91
+ // The card rides the official Plugins page. Like ModLens it registers with no
92
+ // inject face and fetches its own loopback config route, so it depends on no
93
+ // settings, locale, or connection service.
82
94
  //
83
- // The `settings.plugin.item` slot is keyed BY the settings namespace the
84
- // card edits (rc.8 contract): the configurable tab only dispatches entries
85
- // whose `options.key` is both present and served by the Host's settings
86
- // describe mirror. Registering with `id` alone throws
87
- // `keyed slot "settings.plugin.item" requires options.key` and the card
88
- // silently disappears from Settings → Plugins.
95
+ // dsh 0.1.7 replaced the namespace-keyed `settings.plugin.item` slot with the
96
+ // list slot `plugins.item`, which is where a host-plane plugin's own
97
+ // configuration page belongs ("one companion package per host-plane
98
+ // namespace"); the page renders the contribution as the card's one-liner and,
99
+ // once opened, as the body of the plugin's own page. Registering into the
100
+ // retired slot left the card invisible on 0.1.7.
89
101
  //
90
- // `id`/`order` ride the legacy list-slot shape (older DSH versions
91
- // dispatched this slot by id): harmless metadata on the keyed slot, and
92
- // what keeps the card mounting if this plugin ever loads into an older
93
- // deployment. Spread from a typed constant so the keyed registration type
94
- // stays exact.
95
- const legacyListOptions = { id: 'lan-gateway', order: 30 } as const
96
- ctx.slots.inject('settings.plugin.item', function* () {
97
- yield ctx.slots.register({
98
- name: 'settings.plugin.item',
99
- key: 'lan-gateway',
100
- ...legacyListOptions,
101
- }, LanGatewayCard)
102
- })
102
+ // `whileServed` keeps the entry off the page until the Host's settings mirror
103
+ // serves this plugin's entry. That is the one gate worth having: without a
104
+ // Loader entry there is nothing to write to, and the card would appear only
105
+ // to fail every save with the route's 409.
106
+ ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () =>
107
+ ctx.slots.inject('plugins.item', () => ctx.slots.register({
108
+ name: 'plugins.item',
109
+ id: ENTRY_ID,
110
+ order: 30,
111
+ label: () => cardTitle(),
112
+ }, LanGatewayCard))))
103
113
  }
@@ -1,53 +1,49 @@
1
1
  /**
2
- * The lan-gateway settings card shown in the official DSH Settings → Plugins
3
- * page (the `settings.plugin.item` slot).
2
+ * The lan-gateway settings card, rendered by the official DSH Plugins page
3
+ * through its `plugins.item` slot.
4
4
  *
5
5
  * ModLens-style: the card carries NO injected services. It reads and writes
6
6
  * the loopback-only `/lan-gateway/config` host route (the browser never sees
7
- * the settings seam or any secret), so the client bundle's only dependency is
8
- * the `slots` service that every plugin already has.
7
+ * the settings seam or any secret), so the only platform service it needs is
8
+ * the `slots` service every plugin already has.
9
9
  *
10
10
  * @module @riceawa/dsh-lan-gateway/client/card
11
11
  */
12
12
 
13
13
  import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
14
14
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
15
+ // Type-only. The Plugins page owns the `plugins.item` contract, and its own
16
+ // doc says a registrant merges that contract with `import type` instead of
17
+ // importing the package at runtime. Taking the contract from its owner is also
18
+ // what turns the next upstream rename of this slot into a compile error here,
19
+ // rather than a card that quietly stops rendering.
20
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
21
+ import {
22
+ FIELDS,
23
+ TRISTATE_OPTIONS,
24
+ formatValue,
25
+ parseValue,
26
+ type FieldDef,
27
+ type LanGatewaySettings,
28
+ } from '../config-fields.ts'
15
29
 
16
30
  /**
17
- * The official Settings → Plugins page declares the `settings.plugin.item`
18
- * slot keyed by the settings namespace each card edits (newer DSH releases;
19
- * older releases dispatched it as a list slot by `id`). The published package
20
- * ships no `src/`, so the entry is re-declared here — the runtime slot is
21
- * real; this only restores the compile-time table.
31
+ * Props the renderer binds for this card. The Plugins page asks for either the
32
+ * card's one-liner (`summary`) or the body of its own page (`page`), and draws
33
+ * the page's title, icon, and crumb itself. The card needs no injected face —
34
+ * it fetches its own route.
22
35
  */
23
- declare module '@deepseek-ai/dsh-client-ui-slots' {
24
- interface SlotMap {
25
- /** One plugin's card inside the plugin configuration section. */
26
- 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: { children?: never } }
27
- }
28
- }
29
-
30
- /** Props the renderer binds for this card (unused — the card is self-loading). */
31
- export type LanGatewayCardProps = PropsRuntime<'settings.plugin.item'>
36
+ export type LanGatewayCardProps = PropsRuntime<'plugins.item'>
32
37
 
33
- /** The wire shape of the `lan-gateway` config section. */
34
- export interface LanGatewaySettings {
35
- enabled?: boolean
36
- gatewayPort?: number
37
- dshTargetPort?: number
38
- lanCidrs?: string[]
39
- lanPasswordless?: boolean
40
- cookieMaxAgeDays?: number
41
- tlsEnabled?: boolean
42
- tlsMode?: 'self-signed' | 'custom'
43
- tlsCertPath?: string
44
- tlsKeyPath?: string
45
- tlsSelfSignedHosts?: string
46
- tlsCertMaxAgeDays?: number
47
- allowInsecurePlaintext?: boolean
48
- trustedTerminator?: string
49
- secureCookies?: boolean
50
- }
38
+ /**
39
+ * The card's field table and value codecs live in `config-fields.ts`, shared
40
+ * with the host: the host's config route decides which submitted keys are
41
+ * editable and which empty value means "clear", and a table duplicated here
42
+ * would let the two disagree about a field the card can render but the route
43
+ * would refuse. Re-exported so the existing tests keep their import path.
44
+ */
45
+ export { FIELDS, TRISTATE_OPTIONS, formatValue, parseValue }
46
+ export type { FieldDef, LanGatewaySettings }
51
47
 
52
48
  /** GET /lan-gateway/config response. */
53
49
  interface RouteState {
@@ -70,7 +66,6 @@ interface Labels {
70
66
  saving: string
71
67
  discard: string
72
68
  reset: string
73
- overridden: string
74
69
  readOnly: string
75
70
  saveFailed: string
76
71
  loadFailed: string
@@ -93,10 +88,9 @@ const LABELS: Record<'zh' | 'en', Labels> = {
93
88
  saving: '保存中…',
94
89
  discard: '放弃',
95
90
  reset: '重置',
96
- overridden: '已覆盖',
97
- readOnly: '网关设置当前不可用(读不到配置路由)。',
91
+ readOnly: '网关设置只能在宿主机本机打开 dsh web 时修改:配置路由仅监听回环地址,经网关远程访问的浏览器会被拒绝。远程请改用 lan_gateway 工具。',
98
92
  saveFailed: '保存未生效,请检查输入后重试。',
99
- loadFailed: '加载网关配置失败。',
93
+ loadFailed: '无法读取网关配置',
100
94
  emptyMeansClear: '留空 = 使用默认',
101
95
  running: '运行中',
102
96
  stopped: '已停止',
@@ -115,7 +109,7 @@ const LABELS: Record<'zh' | 'en', Labels> = {
115
109
  'field.allowInsecurePlaintext': '允许明文 HTTP',
116
110
  'hint.allowInsecurePlaintext': '危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输',
117
111
  'field.trustedTerminator': '受信 TLS 终止代理',
118
- 'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明',
112
+ 'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明。注意:登录限流以 TCP 源地址为键,代理之后所有浏览器共用一个额度(5 次/分钟)',
119
113
  'field.secureCookies': '会话 cookie 的 Secure 属性',
120
114
  'hint.secureCookies': '自动 = TLS 或已声明受信终止代理时加 Secure。受信代理只做明文鉴权、浏览器走 http 访问时须设为 false,否则浏览器拒收 Secure cookie,登录会无限弹回登录页',
121
115
  'opt.auto': '自动',
@@ -128,13 +122,13 @@ const LABELS: Record<'zh' | 'en', Labels> = {
128
122
  'field.tlsMode': '证书来源',
129
123
  'hint.tlsMode': 'self-signed = 自动生成自签名证书;custom = 使用自己的证书',
130
124
  'field.tlsSelfSignedHosts': '自签名证书域名/IP',
131
- 'hint.tlsSelfSignedHosts': '逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5',
125
+ 'hint.tlsSelfSignedHosts': '逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5。仅影响下次换发:已有证书沿用至到期,改动不会立刻生效',
132
126
  'field.tlsCertPath': '证书文件路径(custom)',
133
127
  'hint.tlsCertPath': 'PEM 格式证书(或证书链)的绝对路径',
134
128
  'field.tlsKeyPath': '私钥文件路径(custom)',
135
129
  'hint.tlsKeyPath': '与证书配套的 PEM 私钥绝对路径',
136
130
  'field.tlsCertMaxAgeDays': '自签名证书有效期(天)',
137
- 'hint.tlsCertMaxAgeDays': '默认 825(约 27 个月)',
131
+ 'hint.tlsCertMaxAgeDays': '默认 825(约 27 个月)。仅影响下次换发:已有证书沿用至到期',
138
132
  },
139
133
  en: {
140
134
  title: 'LAN Gateway',
@@ -144,10 +138,9 @@ const LABELS: Record<'zh' | 'en', Labels> = {
144
138
  saving: 'Saving…',
145
139
  discard: 'Discard',
146
140
  reset: 'Reset',
147
- overridden: 'overridden',
148
- readOnly: 'Gateway settings unavailable (config route unreachable).',
141
+ readOnly: 'Gateway settings can only be changed where dsh web runs locally: the config route listens on loopback only, so a browser reaching dsh through the gateway is refused. Use the lan_gateway tool remotely.',
149
142
  saveFailed: 'The save did not land — check the inputs and retry.',
150
- loadFailed: 'Failed to load gateway configuration.',
143
+ loadFailed: 'Cannot read the gateway configuration',
151
144
  emptyMeansClear: 'Empty = default',
152
145
  running: 'Running',
153
146
  stopped: 'Stopped',
@@ -166,7 +159,7 @@ const LABELS: Record<'zh' | 'en', Labels> = {
166
159
  'field.allowInsecurePlaintext': 'Allow plaintext HTTP',
167
160
  'hint.allowInsecurePlaintext': 'Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear',
168
161
  'field.trustedTerminator': 'Trusted TLS terminator',
169
- 'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared',
162
+ 'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared. Note: login rate limiting keys on the TCP source address, so behind a proxy every browser shares one budget (5/min)',
170
163
  'field.secureCookies': 'Session cookie Secure attribute',
171
164
  'hint.secureCookies': 'Auto = Secure when TLS or a trusted terminator is declared. Set false when the trusted proxy only authenticates over plaintext and browsers reach it over http — otherwise browsers drop the Secure cookie and every login bounces back to the login page',
172
165
  'opt.auto': 'Auto',
@@ -179,13 +172,13 @@ const LABELS: Record<'zh' | 'en', Labels> = {
179
172
  'field.tlsMode': 'Certificate source',
180
173
  'hint.tlsMode': 'self-signed = auto-generated certificate; custom = your own files',
181
174
  'field.tlsSelfSignedHosts': 'Self-signed hosts (SANs)',
182
- 'hint.tlsSelfSignedHosts': 'Comma separated DNS/IP names, e.g. localhost, 192.168.1.5',
175
+ 'hint.tlsSelfSignedHosts': 'Comma separated DNS/IP names, e.g. localhost, 192.168.1.5. Applies to the next issuance only: an existing certificate is reused until it expires',
183
176
  'field.tlsCertPath': 'Certificate path (custom)',
184
177
  'hint.tlsCertPath': 'Absolute path to a PEM certificate (or chain)',
185
178
  'field.tlsKeyPath': 'Private key path (custom)',
186
179
  'hint.tlsKeyPath': 'Absolute path to the matching PEM private key',
187
180
  'field.tlsCertMaxAgeDays': 'Self-signed validity (days)',
188
- 'hint.tlsCertMaxAgeDays': 'Default 825 (about 27 months)',
181
+ 'hint.tlsCertMaxAgeDays': 'Default 825 (about 27 months). Applies to the next issuance only: an existing certificate is reused until it expires',
189
182
  },
190
183
  }
191
184
 
@@ -194,91 +187,13 @@ function labels(): Labels {
194
187
  return lang.startsWith('zh') ? LABELS.zh : LABELS.en
195
188
  }
196
189
 
197
- /* ------------------------------------------------------------------ */
198
- /* Field model */
199
- /* ------------------------------------------------------------------ */
200
-
201
- type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
202
-
203
- interface FieldDef {
204
- field: keyof LanGatewaySettings
205
- kind: FieldKind
206
- optional?: boolean
207
- options?: readonly string[]
208
- }
209
-
210
190
  /**
211
- * The card's field table and its two value codecs are exported for tests: the
212
- * tri-state codec is the load-bearing part of the settings round-trip (an
213
- * unset value must stay distinguishable from an explicit false, or the
214
- * plaintext-proxy escape hatch silently reverts).
191
+ * The card's title in the browser's language, for the Plugins page's list
192
+ * entry. A thunk so the label follows the page's locale without re-registering.
193
+ * @returns the localized card title.
215
194
  */
216
- export { TRISTATE_OPTIONS, FIELDS, formatValue, parseValue }
217
- export type { FieldDef, Write }
218
-
219
- /** The three states of a tri-state field, in display order. */
220
- const TRISTATE_OPTIONS = ['auto', 'true', 'false'] as const
221
-
222
- const FIELDS: readonly FieldDef[] = [
223
- { field: 'enabled', kind: 'boolean' },
224
- { field: 'gatewayPort', kind: 'number' },
225
- { field: 'dshTargetPort', kind: 'number', optional: true },
226
- { field: 'lanCidrs', kind: 'cidrs' },
227
- { field: 'lanPasswordless', kind: 'boolean' },
228
- { field: 'cookieMaxAgeDays', kind: 'number' },
229
- { field: 'tlsEnabled', kind: 'boolean' },
230
- { field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
231
- { field: 'tlsSelfSignedHosts', kind: 'text' },
232
- { field: 'tlsCertPath', kind: 'text', optional: true },
233
- { field: 'tlsKeyPath', kind: 'text', optional: true },
234
- { field: 'tlsCertMaxAgeDays', kind: 'number' },
235
- { field: 'allowInsecurePlaintext', kind: 'boolean' },
236
- { field: 'trustedTerminator', kind: 'text', optional: true },
237
- { field: 'secureCookies', kind: 'tristate' },
238
- ]
239
-
240
- function formatValue(def: FieldDef, value: unknown): string {
241
- switch (def.kind) {
242
- case 'boolean': return value === true ? 'true' : 'false'
243
- case 'number': return typeof value === 'number' ? String(value) : ''
244
- case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
245
- case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
246
- // Tri-state: an unset value is a distinct third state ("auto"), never "false".
247
- case 'tristate': return value === true ? 'true' : value === false ? 'false' : 'auto'
248
- case 'text': return typeof value === 'string' ? value : ''
249
- }
250
- }
251
-
252
- type Write = { kind: 'set'; value: unknown } | { kind: 'clear' }
253
-
254
- /** Parse draft text into a value for the POST body; undefined blocks saving. */
255
- function parseValue(def: FieldDef, text: string): Write | undefined {
256
- const trimmed = text.trim()
257
- switch (def.kind) {
258
- case 'boolean':
259
- if (trimmed === 'true') return { kind: 'set', value: true }
260
- if (trimmed === 'false') return { kind: 'set', value: false }
261
- return undefined
262
- case 'number':
263
- if (trimmed === '') return def.optional ? { kind: 'clear' } : undefined
264
- if (!/^\d+$/.test(trimmed)) return undefined
265
- return { kind: 'set', value: Number(trimmed) }
266
- case 'cidrs': {
267
- const cidrs = trimmed.split(',').map(s => s.trim()).filter(s => s !== '')
268
- return cidrs.length === 0 ? { kind: 'clear' } : { kind: 'set', value: cidrs }
269
- }
270
- case 'select':
271
- return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
272
- case 'tristate':
273
- // 'auto' clears the key so it re-inherits the composition layer (and the
274
- // resolution rule), which is what an unset tri-state means.
275
- if (trimmed === 'auto') return { kind: 'clear' }
276
- if (trimmed === 'true') return { kind: 'set', value: true }
277
- if (trimmed === 'false') return { kind: 'set', value: false }
278
- return undefined
279
- case 'text':
280
- return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
281
- }
195
+ export function cardTitle(): string {
196
+ return labels().title
282
197
  }
283
198
 
284
199
  /* ------------------------------------------------------------------ */
@@ -288,10 +203,14 @@ function parseValue(def: FieldDef, text: string): Write | undefined {
288
203
  /**
289
204
  * Render the LAN gateway card. Self-loading: fetches the config route on
290
205
  * mount, posts the edited config on save.
291
- * @param _props - unused; the card needs no injected face.
292
- * @returns the card, or nothing while the route is unreachable.
206
+ *
207
+ * `view` swaps between the card's one-liner and its page body, so the branch
208
+ * sits after the hooks: the Plugins page re-renders one contribution under the
209
+ * other view when the card is opened.
210
+ * @param props - the view the Plugins page is asking for.
211
+ * @returns the one-liner, the card, or nothing while the route is unreachable.
293
212
  */
294
- export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
213
+ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
295
214
  const t = labels()
296
215
  const [open, setOpen] = useState(false)
297
216
  const [route, setRoute] = useState<RouteState | null>(null)
@@ -314,7 +233,32 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
314
233
  return () => { cancelled = true }
315
234
  }, [])
316
235
 
317
- if (loadFailed) return null
236
+ // The Plugins page lists this plugin as one card and opens its own page on
237
+ // demand: `summary` is the one-liner the list shows, `page` the body. The
238
+ // hooks above run for both views, because the same contribution flips
239
+ // between them.
240
+ if (props.view === 'summary') return t.description
241
+
242
+ // A remote browser reaches this card through the gateway, which answers 403
243
+ // for the plugin's own prefix by design, so the route is unreachable exactly
244
+ // where a user is most likely to go looking for the setting. Rendering
245
+ // nothing left them with a blank entry and no way to tell a missing card from
246
+ // a broken one; say what is wrong and where the card does work instead.
247
+ if (loadFailed) {
248
+ return (
249
+ <div style={styles.card}>
250
+ <div style={styles.header}>
251
+ <span style={styles.headerTop}>
252
+ <span style={styles.name}>{t.title}</span>
253
+ </span>
254
+ <span style={styles.description}>{t.loadFailed}</span>
255
+ </div>
256
+ <div style={styles.body}>
257
+ <p style={styles.hint}>{t.readOnly}</p>
258
+ </div>
259
+ </div>
260
+ )
261
+ }
318
262
  if (route === null) return null
319
263
 
320
264
  const { config } = route
@@ -352,18 +296,22 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
352
296
  setSaving(true)
353
297
  setFailed(null)
354
298
  try {
355
- // Build the next full config: the loaded one with drafts applied.
356
- const next: Record<string, unknown> = {}
357
- for (const def of FIELDS) {
358
- const text = drafts[def.field] ?? formatValue(def, config[def.field])
359
- const write = parseValue(def, text)
299
+ // A patch of the edited fields only, never the whole config: the card
300
+ // cannot express every key the section may hold (a custom `cookieName`,
301
+ // say), and posting a synthesized full config made the route treat those
302
+ // keys as submitted — resetting each one to its schema default.
303
+ const patch: Record<string, unknown> = {}
304
+ for (const [field, text] of Object.entries(drafts)) {
305
+ const def = FIELDS.find(f => f.field === field)
306
+ if (def === undefined) continue
307
+ const write = parseValue(def, text ?? '')
360
308
  if (write === undefined) continue
361
- next[def.field] = write.kind === 'clear' ? null : write.value
309
+ patch[field] = write.kind === 'clear' ? null : write.value
362
310
  }
363
311
  const response = await fetch('/lan-gateway/config', {
364
312
  method: 'POST',
365
313
  headers: { 'content-type': 'application/json' },
366
- body: JSON.stringify(next),
314
+ body: JSON.stringify(patch),
367
315
  })
368
316
  const body = await response.json().catch(() => ({})) as Partial<RouteState> & { error?: string }
369
317
  if (!response.ok) {
@@ -473,7 +421,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
473
421
  const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
474
422
 
475
423
  return (
476
- <li style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
424
+ <div style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
477
425
  <button
478
426
  type="button"
479
427
  style={styles.header}
@@ -515,7 +463,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
515
463
  </div>
516
464
  )
517
465
  : null}
518
- </li>
466
+ </div>
519
467
  )
520
468
  }
521
469