@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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <img src="https://img.shields.io/badge/DeepSeek%20Harness-4d6bfe?logo=deepseek&logoColor=fff&style=flat-square" alt="DeepSeek Harness" />
9
- <img src="https://img.shields.io/badge/version-0.5.5-2b7fff?style=flat-square" alt="version 0.5.5" />
9
+ <img src="https://img.shields.io/badge/version-0.6.1-2b7fff?style=flat-square" alt="version 0.6.1" />
10
10
  <img src="https://img.shields.io/badge/TLS-8b5cf6?logo=lock&logoColor=fff&style=flat-square" alt="TLS" />
11
11
  <img src="https://img.shields.io/github/license/rice-awa/dsh-lan-gateway?style=flat-square" alt="MIT license" />
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
@@ -14,7 +14,7 @@
14
14
 
15
15
  `dsh web` 明确拒绝 `--host 0.0.0.0`,以免把远程代码执行暴露到网络。本插件的做法是让 dsh 继续只绑 `127.0.0.1`,另起一个反向代理监听未指定地址(双栈,IPv4 与 IPv6 客户端都可接入),转发到 loopback 端口并改写 `Host` / `Origin`。
16
16
 
17
- 默认拒绝:loopback、LAN、公网三种来源都要先在网关登录页取得 HMAC 会话 cookie,LAN 免密需要显式打开 `lanPasswordless`,默认关闭。底座为 dsh ≥ 0.1.2-rc.1 时(含 QVD-2026-57410 的上游修复),网关在进程内中继一条共享上游会话,上游自身的授权仍然把关每个请求,网关只决定谁可以使用这条会话。
17
+ 默认拒绝:loopback、LAN、公网三种来源都要先在网关登录页取得 HMAC 会话 cookie,LAN 免密需要显式打开 `lanPasswordless`,默认关闭。底座要求 dsh ≥ 0.1.7(本插件按该版本的 settings API 写入配置;含 QVD-2026-57410 的上游修复),网关在进程内中继一条共享上游会话,上游自身的授权仍然把关每个请求,网关只决定谁可以使用这条会话。
18
18
 
19
19
  插件另外提供两项功能:
20
20
 
@@ -44,10 +44,10 @@ cd dsh-lan-gateway
44
44
  pnpm install
45
45
  pnpm build # host → lib/index.js
46
46
  pnpm build:client # client → lib/client.js
47
- pnpm test # 117 项
47
+ pnpm test # 204 项
48
48
  ```
49
49
 
50
- 仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,安装后可直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写入配置,也不回显。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
50
+ 仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,安装后可直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写入配置,也不回显。改密码也可以不走模型:在本机打开 **Plugins** 页的「LAN 网关」卡片,直接填两次新密码覆盖(见下「登录密码」)。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
51
51
 
52
52
  如需在手机 / 平板上访问,可另外安装 [dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile) 做移动端 UI 适配:
53
53
 
@@ -66,11 +66,11 @@ lan_gateway tls-regenerate # 换发自签名证书(tlsMode=self-signed 时
66
66
  lan_gateway disable # 关闭
67
67
  ```
68
68
 
69
- `lan_gateway` 是模型可调用的工具,上述命令无需手动执行。直接在对话里说「查看网关状态」「设置网关密码为 ……」即可,密码以参数传给模型,不会写入任何配置文件。
69
+ `lan_gateway` 是模型可调用的工具,上述命令无需手动执行。直接在对话里说「查看网关状态」「设置网关密码为 ……」即可,密码以参数传给模型,不会写入任何配置文件。改密码也可以完全不经模型:在本机 Plugins 页的插件卡片里直接改(见下「登录密码」)。
70
70
 
71
71
  ## 配置
72
72
 
73
- 所有可调项都暴露为 `lan-gateway` 用户设置命名空间。打开 **DSH 的 Settings → Plugins → 可配置插件**,展开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。下表既是卡片字段,也是配置键:
73
+ 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页,在官方插件列表里点开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。卡片挂在 Plugins 页的 `plugins.item` 槽上,且只在底座确实 served 本插件条目时注册——没有 Loader 条目(写入必然 409)时卡片不会出现。下表既是卡片字段,也是配置键;卡片顶部另有一栏「登录密码」,改密码不走配置表(见下「登录密码」):
74
74
 
75
75
  | 键 | 默认值 | 说明 |
