@fonlan/dsh-web-auth 0.1.0 → 0.1.2
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 +9 -8
- package/cordis.patch.yml +4 -1
- package/lib/client/index.d.ts +8 -2
- package/lib/client.js +180 -132
- package/lib/context-types.d.ts +7 -0
- package/lib/index.js +6 -0
- package/lib/settings.d.ts +62 -0
- package/lib/settings.js +88 -0
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
- **登录页 + HttpOnly 签名 Cookie**(HMAC-SHA256,7 天滑动续期),浏览器自动携带,HTTP / WebSocket / SSE / 静态资源全覆盖
|
|
8
8
|
- **首次设置**:未配置密码时,登录页显示「设置访问密码」表单(引导流程,见下文安全说明)
|
|
9
|
-
- **GUI 改密**:设置 →
|
|
9
|
+
- **GUI 改密**:设置 → 插件 →「访问认证」卡片(插件配置 tab),需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
|
|
10
10
|
- **退出登录**:设置卡片按钮,或访问 `/logout`
|
|
11
11
|
- **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
|
|
12
12
|
- **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任回环对端的 `X-Forwarded-For`
|
|
13
|
-
- **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 patch
|
|
13
|
+
- **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 `web-auth` settings 命名空间 → host 半区应用 patch → HMR 重绑 webserver)
|
|
14
14
|
- 密码以 **scrypt 加盐哈希**存储(`$DSH_HOME/web-auth/password.hash`,0600),永不回显、不落明文配置
|
|
15
15
|
|
|
16
16
|
## 安装
|
|
@@ -34,7 +34,7 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
34
34
|
1. 未设置过密码时,登录页显示「设置访问密码」——输入 **≥8 位**的新密码并确认
|
|
35
35
|
2. 设置成功后立即签发会话并跳回原页面;此后每次访问都要输密码
|
|
36
36
|
|
|
37
|
-
改密入口在 **设置 → 插件配置 → 访问认证**(需先登录)。
|
|
37
|
+
改密入口在 **设置 → 插件 → 插件配置 → 访问认证**(需先登录)。
|
|
38
38
|
|
|
39
39
|
## 行为细节
|
|
40
40
|
|
|
@@ -49,15 +49,15 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
49
49
|
| 连续失败 | 按 IP 限速,提示「尝试过于频繁」 |
|
|
50
50
|
| 会话过期 | 跳回登录页;滑动续期:剩余不足 24h 自动刷新 |
|
|
51
51
|
| 修改密码 | 校验旧密码 → 轮换签名密钥 → **全员下线**,跳转登录页 |
|
|
52
|
-
| 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→
|
|
52
|
+
| 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
|
|
53
53
|
| `/logout` | 清除 Cookie,跳转登录页 |
|
|
54
54
|
|
|
55
55
|
### 切换监听地址
|
|
56
56
|
|
|
57
|
-
dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
|
|
57
|
+
dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
|
|
58
58
|
|
|
59
|
-
-
|
|
60
|
-
- dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver
|
|
59
|
+
- 切换写入 `web-auth` settings 命名空间(`$DSH_HOME/settings.yaml`,revision 设栅);host 半区监听到提交后应用到当前部署的 patch 层:profile 的 `cordis.patch.yml`;若 home 层(`$DSH_HOME/cordis.patch.yml`)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、`!!js` 端口表达式)原样保留。
|
|
60
|
+
- dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:**关闭旧监听、按新地址重新绑定**,全程无需重启进程。重启进程后该设置依然生效(settings 命名空间 + patch 文件都在)。
|
|
61
61
|
- 切换瞬间所有连接(包括当前页面与 WebSocket)会断开重连,属正常现象;卡片会轮询状态确认新地址生效。
|
|
62
62
|
- 注意:若你正通过**局域网 IP**(`0.0.0.0` 模式)访问并切回 `127.0.0.1`,切回后只能在本机访问,请改用 `127.0.0.1` 重新打开页面。
|
|
63
63
|
|
|
@@ -86,8 +86,9 @@ npm run test # 32 项单元 + 集成测试(node:test,真实 HTTP/WS
|
|
|
86
86
|
- `src/gate.ts` — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
|
|
87
87
|
- `src/handlers.ts` — 登录页/登录/登出/改密/状态/监听切换 路由处理
|
|
88
88
|
- `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js` 表达式)
|
|
89
|
+
- `src/settings.ts` — `web-auth` settings 命名空间注册 + listenHost 变更应用(卡片的分发键与数据源)
|
|
89
90
|
- `src/index.ts` — cordis 插件装配(host 半区)
|
|
90
|
-
- `src/client/index.tsx` —
|
|
91
|
+
- `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.plugin.item` 插槽,keyed by `web-auth` 命名空间)
|
|
91
92
|
- `cordis.patch.yml` — bundle 补丁层(insert 插件行)
|
|
92
93
|
|
|
93
94
|
## License
|
package/cordis.patch.yml
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
# the auth gate wraps the HTTP listener after it is bound.
|
|
12
12
|
#
|
|
13
13
|
# The package also declares `dsh.client` (see package.json), so the web app
|
|
14
|
-
# loads the browser half (the
|
|
14
|
+
# loads the browser half (the plugin's own Settings Card) automatically. The
|
|
15
|
+
# card registers on the `settings.plugin.item` slot keyed by the `web-auth`
|
|
16
|
+
# settings namespace (see src/settings.ts) — the 设置 → 插件 → 插件配置 tab
|
|
17
|
+
# pairs the two.
|
|
15
18
|
#
|
|
16
19
|
# There is intentionally NO password here: passwords are maintained through
|
|
17
20
|
# the GUI (login page first setup / settings card change), stored hashed
|
package/lib/client/index.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
-
/** Services provided by other browser plugins before this one mounts.
|
|
2
|
+
/** Services provided by other browser plugins before this one mounts.
|
|
3
|
+
*
|
|
4
|
+
* `connection` and `remote` are required by `settingsScope.bind` (it resolves
|
|
5
|
+
* the settings transport and the forwarded-invalidation subscription through
|
|
6
|
+
* `ctx.get`), so the fiber must wait for them — exactly like
|
|
7
|
+
* dsh-client-ui-settings-plugins does for its own cards.
|
|
8
|
+
*/
|
|
3
9
|
export declare const inject: string[];
|
|
4
10
|
/**
|
|
5
|
-
* Client plugin body: register the settings
|
|
11
|
+
* Client plugin body: register the settings card. The shell's declaration
|
|
6
12
|
* may not be on the ledger yet, so the registration waits via slots.inject.
|
|
7
13
|
* @param ctx - the client cordis context.
|
|
8
14
|
*/
|
package/lib/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
window.__ModuleLoader__.load({
|
|
2
|
-
id: "dsh-web-auth",
|
|
2
|
+
id: "@fonlan/dsh-web-auth",
|
|
3
3
|
factory: (require) => {
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
@@ -37,9 +37,36 @@ var dshWebAuthModule = (() => {
|
|
|
37
37
|
});
|
|
38
38
|
var import_react = __require("react");
|
|
39
39
|
var import_jsx_runtime = __require("react/jsx-runtime");
|
|
40
|
-
var inject = ["slots"];
|
|
40
|
+
var inject = ["slots", "settingsScope", "connection", "remote"];
|
|
41
|
+
var WEB_AUTH_NS = "web-auth";
|
|
41
42
|
var CSS = `
|
|
42
|
-
.dwa-card {
|
|
43
|
+
.dwa-card {
|
|
44
|
+
border: 1px solid var(--dsw-alias-border-l2, #2a3346);
|
|
45
|
+
background: var(--dsw-alias-bg-layer-3, #0d1017);
|
|
46
|
+
border-radius: 12px;
|
|
47
|
+
list-style: none;
|
|
48
|
+
transition: border-color .16s, background .16s;
|
|
49
|
+
}
|
|
50
|
+
.dwa-card:hover { border-color: var(--dsw-alias-label-dimmed, #4a5468); }
|
|
51
|
+
.dwa-card[data-open] {
|
|
52
|
+
background: var(--dsw-alias-bg-layer-2, #141a26);
|
|
53
|
+
border-color: var(--dsw-alias-label-dimmed, #4a5468);
|
|
54
|
+
}
|
|
55
|
+
.dwa-head {
|
|
56
|
+
appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer;
|
|
57
|
+
background: transparent; border: 0; border-radius: 12px;
|
|
58
|
+
align-items: center; gap: 12px; padding: 14px 16px; display: flex;
|
|
59
|
+
}
|
|
60
|
+
.dwa-head:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff); outline-offset: -2px; }
|
|
61
|
+
.dwa-headText { flex-direction: column; flex: 1; gap: 4px; min-width: 0; display: flex; }
|
|
62
|
+
.dwa-title { color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 15px; font-weight: 600; line-height: 1.4; }
|
|
63
|
+
.dwa-sub { color: var(--dsw-alias-label-tertiary, #8b93a7); font-size: 13px; line-height: 1.5; }
|
|
64
|
+
.dwa-chevron {
|
|
65
|
+
color: var(--dsw-alias-label-tertiary, #8b93a7); flex: none;
|
|
66
|
+
transition: transform .16s; display: flex; align-items: center; justify-content: center;
|
|
67
|
+
}
|
|
68
|
+
.dwa-chevron[data-open] { transform: rotate(180deg); }
|
|
69
|
+
.dwa-body { border-top: 1px solid var(--dsw-alias-border-l2, #2a3346); margin: 0 16px; padding: 12px 0 8px; display: flex; flex-direction: column; gap: 12px; }
|
|
43
70
|
.dwa-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
44
71
|
.dwa-status { font-size: 13px; line-height: 1.6; }
|
|
45
72
|
.dwa-status[data-on] { color: var(--dsw-alias-label-primary, #e6e6e6); }
|
|
@@ -104,33 +131,48 @@ var dshWebAuthModule = (() => {
|
|
|
104
131
|
function isListenHost(value) {
|
|
105
132
|
return value === "127.0.0.1" || value === "0.0.0.0";
|
|
106
133
|
}
|
|
107
|
-
function
|
|
134
|
+
async function fetchStatus() {
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch("/api/web-auth/status");
|
|
137
|
+
if (!response.ok) return null;
|
|
138
|
+
const data = await response.json();
|
|
139
|
+
return { configured: data.configured === true, host: isListenHost(data.host) ? data.host : void 0 };
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function AuthCard(props) {
|
|
145
|
+
const { scope } = props;
|
|
146
|
+
const snapshot = (0, import_react.useSyncExternalStore)(
|
|
147
|
+
(listener) => scope.subscribe(listener),
|
|
148
|
+
() => scope.getSnapshot()
|
|
149
|
+
);
|
|
108
150
|
const [configured, setConfigured] = (0, import_react.useState)(null);
|
|
151
|
+
const [host, setHost] = (0, import_react.useState)(null);
|
|
109
152
|
const [oldPassword, setOldPassword] = (0, import_react.useState)("");
|
|
110
153
|
const [newPassword, setNewPassword] = (0, import_react.useState)("");
|
|
111
154
|
const [confirm, setConfirm] = (0, import_react.useState)("");
|
|
112
155
|
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
113
156
|
const [message, setMessage] = (0, import_react.useState)(null);
|
|
114
|
-
const [listenHost, setListenHost] = (0, import_react.useState)(null);
|
|
115
157
|
const [listenPick, setListenPick] = (0, import_react.useState)("127.0.0.1");
|
|
116
158
|
const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
|
|
117
159
|
const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
|
|
160
|
+
const [open, setOpen] = (0, import_react.useState)(false);
|
|
118
161
|
(0, import_react.useEffect)(() => {
|
|
119
162
|
let alive = true;
|
|
120
|
-
void
|
|
121
|
-
if (!alive) return;
|
|
122
|
-
setConfigured(
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
setListenPick(
|
|
163
|
+
void fetchStatus().then((status) => {
|
|
164
|
+
if (!alive || status === null) return;
|
|
165
|
+
setConfigured(status.configured);
|
|
166
|
+
if (status.host !== void 0) {
|
|
167
|
+
setHost(status.host);
|
|
168
|
+
setListenPick(status.host);
|
|
126
169
|
}
|
|
127
|
-
}).catch(() => {
|
|
128
|
-
if (alive) setConfigured(null);
|
|
129
170
|
});
|
|
130
171
|
return () => {
|
|
131
172
|
alive = false;
|
|
132
173
|
};
|
|
133
174
|
}, []);
|
|
175
|
+
if (snapshot.status === "unavailable") return null;
|
|
134
176
|
const submit = async () => {
|
|
135
177
|
if (newPassword.length < 8) {
|
|
136
178
|
setMessage({ kind: "err", text: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" });
|
|
@@ -156,144 +198,150 @@ var dshWebAuthModule = (() => {
|
|
|
156
198
|
}
|
|
157
199
|
};
|
|
158
200
|
const applyListen = async () => {
|
|
159
|
-
if (
|
|
201
|
+
if (host === listenPick) return;
|
|
160
202
|
setListenBusy(true);
|
|
161
203
|
setListenMsg(null);
|
|
162
204
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
for (let i = 0; i < 15; i++) {
|
|
173
|
-
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
174
|
-
try {
|
|
175
|
-
const check = await fetch("/api/web-auth/status");
|
|
176
|
-
const status = await check.json();
|
|
177
|
-
if (isListenHost(status.host) && status.host === listenPick) {
|
|
178
|
-
setListenHost(listenPick);
|
|
179
|
-
setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
} catch {
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
|
|
186
|
-
} else {
|
|
187
|
-
setListenHost(listenPick);
|
|
188
|
-
setListenMsg({ kind: "ok", text: "\u76D1\u542C\u5730\u5740\u672A\u53D8\u5316\u3002" });
|
|
205
|
+
await scope.set("listenHost", listenPick);
|
|
206
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF0C\u9875\u9762\u5C06\u77ED\u6682\u65AD\u8FDE\u2026" });
|
|
207
|
+
for (let i = 0; i < 15; i++) {
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
209
|
+
const status = await fetchStatus();
|
|
210
|
+
if (status?.host === listenPick) {
|
|
211
|
+
setHost(listenPick);
|
|
212
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
|
|
213
|
+
return;
|
|
189
214
|
}
|
|
190
|
-
} else {
|
|
191
|
-
const text = data.error === "invalid-host" ? "\u65E0\u6548\u7684\u76D1\u542C\u5730\u5740\u3002" : data.error === "unsupported" ? "\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u4FEE\u6539\u76D1\u542C\u5730\u5740\u3002" : "\u5207\u6362\u5931\u8D25\uFF1A" + String(data.error ?? response.status);
|
|
192
|
-
setListenMsg({ kind: "err", text });
|
|
193
215
|
}
|
|
216
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
|
|
194
217
|
} catch {
|
|
195
|
-
setListenMsg({ kind: "err", text: "\
|
|
218
|
+
setListenMsg({ kind: "err", text: "\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002" });
|
|
196
219
|
} finally {
|
|
197
220
|
setListenBusy(false);
|
|
198
221
|
}
|
|
199
222
|
};
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-card", children: [
|
|
204
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-row", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-status", "data-on": configured ? void 0 : "", "data-off": configured ? "" : void 0, children: configured ? "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u5DF2\u542F\u7528" : "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u672A\u8BBE\u7F6E\u5BC6\u7801\uFF08\u5F53\u524D\u672A\u542F\u7528\uFF09" }) }),
|
|
205
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u8BBE\u7F6E\u540E\uFF0C\u8BBF\u95EE\u672C\u670D\u52A1\u9700\u8981\u8F93\u5165\u5BC6\u7801\uFF1B\u4F1A\u8BDD\u6709\u6548\u671F 7 \u5929\uFF08\u6D3B\u52A8\u81EA\u52A8\u7EED\u671F\uFF09\u3002\u4FEE\u6539\u5BC6\u7801\u4F1A\u4F7F\u6240\u6709\u5DF2\u767B\u5F55\u4F1A\u8BDD\u7ACB\u5373\u5931\u6548\u3002" }),
|
|
206
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
207
|
-
configured && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
208
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-old", children: "\u65E7\u5BC6\u7801" }),
|
|
209
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
210
|
-
"input",
|
|
211
|
-
{
|
|
212
|
-
id: "dwa-old",
|
|
213
|
-
className: "dwa-input",
|
|
214
|
-
type: "password",
|
|
215
|
-
autoComplete: "current-password",
|
|
216
|
-
value: oldPassword,
|
|
217
|
-
onChange: (e) => setOldPassword(e.target.value)
|
|
218
|
-
}
|
|
219
|
-
)
|
|
220
|
-
] }),
|
|
221
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
222
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-new", children: "\u65B0\u5BC6\u7801\uFF08\u81F3\u5C11 8 \u4F4D\uFF09" }),
|
|
223
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
224
|
-
"input",
|
|
225
|
-
{
|
|
226
|
-
id: "dwa-new",
|
|
227
|
-
className: "dwa-input",
|
|
228
|
-
type: "password",
|
|
229
|
-
autoComplete: "new-password",
|
|
230
|
-
value: newPassword,
|
|
231
|
-
onChange: (e) => setNewPassword(e.target.value)
|
|
232
|
-
}
|
|
233
|
-
)
|
|
234
|
-
] }),
|
|
235
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
236
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-confirm", children: "\u786E\u8BA4\u65B0\u5BC6\u7801" }),
|
|
237
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
238
|
-
"input",
|
|
239
|
-
{
|
|
240
|
-
id: "dwa-confirm",
|
|
241
|
-
className: "dwa-input",
|
|
242
|
-
type: "password",
|
|
243
|
-
autoComplete: "new-password",
|
|
244
|
-
value: confirm,
|
|
245
|
-
onChange: (e) => setConfirm(e.target.value)
|
|
246
|
-
}
|
|
247
|
-
)
|
|
248
|
-
] }),
|
|
249
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-actions", children: [
|
|
250
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", disabled: busy, onClick: () => void submit(), children: "\u4FEE\u6539\u5BC6\u7801" }),
|
|
251
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", "data-ghost": "", onClick: () => {
|
|
252
|
-
window.location.href = "/logout";
|
|
253
|
-
}, children: "\u9000\u51FA\u767B\u5F55" })
|
|
254
|
-
] }),
|
|
255
|
-
message !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": message.kind, role: "alert", children: message.text }),
|
|
256
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
257
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
258
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-listen", children: "\u76D1\u542C\u5730\u5740" }),
|
|
259
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
260
|
-
"select",
|
|
261
|
-
{
|
|
262
|
-
id: "dwa-listen",
|
|
263
|
-
className: "dwa-input",
|
|
264
|
-
value: listenPick,
|
|
265
|
-
disabled: listenBusy || listenHost === null,
|
|
266
|
-
onChange: (e) => setListenPick(e.target.value),
|
|
267
|
-
children: LISTEN_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
|
|
268
|
-
}
|
|
269
|
-
)
|
|
270
|
-
] }),
|
|
271
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-actions", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
223
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-card", "data-open": open ? "" : void 0, children: [
|
|
224
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
272
225
|
"button",
|
|
273
226
|
{
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
227
|
+
type: "button",
|
|
228
|
+
className: "dwa-head",
|
|
229
|
+
"aria-expanded": open,
|
|
230
|
+
"aria-label": (open ? "\u6536\u8D77" : "\u5C55\u5F00") + "\uFF1A\u8BBF\u95EE\u8BA4\u8BC1",
|
|
231
|
+
onClick: () => setOpen(!open),
|
|
232
|
+
children: [
|
|
233
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dwa-headText", children: [
|
|
234
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-title", children: "\u8BBF\u95EE\u8BA4\u8BC1" }),
|
|
235
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-sub", children: "Web \u8BBF\u95EE\u5BC6\u7801\u4E0E\u76D1\u542C\u5730\u5740" })
|
|
236
|
+
] }),
|
|
237
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dwa-chevron", "data-open": open ? "" : void 0, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
238
|
+
"path",
|
|
239
|
+
{
|
|
240
|
+
d: "M3.5 5.25L7 8.75L10.5 5.25",
|
|
241
|
+
stroke: "currentColor",
|
|
242
|
+
strokeWidth: "1.5",
|
|
243
|
+
strokeLinecap: "round",
|
|
244
|
+
strokeLinejoin: "round"
|
|
245
|
+
}
|
|
246
|
+
) }) })
|
|
247
|
+
]
|
|
278
248
|
}
|
|
279
|
-
)
|
|
280
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
281
|
-
|
|
249
|
+
),
|
|
250
|
+
open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-body", children: [
|
|
251
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-row", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-status", "data-on": configured ? void 0 : "", "data-off": configured ? "" : void 0, children: configured === null ? "\u52A0\u8F7D\u4E2D\u2026" : configured ? "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u5DF2\u542F\u7528" : "\u8BBF\u95EE\u8BA4\u8BC1\uFF1A\u672A\u8BBE\u7F6E\u5BC6\u7801\uFF08\u5F53\u524D\u672A\u542F\u7528\uFF09" }) }),
|
|
252
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u8BBE\u7F6E\u540E\uFF0C\u8BBF\u95EE\u672C\u670D\u52A1\u9700\u8981\u8F93\u5165\u5BC6\u7801\uFF1B\u4F1A\u8BDD\u6709\u6548\u671F 7 \u5929\uFF08\u6D3B\u52A8\u81EA\u52A8\u7EED\u671F\uFF09\u3002\u4FEE\u6539\u5BC6\u7801\u4F1A\u4F7F\u6240\u6709\u5DF2\u767B\u5F55\u4F1A\u8BDD\u7ACB\u5373\u5931\u6548\u3002" }),
|
|
253
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
254
|
+
configured && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
255
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-old", children: "\u65E7\u5BC6\u7801" }),
|
|
256
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
257
|
+
"input",
|
|
258
|
+
{
|
|
259
|
+
id: "dwa-old",
|
|
260
|
+
className: "dwa-input",
|
|
261
|
+
type: "password",
|
|
262
|
+
autoComplete: "current-password",
|
|
263
|
+
value: oldPassword,
|
|
264
|
+
onChange: (e) => setOldPassword(e.target.value)
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
] }),
|
|
268
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
269
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-new", children: "\u65B0\u5BC6\u7801\uFF08\u81F3\u5C11 8 \u4F4D\uFF09" }),
|
|
270
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
271
|
+
"input",
|
|
272
|
+
{
|
|
273
|
+
id: "dwa-new",
|
|
274
|
+
className: "dwa-input",
|
|
275
|
+
type: "password",
|
|
276
|
+
autoComplete: "new-password",
|
|
277
|
+
value: newPassword,
|
|
278
|
+
onChange: (e) => setNewPassword(e.target.value)
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
] }),
|
|
282
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
283
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-confirm", children: "\u786E\u8BA4\u65B0\u5BC6\u7801" }),
|
|
284
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
285
|
+
"input",
|
|
286
|
+
{
|
|
287
|
+
id: "dwa-confirm",
|
|
288
|
+
className: "dwa-input",
|
|
289
|
+
type: "password",
|
|
290
|
+
autoComplete: "new-password",
|
|
291
|
+
value: confirm,
|
|
292
|
+
onChange: (e) => setConfirm(e.target.value)
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
] }),
|
|
296
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-actions", children: [
|
|
297
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", disabled: busy, onClick: () => void submit(), children: "\u4FEE\u6539\u5BC6\u7801" }),
|
|
298
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "dwa-btn", "data-ghost": "", onClick: () => {
|
|
299
|
+
window.location.href = "/logout";
|
|
300
|
+
}, children: "\u9000\u51FA\u767B\u5F55" })
|
|
301
|
+
] }),
|
|
302
|
+
message !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": message.kind, role: "alert", children: message.text }),
|
|
303
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
|
|
304
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-field", children: [
|
|
305
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "dwa-label", htmlFor: "dwa-listen", children: "\u76D1\u542C\u5730\u5740" }),
|
|
306
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
307
|
+
"select",
|
|
308
|
+
{
|
|
309
|
+
id: "dwa-listen",
|
|
310
|
+
className: "dwa-input",
|
|
311
|
+
value: listenPick,
|
|
312
|
+
disabled: listenBusy || host === null || !snapshot.writable,
|
|
313
|
+
onChange: (e) => setListenPick(e.target.value),
|
|
314
|
+
children: LISTEN_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
] }),
|
|
318
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-actions", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
319
|
+
"button",
|
|
320
|
+
{
|
|
321
|
+
className: "dwa-btn",
|
|
322
|
+
disabled: listenBusy || host === null || host === listenPick || !snapshot.writable,
|
|
323
|
+
onClick: () => void applyListen(),
|
|
324
|
+
children: "\u5E94\u7528\u76D1\u542C\u5730\u5740"
|
|
325
|
+
}
|
|
326
|
+
) }),
|
|
327
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u5207\u6362\u540E dsh \u4F1A\u7ACB\u5373\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF08WebSocket \u77ED\u6682\u65AD\u8FDE\u540E\u81EA\u52A8\u91CD\u8FDE\uFF09\u3002\u6CE8\u610F\uFF1A\u82E5\u5F53\u524D\u901A\u8FC7\u5C40\u57DF\u7F51 IP \u8BBF\u95EE\uFF0C\u5207\u56DE 127.0.0.1 \u540E\u53EA\u80FD\u5728\u672C\u673A\u8BBF\u95EE\u3002" }),
|
|
328
|
+
listenMsg !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": listenMsg.kind, role: "alert", children: listenMsg.text })
|
|
329
|
+
] })
|
|
282
330
|
] });
|
|
283
331
|
}
|
|
284
332
|
function apply(ctx) {
|
|
285
333
|
injectStyle();
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
334
|
+
const services = ctx;
|
|
335
|
+
const scope = services.settingsScope.bind({ namespace: WEB_AUTH_NS });
|
|
336
|
+
services.slots.inject(
|
|
337
|
+
"settings.plugin.item",
|
|
338
|
+
() => services.slots.register(
|
|
290
339
|
{
|
|
291
|
-
name: "settings.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
label: () => "\u8BBF\u95EE\u8BA4\u8BC1"
|
|
340
|
+
name: "settings.plugin.item",
|
|
341
|
+
key: WEB_AUTH_NS,
|
|
342
|
+
inject: () => ({ scope })
|
|
295
343
|
},
|
|
296
|
-
|
|
344
|
+
AuthCard
|
|
297
345
|
)
|
|
298
346
|
);
|
|
299
347
|
}
|
package/lib/context-types.d.ts
CHANGED
|
@@ -34,4 +34,11 @@ export interface PluginContext {
|
|
|
34
34
|
* boot anchors on — the loader inherits it down the whole tree).
|
|
35
35
|
*/
|
|
36
36
|
baseUrl: string;
|
|
37
|
+
/**
|
|
38
|
+
* Attach a callback when listed services are ready (cordis registry,
|
|
39
|
+
* mixed onto every context). Used to attach the settings namespace
|
|
40
|
+
* opportunistically: the callback only runs where a settings service
|
|
41
|
+
* exists (web profiles), so CLI/headless profiles skip it.
|
|
42
|
+
*/
|
|
43
|
+
inject(deps: string[], callback: (sctx: PluginContext) => void): unknown;
|
|
37
44
|
}
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { RateLimiter } from "./auth-core.js";
|
|
|
17
17
|
import { readSecret, readPasswordHash, resolveDshHome, rotateSecret, writePasswordHash } from "./state.js";
|
|
18
18
|
import { createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
|
|
19
19
|
import { LISTEN_APPLY_DELAY_MS, prepareWebserverHostPatch, resolveListenPatchPath, writeWebserverHostPatch } from "./profile-patch.js";
|
|
20
|
+
import { installWebAuthSettings } from "./settings.js";
|
|
20
21
|
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { join } from 'node:path';
|
|
22
23
|
/** Stable Cordis plugin name. */
|
|
@@ -56,6 +57,11 @@ export function apply(ctx) {
|
|
|
56
57
|
// away; failures are logged, never thrown — the client was confirmed.
|
|
57
58
|
listen: buildListenController(ctx)
|
|
58
59
|
};
|
|
60
|
+
// The plugin's own Settings Card (设置 → 插件配置 → web-auth) rides the
|
|
61
|
+
// `web-auth` settings namespace: registering it makes the tab dispatch the
|
|
62
|
+
// card, and committed listenHost changes are applied here. Attaches only
|
|
63
|
+
// when a settings service exists (CLI/headless profiles skip it).
|
|
64
|
+
installWebAuthSettings(ctx, env.listen);
|
|
59
65
|
if (state.readHash() === undefined) {
|
|
60
66
|
// console.error, not ctx.logger.warn: the cordis default logger level
|
|
61
67
|
// suppresses warn (level 2) unless the deployment raises it, and this
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — the `web-auth` settings namespace.
|
|
3
|
+
*
|
|
4
|
+
* The namespace exists so the plugin's browser half can register its own
|
|
5
|
+
* Settings Card: the settings section ("插件配置" tab) dispatches
|
|
6
|
+
* `settings.plugin.item` for every namespace the Host serves, keyed by the
|
|
7
|
+
* namespace string, and the client registers its card under the same key.
|
|
8
|
+
* The namespace carries the one genuine configuration value the plugin owns:
|
|
9
|
+
* the web server's bind host (`listenHost`). The password is deliberately
|
|
10
|
+
* NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
|
|
11
|
+
* changed through a dedicated API, never through the settings document.
|
|
12
|
+
*
|
|
13
|
+
* Applying `listenHost` still goes through the profile patch layer (the
|
|
14
|
+
* webserver's bind host is composition config; the patch is how it is
|
|
15
|
+
* changed, and HMR hot-applies it). The settings document is the user-facing
|
|
16
|
+
* source of intent; this module translates a committed change into the same
|
|
17
|
+
* prepare → deferred-commit path the REST API used before.
|
|
18
|
+
*/
|
|
19
|
+
import z from '@deepseek-ai/schemastery';
|
|
20
|
+
import { type ListenHost } from './profile-patch.ts';
|
|
21
|
+
import type { ListenController } from './handlers.ts';
|
|
22
|
+
/** The settings namespace string (the card's dispatch key, spelled the same client-side). */
|
|
23
|
+
export declare const WEB_AUTH_NS = "web-auth";
|
|
24
|
+
/** Branded namespace for the settings provider. */
|
|
25
|
+
export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
|
|
26
|
+
/** The namespace's resolved shape. */
|
|
27
|
+
export interface WebAuthSettings {
|
|
28
|
+
/** The web server's bind host. */
|
|
29
|
+
listenHost: ListenHost;
|
|
30
|
+
}
|
|
31
|
+
/** Schema resolving the namespace (schemastery, matching the webserver's own).
|
|
32
|
+
*
|
|
33
|
+
* Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
|
|
34
|
+
* written by an out-of-band client) must REJECT the write — keeping the last
|
|
35
|
+
* good resolved value — rather than silently resolving to the default. A
|
|
36
|
+
* default would look like a genuine user change to the watch below and could
|
|
37
|
+
* rebind the webserver off a value nobody actually chose.
|
|
38
|
+
*/
|
|
39
|
+
export declare const WebAuthSettingsSchema: z<WebAuthSettings>;
|
|
40
|
+
/**
|
|
41
|
+
* Register the `web-auth` settings namespace and translate committed
|
|
42
|
+
* `listenHost` changes into the live bind-host switch.
|
|
43
|
+
*
|
|
44
|
+
* The registration rides the plugin fiber and attaches only when a settings
|
|
45
|
+
* service exists (`ctx.inject(['settings'])`), so CLI/headless profiles —
|
|
46
|
+
* which have no web surface and no card — keep working without one. The
|
|
47
|
+
* composition `base` is the webserver's host at boot: the patch layer IS the
|
|
48
|
+
* deployment's composition, and seeding it this way makes the resolved value
|
|
49
|
+
* match reality with no user layer and no settings-document write.
|
|
50
|
+
*
|
|
51
|
+
* The watch callback applies a changed host through the same
|
|
52
|
+
* prepare → deferred-commit controller the REST API uses; a change that is a
|
|
53
|
+
* no-op against the current runtime host is skipped, and a commit that did
|
|
54
|
+
* not actually change the resolved value (invalid writes keep the last good
|
|
55
|
+
* value; document reloads re-resolve) never touches the webserver. This also
|
|
56
|
+
* makes the initial attach harmless — the resolved value equals the runtime
|
|
57
|
+
* host.
|
|
58
|
+
*
|
|
59
|
+
* @param ctx - the plugin context (must expose `settings` when available).
|
|
60
|
+
* @param listen - the live listen-host controller (prepare/commit).
|
|
61
|
+
*/
|
|
62
|
+
export declare function installWebAuthSettings(ctx: import('./context-types.ts').PluginContext, listen: ListenController): void;
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — the `web-auth` settings namespace.
|
|
3
|
+
*
|
|
4
|
+
* The namespace exists so the plugin's browser half can register its own
|
|
5
|
+
* Settings Card: the settings section ("插件配置" tab) dispatches
|
|
6
|
+
* `settings.plugin.item` for every namespace the Host serves, keyed by the
|
|
7
|
+
* namespace string, and the client registers its card under the same key.
|
|
8
|
+
* The namespace carries the one genuine configuration value the plugin owns:
|
|
9
|
+
* the web server's bind host (`listenHost`). The password is deliberately
|
|
10
|
+
* NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
|
|
11
|
+
* changed through a dedicated API, never through the settings document.
|
|
12
|
+
*
|
|
13
|
+
* Applying `listenHost` still goes through the profile patch layer (the
|
|
14
|
+
* webserver's bind host is composition config; the patch is how it is
|
|
15
|
+
* changed, and HMR hot-applies it). The settings document is the user-facing
|
|
16
|
+
* source of intent; this module translates a committed change into the same
|
|
17
|
+
* prepare → deferred-commit path the REST API used before.
|
|
18
|
+
*/
|
|
19
|
+
import z from '@deepseek-ai/schemastery';
|
|
20
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
21
|
+
import { isAllowedListenHost } from "./profile-patch.js";
|
|
22
|
+
/** The settings namespace string (the card's dispatch key, spelled the same client-side). */
|
|
23
|
+
export const WEB_AUTH_NS = 'web-auth';
|
|
24
|
+
/** Branded namespace for the settings provider. */
|
|
25
|
+
export const NS = settingsNamespace(WEB_AUTH_NS);
|
|
26
|
+
/** Schema resolving the namespace (schemastery, matching the webserver's own).
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
|
|
29
|
+
* written by an out-of-band client) must REJECT the write — keeping the last
|
|
30
|
+
* good resolved value — rather than silently resolving to the default. A
|
|
31
|
+
* default would look like a genuine user change to the watch below and could
|
|
32
|
+
* rebind the webserver off a value nobody actually chose.
|
|
33
|
+
*/
|
|
34
|
+
export const WebAuthSettingsSchema = z.object({
|
|
35
|
+
listenHost: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')])
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Register the `web-auth` settings namespace and translate committed
|
|
39
|
+
* `listenHost` changes into the live bind-host switch.
|
|
40
|
+
*
|
|
41
|
+
* The registration rides the plugin fiber and attaches only when a settings
|
|
42
|
+
* service exists (`ctx.inject(['settings'])`), so CLI/headless profiles —
|
|
43
|
+
* which have no web surface and no card — keep working without one. The
|
|
44
|
+
* composition `base` is the webserver's host at boot: the patch layer IS the
|
|
45
|
+
* deployment's composition, and seeding it this way makes the resolved value
|
|
46
|
+
* match reality with no user layer and no settings-document write.
|
|
47
|
+
*
|
|
48
|
+
* The watch callback applies a changed host through the same
|
|
49
|
+
* prepare → deferred-commit controller the REST API uses; a change that is a
|
|
50
|
+
* no-op against the current runtime host is skipped, and a commit that did
|
|
51
|
+
* not actually change the resolved value (invalid writes keep the last good
|
|
52
|
+
* value; document reloads re-resolve) never touches the webserver. This also
|
|
53
|
+
* makes the initial attach harmless — the resolved value equals the runtime
|
|
54
|
+
* host.
|
|
55
|
+
*
|
|
56
|
+
* @param ctx - the plugin context (must expose `settings` when available).
|
|
57
|
+
* @param listen - the live listen-host controller (prepare/commit).
|
|
58
|
+
*/
|
|
59
|
+
export function installWebAuthSettings(ctx, listen) {
|
|
60
|
+
ctx.inject(['settings'], (sctx) => {
|
|
61
|
+
const current = listen.current();
|
|
62
|
+
const base = {
|
|
63
|
+
listenHost: isAllowedListenHost(current) ? current : '127.0.0.1'
|
|
64
|
+
};
|
|
65
|
+
const scope = sctx.settings.register(NS, WebAuthSettingsSchema, { base });
|
|
66
|
+
const dispose = scope.watch((next, prev) => {
|
|
67
|
+
const desired = next.listenHost;
|
|
68
|
+
// Only a real resolved-value change is a user gesture worth applying:
|
|
69
|
+
// a commit that re-resolves to the same value (document reloads, a
|
|
70
|
+
// rejected patch keeping the last good value) must not touch the
|
|
71
|
+
// webserver. Combined with the no-default schema, an invalid write
|
|
72
|
+
// cannot masquerade as a change.
|
|
73
|
+
if (desired === prev.listenHost)
|
|
74
|
+
return;
|
|
75
|
+
if (desired === listen.current())
|
|
76
|
+
return;
|
|
77
|
+
try {
|
|
78
|
+
listen.commit(listen.prepare(desired));
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// The settings document already committed; the apply is best-effort
|
|
82
|
+
// (failures logged, never thrown — the client was already confirmed).
|
|
83
|
+
console.error('[dsh-web-auth] failed to apply listenHost from settings:', error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
sctx.effect(() => dispose, 'web-auth: listen host settings watcher');
|
|
87
|
+
});
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-web-auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DSH web plugin: password-gate authentication (login page + HttpOnly signed cookie) for exposing dsh web behind a reverse proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "rm -rf lib && tsc -p tsconfig.build.json && node scripts/build-client.mjs",
|
|
34
|
+
"prepare": "npm run build",
|
|
34
35
|
"typecheck": "tsc --noEmit",
|
|
35
36
|
"test": "npm run build && tsc -p tsconfig.test.json && node --test --test-force-exit 'lib-test/*.test.js'",
|
|
36
37
|
"test:only": "tsc -p tsconfig.test.json && node --test --test-force-exit 'lib-test/*.test.js'",
|
|
@@ -46,21 +47,32 @@
|
|
|
46
47
|
"client": {
|
|
47
48
|
"inject": [
|
|
48
49
|
"@deepseek-ai/dsh-client-runtime",
|
|
49
|
-
"@deepseek-ai/dsh-client-ui-slots"
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
52
|
+
"@deepseek-ai/dsh-client-connection",
|
|
53
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
50
54
|
],
|
|
51
55
|
"platform": "web"
|
|
52
56
|
}
|
|
53
57
|
},
|
|
54
58
|
"peerDependencies": {
|
|
55
59
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
60
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
61
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
56
62
|
"react": "^18.2.0",
|
|
57
63
|
"react-dom": "^18.2.0"
|
|
58
64
|
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
67
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
68
|
+
},
|
|
59
69
|
"devDependencies": {
|
|
60
70
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
71
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
62
72
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
63
73
|
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
74
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
75
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
64
76
|
"@types/node": "^22.0.0",
|
|
65
77
|
"@types/react": "^18.3.0",
|
|
66
78
|
"esbuild": "^0.24.0",
|