@fonlan/dsh-web-auth 0.1.7 → 0.1.9

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/cordis.patch.yml CHANGED
@@ -11,13 +11,13 @@
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 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.
14
+ # loads the browser half (the plugin's own settings page) automatically. The
15
+ # page registers on the `settings.section` slot as the 「访问认证」 entry in the
16
+ # settings sidebar and binds the `web-auth` settings namespace
17
+ # (see src/settings.ts) through the client settings scope.
18
18
  #
19
19
  # There is intentionally NO password here: passwords are maintained through
20
- # the GUI (login page first setup / settings card change), stored hashed
20
+ # the GUI (login page first setup / settings page change), stored hashed
21
21
  # under $DSH_HOME/web-auth/. Until a password is set, auth stays disabled
22
22
  # (see the README for the warning this prints at boot).
23
23
  - insert:
@@ -4,11 +4,12 @@ import type { Context } from '@deepseek-ai/cordis';
4
4
  * `connection` and `remote` are required by `settingsScope.bind` (it resolves
5
5
  * the settings transport and the forwarded-invalidation subscription through
6
6
  * `ctx.get`), so the fiber must wait for them — exactly like
7
- * dsh-client-ui-settings-plugins does for its own cards.
7
+ * the settings shell does for its own sections. dsh >= 0.1.7 dropped
8
+ * `settingsScope` itself, so it is resolved optionally below.
8
9
  */
9
10
  export declare const inject: string[];
10
11
  /**
11
- * Client plugin body: register the settings card. The shell's declaration
12
+ * Client plugin body: register the settings page. The shell's declaration
12
13
  * may not be on the ledger yet, so the registration waits via slots.inject.
13
14
  * @param ctx - the client cordis context.
14
15
  */
package/lib/client.js CHANGED
@@ -37,36 +37,14 @@ 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", "settingsScope", "connection", "remote"];
40
+ var inject = ["slots"];
41
41
  var WEB_AUTH_NS = "web-auth";
42
42
  var CSS = `
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
+ .dwa-page { display: flex; flex-direction: column; gap: 16px; max-width: 720px; }
44
+ .dwa-page-head { display: flex; flex-direction: column; gap: 6px; }
45
+ .dwa-page-title { color: var(--dsw-alias-label-primary, #e6e6e6); margin: 0; font-size: 15px; font-weight: 600; line-height: 1.4; }
46
+ .dwa-page-sub { color: var(--dsw-alias-label-tertiary, #8b93a7); margin: 0; font-size: 13px; line-height: 1.5; }
47
+ .dwa-body { display: flex; flex-direction: column; gap: 12px; }
70
48
  .dwa-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
71
49
  .dwa-status { font-size: 13px; line-height: 1.6; }
72
50
  .dwa-status[data-on] { color: var(--dsw-alias-label-primary, #e6e6e6); }
@@ -99,7 +77,7 @@ var dshWebAuthModule = (() => {
99
77
  .dwa-divider { border: none; border-top: 1px solid var(--dsw-alias-border-l1, #232a38); margin: 2px 0; }
100
78
  `;