76
76
  | --- | --- | --- |
@@ -95,6 +95,16 @@ lan_gateway disable # 关闭
95
95
 
96
96
  配置里残留 `authRequired: false`(v0.4 及更早的写法)会被启停守卫拒绝并提示迁移,不会静默降级成免密。
97
97
 
98
+ ### 登录密码
99
+
100
+ 卡片顶部就是「登录密码」栏:一个「已设置 / 未设置」状态,两个密码框(新密码、再次输入),点「修改密码」直接覆盖原密码。
101
+
102
+ - **不回显旧密码。** 界面只报告密码是否存在;卡片读不到旧密码。`state.json` 里只有 scrypt 哈希与盐,`/lan-gateway/password` 也从不把哈希、盐或长度返回给浏览器。
103
+ - **不需要旧密码。** 该路由与配置路由共用同一道围栏:只接受本机 loopback 的同源请求(Host 必须是回环、跨站请求拒绝、写操作必须带匹配的 `Origin`),经网关远程访问的浏览器一律 403。能通过这道门的本地用户本来就能读 `~/.dsh`。
104
+ - **改完即生效。** 写盘后递增会话代次:旧密码立即失效,所有已登录会话与已建立的 WebSocket 全部作废,各来源需要重新登录。
105
+ - **只设置,不清空。** 清空密码会按设计停掉监听器(没有密码不允许监听),卡片不做这件事;要清空请用 `lan_gateway set-password` 并留空密码。
106
+ - **密码不是配置键。** 它不写进 profile 条目,也不会出现在 `--dump-config` 里;保存配置字段动不到它,改密码也不会碰你尚未保存的字段草稿。
107
+
98
108
  ### 入口加密
99
109
 
100
110
  网关默认拒绝纯明文监听,以下三种方式任选其一方可启动:
@@ -169,8 +179,8 @@ lan_gateway disable # 关闭
169
179
  ## 安全模型
170
180
 
171
181
  - **来源分级只认 `socket.remoteAddress`**(IPv4-mapped IPv6 会先解包),分 loopback / lan / internet 三档,绝不信任 `X-Forwarded-For`。分级本身不授予任何访问,每一档默认都要出示有效网关会话,否则 302 到 `/__login`。
172
- - **LAN 免密是显式 opt-in**。`lanPasswordless: true` 只让命中 `lanCidrs` 或 loopback 的来源跳过网关自己的登录页;底座 ≥ 0.1.2-rc.1 时上游会话仍把关每个请求。底座没有浏览器会话认证时这个开关拒绝启用,否则等同于把 QVD-2026-57410 原样恢复。
173
- - **共享上游会话中继**(dsh ≥ 0.1.2-rc.1)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 = 单操作者」:通过网关登录的用户共用同一条上游会话,持钥的上游才是真正的授权主体。
182
+ - **LAN 免密是显式 opt-in**。`lanPasswordless: true` 只让命中 `lanCidrs` 或 loopback 的来源跳过网关自己的登录页;受支持的底座(dsh ≥ 0.1.7)上游会话仍把关每个请求。底座没有浏览器会话认证时这个开关拒绝启用,否则等同于把 QVD-2026-57410 原样恢复。
183
+ - **共享上游会话中继**(dsh ≥ 0.1.7)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 = 单操作者」:通过网关登录的用户共用同一条上游会话,持钥的上游才是真正的授权主体。
174
184
  - **登录页**。`/__login` 由网关独占、不转发。密码以 scrypt 校验,每写一次重新加盐;登录尝试按来源限流(5 次 / 分钟)。
175
185
  - **会话 cookie** 是 `payload.signature` 结构(HMAC-SHA256),带撤销 epoch 与逐会话 id,`HttpOnly; SameSite=Strict`。登出撤销的是当前这条会话:它建立的 WebSocket 一并断开,其他设备不受影响。改密、清密、`rotate-secret` 递增 epoch,作废全部已签发 cookie 并断开全部已建立的 WebSocket,客户端需要重新登录。清空密码会直接停止监听。
176
186
  - **管理面不外泄**。`/lan-gateway/*`(含配置路由)由网关独占、一律 403 不转发,远程访问者无法借网关改写 Host 触及本机 loopback 的配置接口。原生 `/lan-gateway/config` 只应答回环 Host 且同源的请求。远程管理走 `lan_gateway` 工具。
