@fonlan/dsh-web-auth 0.1.8 → 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.
@@ -4,7 +4,8 @@ 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
- * the settings shell does for its own sections.
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
  /**
package/lib/client.js CHANGED
@@ -37,7 +37,7 @@ 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
43
  .dwa-page { display: flex; flex-direction: column; gap: 16px; max-width: 720px; }
@@ -285,22 +285,25 @@ var dshWebAuthModule = (() => {
285
285
  ] });
286
286
  }
287
287
  function apply(ctx) {
288
- injectStyle();
289
- const services = ctx;
290
- const scope = services.settingsScope.bind({ namespace: WEB_AUTH_NS });
291
- services.slots.inject(
292
- "settings.section",
293
- () => services.slots.register(
294
- {
295
- name: "settings.section",
296
- id: "web-auth",
297
- order: 120,
298
- label: "\u8BBF\u95EE\u8BA4\u8BC1",
299
- inject: () => ({ scope })
300
- },
301
- WebAuthSettingsSection
302
- )
303
- );
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
+ });
304
307
  }
305
308
  return __toCommonJS(index_exports);
306
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
@@ -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) {
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
@@ -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),
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-web-auth",
3
- "version": "0.1.8",
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",