@riceawa/dsh-lan-gateway 0.6.2 → 0.7.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/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.6.2-2b7fff?style=flat-square" alt="version 0.6.2" />
9
+ <img src="https://img.shields.io/badge/version-0.7.0-2b7fff?style=flat-square" alt="version 0.7.0" />
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>
@@ -44,7 +44,7 @@ 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 # 206 项
47
+ pnpm test # 216 项
48
48
  ```
49
49
 
50
50
  仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,安装后可直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写入配置,也不回显。改密码也可以不走模型:在本机打开 **Plugins** 页的「LAN 网关」卡片,直接填两次新密码覆盖(见下「登录密码」)。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
@@ -70,7 +70,7 @@ lan_gateway disable # 关闭
70
70
 
71
71
  ## 配置
72
72
 
73
- 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页,在官方插件列表里点开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。卡片挂在 Plugins 页的 `plugins.item` 槽上,且只在底座确实 served 本插件条目时注册——没有 Loader 条目(写入必然 409)时卡片不会出现。下表既是卡片字段,也是配置键;卡片顶部另有一栏「登录密码」,改密码不走配置表(见下「登录密码」):
73
+ 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页 →「已安装」→ `@riceawa/dsh-lan-gateway`:本插件的配置就渲染在这个包自己的页面上——挂的是 dsh 给「bundle 自己的配置」声明的 `plugins.bundle.config` 槽(按**包名** `@riceawa/dsh-lan-gateway` 挂载,与 dsh-mnemon、dshmarket 等插件同一套机制),页面标题、图标与面包屑由 Plugins 页自己绘制。保存即生效,监听器会按新配置自动重启。宿主机上用 `127.0.0.1` / `localhost` 打开网关地址时该页面同样可用(网关只对宿主机本机浏览器放行它的管理前缀,见「安全模型」);用局域网 IP、域名或从别的机器访问时页面读不到配置,改用 `lan_gateway` 工具。注册只在底座确实 served 本插件条目时发生——没有 Loader 条目(写入必然 409)时页面不会出现。下表既是卡片字段,也是配置键;页面顶部另有一栏「登录密码」,改密码不走配置表(见下「登录密码」):
74
74
 
75
75
  | 键 | 默认值 | 说明 |
76
76
  | --- | --- | --- |
@@ -100,7 +100,7 @@ lan_gateway disable # 关闭
100
100
  卡片顶部就是「登录密码」栏:一个「已设置 / 未设置」状态,两个密码框(新密码、再次输入),点「修改密码」直接覆盖原密码。
101
101
 
102
102
  - **不回显旧密码。** 界面只报告密码是否存在;卡片读不到旧密码。`state.json` 里只有 scrypt 哈希与盐,`/lan-gateway/password` 也从不把哈希、盐或长度返回给浏览器。
103
- - **不需要旧密码。** 该路由与配置路由共用同一道围栏:只接受本机 loopback 的同源请求(Host 必须是回环、跨站请求拒绝、写操作必须带匹配的 `Origin`),经网关远程访问的浏览器一律 403。能通过这道门的本地用户本来就能读 `~/.dsh`。
103
+ - **不需要旧密码。** 该路由与配置路由共用同一道围栏:只接受本机 loopback 的同源请求(Host 必须是回环、跨站请求拒绝、写操作必须带匹配的 `Origin`)。经网关访问时,只有宿主机本机浏览器(回环来源 + 回环地址)能被放行到这道围栏,远程浏览器仍是一律 403。能通过这两道门的本地用户本来就能读 `~/.dsh`。
104
104
  - **改完即生效。** 写盘后递增会话代次:旧密码立即失效,所有已登录会话与已建立的 WebSocket 全部作废,各来源需要重新登录。
105
105
  - **只设置,不清空。** 清空密码会按设计停掉监听器(没有密码不允许监听),卡片不做这件事;要清空请用 `lan_gateway set-password` 并留空密码。
106
106
  - **密码不是配置键。** 它不写进 profile 条目,也不会出现在 `--dump-config` 里;保存配置字段动不到它,改密码也不会碰你尚未保存的字段草稿。
@@ -183,7 +183,7 @@ lan_gateway disable # 关闭
183
183
  - **共享上游会话中继**(dsh ≥ 0.1.7)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 = 单操作者」:通过网关登录的用户共用同一条上游会话,持钥的上游才是真正的授权主体。
184
184
  - **登录页**。`/__login` 由网关独占、不转发。密码以 scrypt 校验,每写一次重新加盐;登录尝试按来源限流(5 次 / 分钟)。
185
185
  - **会话 cookie** 是 `payload.signature` 结构(HMAC-SHA256),带撤销 epoch 与逐会话 id,`HttpOnly; SameSite=Strict`。登出撤销的是当前这条会话:它建立的 WebSocket 一并断开,其他设备不受影响。改密、清密、`rotate-secret` 递增 epoch,作废全部已签发 cookie 并断开全部已建立的 WebSocket,客户端需要重新登录。清空密码会直接停止监听。
186
- - **管理面不外泄**。`/lan-gateway/*`(含配置路由)由网关独占、一律 403 不转发,远程访问者无法借网关改写 Host 触及本机 loopback 的配置接口。原生 `/lan-gateway/config` 只应答回环 Host 且同源的请求。远程管理走 `lan_gateway` 工具。
186
+ - **管理面不外泄**。`/lan-gateway/*`(含配置路由与改密路由)由网关独占:唯一放行的是**宿主机本机浏览器**,即「TCP 来源为 loopback **且** 浏览器地址栏写的是回环权威(`127.0.0.1` / `localhost` / `::1`)」的请求,其余一律 403 不转发,远程访问者无法借网关改写 Host 触及本机 loopback 的配置接口。两个条件缺一不可:来源一项远程无法伪造,地址一项则挡住受信 TLS 终止代理的部署——那里所有请求的 TCP 来源都是代理自己的回环地址,只有回环权威仍被拒。放行的请求照样要过会话门与 CSRF 围栏,再以改写后的 loopback Host 交给插件自己的回环同源围栏。升级请求(WebSocket)不在此列,一律拒。原生 `/lan-gateway/config` 只应答回环 Host 且同源的请求。远程管理走 `lan_gateway` 工具。
187
187
  - **CSRF 围栏**(HTTP 与 WebSocket)。网关把 Origin 改写回 loopback,会蒙蔽 dsh 自身的 CSRF 防线,所以在改写前对每个转发请求自检:`sec-fetch-site: cross-site` 直接拒;Origin 必须匹配访问者实际使用的网关权威来源;状态变更方法与 WebSocket 升级请求必须携带同源 Origin,否则 403。
188
188
  - **未设置密码时拒绝监听**,与来源无关。
189
189
 
@@ -204,7 +204,7 @@ client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomVa
204
204
  ## 开发
205
205
 
206
206
  ```bash
207
- pnpm test # 206 项
207
+ pnpm test # 216 项
208
208
  pnpm typecheck # tsc 双端(host + client)
209
209
  ```
210
210
 
@@ -212,18 +212,20 @@ pnpm typecheck # tsc 双端(host + client)
212
212
  ✓ tests/gateway.test.ts (40) 分类 / HMAC cookie / epoch / 逐会话撤销 / 密码状态 / 限流
213
213
  ✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
214
214
  Secure cookie 属性推断(含 null 清除路径)
215
- ✓ tests/request-policy.test.ts (46) 判定缝纯函数:路径归一化 / 归属前缀 / 同站与登录围栏 /
216
- 两个方向的头部变换
215
+ ✓ tests/request-policy.test.ts (50) 判定缝纯函数:路径归一化 / 归属前缀 / 同站与登录围栏 /
216
+ 回环权威(Host)判定 / 两个方向的头部变换
217
217
  ✓ tests/upstream-session.test.ts ( 8) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
218
218
  invalidate 重获取 / 日志播报 / 保住已持有会话
219
219
  ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
220
220
  ✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
221
221
  ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
222
- ✓ tests/settings-card.test.ts (17) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
222
+ ✓ tests/settings-card.test.ts (19) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
223
223
  卡片注册槽与条目 id 契约 / 密码草稿闸门(长度与确认)/
224
- 密码状态徽标三态(缺字段 = 未知,不是「未设置」)
225
- ✓ tests/integration/gateway.test.ts (26) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
226
- cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继
224
+ 密码状态徽标三态(缺字段 = 未知,不是「未设置」)/
225
+ 「网关自己拒绝」与其它失败的文案分流
226
+ ✓ tests/integration/gateway.test.ts (30) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
227
+ cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继 /
228
+ 管理面只对本机浏览器放行(来源与 Host 两项都要满足)
227
229
  ✓ tests/integration/management-plane.test.ts (24) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
228
230
  清空后继承 / 拒绝不可启动配置 / 卡片改密路由(覆盖原密码 /
229
231
  递增代次 / 不返回哈希 / 不给清空 / 无 settings 也生效)
package/lib/client.js CHANGED
@@ -151,14 +151,18 @@ window.__ModuleLoader__.load({
151
151
  //#endregion
152
152
  //#region src/client/lan-gateway-card.tsx
153
153
  /**
154
- * The lan-gateway settings card, rendered by the official DSH Plugins page
155
- * through its `plugins.item` slot.
154
+ * The lan-gateway configuration card, rendered by the official DSH Plugins page
155
+ * on this bundle's own page through its `plugins.bundle.config` 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
159
  * the settings seam or any secret), so the only platform service it needs is
160
160
  * the `slots` service every plugin already has.
161
161
  *
162
+ * The page — not the card — draws the plugin's title, icon, and crumb, and the
163
+ * card is the page body: `view: 'summary'` renders nothing (bundle
164
+ * configuration is `page`-only).
165
+ *
162
166
  * @module @riceawa/dsh-lan-gateway/client/card
163
167
  */