101
79
  function injectStyle() {
102
- const id = "dsh-web-auth/card.css";
80
+ const id = "dsh-web-auth/settings-section.css";
103
81
  if (document.getElementById(id) !== null) return;
104
82
  const tag = document.createElement("style");
105
83
  tag.id = id;
@@ -141,7 +119,7 @@ var dshWebAuthModule = (() => {
141
119
  return null;
142
120
  }
143
121
  }
144
- function AuthCard(props) {
122
+ function WebAuthSettingsSection(props) {
145
123
  const { scope } = props;
146
124
  const snapshot = (0, import_react.useSyncExternalStore)(
147
125
  (listener) => scope.subscribe(listener),
@@ -157,7 +135,6 @@ var dshWebAuthModule = (() => {
157
135
  const [listenPick, setListenPick] = (0, import_react.useState)("127.0.0.1");
158
136
  const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
159
137
  const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
160
- const [open, setOpen] = (0, import_react.useState)(false);
161
138
  (0, import_react.useEffect)(() => {
162
139
  let alive = true;
163
140
  void fetchStatus().then((status) => {
@@ -220,34 +197,12 @@ var dshWebAuthModule = (() => {
220
197
  setListenBusy(false);
221
198
  }
222
199
  };
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)(
225
- "button",
226
- {
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
- ]
248
- }
249
- ),
250
- open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-body", children: [
200
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-page", children: [
201
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { className: "dwa-page-head", children: [
202
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { className: "dwa-page-title", children: "\u8BBF\u95EE\u8BA4\u8BC1" }),
203
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-page-sub", children: "Web \u8BBF\u95EE\u5BC6\u7801\u4E0E\u76D1\u542C\u5730\u5740" })
204
+ ] }),
205
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-body", children: [
251
206
  /* @__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
207
  /* @__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
208
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("hr", { className: "dwa-divider" }),
@@ -330,20 +285,25 @@ var dshWebAuthModule = (() => {
330
285
  ] });
331
286
  }
332
287
  function apply(ctx) {
333
- injectStyle();
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(
339
- {
340
- name: "settings.plugin.item",
341
- key: WEB_AUTH_NS,
342
- inject: () => ({ scope })
343
- },
344
- AuthCard
345
- )
346
- );
288
+ ctx.inject(["settingsScope"], (raw) => {
289
+ const c = raw;
290
+ if (c.settingsScope === void 0) return;
291
+ injectStyle();
292
+ const scope = c.settingsScope.bind({ namespace: WEB_AUTH_NS });
293
+ c.slots.inject(
294
+ "settings.section",
295
+ () => c.slots.register(
296
+ {
297
+ name: "settings.section",
298
+ id: "web-auth",
299
+ order: 120,
300
+ label: "\u8BBF\u95EE\u8BA4\u8BC1",
301
+ inject: () => ({ scope })
302
+ },
303
+ WebAuthSettingsSection
304
+ )
305
+ );
306
+ });
347
307
  }
348
308
  return __toCommonJS(index_exports);
349
309
  })();
package/lib/gate.d.ts CHANGED
@@ -25,8 +25,13 @@ export interface GateHandlers {
25
25
  * rejection must not prevent the downstream dispatch.
26
26
  */
27
27
  passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
28
- /** Decide whether one WebSocket upgrade may proceed. */
29
- allowUpgrade(req: IncomingMessage): boolean;
28
+ /**
29
+ * Decide whether one WebSocket upgrade may proceed. May be async: the gate
30
+ * awaits the decision before forwarding, letting a handler repair the
31
+ * request (for example by resolving a session cookie onto its headers)
32
+ * instead of only approving or refusing it.
33
+ */
34
+ allowUpgrade(req: IncomingMessage): boolean | Promise<boolean>;
30
35
  }
31
36
  /** Standard HTTP rejection for a denied WebSocket upgrade. */
32
37
  export declare function rejectUpgrade(socket: Duplex, status?: number, body?: string): void;
package/lib/gate.js CHANGED
@@ -38,14 +38,24 @@ export function installGate(server, handlers) {
38
38
  }
39
39
  });
40
40
  server.on('upgrade', (req, socket, head) => {
41
- if (!handlers.allowUpgrade(req)) {
42
- rejectUpgrade(socket);
43
- return;
44
- }
45
- for (const listener of upgradeListeners) {
46
- ;
47
- listener(req, socket, head);
48
- }
41
+ void (async () => {
42
+ let allowed;
43
+ try {
44
+ allowed = await handlers.allowUpgrade(req);
45
+ }
46
+ catch {
47
+ allowed = false;
48
+ }
49
+ if (!allowed) {
50
+ if (!socket.destroyed && socket.writable)
51
+ rejectUpgrade(socket);
52
+ return;
53
+ }
54
+ for (const listener of upgradeListeners) {
55
+ ;
56
+ listener(req, socket, head);
57
+ }
58
+ })();
49
59
  });
50
60
  return () => {
51
61
  if (!gated.has(server))
package/lib/handlers.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-web-auth — request handlers: the gate decision (whitelist + session
3
3
  * check), the /login page + form processing (including first-password setup),
4
- * /logout, and the JSON change-password API for the settings card.
4
+ * /logout, and the JSON change-password API for the settings page.
5
5
  */
6
6
  import type { IncomingMessage, ServerResponse } from 'node:http';
7
7
  import { type RateLimiter } from './auth-core.ts';
@@ -45,7 +45,7 @@ export interface HandlerEnv {
45
45
  loopbackAuthority?: string;
46
46
  /**
47
47
  * Listen-address controller (bound by the host half when the profile
48
- * patch layer is reachable). Absent = the settings card cannot change
48
+ * patch layer is reachable). Absent = the settings page cannot change
49
49
  * the bind host.
50
50
  */
51
51
  listen?: ListenController;
@@ -94,9 +94,21 @@ export declare function parseUrlEncoded(body: string): Record<string, string>;
94
94
  export declare function setSessionCookie(res: ServerResponse, secret: Buffer, now: number, secure: boolean): void;
95
95
  export interface Gate {
96
96
  allow(req: IncomingMessage, res: ServerResponse): boolean;
97
- allowUpgrade(req: IncomingMessage): boolean;
97
+ allowUpgrade(req: IncomingMessage): boolean | Promise<boolean>;
98
98
  passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
99
99
  }
100
+ /**
101
+ * Name of DSH's authority-bound browser-session cookie for one authority.
102
+ * Mirrors the host's own `dsh-auth-<base64url(sha256(authority))>` scheme; if
103
+ * the scheme ever drifts this only misreads "carried" as "missing", which
104
+ * costs one harmless extra redirect, never a denial.
105
+ *
106
+ * Exported for the integration suite, which models a browser jar: the gate's
107
+ * "already carries the cookie" check is name-based, so a test that stores a
108
+ * differently-named cookie would silently exercise the late-mint path instead
109
+ * of the steady state.
110
+ */
111
+ export declare function dshAuthCookieName(authority: string): string;
100
112
  /** Number of `token` query parameters in the request URL. */
101
113
  export declare function tokenQueryCount(req: IncomingMessage): number;
102
114
  /**
package/lib/handlers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { COOKIE_NAME, MIN_PASSWORD_LENGTH, SESSION_TTL_SECONDS, hashPassword, needsSessionRefresh, sanitizeNext, signSession, verifyPassword, verifySession } from "./auth-core.js";
2
3
  import { renderLoginPage } from "./login-page.js";
3
4
  import { ALLOWED_LISTEN_HOSTS, isAllowedListenHost } from "./profile-patch.js";
@@ -109,6 +110,35 @@ function clearCookie(res, secure) {
109
110
  }
110
111
  /** Minimum interval between server-side DSH cookie mints (debounce). */
111
112
  const DSH_MINT_DEBOUNCE_MS = 1000;
113
+ /** Per-peer cooldown between late-mint redirects (loop breaker). */
114
+ const LATE_MINT_COOLDOWN_MS = 5000;
115
+ /**
116
+ * Name of DSH's authority-bound browser-session cookie for one authority.
117
+ * Mirrors the host's own `dsh-auth-<base64url(sha256(authority))>` scheme; if
118
+ * the scheme ever drifts this only misreads "carried" as "missing", which
119
+ * costs one harmless extra redirect, never a denial.
120
+ *
121
+ * Exported for the integration suite, which models a browser jar: the gate's
122
+ * "already carries the cookie" check is name-based, so a test that stores a
123
+ * differently-named cookie would silently exercise the late-mint path instead
124
+ * of the steady state.
125
+ */
126
+ export function dshAuthCookieName(authority) {
127
+ return 'dsh-auth-' + createHash('sha256').update(authority).digest('base64url');
128
+ }
129
+ /**
130
+ * Whether the request already carries a DSH browser-session cookie for the
131
+ * loopback authority every authenticated request is rewritten to. Unknown
132
+ * authority (webserver not bound yet) counts as carried: no intervention.
133
+ */
134
+ function carriesLoopbackDshCookie(req, authority) {
135
+ if (authority === undefined)
136
+ return true;
137
+ const raw = req.headers.cookie;
138
+ if (raw === undefined)
139
+ return false;
140
+ return cookieValue(raw, dshAuthCookieName(authority)) !== undefined;
141
+ }
112
142
  /** Number of `token` query parameters in the request URL. */
113
143
  export function tokenQueryCount(req) {
114
144
  try {
@@ -181,6 +211,54 @@ export function createGate(env) {
181
211
  console.error('[dsh-web-auth] DSH cookie mint failed:', error);
182
212
  }
183
213
  };
214
+ /** Last late-mint redirect per peer address (loop breaker). */
215
+ const lateMintAt = new Map();
216
+ /**
217
+ * One late-mint redirect per peer per cooldown window. A client that keeps
218
+ * refusing the relayed cookie must not be bounced forever; after the first
219
+ * bounce it passes through and DSH's own response (401 + the relayed
220
+ * cookie) is the fallback, exactly the pre-existing behavior.
221
+ */
222
+ const lateMintDue = (req) => {
223
+ const peer = req.socket.remoteAddress ?? '';
224
+ const t = now();
225
+ const last = lateMintAt.get(peer);
226
+ if (last !== undefined && t - last < LATE_MINT_COOLDOWN_MS)
227
+ return false;
228
+ lateMintAt.set(peer, t);
229
+ return true;
230
+ };
231
+ /**
232
+ * Answer a session-authenticated request that carries no DSH browser-
233
+ * session cookie for the loopback authority: mint the cookie server-side
234
+ * and bounce once through the same URL so the browser stores it. Without
235
+ * this, a browser holding only the plugin session reaches DSH and gets its
236
+ * raw 401 dead end ("reopen the URL printed by dsh web"), recovering only
237
+ * if the user happens to visit again; the redirect makes the first visit
238
+ * succeed. Falls back to /login when the mint is unavailable — that page
239
+ * retries the mint and offers the password form.
240
+ */
241
+ const respondWithLateMint = async (req, res) => {
242
+ const mint = env.mintDshCookie;
243
+ const cookie = mint === undefined ? undefined : await mint().catch(() => undefined);
244
+ // Share the mint throttle with the opportunistic mint in `passed()`: the
245
+ // retry that follows this bounce would otherwise spend a second loopback
246
+ // exchange to mint the very cookie it just stored.
247
+ if (cookie !== undefined)
248
+ lastMintAt = now();
249
+ if (res.headersSent)
250
+ return;
251
+ if (cookie === undefined) {
252
+ redirect(res, '/login?next=' + encodeURIComponent(sanitizeNext(req.url) ?? '/'));
253
+ return;
254
+ }
255
+ res.writeHead(302, {
256
+ 'cache-control': 'no-store',
257
+ location: req.url ?? '/',
258
+ 'set-cookie': cookie
259
+ });
260
+ res.end();
261
+ };
184
262
  /**
185
263
  * Present AUTHENTICATED traffic as loopback to every downstream
186
264
  * browser-trust fence, without tracking plugin path prefixes. The /api
@@ -231,10 +309,22 @@ export function createGate(env) {
231
309
  // response, so the post-login / request is already authenticated and
232
310
  // the browser never needs the token itself.
233
311
  if (method === 'GET' && pathname === '/' && tokenQueryCount(req) > 0) {
234
- if (sessionOf(req) !== undefined || isMintReentry(req)) {
312
+ if (isMintReentry(req)) {
235
313
  rewriteAsLoopback(req);
236
314
  return true;
237
315
  }
316
+ const tokenedSession = sessionOf(req);
317
+ if (tokenedSession !== undefined) {
318
+ if (needsSessionRefresh(tokenedSession, now())) {
319
+ setSessionCookie(res, env.state.secret, now(), isSecureRequest(req));
320
+ }
321
+ rewriteAsLoopback(req);
322
+ if (!carriesLoopbackDshCookie(req, env.loopbackAuthority) && lateMintDue(req)) {
323
+ void respondWithLateMint(req, res);
324
+ return false;
325
+ }
326
+ return true;
327
+ }
238
328
  redirect(res, '/login?next=' + encodeURIComponent(sanitizeNext(pathname) ?? '/'));
239
329
  return false;
240
330
  }
@@ -244,6 +334,12 @@ export function createGate(env) {
244
334
  setSessionCookie(res, env.state.secret, now(), isSecureRequest(req));
245
335
  }
246
336
  rewriteAsLoopback(req);
337
+ if ((method === 'GET' || method === 'HEAD') &&
338
+ !carriesLoopbackDshCookie(req, env.loopbackAuthority) &&
339
+ lateMintDue(req)) {
340
+ void respondWithLateMint(req, res);
341
+ return false;
342
+ }
247
343
  return true;
248
344
  }
249
345
  if (method === 'GET' || method === 'HEAD') {
@@ -255,10 +351,30 @@ export function createGate(env) {
255
351
  }
256
352
  return false;
257
353
  },
258
- allowUpgrade(req) {
354
+ async allowUpgrade(req) {
259
355
  if (sessionOf(req) === undefined)
260
356
  return false;
261
357
  rewriteAsLoopback(req);
358
+ // A WebSocket upgrade carries cookies only through its request headers,
359
+ // and there is no redirect to repair a missing one — so resolve the DSH
360
+ // browser cookie onto this very request before the host's own gate reads
361
+ // it. Without this, a browser that reached the app through a cached page
362
+ // (no HTTP round-trip, so no late-mint) has every /api/remote.mux upgrade
363
+ // refused: all remote calls fail, surfacing as errors like the welcome
364
+ // acknowledgement failing to save.
365
+ if (!carriesLoopbackDshCookie(req, env.loopbackAuthority)) {
366
+ const mint = env.mintDshCookie;
367
+ const cookie = mint === undefined ? undefined : await mint().catch(() => undefined);
368
+ if (cookie !== undefined) {
369
+ // Same shared throttle as the other mint sites (see respondWithLateMint).
370
+ lastMintAt = now();
371
+ const pair = cookie.split(';')[0]?.trim();
372
+ if (pair !== undefined && pair.length > 0) {
373
+ req.headers.cookie =
374
+ req.headers.cookie === undefined ? pair : req.headers.cookie + '; ' + pair;
375
+ }
376
+ }
377
+ }
262
378
  return true;
263
379
  },
264
380
  async passed(req, res) {
@@ -353,7 +469,7 @@ export function handleLogout(req, res, env) {
353
469
  clearCookie(res, isSecureRequest(req));
354
470
  redirect(res, '/login?loggedOut=1');
355
471
  }
356
- // ── GET /api/web-auth/status — settings-card state ─────────────────────────
472
+ // ── GET /api/web-auth/status — settings page state ─────────────────────────
357
473
  export function handleStatus(req, res, env) {
358
474
  const body = { configured: env.state.readHash() !== undefined };
359
475
  const current = env.listen?.current();
package/lib/index.d.ts CHANGED
@@ -16,12 +16,47 @@ export interface WebAuthConfig {
16
16
  */
17
17
  unlockRemoteSettings: boolean;
18
18
  }
19
- /** Schema resolving the composition config (cordis Standard-Schema path). */
20
- export declare const Config: z<WebAuthConfig>;
19
+ /**
20
+ * One `volatile()` config field as the loader hands it to `apply()`: a live
21
+ * handle, not the value it currently holds. Spelled structurally rather than as
22
+ * cosmokit's `Volatile<T>` — cosmokit is a transitive dependency here, and
23
+ * importing it would put a second cosmokit/schemastery pair into the emitted
24
+ * declaration.
25
+ */
26
+ export interface LiveSettingsField<T> {
27
+ get(): T;
28
+ }
29
+ /** Hand-written entry config (profile patch / test fixture): the field is optional. */
30
+ export interface WebAuthConfigInput {
31
+ unlockRemoteSettings?: boolean | null;
32
+ }
33
+ /** The resolved entry config `apply()` receives: the field is a live handle. */
34
+ export interface ResolvedWebAuthConfig {
35
+ unlockRemoteSettings: LiveSettingsField<boolean>;
36
+ }
37
+ /**
38
+ * Composition config schema (cordis Standard-Schema path).
39
+ *
40
+ * `.volatile()` is REQUIRED here, not cosmetic: dsh >= 0.1.7 derives the
41
+ * plugin's settings page from this schema, and `dsh-settings` SKIPS an entry
42
+ * whose schema declares no volatile field (`volatileForm(schema)` is undefined)
43
+ * while refusing every write to a non-volatile path — so without it this
44
+ * plugin's page disappears from Settings entirely, whichever page renders it.
45
+ *
46
+ * The schema is annotated through the package's own default export with locally
47
+ * declared type arguments: schemastery >= 3.18.3 carries a third `Mode` type
48
+ * parameter, so a `volatile()` field's OUTPUT type is `Volatile<T>` and the
49
+ * old `z<WebAuthConfig>` annotation no longer compiles; naming it here also
50
+ * keeps the declaration build portable (TS2742 otherwise).
51
+ */
52
+ export type WebAuthConfigSchema = z<WebAuthConfigInput, ResolvedWebAuthConfig>;
53
+ /** Resolved schema for the loader and the settings plane. */
54
+ export declare const Config: WebAuthConfigSchema;
21
55
  /**
22
56
  * Mount the auth gate and its routes.
23
57
  * @param ctx - plugin context carrying the webServer service (listening).
24
58
  * @param config - validated composition config (Config schema defaults when
25
- * the patch row spells none).
59
+ * the patch row spells none); `unlockRemoteSettings` may arrive as a live
60
+ * volatile handle and is unwrapped below.
26
61
  */
27
- export declare function apply(ctx: PluginContext, config: WebAuthConfig): void;
62
+ export declare function apply(ctx: PluginContext, config: WebAuthConfig | ResolvedWebAuthConfig): void;
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Host half: wraps the webserver's HTTP/upgrade listeners so every request
5
5
  * (routes, static fallback, WebSocket upgrades) must carry a valid session
6
6
  * cookie, serves the /login page (including first-password setup), /logout,
7
- * and the JSON change-password API used by the settings card (client half).
7
+ * and the JSON change-password API used by the settings page (client half).
8
8
  *
9
9
  * State lives under $DSH_HOME/web-auth/ (password.hash, secret) — see
10
10
  * state.ts. Until a password is configured the gate is OPEN and a boot
@@ -31,17 +31,37 @@ import z from '@deepseek-ai/schemastery';
31
31
  export const name = 'web-auth';
32
32
  /** Services required before the gate can be installed. */
33
33
  export const inject = ['webServer'];
34
- /** Schema resolving the composition config (cordis Standard-Schema path). */
34
+ /** Resolved schema for the loader and the settings plane. */
35
35
  export const Config = z.object({
36
- unlockRemoteSettings: z.boolean().default(false)
36
+ unlockRemoteSettings: z.boolean().default(false).volatile()
37
37
  });
38
+ /**
39
+ * Read a config field that may be a plain value or a live (`.volatile()`) node.
40
+ *
41
+ * The loader resolves a volatile field to a cosmokit cell (`{ get() }`), which
42
+ * is an object and therefore always truthy — reading it directly would turn the
43
+ * opt-in flag on everywhere. Hand-written configs (patch fixtures, tests) pass
44
+ * plain values, so both shapes are accepted here.
45
+ */
46
+ function liveValue(node, fallback) {
47
+ if (node === undefined || node === null)
48
+ return fallback;
49
+ const getter = node.get;
50
+ if (typeof getter === 'function') {
51
+ const value = getter.call(node);
52
+ return value === undefined ? fallback : value;
53
+ }
54
+ return node;
55
+ }
38
56
  /**
39
57
  * Mount the auth gate and its routes.
40
58
  * @param ctx - plugin context carrying the webServer service (listening).
41
59
  * @param config - validated composition config (Config schema defaults when
42
- * the patch row spells none).
60
+ * the patch row spells none); `unlockRemoteSettings` may arrive as a live
61
+ * volatile handle and is unwrapped below.
43
62
  */
44
63
  export function apply(ctx, config) {
64
+ const unlockRemoteSettings = liveValue(config.unlockRemoteSettings, false);
45
65
  const dshHome = resolveDshHome();
46
66
  const state = {
47
67
  secret: readSecret(dshHome),
@@ -86,10 +106,10 @@ export function apply(ctx, config) {
86
106
  // away; failures are logged, never thrown — the client was confirmed.
87
107
  listen: buildListenController(ctx)
88
108
  };
89
- // The plugin's own Settings Card (设置 → 插件配置 → web-auth) rides the
90
- // `web-auth` settings namespace: registering it makes the tab dispatch the
91
- // card, and committed listenHost changes are applied here. Attaches only
92
- // when a settings service exists (CLI/headless profiles skip it).
109
+ // The plugin's own settings page (设置 → 侧栏「访问认证」) rides the
110
+ // `web-auth` settings namespace: binding the namespace is how the page reads
111
+ // and writes it, and committed listenHost changes are applied here. Attaches
112
+ // only when a settings service exists (CLI/headless profiles skip it).
93
113
  installWebAuthSettings(ctx, env.listen);
94
114
  if (state.readHash() === undefined) {
95
115
  // console.error, not ctx.logger.warn: the cordis default logger level
@@ -116,7 +136,7 @@ export function apply(ctx, config) {
116
136
  // ── remote settings unlock (opt-in): inject ownsHost into the boot page ───
117
137
  // A config-only patch change restarts this fiber (cordis update →
118
138
  // dispose + re-apply), so the tap flips live with the composition.
119
- if (config.unlockRemoteSettings) {
139
+ if (unlockRemoteSettings) {
120
140
  ctx.effect(() => ctx.webServer.tapIndex(injectOwnsHost), 'web-auth: remote settings unlock tap');
121
141
  }
122
142
  // ── routes (exact table; /api/web-auth/password wins over the /api prefix) ─
package/lib/settings.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * dsh-web-auth — the `web-auth` settings namespace.
3
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.
4
+ * The namespace exists so the plugin's browser half can read and write its
5
+ * own settings page: the page registers on the `settings.section` slot (the
6
+ * 「访问认证」 entry in the settings sidebar) and binds this namespace through
7
+ * the client settings scope, which is the only write path to `listenHost`.
8
8
  * The namespace carries the one genuine configuration value the plugin owns:
9
9
  * the web server's bind host (`listenHost`). The password is deliberately
10
10
  * NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
package/lib/settings.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * dsh-web-auth — the `web-auth` settings namespace.
3
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.
4
+ * The namespace exists so the plugin's browser half can read and write its
5
+ * own settings page: the page registers on the `settings.section` slot (the
6
+ * 「访问认证」 entry in the settings sidebar) and binds this namespace through
7
+ * the client settings scope, which is the only write path to `listenHost`.
8
8
  * The namespace carries the one genuine configuration value the plugin owns:
9
9
  * the web server's bind host (`listenHost`). The password is deliberately
10
10
  * NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-web-auth",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -59,21 +59,21 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@deepseek-ai/cordis": "^4.0.1",
62
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
63
- "@deepseek-ai/schemastery": "^3.18.2",
62
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1",
63
+ "@deepseek-ai/schemastery": "^3.18.4",
64
64
  "react": "^18.2.0",
65
65
  "react-dom": "^18.2.0"
66
66
  },
67
67
  "dependencies": {
68
68
  "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
69
- "@deepseek-ai/schemastery": "^3.18.2"
69
+ "@deepseek-ai/schemastery": "^3.18.4"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@deepseek-ai/cordis": "^4.0.1",
73
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
74
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
75
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
76
- "@deepseek-ai/schemastery": "^3.18.2",
73
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
74
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
75
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
76
+ "@deepseek-ai/schemastery": "^3.18.4",
77
77
  "@types/node": "^22.0.0",
78
78
  "@types/react": "^18.3.0",
79
79
  "esbuild": "^0.24.0",