@@ -194,22 +204,29 @@ client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomVa
194
204
  ## 开发
195
205
 
196
206
  ```bash
197
- pnpm test # 117 项
207
+ pnpm test # 204 项
198
208
  pnpm typecheck # tsc 双端(host + client)
199
209
  ```
200
210
 
201
211
  ```
202
- ✓ tests/gateway.test.ts (40) 分类 / HMAC cookie / epoch / 逐会话撤销 / 密码状态 / 限流
203
- ✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
204
- Secure cookie 属性推断(含 null 清除路径)
205
- ✓ tests/integration/gateway.test.ts (26) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
206
- cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继
207
- ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
208
- ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
209
- ✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
210
- ✓ tests/upstream-session.test.ts ( 8) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
211
- invalidate 重获取 / 日志播报 / 保住已持有会话
212
- ✓ tests/settings-card.test.ts ( 6) 设置页字段编解码:三态 auto ↔ false 不可混淆
212
+ ✓ tests/gateway.test.ts (40) 分类 / HMAC cookie / epoch / 逐会话撤销 / 密码状态 / 限流
213
+ ✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
214
+ Secure cookie 属性推断(含 null 清除路径)
215
+ ✓ tests/request-policy.test.ts (46) 判定缝纯函数:路径归一化 / 归属前缀 / 同站与登录围栏 /
216
+ 两个方向的头部变换
217
+ ✓ tests/upstream-session.test.ts ( 8) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
218
+ invalidate 重获取 / 日志播报 / 保住已持有会话
219
+ ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
220
+ ✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
221
+ ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
222
+ ✓ tests/settings-card.test.ts (15) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
223
+ 卡片注册槽与条目 id 契约 / 密码草稿闸门(长度与确认)
224
+ ✓ tests/integration/gateway.test.ts (26) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
225
+ cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继
226
+ ✓ tests/integration/management-plane.test.ts (24) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
227
+ 清空后继承 / 拒绝不可启动配置 / 卡片改密路由(覆盖原密码 /
228
+ 递增代次 / 不返回哈希 / 不给清空 / 无 settings 也生效)
229
+ ✓ tests/integration/session-races.test.ts ( 8) 改密落在登录与握手途中的竞态 / 上游非 101 应答
213
230
  ```
214
231
 
215
232
  ### 发布
@@ -233,8 +250,8 @@ pnpm typecheck # tsc 双端(host + client)
233
250
  pnpm install --frozen-lockfile
234
251
  pnpm typecheck && pnpm test
235
252
  npm publish --access public # prepack 自动构建 lib/
236
- git tag -a v0.5.5 -m "…" && git push origin v0.5.5
237
- gh release create v0.5.5 --generate-notes ./*.tgz # 可选:Release + tgz 附件
253
+ git tag -a v0.6.0 -m "…" && git push origin v0.6.0
254
+ gh release create v0.6.0 --generate-notes ./*.tgz # 可选:Release + tgz 附件
238
255
  ```
239
256
 
240
257
  ## 安全评估
@@ -246,6 +263,10 @@ gh release create v0.5.5 --generate-notes ./*.tgz # 可选:Release + tgz 附
246
263
  - [上游研究](docs/security/qvd-2026-57410-research.md):公开通告、上游提交与版本核对。
247
264
  - [0.5.4 复审与修复清单](docs/security/audit-2026-09-19-fix-list.md):0.5.3 的实现细节复审(G1–G12)与修复记录。
248
265
 
266
+ 架构层面的复审另见 [docs/review/](docs/review/):
267
+
268
+ - [架构复审(2026-09-19)](docs/review/architecture-review-2026-09-19.md):针对 0.5.4 的 C1–C6 结构候选与 D1–D14 缺陷清单,0.5.5 已全部关闭。文档记录的是复审当时的状态,不是待办列表。
269
+
249
270
  ## 许可
250
271
 
251
272
  [MIT](./LICENSE)
package/lib/client.js CHANGED
@@ -151,16 +151,31 @@ window.__ModuleLoader__.load({
151
151
  //#endregion
152
152
  //#region src/client/lan-gateway-card.tsx
153
153
  /**
154
- * The lan-gateway settings card shown in the official DSH Settings → Plugins
155
- * page (the `settings.plugin.item` slot).
154
+ * The lan-gateway settings card, rendered by the official DSH Plugins page
155
+ * through its `plugins.item` slot.
156
156
  *
157
157
  * ModLens-style: the card carries NO injected services. It reads and writes
158
158
  * the loopback-only `/lan-gateway/config` host route (the browser never sees
159
- * the settings seam or any secret), so the client bundle's only dependency is
160
- * the `slots` service that every plugin already has.
159
+ * the settings seam or any secret), so the only platform service it needs is
160
+ * the `slots` service every plugin already has.
161
161
  *
162
162
  * @module @riceawa/dsh-lan-gateway/client/card
163
163
  */
