@fonlan/dsh-web-auth 0.1.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/lib/client.js ADDED
@@ -0,0 +1,305 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-web-auth",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ "use strict";
7
+ var dshWebAuthModule = (() => {
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
+ var __getOwnPropNames = Object.getOwnPropertyNames;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
13
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
14
+ }) : x)(function(x) {
15
+ if (typeof require !== "undefined") return require.apply(this, arguments);
16
+ throw Error('Dynamic require of "' + x + '" is not supported');
17
+ });
18
+ var __export = (target, all) => {
19
+ for (var name in all)
20
+ __defProp(target, name, { get: all[name], enumerable: true });
21
+ };
22
+ var __copyProps = (to, from, except, desc) => {
23
+ if (from && typeof from === "object" || typeof from === "function") {
24
+ for (let key of __getOwnPropNames(from))
25
+ if (!__hasOwnProp.call(to, key) && key !== except)
26
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
27
+ }
28
+ return to;
29
+ };
30
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
31
+
32
+ // src/client/index.tsx
33
+ var index_exports = {};
34
+ __export(index_exports, {
35
+ apply: () => apply,
36
+ inject: () => inject
37
+ });
38
+ var import_react = __require("react");
39
+ var import_jsx_runtime = __require("react/jsx-runtime");
40
+ var inject = ["slots"];
41
+ var CSS = `
42
+ .dwa-card { display: flex; flex-direction: column; gap: 12px; padding: 16px; }
43
+ .dwa-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
44
+ .dwa-status { font-size: 13px; line-height: 1.6; }
45
+ .dwa-status[data-on] { color: var(--dsw-alias-label-primary, #e6e6e6); }
46
+ .dwa-status[data-off] { color: var(--dsw-alias-state-warn-primary, #e0a53b); }
47
+ .dwa-hint { color: var(--dsw-alias-label-tertiary, #8b93a7); font-size: 12px; line-height: 1.6; margin: 0; }
48
+ .dwa-field { display: flex; flex-direction: column; gap: 5px; }
49
+ .dwa-label { color: var(--dsw-alias-label-secondary, #aab2c5); font-size: 12px; font-weight: 600; }
50
+ .dwa-input {
51
+ height: 34px; padding: 0 12px; border-radius: 8px; outline: none;
52
+ border: 1px solid var(--dsw-alias-border-l2, #2a3346);
53
+ background: var(--dsw-alias-bg-layer-3, #0d1017);
54
+ color: var(--dsw-alias-label-primary, #e6e6e6); font: inherit; font-size: 13px;
55
+ }
56
+ .dwa-input:focus { border-color: var(--dsw-alias-brand-primary, #4f8cff); }
57
+ .dwa-actions { display: flex; align-items: center; gap: 10px; }
58
+ .dwa-btn {
59
+ height: 32px; padding: 0 14px; border: none; border-radius: 8px; cursor: pointer;
60
+ background: var(--dsw-alias-button-info-fill, #4f8cff); color: #fff;
61
+ font: inherit; font-size: 13px; font-weight: 600;
62
+ }
63
+ .dwa-btn:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover, #6b9dff); }
64
+ .dwa-btn:disabled { opacity: .5; cursor: default; }
65
+ .dwa-btn[data-ghost] {
66
+ background: transparent; color: var(--dsw-alias-label-secondary, #aab2c5);
67
+ border: 1px solid var(--dsw-alias-border-l2, #2a3346); font-weight: 500;
68
+ }
69
+ .dwa-msg { margin: 0; font-size: 12.5px; line-height: 1.6; }
70
+ .dwa-msg[data-kind=ok] { color: var(--dsw-alias-state-success-primary, #4caf7d); }
71
+ .dwa-msg[data-kind=err] { color: var(--dsw-alias-state-error-primary, #ff8f8f); }
72
+ .dwa-divider { border: none; border-top: 1px solid var(--dsw-alias-border-l1, #232a38); margin: 2px 0; }
73
+ `;
74
+ function injectStyle() {
75
+ const id = "dsh-web-auth/card.css";
76
+ if (document.getElementById(id) !== null) return;
77
+ const tag = document.createElement("style");
78
+ tag.id = id;
79
+ tag.dataset.plugin = "dsh-web-auth";
80
+ tag.textContent = CSS;
81
+ document.head.appendChild(tag);
82
+ }
83
+ async function changePassword(oldPassword, newPassword) {
84
+ try {
85
+ const response = await fetch("/api/web-auth/password", {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json" },
88
+ body: JSON.stringify({ oldPassword, newPassword })
89
+ });
90
+ const data = await response.json();
91
+ if (response.ok && data.ok === true) return { ok: true, message: "\u5BC6\u7801\u5DF2\u4FEE\u6539\uFF0C\u6240\u6709\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002" };
92
+ if (data.error === "wrong-password") return { ok: false, message: "\u65E7\u5BC6\u7801\u9519\u8BEF\u3002" };
93
+ if (data.error === "password-too-short") return { ok: false, message: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" };
94
+ if (data.error === "rate-limited") return { ok: false, message: "\u5C1D\u8BD5\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002" };
95
+ return { ok: false, message: "\u4FEE\u6539\u5931\u8D25\uFF1A" + String(data.error ?? response.status) };
96
+ } catch {
97
+ return { ok: false, message: "\u7F51\u7EDC\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002" };
98
+ }
99
+ }
100
+ var LISTEN_OPTIONS = [
101
+ { value: "127.0.0.1", label: "127.0.0.1\uFF08\u4EC5\u672C\u673A\uFF09" },
102
+ { value: "0.0.0.0", label: "0.0.0.0\uFF08\u6240\u6709\u7F51\u5361\uFF0C\u5C40\u57DF\u7F51\u53EF\u8BBF\u95EE\uFF09" }
103
+ ];
104
+ function isListenHost(value) {
105
+ return value === "127.0.0.1" || value === "0.0.0.0";
106
+ }
107
+ function PasswordSection(_props) {
108
+ const [configured, setConfigured] = (0, import_react.useState)(null);
109
+ const [oldPassword, setOldPassword] = (0, import_react.useState)("");
110
+ const [newPassword, setNewPassword] = (0, import_react.useState)("");
111
+ const [confirm, setConfirm] = (0, import_react.useState)("");
112
+ const [busy, setBusy] = (0, import_react.useState)(false);
113
+ const [message, setMessage] = (0, import_react.useState)(null);
114
+ const [listenHost, setListenHost] = (0, import_react.useState)(null);
115
+ const [listenPick, setListenPick] = (0, import_react.useState)("127.0.0.1");
116
+ const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
117
+ const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
118
+ (0, import_react.useEffect)(() => {
119
+ let alive = true;
120
+ void fetch("/api/web-auth/status").then((response) => response.ok ? response.json() : null).then((data) => {
121
+ if (!alive) return;
122
+ setConfigured(data?.configured === true);
123
+ if (isListenHost(data?.host)) {
124
+ setListenHost(data.host);
125
+ setListenPick(data.host);
126
+ }
127
+ }).catch(() => {
128
+ if (alive) setConfigured(null);
129
+ });
130
+ return () => {
131
+ alive = false;
132
+ };
133
+ }, []);
134
+ const submit = async () => {
135
+ if (newPassword.length < 8) {
136
+ setMessage({ kind: "err", text: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" });
137
+ return;
138
+ }
139
+ if (newPassword !== confirm) {
140
+ setMessage({ kind: "err", text: "\u4E24\u6B21\u8F93\u5165\u7684\u65B0\u5BC6\u7801\u4E0D\u4E00\u81F4\u3002" });
141
+ return;
142
+ }
143
+ setBusy(true);
144
+ setMessage(null);
145
+ const result = await changePassword(oldPassword, newPassword);
146
+ setBusy(false);
147
+ setMessage({ kind: result.ok ? "ok" : "err", text: result.message });
148
+ if (result.ok) {
149
+ setConfigured(true);
150
+ setOldPassword("");
151
+ setNewPassword("");
152
+ setConfirm("");
153
+ window.setTimeout(() => {
154
+ window.location.href = "/login";
155
+ }, 1600);
156
+ }
157
+ };
158
+ const applyListen = async () => {
159
+ if (listenHost === listenPick) return;
160
+ setListenBusy(true);
161
+ setListenMsg(null);
162
+ try {
163
+ const response = await fetch("/api/web-auth/listen", {
164
+ method: "POST",
165
+ headers: { "content-type": "application/json" },
166
+ body: JSON.stringify({ host: listenPick })
167
+ });
168
+ const data = await response.json();
169
+ if (response.ok && data.ok === true) {
170
+ if (data.applying === true) {
171
+ 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" });
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" });
189
+ }
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
+ }
194
+ } catch {
195
+ setListenMsg({ kind: "err", text: "\u7F51\u7EDC\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002" });
196
+ } finally {
197
+ setListenBusy(false);
198
+ }
199
+ };
200
+ if (configured === null) {
201
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dwa-card", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u52A0\u8F7D\u4E2D\u2026" }) });
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)(
272
+ "button",
273
+ {
274
+ className: "dwa-btn",
275
+ disabled: listenBusy || listenHost === null || listenHost === listenPick,
276
+ onClick: () => void applyListen(),
277
+ children: "\u5E94\u7528\u76D1\u542C\u5730\u5740"
278
+ }
279
+ ) }),
280
+ /* @__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" }),
281
+ listenMsg !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": listenMsg.kind, role: "alert", children: listenMsg.text })
282
+ ] });
283
+ }
284
+ function apply(ctx) {
285
+ injectStyle();
286
+ const slots = ctx.slots;
287
+ slots.inject(
288
+ "settings.section",
289
+ () => slots.register(
290
+ {
291
+ name: "settings.section",
292
+ id: "web-auth",
293
+ order: 120,
294
+ label: () => "\u8BBF\u95EE\u8BA4\u8BC1"
295
+ },
296
+ PasswordSection
297
+ )
298
+ );
299
+ }
300
+ return __toCommonJS(index_exports);
301
+ })();
302
+
303
+ return typeof dshWebAuthModule !== "undefined" ? dshWebAuthModule : module.exports;
304
+ }
305
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Structural cordis Context for the host half. A third-party plugin resolves
3
+ * outside the DSH monorepo's single cordis instance, so the upstream
4
+ * `declare module` augmentations do not reach this Context. The members
5
+ * below mirror the runtime shapes this plugin touches.
6
+ */
7
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
8
+ /** The webServer service face (subset of @deepseek-ai/dsh-host-webserver). */
9
+ export interface WebServerService {
10
+ /** The underlying node:http server (gated by wrapping its listeners). */
11
+ server: Server;
12
+ /** The listening port (the OS-assigned value when config.port is 0). */
13
+ port: number;
14
+ /** The configured bind host ('127.0.0.1' or '0.0.0.0'). */
15
+ host: string;
16
+ /** Register an exact-path route; returns the disposer. */
17
+ register(route: {
18
+ kind: 'exact';
19
+ path: string;
20
+ handler: (req: IncomingMessage, res: ServerResponse) => void;
21
+ }): () => void;
22
+ }
23
+ /** The plugin context members this plugin consumes. */
24
+ export interface PluginContext {
25
+ webServer: WebServerService;
26
+ logger: {
27
+ warn(message: string): void;
28
+ error(message: string): void;
29
+ };
30
+ /** Register a setup that returns a disposer, tied to this fiber. */
31
+ effect(setup: () => unknown, label?: string): void;
32
+ /**
33
+ * The composition root directory as a file URL (the profile directory the
34
+ * boot anchors on — the loader inherits it down the whole tree).
35
+ */
36
+ baseUrl: string;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
package/lib/gate.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * dsh-web-auth — HTTP gate: wraps the node:http server's request/upgrade
3
+ * listeners so EVERY request (routes, static fallback, WebSocket upgrades)
4
+ * passes the auth check before the original dispatch sees it.
5
+ *
6
+ * The webserver package registers exactly one 'request' listener (its route
7
+ * dispatcher) and one 'upgrade' listener. The gate captures them, removes
8
+ * them, and re-installs a single wrapper: denied requests are answered by
9
+ * the gate itself; allowed requests are forwarded to the captured listeners
10
+ * in original order. Disposal restores the originals.
11
+ *
12
+ * Installation is deferred until the server is actually listening, so the
13
+ * captured listener set is the final one. A WeakSet guard makes re-entry
14
+ * (HMR re-mount) a no-op.
15
+ */
16
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
17
+ import type { Duplex } from 'node:stream';
18
+ export interface GateHandlers {
19
+ /** Decide whether one request may proceed; false = gate answers it. */
20
+ allow(req: IncomingMessage, res: ServerResponse): boolean;
21
+ /** Hook after an allowed request (cookie refresh). */
22
+ passed?(req: IncomingMessage, res: ServerResponse): void;
23
+ /** Decide whether one WebSocket upgrade may proceed. */
24
+ allowUpgrade(req: IncomingMessage): boolean;
25
+ }
26
+ /** Standard HTTP rejection for a denied WebSocket upgrade. */
27
+ export declare function rejectUpgrade(socket: Duplex, status?: number, body?: string): void;
28
+ /**
29
+ * Install the auth gate on a listening HTTP server. Returns the disposer
30
+ * restoring the original listeners (idempotent).
31
+ */
32
+ export declare function installGate(server: Server, handlers: GateHandlers): () => void;
package/lib/gate.js ADDED
@@ -0,0 +1,55 @@
1
+ const gated = new WeakSet();
2
+ /** Standard HTTP rejection for a denied WebSocket upgrade. */
3
+ export function rejectUpgrade(socket, status = 401, body = 'unauthorized') {
4
+ socket.end([
5
+ `HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Forbidden'}`,
6
+ 'Connection: close',
7
+ 'Content-Type: text/plain; charset=utf-8',
8
+ 'Content-Length: ' + Buffer.byteLength(body),
9
+ '',
10
+ body
11
+ ].join('\r\n'));
12
+ }
13
+ /**
14
+ * Install the auth gate on a listening HTTP server. Returns the disposer
15
+ * restoring the original listeners (idempotent).
16
+ */
17
+ export function installGate(server, handlers) {
18
+ if (gated.has(server))
19
+ return () => { };
20
+ gated.add(server);
21
+ const requestListeners = server.listeners('request');
22
+ const upgradeListeners = server.listeners('upgrade');
23
+ server.removeAllListeners('request');
24
+ server.removeAllListeners('upgrade');
25
+ server.on('request', (req, res) => {
26
+ if (!handlers.allow(req, res))
27
+ return;
28
+ handlers.passed?.(req, res);
29
+ for (const listener of requestListeners) {
30
+ ;
31
+ listener(req, res);
32
+ }
33
+ });
34
+ server.on('upgrade', (req, socket, head) => {
35
+ if (!handlers.allowUpgrade(req)) {
36
+ rejectUpgrade(socket);
37
+ return;
38
+ }
39
+ for (const listener of upgradeListeners) {
40
+ ;
41
+ listener(req, socket, head);
42
+ }
43
+ });
44
+ return () => {
45
+ if (!gated.has(server))
46
+ return;
47
+ gated.delete(server);
48
+ server.removeAllListeners('request');
49
+ server.removeAllListeners('upgrade');
50
+ for (const listener of requestListeners)
51
+ server.on('request', listener);
52
+ for (const listener of upgradeListeners)
53
+ server.on('upgrade', listener);
54
+ };
55
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * dsh-web-auth — request handlers: the gate decision (whitelist + session
3
+ * check), the /login page + form processing (including first-password setup),
4
+ * /logout, and the JSON change-password API for the settings card.
5
+ */
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { type RateLimiter } from './auth-core.ts';
8
+ import { type ListenHost } from './profile-patch.ts';
9
+ /** Maximum accepted request body for the login form / API. */
10
+ export declare const MAX_BODY_BYTES: number;
11
+ export interface AuthState {
12
+ /** Current session-signing secret (rotated on password change). */
13
+ secret: Buffer;
14
+ /** Current password hash (re-read per attempt so GUI changes apply live). */
15
+ readHash(): string | undefined;
16
+ writeHash(hash: string): void;
17
+ /** Replace the signing secret; returns the new secret. */
18
+ rotateSecret(): Buffer;
19
+ }
20
+ export interface HandlerEnv {
21
+ state: AuthState;
22
+ limiter: RateLimiter;
23
+ now?: () => number;
24
+ /**
25
+ * Loopback authority ("127.0.0.1:<port>") used to rewrite the Host/Origin
26
+ * of AUTHENTICATED requests (any path prefix).
27
+ * The /api gateway pins its privileged methods (settings.*, credentials.*,
28
+ * llm.discoverModels, agentPreset.*, host.*) to loopback because its
29
+ * trustedHosts fence is DNS-rebinding defense, NOT authentication — the
30
+ * upstream comment says the plane stays loopback "until a real
31
+ * authentication layer exists". Plugin surfaces that mirror the /api fence
32
+ * (dsh-better-sidebar's /sidebar routes) behave the same way. This plugin
33
+ * IS that layer: a request carrying a valid session cookie is same-origin
34
+ * by construction (SameSite=Lax), so it may present as loopback. Requests
35
+ * without a session never reach the fences (the gate rejects them first).
36
+ */
37
+ loopbackAuthority?: string;
38
+ /**
39
+ * Listen-address controller (bound by the host half when the profile
40
+ * patch layer is reachable). Absent = the settings card cannot change
41
+ * the bind host.
42
+ */
43
+ listen?: ListenController;
44
+ }
45
+ /**
46
+ * Live listen-host switching: the webserver's bind host is composition
47
+ * config, so the change is written into the owning patch layer (profile or
48
+ * home cordis.patch.yml) and HMR hot-applies it — the webserver row
49
+ * reloads, closes its listener, and rebinds. The controller is split into
50
+ * prepare (reportable failures) and commit (after the response flushed,
51
+ * failures logged) because the reload tears down every connection,
52
+ * including the one carrying the confirmation.
53
+ */
54
+ export interface ListenController {
55
+ /** The currently resolved bind host (the webserver's runtime config). */
56
+ current(): string | undefined;
57
+ /**
58
+ * Read + transform the owning patch layer for `host`, returning the next
59
+ * file content. Throws when the patch file cannot be prepared — the
60
+ * change is refused before anything is written.
61
+ */
62
+ prepare(host: ListenHost): string;
63
+ /**
64
+ * Write the prepared content (typically deferred a beat after the
65
+ * response). Failures are logged, never thrown: the caller already
66
+ * confirmed the change to the client.
67
+ */
68
+ commit(content: string): void;
69
+ }
70
+ export declare function pathnameOf(req: IncomingMessage): string;
71
+ /** Read one cookie by name from the Cookie header. */
72
+ export declare function cookieValue(header: string | undefined, name: string): string | undefined;
73
+ /**
74
+ * The client address for rate limiting. X-Forwarded-For is honored ONLY when
75
+ * the direct peer is loopback (the reverse proxy sits on the same host), so
76
+ * a remote attacker hitting dsh directly cannot forge a fresh identity.
77
+ */
78
+ export declare function clientIp(req: IncomingMessage): string;
79
+ /** Whether the browser's connection is HTTPS (reverse-proxied or direct). */
80
+ export declare function isSecureRequest(req: IncomingMessage): boolean;
81
+ /** Collect the request body; undefined when it exceeds the cap (413). */
82
+ export declare function readBody(req: IncomingMessage, maxBytes?: number): Promise<string | undefined>;
83
+ /** Parse application/x-www-form-urlencoded. */
84
+ export declare function parseUrlEncoded(body: string): Record<string, string>;
85
+ /** Set (or refresh) the session cookie on a response. */
86
+ export declare function setSessionCookie(res: ServerResponse, secret: Buffer, now: number, secure: boolean): void;
87
+ export interface Gate {
88
+ allow(req: IncomingMessage, res: ServerResponse): boolean;
89
+ allowUpgrade(req: IncomingMessage): boolean;
90
+ }
91
+ /**
92
+ * Build the gate used to wrap the HTTP server listeners. Whitelisted paths
93
+ * (login page/form, logout) pass through; everything else requires a valid
94
+ * session cookie. Page requests (GET/HEAD) are redirected to /login with the
95
+ * original URL as `next`; other methods get a plain 401 JSON. WebSocket
96
+ * upgrades without a session are rejected at the handshake.
97
+ */
98
+ export declare function createGate(env: HandlerEnv): Gate;
99
+ export declare function handleLoginPage(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
100
+ export declare function handleLoginPost(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
101
+ export declare function handleLogout(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
102
+ export declare function handleStatus(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
103
+ export interface ChangePasswordBody {
104
+ oldPassword?: string;
105
+ newPassword?: string;
106
+ }
107
+ export declare function handleChangePassword(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
108
+ export interface ListenChangeBody {
109
+ host?: unknown;
110
+ }
111
+ /**
112
+ * Switch the web server's bind host between '127.0.0.1' and '0.0.0.0'.
113
+ *
114
+ * The change is applied to the owning patch layer (profile or home
115
+ * cordis.patch.yml); HMR then reloads the webserver row, which closes and
116
+ * rebinds the listener. The response is written BEFORE the patch file is
117
+ * committed — the reload tears down every connection, including this one —
118
+ * so the client gets its confirmation first and the write lands a beat
119
+ * later (see {@link ListenController}).
120
+ */
121
+ export declare function handleListenChange(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;