164
168
  /**
@@ -185,6 +189,22 @@ window.__ModuleLoader__.load({
185
189
  if (password !== confirm) return "mismatch";
186
190
  return null;
187
191
  }
192
+ /**
193
+ * Whether the config route's refusal came from the gateway rather than from dsh
194
+ * itself.
195
+ *
196
+ * The gateway owns the `/lan-gateway*` prefix and answers it with a bare 403
197
+ * `forbidden` whenever the client is not on the host; dsh, when it answers the
198
+ * route at all, does not produce that pair. Telling the two apart is what lets
199
+ * the card say "you are not on the host loopback" instead of the generic
200
+ * "cannot read the configuration".
201
+ * @param status - the HTTP status of the failed read.
202
+ * @param body - that response's body.
203
+ * @returns true when the gateway itself refused the read.
204
+ */
205
+ function refusedByGateway(status, body) {
206
+ return status === 403 && body.trim() === "forbidden";
207
+ }
188
208
  const LABELS = {
189
209
  zh: {
190
210
  title: "LAN 网关",
@@ -194,7 +214,8 @@ window.__ModuleLoader__.load({
194
214
  saving: "保存中…",
195
215
  discard: "放弃",
196
216
  reset: "重置",
197
- readOnly: "网关设置只能在宿主机本机打开 dsh web 时修改:配置路由仅监听回环地址,经网关远程访问的浏览器会被拒绝。远程请改用 lan_gateway 工具。",
217
+ readOnly: "读不到网关配置:这个接口只在宿主机本机应答——Host 必须是回环地址且同源。请在宿主机上打开 dsh web 时修改,远程请改用 lan_gateway 工具。",
218
+ readOnlyGateway: "网关拒绝了这次读取:/lan-gateway/* 管理面由网关独占,只放行「TCP 来源为回环 且 地址写的是 127.0.0.1 / localhost」的浏览器。你现在不是从宿主机回环地址访问的——请在宿主机上用 127.0.0.1 或 localhost 打开本页(局域网 IP、域名都不算),或远程改用 lan_gateway 工具。",
198
219
  saveFailed: "保存未生效,请检查输入后重试。",
199
220
  loadFailed: "无法读取网关配置",
200
221
  emptyMeansClear: "留空 = 使用默认",
@@ -259,7 +280,8 @@ window.__ModuleLoader__.load({
259
280
  saving: "Saving…",
260
281
  discard: "Discard",
261
282
  reset: "Reset",
262
- 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.",
283
+ readOnly: "Cannot read the configuration: the route answers on the host only — the Host must be a loopback address and same-origin. Change the settings where dsh web runs on the host, or use the lan_gateway tool remotely.",
284
+ readOnlyGateway: "The gateway refused this read: it owns the /lan-gateway/* management plane and admits only browsers that are both loopback-sourced and using a 127.0.0.1 / localhost address. You are not on the host loopback — open this page on the host via 127.0.0.1 or localhost (a LAN IP or a hostname does not qualify), or use the lan_gateway tool remotely.",
263
285
  saveFailed: "The save did not land — check the inputs and retry.",
264
286
  loadFailed: "Cannot read the gateway configuration",
265
287
  emptyMeansClear: "Empty = default",
@@ -321,28 +343,19 @@ window.__ModuleLoader__.load({
321
343
  return (typeof navigator !== "undefined" ? navigator.language : "en").toLowerCase().startsWith("zh") ? LABELS.zh : LABELS.en;
322
344
  }
323
345
  /**
324
- * The card's title in the browser's language, for the Plugins page's list
325
- * entry. A thunk so the label follows the page's locale without re-registering.
326
- * @returns the localized card title.
327
- */
328
- function cardTitle() {
329
- return labels().title;
330
- }
331
- /**
332
- * Render the LAN gateway card. Self-loading: fetches the config route on
333
- * mount, posts the edited config on save.
346
+ * Render the LAN gateway configuration card. Self-loading: fetches the config
347
+ * route on mount, posts the edited config on save.
334
348
  *
335
- * `view` swaps between the card's one-liner and its page body, so the branch
336
- * sits after the hooks: the Plugins page re-renders one contribution under the
337
- * other view when the card is opened.
349
+ * `summary` renders nothing: dsh asks a bundle's own configuration for its page
350
+ * body only, and the card's former one-liner (a list entry on the official
351
+ * plugin card) went away with `plugins.item`.
338
352
  * @param props - the view the Plugins page is asking for.
339
- * @returns the one-liner, the card, or nothing while the route is unreachable.
353
+ * @returns the page body, or nothing while the view is `summary`.
340
354
  */
341
355
  function LanGatewayCard(props) {
342
356
  const t = labels();
343
- const [open, setOpen] = (0, react.useState)(false);
344
357
  const [route, setRoute] = (0, react.useState)(null);
345
- const [loadFailed, setLoadFailed] = (0, react.useState)(false);
358
+ const [failure, setFailure] = (0, react.useState)(null);
346
359
  const [drafts, setDrafts] = (0, react.useState)({});
347
360
  const [saving, setSaving] = (0, react.useState)(false);
348
361
  const [failed, setFailed] = (0, react.useState)(null);
@@ -355,36 +368,28 @@ window.__ModuleLoader__.load({
355
368
  let cancelled = false;
356
369
  fetch("/lan-gateway/config").then(async (response) => {
357
370
  if (cancelled) return;
358
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
371
+ if (!response.ok) {
372
+ const body = await response.text().catch(() => "");
373
+ if (!cancelled) setFailure(refusedByGateway(response.status, body) ? "gateway" : "other");
374
+ return;
375
+ }
359
376
  setRoute(await response.json());
360
377
  }).catch(() => {
361
- if (!cancelled) setLoadFailed(true);
378
+ if (!cancelled) setFailure("other");
362
379
  });
363
380
  return () => {
364
381
  cancelled = true;
365
382
  };
366
383
  }, []);
367
- if (props.view === "summary") return t.description;
368
- if (loadFailed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
384
+ if (props.view === "summary") return null;
385
+ if (failure !== null) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
369
386
  style: styles.card,
370
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
371
- style: styles.header,
372
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
373
- style: styles.headerTop,
374
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
375
- style: styles.name,
376
- children: t.title
377
- })
378
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
379
- style: styles.description,
380
- children: t.loadFailed
381
- })]
382
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
383
- style: styles.body,
384
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
385
- style: styles.hint,
386
- children: t.readOnly
387
- })
387
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
388
+ style: styles.status,
389
+ children: t.loadFailed
390
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
391
+ style: styles.hint,
392
+ children: failure === "gateway" ? t.readOnlyGateway : t.readOnly
388
393
  })]