164
+ /**
165
+ * Judge a password draft the way the host's password route will, so a draft the
166
+ * card enables is never answered with a 400. The length bound is the shared
167
+ * {@link MIN_PASSWORD_LENGTH}; the confirmation is a UI concern and is checked
168
+ * here rather than server-side (the host is told one password, and storing only
169
+ * what was typed twice is the browser's job).
170
+ * @param password - the new password draft.
171
+ * @param confirm - the confirmation draft.
172
+ * @returns the reason it cannot be submitted, or `null` when it can.
173
+ */
174
+ function passwordProblem(password, confirm) {
175
+ if (password.length < 8) return "tooShort";
176
+ if (password !== confirm) return "mismatch";
177
+ return null;
178
+ }
164
179
  const LABELS = {
165
180
  zh: {
166
181
  title: "LAN 网关",
@@ -178,6 +193,19 @@ window.__ModuleLoader__.load({
178
193
  stopped: "已停止",
179
194
  tls: "TLS",
180
195
  lastError: "上次错误",
196
+ passwordSection: "登录密码",
197
+ passwordHint: "当前密码不会显示(也读不出来)。输入两次新密码后直接覆盖原密码;改密会递增会话代次,所有已登录会话与已建立的 WebSocket 立即失效。仅在从本机 loopback 打开 dsh web 时可改。",
198
+ passwordNew: "新密码(至少 8 位)",
199
+ passwordConfirm: "再次输入新密码",
200
+ passwordSet: "已设置",
201
+ passwordUnset: "未设置",
202
+ passwordRequired: "未设置密码时网关拒绝启动。",
203
+ passwordChange: "修改密码",
204
+ passwordChanged: "密码已更新:旧密码立即失效,所有已登录会话已作废。",
205
+ passwordFailed: "修改密码失败,请重试。",
206
+ passwordStaleHost: "宿主端没有响应这个接口:可能还没重启 dsh web 加载新版本。重启后再试;重启前仍可用 lan_gateway 工具改密。",
207
+ passwordTooShort: "密码至少 8 位。",
208
+ passwordMismatch: "两次输入不一致。",
181
209
  "field.enabled": "启用网关",
182
210
  "hint.enabled": "启动时监听 0.0.0.0 网关端口",
183
211
  "field.gatewayPort": "网关端口",
@@ -228,6 +256,19 @@ window.__ModuleLoader__.load({
228
256
  stopped: "Stopped",
229
257
  tls: "TLS",
230
258
  lastError: "Last error",
259
+ passwordSection: "Login password",
260
+ 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.",
261
+ passwordNew: "New password (min 8 chars)",
262
+ passwordConfirm: "Repeat new password",
263
+ passwordSet: "Set",
264
+ passwordUnset: "Not set",
265
+ passwordRequired: "The gateway refuses to start without a password.",
266
+ passwordChange: "Change password",
267
+ passwordChanged: "Password updated: the old one no longer works and every signed-in session was revoked.",
268
+ passwordFailed: "The password change failed — retry.",
269
+ 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.",
270
+ passwordTooShort: "At least 8 characters.",
271
+ passwordMismatch: "The two entries do not match.",
231
272
  "field.enabled": "Enable gateway",
232
273
  "hint.enabled": "Listen on the gateway port at boot",
233
274
  "field.gatewayPort": "Gateway port",
@@ -267,12 +308,24 @@ window.__ModuleLoader__.load({
267
308
  return (typeof navigator !== "undefined" ? navigator.language : "en").toLowerCase().startsWith("zh") ? LABELS.zh : LABELS.en;
268
309
  }
269
310
  /**
311
+ * The card's title in the browser's language, for the Plugins page's list
312
+ * entry. A thunk so the label follows the page's locale without re-registering.
313
+ * @returns the localized card title.
314
+ */
315
+ function cardTitle() {
316
+ return labels().title;
317
+ }
318
+ /**
270
319
  * Render the LAN gateway card. Self-loading: fetches the config route on
271
320
  * mount, posts the edited config on save.
272
- * @param _props - unused; the card needs no injected face.
273
- * @returns the card, or nothing while the route is unreachable.
321
+ *
322
+ * `view` swaps between the card's one-liner and its page body, so the branch
323
+ * sits after the hooks: the Plugins page re-renders one contribution under the
324
+ * other view when the card is opened.
325
+ * @param props - the view the Plugins page is asking for.
326
+ * @returns the one-liner, the card, or nothing while the route is unreachable.
274
327
  */
275
- function LanGatewayCard(_props) {
328
+ function LanGatewayCard(props) {
276
329
  const t = labels();
277
330
  const [open, setOpen] = (0, react.useState)(false);
278
331
  const [route, setRoute] = (0, react.useState)(null);
@@ -280,6 +333,11 @@ window.__ModuleLoader__.load({
280
333
  const [drafts, setDrafts] = (0, react.useState)({});
281
334
  const [saving, setSaving] = (0, react.useState)(false);
282
335
  const [failed, setFailed] = (0, react.useState)(null);
336
+ const [password, setPasswordDraft] = (0, react.useState)("");
337
+ const [passwordConfirm, setPasswordConfirmDraft] = (0, react.useState)("");
338
+ const [passwordBusy, setPasswordBusy] = (0, react.useState)(false);
339
+ const [passwordError, setPasswordError] = (0, react.useState)(null);
340
+ const [passwordNotice, setPasswordNotice] = (0, react.useState)(null);
283
341
  (0, react.useEffect)(() => {
284
342
  let cancelled = false;
285
343
  fetch("/lan-gateway/config").then(async (response) => {
@@ -293,7 +351,8 @@ window.__ModuleLoader__.load({
293
351
  cancelled = true;
294
352
  };
295
353
  }, []);
296
- if (loadFailed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
354
+ if (props.view === "summary") return t.description;
355
+ if (loadFailed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
297
356
  style: styles.card,
298
357
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
299
358
  style: styles.header,
@@ -341,6 +400,56 @@ window.__ModuleLoader__.load({
341
400
  return def === void 0 || parseValue(def, text ?? "") === void 0;
342
401
  });
343
402
  const dirty = Object.keys(drafts).length > 0;
403
+ const passwordDraftProblem = passwordProblem(password, passwordConfirm);
404
+ const passwordTyping = password !== "" || passwordConfirm !== "";
405
+ /** Adopt the host's post-write snapshot, keeping the last known values. */
406
+ const adopt = (body) => {
407
+ if (body.config === void 0) return;
408
+ setRoute({
409
+ config: body.config,
410
+ running: body.running ?? false,
411
+ port: body.port ?? 0,
412
+ tls: body.tls ?? "",
413
+ lastError: body.lastError ?? null,
414
+ passwordSet: body.passwordSet === true
415
+ });
416
+ };
417
+ /**
418
+ * Overwrite the login password. Deliberately writes only to
419
+ * `/lan-gateway/password`: the credential is not part of the config patch, so
420
+ * a change here can never disturb an unsaved settings draft, and the old
421
+ * password is neither sent nor requested.
422
+ */
423
+ const changePassword = async () => {
424
+ if (passwordBusy || passwordProblem(password, passwordConfirm) !== null) return;
425
+ setPasswordBusy(true);
426
+ setPasswordError(null);
427
+ setPasswordNotice(null);
428
+ try {
429
+ const response = await fetch("/lan-gateway/password", {
430
+ method: "POST",
431
+ headers: { "content-type": "application/json" },
432
+ body: JSON.stringify({ password })
433
+ });
434
+ const body = await response.json().catch(() => null);
435
+ if (!response.ok) {
436
+ setPasswordError(body?.error ?? (response.status === 404 ? t.passwordStaleHost : `HTTP ${response.status}`));
437
+ return;
438
+ }
439
+ if (body === null) {
440
+ setPasswordError(t.passwordStaleHost);
441
+ return;
442
+ }
443
+ setPasswordDraft("");
444
+ setPasswordConfirmDraft("");
445
+ setPasswordNotice(t.passwordChanged);
446
+ adopt(body);
447
+ } catch {
448
+ setPasswordError(t.passwordFailed);
449
+ } finally {
450
+ setPasswordBusy(false);
451
+ }
452
+ };
344
453
  const save = async () => {
345
454
  if (!dirty || saving || invalid()) return;
346
455
  setSaving(true);
@@ -364,13 +473,7 @@ window.__ModuleLoader__.load({
364
473
  setFailed(body.error ?? `HTTP ${response.status}`);
365
474
  return;
366
475
  }
367
- if (body.config !== void 0) setRoute({
368
- config: body.config,
369
- running: body.running ?? false,
370
- port: body.port ?? 0,
371
- tls: body.tls ?? "",
372
- lastError: body.lastError ?? null
373
- });
476
+ adopt(body);
374
477
  setDrafts({});
375
478
  } catch {
376
479
  setFailed(t.saveFailed);
@@ -474,7 +577,7 @@ window.__ModuleLoader__.load({
474
577
  }
475
578
  };
476
579
  const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`;
477
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
580
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
478
581
  style: open ? {
479
582
  ...styles.card,
480
583
  ...styles.cardOpen
@@ -526,6 +629,85 @@ window.__ModuleLoader__.load({
526
629
  route.lastError
527
630
  ]
528
631
  }) : null,
632
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
633
+ style: styles.section,
634
+ children: [
635
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
636
+ style: styles.sectionHead,
637
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
638
+ style: styles.label,
639
+ children: t.passwordSection
640
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
641
+ style: route.passwordSet === true ? styles.badge : styles.badgeAlert,
642
+ children: route.passwordSet === true ? t.passwordSet : t.passwordUnset
643
+ })]
644
+ }),
645
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
646
+ style: styles.hint,
647
+ children: t.passwordHint
648
+ }),
649
+ route.passwordSet === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
650
+ style: styles.error,
651
+ children: t.passwordRequired
652
+ }),
653
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
654
+ style: styles.passwordRow,
655
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
656
+ id: "lan-gw-password",
657
+ type: "password",
658
+ autoComplete: "new-password",
659
+ "aria-label": t.passwordNew,
660
+ style: passwordInput,
661
+ placeholder: t.passwordNew,
662
+ value: password,
663
+ disabled: passwordBusy,
664
+ onChange: (e) => {
665
+ setPasswordDraft(e.target.value);
666
+ setPasswordError(null);
667
+ setPasswordNotice(null);
668
+ }
669
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
670
+ id: "lan-gw-password-confirm",
671
+ type: "password",
672
+ autoComplete: "new-password",
673
+ "aria-label": t.passwordConfirm,
674
+ style: passwordInput,
675
+ placeholder: t.passwordConfirm,
676
+ value: passwordConfirm,
677
+ disabled: passwordBusy,
678
+ onChange: (e) => {
679
+ setPasswordConfirmDraft(e.target.value);
680
+ setPasswordError(null);
681
+ setPasswordNotice(null);
682
+ }
683
+ })]
684
+ }),
685
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
686
+ style: styles.passwordFoot,
687
+ children: [passwordError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
688
+ style: styles.error,
689
+ role: "alert",
690
+ children: passwordError
691
+ }) : passwordNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
692
+ style: styles.notice,
693
+ role: "status",
694
+ children: passwordNotice
695
+ }) : passwordTyping && passwordDraftProblem !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
696
+ style: styles.error,
697
+ role: "status",
698
+ children: passwordDraftProblem === "tooShort" ? t.passwordTooShort : t.passwordMismatch
699
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
700
+ type: "button",
701
+ style: styles.save,
702
+ disabled: passwordBusy || passwordDraftProblem !== null,
703
+ onClick: () => {
704
+ changePassword();
705
+ },
706
+ children: passwordBusy ? t.saving : t.passwordChange
707
+ })]
708
+ })
709
+ ]
710
+ }),
529
711
  FIELDS.map((def) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: renderControl(def) }, def.field)),
530
712
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
531
713
  style: styles.footer,
@@ -659,6 +841,58 @@ window.__ModuleLoader__.load({
659
841
  display: "flex",
660
842
  flexDirection: "column"
661
843
  },
844
+ section: {
845
+ display: "flex",
846
+ flexDirection: "column",
847
+ gap: "8px",
848
+ padding: "14px 0",
849
+ borderBottom: `1px solid ${L.border}`
850
+ },
851
+ sectionHead: {
852
+ display: "flex",
853
+ alignItems: "center",
854
+ gap: "8px"
855
+ },
856
+ badge: {
857
+ borderRadius: "999px",
858
+ padding: "1px 8px",
859
+ fontSize: "11px",
860
+ lineHeight: "17px",
861
+ fontWeight: 500,
862
+ whiteSpace: "nowrap",
863
+ background: L.badgeBg,
864
+ color: L.labelSecondary
865
+ },
866
+ badgeAlert: {
867
+ borderRadius: "999px",
868
+ padding: "1px 8px",
869
+ fontSize: "11px",
870
+ lineHeight: "17px",
871
+ fontWeight: 500,
872
+ whiteSpace: "nowrap",
873
+ background: L.badgeBg,
874
+ color: L.error
875
+ },
876
+ notice: {
877
+ flex: 1,
878
+ minWidth: 0,
879
+ margin: 0,
880
+ fontSize: "12px",
881
+ lineHeight: 1.5,
882
+ color: L.labelSecondary
883
+ },
884
+ passwordRow: {
885
+ display: "flex",
886
+ flexWrap: "wrap",
887
+ gap: "8px"
888
+ },
889
+ passwordFoot: {
890
+ display: "flex",
891
+ alignItems: "center",
892
+ justifyContent: "flex-end",
893
+ gap: "8px",
894
+ marginTop: "2px"
895
+ },
662
896
  field: {
663
897
  display: "flex",
664
898
  flexDirection: "column",
@@ -746,6 +980,11 @@ window.__ModuleLoader__.load({
746
980
  cursor: "pointer"
747
981
  }
748
982
  };
983
+ /** The shared input style plus the growth two side-by-side password boxes need. */
984
+ const passwordInput = {
985
+ ...styles.input,
986
+ flex: "1 1 160px"
987
+ };
749
988
  //#endregion
750
989
  //#region src/client/index.ts
751
990
  /**
@@ -757,10 +996,9 @@ window.__ModuleLoader__.load({
757
996
  * installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
758
997
  * module scope. With TLS enabled the origin is secure and the shim is a
759
998
  * no-op.
760
- * 2. Settings card: registers the LAN gateway card into the official
761
- * Settings → Plugins page (`settings.plugin.item` slot), editing the
762
- * `lan-gateway` settings namespace so port, CIDRs, auth, and TLS are
763
- * adjustable from the GUI.
999
+ * 2. Settings card: registers the LAN gateway card into the official Plugins
1000
+ * page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
1001
+ * from the GUI.
764
1002
  */
765
1003
  /** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
766
1004
  function uuidFromRandomValues() {
@@ -803,25 +1041,25 @@ window.__ModuleLoader__.load({
803
1041
  }
804
1042
  installRandomUuidShim();
805
1043
  const name = "dsh-lan-gateway";
806
- /** Only the slots service: the card itself is self-loading (ModLens-style). */
807
- const inject = ["slots"];
1044
+ /**
1045
+ * The profile entry id this plugin's bundle patch composes it under, and the
1046
+ * settings namespace dsh ≥ 0.1.7 addresses every write by.
1047
+ */
1048
+ const ENTRY_ID = "dsh-lan-gateway";
1049
+ /** The slots service the card rides, and the settings mirror the gate reads. */
1050
+ const inject = ["slots", "configForms"];
808
1051
  /**
809
1052
  * Mount the settings card and the UUID shim.
810
1053
  * @param ctx - the browser plugin context.
811
1054
  */
812
1055
  function apply(ctx) {
813
1056
  installRandomUuidShim();
814
- const legacyListOptions = {
815
- id: "lan-gateway",
816
- order: 30
817
- };
818
- ctx.slots.inject("settings.plugin.item", function* () {
819
- yield ctx.slots.register({
820
- name: "settings.plugin.item",
821
- key: "lan-gateway",
822
- ...legacyListOptions
823
- }, LanGatewayCard);
824
- });
1057
+ ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () => ctx.slots.inject("plugins.item", () => ctx.slots.register({
1058
+ name: "plugins.item",
1059
+ id: ENTRY_ID,
1060
+ order: 30,
1061
+ label: () => cardTitle()
1062
+ }, LanGatewayCard))));
825
1063
  }
826
1064
  //#endregion
827
1065
  exports.apply = apply;