389
394
  });
390
395
  if (route === null) return null;
@@ -592,46 +597,18 @@ window.__ModuleLoader__.load({
592
597
  };
593
598
  const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`;
594
599
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
595
- style: open ? {
596
- ...styles.card,
597
- ...styles.cardOpen
598
- } : styles.card,
599
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
600
- type: "button",
601
- style: styles.header,
602
- "aria-expanded": open,
603
- onClick: () => {
604
- setOpen(!open);
605
- },
606
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
607
- style: styles.headerTop,
608
- children: [
609
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
610
- style: styles.name,
611
- children: t.title
612
- }),
613
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
614
- style: styles.status,
615
- title: statusLine,
616
- children: statusLine
617
- }),
618
- dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
619
- style: styles.pending,
620
- children: t.unsaved
621
- }) : null,
622
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
623
- style: open ? {
624
- ...styles.chevron,
625
- ...styles.chevronOpen
626
- } : styles.chevron,
627
- children: open ? "▾" : "▸"
628
- })
629
- ]
630
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
631
- style: styles.description,
632
- children: t.description
633
- })]
634
- }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
600
+ style: styles.card,
601
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
602
+ style: styles.statusRow,
603
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
604
+ style: styles.status,
605
+ title: statusLine,
606
+ children: statusLine
607
+ }), dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
608
+ style: styles.pending,
609
+ children: t.unsaved
610
+ }) : null]
611
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
635
612
  style: styles.body,
636
613
  children: [
637
614
  route.lastError ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
@@ -754,7 +731,7 @@ window.__ModuleLoader__.load({
754
731
  ]
755
732
  })
756
733
  ]
757
- }) : null]
734
+ })]
758
735
  });
759
736
  }
760
737
  /** Theme token with a fallback for token-less environments. */
@@ -764,13 +741,10 @@ window.__ModuleLoader__.load({
764
741
  const L = {
765
742
  border: tk("--dsw-alias-border-l2", "rgba(127,127,127,0.35)"),
766
743
  bg: tk("--dsw-alias-bg-layer-3", "transparent"),
767
- bgOpen: tk("--dsw-alias-bg-layer-2", "transparent"),
768
744
  labelPrimary: tk("--dsw-alias-label-primary", "inherit"),
769
745
  labelSecondary: tk("--dsw-alias-label-secondary", "inherit"),
770
746
  labelTertiary: tk("--dsw-alias-label-tertiary", "rgba(127,127,127,0.8)"),
771
- labelDimmed: tk("--dsw-alias-label-dimmed", "rgba(127,127,127,0.6)"),
772
747
  error: tk("--dsw-alias-label-error", "#d1242f"),
773
- brand: tk("--dsw-alias-brand-primary", "#4f6ef7"),
774
748
  badgeBg: tk("--dsw-alias-bg-module-platform", "rgba(127,127,127,0.14)")
775
749
  };
776
750
  const styles = {
@@ -779,45 +753,21 @@ window.__ModuleLoader__.load({
779
753
  border: `1px solid ${L.border}`,
780
754
  borderRadius: "12px",
781
755
  background: L.bg,
782
- transition: "border-color .16s, background .16s",
783
- overflow: "hidden"
784
- },
785
- cardOpen: {
786
- background: L.bgOpen,
787
- borderColor: L.labelDimmed
788
- },
789
- header: {
790
756
  display: "flex",
791
757
  flexDirection: "column",
792
- alignItems: "stretch",
793
- gap: "6px",
794
- width: "100%",
795
- padding: "14px 16px",
796
- border: 0,
797
- background: "none",
798
- font: "inherit",
799
- color: "inherit",
800
- textAlign: "left",
801
- cursor: "pointer"
758
+ overflow: "hidden"
802
759
  },
803
- headerTop: {
760
+ statusRow: {
804
761
  display: "flex",
805
762
  alignItems: "center",
806
763
  gap: "12px",
807
- width: "100%"
808
- },
809
- name: {
810
- flex: "1 1 auto",
811
- minWidth: 0,
812
- fontSize: "15px",
813
- fontWeight: 600,
814
- lineHeight: 1.4,
815
- color: L.labelPrimary
764
+ padding: "12px 16px",
765
+ borderBottom: `1px solid ${L.border}`
816
766
  },
817
767
  status: {
818
768
  flex: "0 1 auto",
819
769
  minWidth: 0,
820
- maxWidth: "60%",
770
+ maxWidth: "80%",
821
771
  fontSize: "11px",
822
772
  lineHeight: 1.4,
823
773
  color: L.labelTertiary,
@@ -825,15 +775,6 @@ window.__ModuleLoader__.load({
825
775
  overflow: "hidden",
826
776
  textOverflow: "ellipsis"
827
777
  },
828
- description: {
829
- display: "block",
830
- fontSize: "13px",
831
- lineHeight: 1.5,
832
- color: L.labelTertiary,
833
- whiteSpace: "nowrap",
834
- overflow: "hidden",
835
- textOverflow: "ellipsis"
836
- },
837
778
  pending: {
838
779
  flex: "none",
839
780
  borderRadius: "999px",
@@ -845,17 +786,8 @@ window.__ModuleLoader__.load({
845
786
  background: L.badgeBg,
846
787
  color: L.labelSecondary
847
788
  },
848
- chevron: {
849
- flex: "none",
850
- color: L.labelTertiary,
851
- fontSize: "12px",
852
- transition: "transform .16s"
853
- },
854
- chevronOpen: { transform: "rotate(180deg)" },
855
789
  body: {
856
- borderTop: `1px solid ${L.border}`,
857
- margin: "0 16px",
858
- paddingBottom: "8px",
790
+ padding: "4px 16px 8px",
859
791
  display: "flex",
860
792
  flexDirection: "column"
861
793
  },
@@ -1015,8 +947,12 @@ window.__ModuleLoader__.load({
1015
947
  * module scope. With TLS enabled the origin is secure and the shim is a
1016
948
  * no-op.
1017
949
  * 2. Settings card: registers the LAN gateway card into the official Plugins
1018
- * page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
1019
- * from the GUI.
950
+ * page as THIS bundle's own configuration (`plugins.bundle.config`, keyed by
951
+ * the package name), so port, CIDRs, auth, and TLS stay adjustable from the
952
+ * GUI on the plugin's own page. `plugins.item` is deliberately not used: dsh
953
+ * reserves it for the official settings pages, one companion package per
954
+ * host-plane namespace, and a bundle's configuration belongs in its own
955
+ * config slot instead.
1020
956
  */
1021
957
  /** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
1022
958
  function uuidFromRandomValues() {
@@ -1060,8 +996,14 @@ window.__ModuleLoader__.load({
1060
996
  installRandomUuidShim();
1061
997
  const name = "dsh-lan-gateway";
1062
998
  /**
1063
- * The profile entry id this plugin's bundle patch composes it under, and the
1064
- * settings namespace dsh ≥ 0.1.7 addresses every write by.
999
+ * The package name dsh keys a bundle's own configuration by in
1000
+ * `plugins.bundle.config`, and the name this profile's bundle row resolves to.
1001
+ * It is the package's npm name — not the locale namespace or the row id.
1002
+ */
1003
+ const PACKAGE_NAME = "@riceawa/dsh-lan-gateway";
1004
+ /**
1005
+ * The profile entry id this plugin's bundle patch composes it under. dsh >=
1006
+ * 0.1.7 addresses both settings writes and the serve gate by entry id.
1065
1007
  */
1066
1008
  const ENTRY_ID = "dsh-lan-gateway";
1067
1009
  /** The slots service the card rides, and the settings mirror the gate reads. */
@@ -1072,11 +1014,9 @@ window.__ModuleLoader__.load({
1072
1014
  */
1073
1015
  function apply(ctx) {
1074
1016
  installRandomUuidShim();
1075
- ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () => ctx.slots.inject("plugins.item", () => ctx.slots.register({
1076
- name: "plugins.item",
1077
- id: ENTRY_ID,
1078
- order: 30,
1079
- label: () => cardTitle()
1017
+ ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () => ctx.slots.inject("plugins.bundle.config", () => ctx.slots.register({
1018
+ name: "plugins.bundle.config",
1019
+ key: PACKAGE_NAME
1080
1020
  }, LanGatewayCard))));
1081
1021
  }
1082
1022
  //#endregion