@fonlan/dsh-web-auth 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  - **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
12
12
  - **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任回环对端的 `X-Forwarded-For`
13
13
  - **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 `web-auth` settings 命名空间 → host 半区应用 patch → HMR 重绑 webserver)
14
+ - **远程设置解锁(可选)**:`unlockRemoteSettings` 组合配置在启动页注入 `__DSH_TRANSPORT__.ownsHost` 标志,让反代域名下的设置面(模型页、插件配置、常规设置等)完整可用,默认关闭(见下文安全权衡)
15
+ - **启动令牌收口**:未登录浏览器带旧 `?token=` 链接访问一律重定向回登录页,只有插件自身的服务端 mint 走回环放行通道——既不需要手工引导,也不会再撞上 DSH 的 `401` 死页面(`0.1.7` 起)
14
16
  - 密码以 **scrypt 加盐哈希**存储(`$DSH_HOME/web-auth/password.hash`,0600),永不回显、不落明文配置
15
17
 
16
18
  ## 安装
@@ -42,7 +44,11 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
42
44
  |---|---|
43
45
  | 未登录访问页面(GET/HEAD) | `302` → `/login?next=原路径`,登录后跳回 |
44
46
  | 未登录访问 API(POST 等) | `401` JSON |
47
+ | DSH 启动令牌交换(`GET /?token=…`) | 仅**插件服务端的 mint 重入**(直连回环对端 + 内部标记头)或**已持有插件会话**的请求放行并改写为回环来源转发:DSH 自行校验令牌、签发自己的 `dsh-auth` cookie(`303` → 干净 `/`);令牌无效则收到 DSH 自带的 `401` |
48
+ | 浏览器直接打开打印的令牌 URL(未登录) | `302` → `/login?next=/`,**丢弃 `token` 参数**:令牌每次启动重新生成,旧书签/旧链接本就无效,放行只会把用户丢到 DSH 的 `401` 页面;登录后服务端 mint 会自动补发有效的 `dsh-auth` cookie |
49
+ | DSH 会话 cookie 自动签发 | 插件在放行响应上**服务端代理 DSH 的令牌交换**(走本机回环 `/?token=`),把 `dsh-auth` Set-Cookie 随响应转发给浏览器——新浏览器无需再手工打开打印的 token URL;每分钟最多一次(结果会持续续期 DSH 的 30 天 cookie) |
45
50
  | 反代域名下已登录访问任意路径(含 `/api`、插件前缀) | 改写为回环来源放行,网关特权方法与插件围栏不再 403 |
51
+ | 反代域名下的设置页(模型页等) | 默认报 `settings are unavailable in this browser`——dsh 客户端按页面 hostname 判定"非本机浏览器",设置镜像保持进程本地。开启 `unlockRemoteSettings` 后启动页注入 `ownsHost`,设置面完整可用(读写仍过密码门 + 回环围栏) |
46
52
  | 跨站请求(即使带 Cookie) | 仍被围栏 `403` 拒绝 |
47
53
  | 未登录 WebSocket 升级 | 握手直接 `401` 拒绝 |
48
54
  | 登录失败 | 跳回登录页显示「密码错误」(统一文案) |
@@ -52,6 +58,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
52
58
  | 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
53
59
  | `/logout` | 清除 Cookie,跳转登录页 |
54
60
 
61
+ > **无需再处理 DSH 自己的启动令牌**:DSH 的浏览器会话认证(`dsh web` 启动时打印的 `http://127.0.0.1:3080/?token=…`)用 cookie 绑定 Host。插件会把所有已认证请求的 Host 改写为回环地址,而 DSH 的 cookie 必须与「DSH 看到的 Host」一致——`0.1.5` 起插件会在登录/会话期间**自动在服务端完成 DSH 令牌交换**并把 `dsh-auth` cookie 转交给浏览器(含持续续期),所以外部设备、新浏览器都不需要再手工访问带 token 的 URL。`0.1.7` 起这条路径进一步收紧:未登录浏览器带 `token` 访问会被重定向到 `/login` 并丢弃该参数(旧令牌在 dsh 重启后即失效,放行只会展示 DSH 自己的 `401` 页面),登录本身就已经完成 mint,不再需要任何手工引导步骤。
62
+
55
63
  ### 切换监听地址
56
64
 
57
65
  dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
@@ -63,6 +71,22 @@ dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 →
63
71
 
64
72
  > 该能力依赖 webserver 行位于 profile/home patch 层(默认安装即如此)。若你的 webserver 配置来自自定义 `--patch` overlay,请直接编辑 overlay 文件。
65
73
 
74
+ ### 远程设置解锁(`unlockRemoteSettings`,默认关闭)
75
+
76
+ **现象**:通过域名反代访问 dsh 时,设置 → 模型页报「加载提供方目录失败: settings are unavailable in this browser」,常规设置、插件配置等设置面同样不可用。这不是故障,是 dsh 的有意设计:设置文档(含 provider 凭据引用等敏感配置)只对"本机浏览器"开放——客户端在页面加载时按 `location.hostname` 判定是否回环(`localhost` / `127.x`),反代域名永远不算,设置镜像因此保持进程本地、根本不发起读取。注意这与服务端无关:本插件已把已认证请求改写为回环来源,`/api` 通道完全正常,只有客户端这道门在拦。
77
+
78
+ **开启**:在 profile 的 `cordis.patch.yml`(`~/.dsh/profiles/<profile>/cordis.patch.yml`)加一条 config 覆盖:
79
+
80
+ ```yaml
81
+ - id: web-auth
82
+ config:
83
+ unlockRemoteSettings: true
84
+ ```
85
+
86
+ HMR 会监听到 patch 变化并热重载 web-auth 行(无需重启进程)。生效后,域名下已登录页面的启动 HTML 会携带一段内联脚本,设置 `window.__DSH_TRANSPORT__.ownsHost = true`——dsh 客户端把它读作"该页面由宿主直接提供",设置镜像随之正常加载。只在成功渲染的 index 响应上注入(未登录请求在闸门处就被 302/401,根本到不了渲染);已存在的 `__DSH_TRANSPORT__` 载体只会合并、不会被覆盖。不需要时删掉该配置即回退(同样热生效)。
87
+
88
+ **安全权衡(请务必理解后再开启)**:`ownsHost` 是 dsh 画的"设置仅本机可改"边界;开启等于把这道边界改由本插件的密码门承担——**每个通过密码认证的浏览器**都能读写 host 设置文档(包括 provider API key 的引用、监听地址等)。若域名暴露在公网,请确保:强密码、反代全程 HTTPS、最好叠加反代层限流/IP 白名单。只在本机/内网使用或设置面不受影响时,保持默认关闭即可。
89
+
66
90
  ## 安全模型与风险(请务必阅读)
67
91
 
68
92
  - **认证是访问控制,不是安全边界**。dsh 的 agent 拥有 bash 执行能力(远程代码执行级别),即使有密码保护,也不要把它当成不可攻破的堡垒:密码强度、反代层 WAF/限流、IP 白名单仍然值得做。
@@ -77,7 +101,7 @@ dsh 默认只监听 `127.0.0.1`。在 **设置 → 插件 → 插件配置 →
77
101
  pnpm install
78
102
  npm run build # host: tsc → lib/;client: esbuild → lib/client.js(ModuleLoader 格式)
79
103
  npm run typecheck
80
- npm run test # 32 项单元 + 集成测试(node:test,真实 HTTP/WS 全流程)
104
+ npm run test # 单元 + 集成测试(node:test,真实 HTTP/WS 全流程)
81
105
  ```
82
106
 
83
107
  仓库结构:
@@ -86,6 +110,7 @@ npm run test # 32 项单元 + 集成测试(node:test,真实 HTTP/WS
86
110
  - `src/gate.ts` — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
87
111
  - `src/handlers.ts` — 登录页/登录/登出/改密/状态/监听切换 路由处理
88
112
  - `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js` 表达式)
113
+ - `src/remote-unlock.ts` — 远程设置解锁:`unlockRemoteSettings` 开启时经 `webServer.tapIndex` 注入 `ownsHost` 启动页脚本
89
114
  - `src/settings.ts` — `web-auth` settings 命名空间注册 + listenHost 变更应用(卡片的分发键与数据源)
90
115
  - `src/index.ts` — cordis 插件装配(host 半区)
91
116
  - `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.plugin.item` 插槽,keyed by `web-auth` 命名空间)
@@ -9,8 +9,12 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
9
9
  export interface WebServerService {
10
10
  /** The underlying node:http server (gated by wrapping its listeners). */
11
11
  server: Server;
12
- /** The listening port (the OS-assigned value when config.port is 0). */
13
- port: number;
12
+ /**
13
+ * The bound listening port (the OS-assigned value when config.port is 0).
14
+ * Undefined until the socket is bound, which happens concurrently with
15
+ * plugin loading — read it per request, never capture it at apply time.
16
+ */
17
+ port?: number;
14
18
  /** The configured bind host ('127.0.0.1' or '0.0.0.0'). */
15
19
  host: string;
16
20
  /** Register an exact-path route; returns the disposer. */
@@ -19,6 +23,12 @@ export interface WebServerService {
19
23
  path: string;
20
24
  handler: (req: IncomingMessage, res: ServerResponse) => void;
21
25
  }): () => void;
26
+ /**
27
+ * Register a raw-HTML index transform over the boot page render (applied
28
+ * after the structured injection rows, in registration order); returns
29
+ * the disposer removing it.
30
+ */
31
+ tapIndex(transform: (html: string) => string): () => void;
22
32
  }
23
33
  /** The plugin context members this plugin consumes. */
24
34
  export interface PluginContext {
package/lib/gate.d.ts CHANGED
@@ -18,8 +18,13 @@ import type { Duplex } from 'node:stream';
18
18
  export interface GateHandlers {
19
19
  /** Decide whether one request may proceed; false = gate answers it. */
20
20
  allow(req: IncomingMessage, res: ServerResponse): boolean;
21
- /** Hook after an allowed request (cookie refresh). */
22
- passed?(req: IncomingMessage, res: ServerResponse): void;
21
+ /**
22
+ * Hook after an allowed request (cookie refresh / DSH cookie mint).
23
+ * May be async: the gate awaits it BEFORE forwarding, so response
24
+ * headers it appends reach the client; it must never throw, and a
25
+ * rejection must not prevent the downstream dispatch.
26
+ */
27
+ passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
23
28
  /** Decide whether one WebSocket upgrade may proceed. */
24
29
  allowUpgrade(req: IncomingMessage): boolean;
25
30
  }
package/lib/gate.js CHANGED
@@ -22,10 +22,16 @@ export function installGate(server, handlers) {
22
22
  const upgradeListeners = server.listeners('upgrade');
23
23
  server.removeAllListeners('request');
24
24
  server.removeAllListeners('upgrade');
25
- server.on('request', (req, res) => {
25
+ server.on('request', async (req, res) => {
26
26
  if (!handlers.allow(req, res))
27
27
  return;
28
- handlers.passed?.(req, res);
28
+ try {
29
+ await handlers.passed?.(req, res);
30
+ }
31
+ catch {
32
+ // A failing passed() hook must never block the downstream dispatch
33
+ // (response headers it could not attach are a soft failure).
34
+ }
29
35
  for (const listener of requestListeners) {
30
36
  ;
31
37
  listener(req, res);
package/lib/handlers.d.ts CHANGED
@@ -21,6 +21,14 @@ export interface HandlerEnv {
21
21
  state: AuthState;
22
22
  limiter: RateLimiter;
23
23
  now?: () => number;
24
+ /**
25
+ * Mint DSH's own browser-session cookie (the one dsh-client-connection
26
+ * validates against the process launch token) server-side, returning the
27
+ * raw Set-Cookie value to relay to the user's browser, or undefined when
28
+ * the exchange cannot run (no connection service, unreachable loopback,
29
+ * fetch failure). See buildDshCookieMinter in index.ts.
30
+ */
31
+ mintDshCookie?(): Promise<string | undefined>;
24
32
  /**
25
33
  * Loopback authority ("127.0.0.1:<port>") used to rewrite the Host/Origin
26
34
  * of AUTHENTICATED requests (any path prefix).
@@ -87,7 +95,16 @@ export declare function setSessionCookie(res: ServerResponse, secret: Buffer, no
87
95
  export interface Gate {
88
96
  allow(req: IncomingMessage, res: ServerResponse): boolean;
89
97
  allowUpgrade(req: IncomingMessage): boolean;
98
+ passed?(req: IncomingMessage, res: ServerResponse): void | Promise<void>;
90
99
  }
100
+ /** Number of `token` query parameters in the request URL. */
101
+ export declare function tokenQueryCount(req: IncomingMessage): number;
102
+ /**
103
+ * Marker header the server-side mint (buildDshCookieMinter) stamps on its
104
+ * loopback re-entry, letting the gate distinguish it from a browser that
105
+ * merely carries a (possibly stale) printed `?token=` URL.
106
+ */
107
+ export declare const MINT_REENTRY_HEADER = "x-dsh-web-auth-mint";
91
108
  /**
92
109
  * Build the gate used to wrap the HTTP server listeners. Whitelisted paths
93
110
  * (login page/form, logout) pass through; everything else requires a valid
package/lib/handlers.js CHANGED
@@ -97,13 +97,44 @@ export function setSessionCookie(res, secret, now, secure) {
97
97
  ];
98
98
  if (secure)
99
99
  parts.push('Secure');
100
- res.setHeader('set-cookie', parts.join('; '));
100
+ // appendHeader (not setHeader): the gate may have already attached the
101
+ // DSH cookie to this response — a setHeader would silently drop it.
102
+ res.appendHeader('set-cookie', parts.join('; '));
101
103
  }
102
104
  function clearCookie(res, secure) {
103
105
  const parts = [COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'];
104
106
  if (secure)
105
107
  parts.push('Secure');
106
- res.setHeader('set-cookie', parts.join('; '));
108
+ res.appendHeader('set-cookie', parts.join('; '));
109
+ }
110
+ /** Minimum interval between server-side DSH cookie mints (debounce). */
111
+ const DSH_MINT_DEBOUNCE_MS = 1000;
112
+ /** Number of `token` query parameters in the request URL. */
113
+ export function tokenQueryCount(req) {
114
+ try {
115
+ return new URL(req.url ?? '/', 'http://x').searchParams.getAll('token').length;
116
+ }
117
+ catch {
118
+ return 0;
119
+ }
120
+ }
121
+ /**
122
+ * Marker header the server-side mint (buildDshCookieMinter) stamps on its
123
+ * loopback re-entry, letting the gate distinguish it from a browser that
124
+ * merely carries a (possibly stale) printed `?token=` URL.
125
+ */
126
+ export const MINT_REENTRY_HEADER = 'x-dsh-web-auth-mint';
127
+ /**
128
+ * Whether this tokened request is the server-side mint's own loopback
129
+ * re-entry: the marker header AND a direct loopback TCP peer (not
130
+ * clientIp's XFF-aware view — a proxied remote client must not pass even
131
+ * if it forges the header, and the mint never traverses the proxy).
132
+ */
133
+ function isMintReentry(req) {
134
+ if (req.headers[MINT_REENTRY_HEADER] !== '1')
135
+ return false;
136
+ const direct = req.socket.remoteAddress ?? '';
137
+ return direct === '127.0.0.1' || direct === '::1' || direct === '::ffff:127.0.0.1';
107
138
  }
108
139
  /**
109
140
  * Build the gate used to wrap the HTTP server listeners. Whitelisted paths
@@ -114,6 +145,8 @@ function clearCookie(res, secure) {
114
145
  */
115
146
  export function createGate(env) {
116
147
  const now = env.now ?? Date.now;
148
+ /** Last server-side DSH cookie mint attempt (epoch ms). */
149
+ let lastMintAt = 0;
117
150
  const whitelisted = (method, pathname) => {
118
151
  if (pathname === '/login')
119
152
  return method === 'GET' || method === 'HEAD' || method === 'POST';
@@ -122,6 +155,32 @@ export function createGate(env) {
122
155
  return false;
123
156
  };
124
157
  const sessionOf = (req) => verifySession(cookieValue(req.headers.cookie, COOKIE_NAME), env.state.secret, now());
158
+ /**
159
+ * Mint DSH's browser-session cookie and attach it to the response when due
160
+ * (debounced globally, ~1/s — the mint is one cheap loopback exchange).
161
+ * Runs for ANY allowed request, authenticated or not: the minted DSH
162
+ * cookie alone never opens the gate (every other path still requires the
163
+ * plugin session), and minting early (login page, setup redirect) means a
164
+ * fresh browser carries both cookies by the time it first reaches DSH.
165
+ */
166
+ const maybeMintDshCookie = async (res) => {
167
+ const mint = env.mintDshCookie;
168
+ if (mint === undefined)
169
+ return;
170
+ const t = now();
171
+ if (t - lastMintAt < DSH_MINT_DEBOUNCE_MS)
172
+ return;
173
+ lastMintAt = t;
174
+ try {
175
+ const cookie = await mint();
176
+ if (cookie !== undefined && !res.headersSent) {
177
+ res.appendHeader('set-cookie', cookie);
178
+ }
179
+ }
180
+ catch (error) {
181
+ console.error('[dsh-web-auth] DSH cookie mint failed:', error);
182
+ }
183
+ };
125
184
  /**
126
185
  * Present AUTHENTICATED traffic as loopback to every downstream
127
186
  * browser-trust fence, without tracking plugin path prefixes. The /api
@@ -155,6 +214,30 @@ export function createGate(env) {
155
214
  const method = req.method ?? 'GET';
156
215
  if (whitelisted(method, pathname))
157
216
  return true;
217
+ // DSH launch-token exchange: GET / with a `token` query param is DSH's
218
+ // own bootstrap surface (the URL printed at startup). DSH validates the
219
+ // token itself and either mints its browser-session cookie (303 → clean
220
+ // /) or answers its own 401. Forward WITHOUT a plugin session only for
221
+ // the server-side mint's loopback re-entry (marker header + direct
222
+ // loopback peer, see buildDshCookieMinter) or when the browser already
223
+ // holds a plugin session — the minted cookie alone cannot open the gate
224
+ // (every other path still demands the session) — and always present the
225
+ // forwarded request as loopback, so the cookie DSH mints binds to the
226
+ // same authority every authenticated request uses after the rewrite.
227
+ // Any OTHER tokened request is a remote browser with a launch token that
228
+ // is stale the moment dsh restarts (the token is per-boot); letting it
229
+ // through renders DSH's raw 401 dead-end page. Redirect to /login
230
+ // instead: the server-side mint relays a fresh DSH cookie on the login
231
+ // response, so the post-login / request is already authenticated and
232
+ // the browser never needs the token itself.
233
+ if (method === 'GET' && pathname === '/' && tokenQueryCount(req) > 0) {
234
+ if (sessionOf(req) !== undefined || isMintReentry(req)) {
235
+ rewriteAsLoopback(req);
236
+ return true;
237
+ }
238
+ redirect(res, '/login?next=' + encodeURIComponent(sanitizeNext(pathname) ?? '/'));
239
+ return false;
240
+ }
158
241
  const session = sessionOf(req);
159
242
  if (session !== undefined) {
160
243
  if (needsSessionRefresh(session, now())) {
@@ -177,6 +260,9 @@ export function createGate(env) {
177
260
  return false;
178
261
  rewriteAsLoopback(req);
179
262
  return true;
263
+ },
264
+ async passed(req, res) {
265
+ await maybeMintDshCookie(res);
180
266
  }
181
267
  };
182
268
  }
package/lib/index.d.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  import type { PluginContext } from './context-types.ts';
2
+ import z from '@deepseek-ai/schemastery';
2
3
  /** Stable Cordis plugin name. */
3
4
  export declare const name = "web-auth";
4
5
  /** Services required before the gate can be installed. */
5
6
  export declare const inject: string[];
7
+ /** The plugin's composition config (patch-layer `config:` overrides). */
8
+ export interface WebAuthConfig {
9
+ /**
10
+ * Inject the `__DSH_TRANSPORT__.ownsHost` flag into the served boot page
11
+ * so pages reached through a reverse proxy/domain load the host settings
12
+ * document and the settings plane works remotely. Default off: the flag
13
+ * is a dsh security boundary, and turning it over lets every
14
+ * session-authenticated browser read and write the host settings document
15
+ * from wherever the domain is reachable. See remote-unlock.ts.
16
+ */
17
+ unlockRemoteSettings: boolean;
18
+ }
19
+ /** Schema resolving the composition config (cordis Standard-Schema path). */
20
+ export declare const Config: z<WebAuthConfig>;
6
21
  /**
7
22
  * Mount the auth gate and its routes.
8
23
  * @param ctx - plugin context carrying the webServer service (listening).
24
+ * @param config - validated composition config (Config schema defaults when
25
+ * the patch row spells none).
9
26
  */
10
- export declare function apply(ctx: PluginContext): void;
27
+ export declare function apply(ctx: PluginContext, config: WebAuthConfig): void;
package/lib/index.js CHANGED
@@ -10,25 +10,38 @@
10
10
  * state.ts. Until a password is configured the gate is OPEN and a boot
11
11
  * warning is logged; the login page then offers first-password setup.
12
12
  *
13
+ * Opt-in composition config (`unlockRemoteSettings`, Config schema below)
14
+ * injects the `__DSH_TRANSPORT__.ownsHost` flag into the served boot page
15
+ * (webServer.tapIndex) so pages reached through a reverse proxy/domain can
16
+ * load the host settings document — see remote-unlock.ts.
17
+ *
13
18
  * Install (bundle layer): dsh plugin --profile web add ./dsh-web-auth
14
19
  */
15
20
  import { installGate } from "./gate.js";
16
21
  import { RateLimiter } from "./auth-core.js";
17
22
  import { readSecret, readPasswordHash, resolveDshHome, rotateSecret, writePasswordHash } from "./state.js";
18
- import { createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
23
+ import { MINT_REENTRY_HEADER, createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
19
24
  import { LISTEN_APPLY_DELAY_MS, prepareWebserverHostPatch, resolveListenPatchPath, writeWebserverHostPatch } from "./profile-patch.js";
20
25
  import { installWebAuthSettings } from "./settings.js";
26
+ import { injectOwnsHost } from "./remote-unlock.js";
21
27
  import { fileURLToPath } from 'node:url';
22
28
  import { join } from 'node:path';
29
+ import z from '@deepseek-ai/schemastery';
23
30
  /** Stable Cordis plugin name. */
24
31
  export const name = 'web-auth';
25
32
  /** Services required before the gate can be installed. */
26
33
  export const inject = ['webServer'];
34
+ /** Schema resolving the composition config (cordis Standard-Schema path). */
35
+ export const Config = z.object({
36
+ unlockRemoteSettings: z.boolean().default(false)
37
+ });
27
38
  /**
28
39
  * Mount the auth gate and its routes.
29
40
  * @param ctx - plugin context carrying the webServer service (listening).
41
+ * @param config - validated composition config (Config schema defaults when
42
+ * the patch row spells none).
30
43
  */
31
- export function apply(ctx) {
44
+ export function apply(ctx, config) {
32
45
  const dshHome = resolveDshHome();
33
46
  const state = {
34
47
  secret: readSecret(dshHome),
@@ -43,10 +56,26 @@ export function apply(ctx) {
43
56
  const env = {
44
57
  state,
45
58
  limiter: new RateLimiter(),
59
+ // Server-side mint of DSH's own browser-session cookie: relayed to the
60
+ // browser on allowed responses so DSH's launch-token auth no longer
61
+ // requires opening the printed ?token= URL per browser. Bound lazily —
62
+ // a headless/CLI profile without the connection service simply never
63
+ // mints (each request then fails DSH auth silently, as before).
64
+ mintDshCookie: buildDshCookieMinter(ctx),
46
65
  // Authenticated /api requests are presented to the gateway's trust fence
47
66
  // as loopback, so the privileged-method pinning (settings.*, credentials.*,
48
67
  // discoverModels, …) passes through the reverse proxy. See handlers.ts.
49
- loopbackAuthority: '127.0.0.1:' + String(ctx.webServer.port),
68
+ // Read per request, never captured: `webServer.port` is the *bound* port
69
+ // and plugins load concurrently with the webserver's bind, so it is still
70
+ // unassigned while this plugin applies (evaluating it here would freeze
71
+ // the authority into an unparseable "127.0.0.1:undefined" that every
72
+ // downstream fence then rejects). No port yet means no rewrite — the
73
+ // request's own Host is already a valid authority, which is also how a
74
+ // profile whose webserver is not listening at all stays consistent.
75
+ get loopbackAuthority() {
76
+ const port = ctx.webServer.port;
77
+ return port === undefined ? undefined : '127.0.0.1:' + String(port);
78
+ },
50
79
  // Live listen-host switching: the webserver's bind host is composition
51
80
  // config owned by the profile patch layer (or the home patch when it
52
81
  // spells the webserver row), so the change is written there and the HMR
@@ -84,6 +113,12 @@ export function apply(ctx) {
84
113
  ctx.effect(() => {
85
114
  return () => disposeGate?.();
86
115
  }, 'web-auth: auth gate');
116
+ // ── remote settings unlock (opt-in): inject ownsHost into the boot page ───
117
+ // A config-only patch change restarts this fiber (cordis update →
118
+ // dispose + re-apply), so the tap flips live with the composition.
119
+ if (config.unlockRemoteSettings) {
120
+ ctx.effect(() => ctx.webServer.tapIndex(injectOwnsHost), 'web-auth: remote settings unlock tap');
121
+ }
87
122
  // ── routes (exact table; /api/web-auth/password wins over the /api prefix) ─
88
123
  const routes = [
89
124
  {
@@ -131,6 +166,53 @@ export function apply(ctx) {
131
166
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }), `web-auth: ${route.path} route`);
132
167
  }
133
168
  }
169
+ /**
170
+ * Server-side DSH browser-session cookie mint.
171
+ *
172
+ * dsh-client-connection exposes the process launch-token URL through its
173
+ * `connection` service (authenticatedUrl — the same call dsh-web-app uses to
174
+ * print the startup URL). This minter fetches that URL over the server's own
175
+ * loopback: the gate's token-exchange branch forwards it (still rewriting
176
+ * Host to loopback), DSH validates the token and answers a 303 with the
177
+ * signed `dsh-auth-<sha256(loopback)>` cookie — exactly the cookie every
178
+ * authenticated request needs after the Host rewrite. The Set-Cookie value is
179
+ * returned so the gate can relay it to the user's browser on ordinary
180
+ * responses, meaning new browsers/endpoints no longer need the printed token
181
+ * (and DSH's 30-day cookie is refreshed continuously).
182
+ *
183
+ * Returns undefined when the exchange cannot run (no connection service,
184
+ * non-loopback-only bind, fetch failure) — the caller degrades to the old
185
+ * behavior where the cookie must arrive via the printed token URL.
186
+ */
187
+ function buildDshCookieMinter(ctx) {
188
+ let connection;
189
+ ctx.inject(['connection'], (cctx) => {
190
+ connection = cctx.connection;
191
+ });
192
+ return async () => {
193
+ if (connection === undefined)
194
+ return undefined;
195
+ const host = ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '::' ? '127.0.0.1' : ctx.webServer.host;
196
+ try {
197
+ const url = connection.authenticatedUrl(`http://${host}:${String(ctx.webServer.port)}`);
198
+ // The marker header is what the gate's token branch keys on to forward
199
+ // this re-entry without a plugin session; without it the loopback
200
+ // tokened GET would be redirected to /login like any stale bookmark.
201
+ const response = await fetch(url, { redirect: 'manual', headers: { [MINT_REENTRY_HEADER]: '1' } });
202
+ const cookies = response.headers.getSetCookie?.() ?? [];
203
+ const all = cookies.length > 0
204
+ ? cookies
205
+ : (response.headers.get('set-cookie') ?? '').length > 0
206
+ ? [response.headers.get('set-cookie') ?? '']
207
+ : [];
208
+ return all.find((cookie) => cookie.startsWith('dsh-auth-')) ?? all[0];
209
+ }
210
+ catch (error) {
211
+ console.error('[dsh-web-auth] could not mint DSH browser-session cookie:', error instanceof Error ? error.message : String(error));
212
+ return undefined;
213
+ }
214
+ };
215
+ }
134
216
  /**
135
217
  * Bind the live listen-host controller to this deployment's patch layer.
136
218
  * Resolves which layer owns the webserver row (home outranks the profile),
@@ -0,0 +1,58 @@
1
+ /**
2
+ * dsh-web-auth — remote settings unlock (opt-in composition config).
3
+ *
4
+ * dsh decides "is this page the operator's local browser?" on the CLIENT,
5
+ * once per page load: dsh-client-connection computes `isLoopback` from the
6
+ * page's location hostname (or the transport's `ownsHost` flag), and the
7
+ * settings UI keeps its describe mirror process-local unless the page counts
8
+ * as loopback — every settings surface (models directory, plugin config, …)
9
+ * then answers "settings are unavailable in this browser". Pages reached
10
+ * through a reverse proxy/domain can never pass the hostname check, so the
11
+ * settings plane stays unreachable there EVEN THOUGH this plugin already
12
+ * presents every session-authenticated request to the server-side fences as
13
+ * loopback (the Host rewrite in handlers.ts): the two trust decisions are
14
+ * read on opposite sides of the wire.
15
+ *
16
+ * This module closes that gap with the one server-side signal the client
17
+ * honors: the webserver renders the boot page through a tap chain
18
+ * (`webServer.tapIndex`, the raw-HTML escape hatch DSH exposes for exactly
19
+ * this), and the client entry reads `globalThis.__DSH_TRANSPORT__.ownsHost`
20
+ * as the "this transport owns the host" flag. A tap that appends a tiny
21
+ * inline script setting the flag makes a proxied page present as a
22
+ * host-owned page: the settings mirror loads over the (already
23
+ * authenticated, already loopback-presented) /api plane and the settings
24
+ * UI works remotely.
25
+ *
26
+ * Deliberate scope:
27
+ * - Runs ONLY on rendered index responses. Unauthenticated requests are
28
+ * answered by the gate (302 /login) or DSH's own index auth before any
29
+ * render, so a proxied page receives the script only after passing both
30
+ * the plugin session and DSH's browser-session cookie.
31
+ * - Only ADDS the flag: an existing `__DSH_TRANSPORT__` carrier (fetch /
32
+ * openStream / loadBundle on an embedding platform) is merged, never
33
+ * replaced.
34
+ * - Off unless the composition opts in (`unlockRemoteSettings: true`):
35
+ * the flag IS the security boundary dsh drew — enabling it lets every
36
+ * authenticated browser read and write the host settings document from
37
+ * wherever the domain is reachable. The deployment owner makes that
38
+ * call, in the patch layer, next to the listen host it extends.
39
+ */
40
+ /** Marker attribute on the injected script tag (also the idempotency key). */
41
+ export declare const OWNS_HOST_MARKER = "data-dsh-web-auth=\"owns-host\"";
42
+ /**
43
+ * The injected script: set `ownsHost` on the page transport, creating the
44
+ * transport object only when the page carries none. A classic (non-module)
45
+ * inline script — parsed before the deferred module entry executes, which is
46
+ * where dsh-client-connection reads the flag once.
47
+ */
48
+ export declare const OWNS_HOST_SCRIPT = "<script data-dsh-web-auth=\"owns-host\">window.__DSH_TRANSPORT__ = window.__DSH_TRANSPORT__ || {}; window.__DSH_TRANSPORT__.ownsHost = true;</script>";
49
+ /**
50
+ * Append the ownsHost script to a rendered index.html body, before
51
+ * `</body>` (falling back to the end when the body tag is missing — the
52
+ * html-to-html contract of `webServer.tapIndex` expects a pure transform).
53
+ * Idempotent: a body that already carries the marker is returned as-is, so
54
+ * double registration across an HMR reload can never double-inject.
55
+ * @param html - the rendered index.html body.
56
+ * @returns the body with the script in place.
57
+ */
58
+ export declare function injectOwnsHost(html: string): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * dsh-web-auth — remote settings unlock (opt-in composition config).
3
+ *
4
+ * dsh decides "is this page the operator's local browser?" on the CLIENT,
5
+ * once per page load: dsh-client-connection computes `isLoopback` from the
6
+ * page's location hostname (or the transport's `ownsHost` flag), and the
7
+ * settings UI keeps its describe mirror process-local unless the page counts
8
+ * as loopback — every settings surface (models directory, plugin config, …)
9
+ * then answers "settings are unavailable in this browser". Pages reached
10
+ * through a reverse proxy/domain can never pass the hostname check, so the
11
+ * settings plane stays unreachable there EVEN THOUGH this plugin already
12
+ * presents every session-authenticated request to the server-side fences as
13
+ * loopback (the Host rewrite in handlers.ts): the two trust decisions are
14
+ * read on opposite sides of the wire.
15
+ *
16
+ * This module closes that gap with the one server-side signal the client
17
+ * honors: the webserver renders the boot page through a tap chain
18
+ * (`webServer.tapIndex`, the raw-HTML escape hatch DSH exposes for exactly
19
+ * this), and the client entry reads `globalThis.__DSH_TRANSPORT__.ownsHost`
20
+ * as the "this transport owns the host" flag. A tap that appends a tiny
21
+ * inline script setting the flag makes a proxied page present as a
22
+ * host-owned page: the settings mirror loads over the (already
23
+ * authenticated, already loopback-presented) /api plane and the settings
24
+ * UI works remotely.
25
+ *
26
+ * Deliberate scope:
27
+ * - Runs ONLY on rendered index responses. Unauthenticated requests are
28
+ * answered by the gate (302 /login) or DSH's own index auth before any
29
+ * render, so a proxied page receives the script only after passing both
30
+ * the plugin session and DSH's browser-session cookie.
31
+ * - Only ADDS the flag: an existing `__DSH_TRANSPORT__` carrier (fetch /
32
+ * openStream / loadBundle on an embedding platform) is merged, never
33
+ * replaced.
34
+ * - Off unless the composition opts in (`unlockRemoteSettings: true`):
35
+ * the flag IS the security boundary dsh drew — enabling it lets every
36
+ * authenticated browser read and write the host settings document from
37
+ * wherever the domain is reachable. The deployment owner makes that
38
+ * call, in the patch layer, next to the listen host it extends.
39
+ */
40
+ /** Marker attribute on the injected script tag (also the idempotency key). */
41
+ export const OWNS_HOST_MARKER = 'data-dsh-web-auth="owns-host"';
42
+ /**
43
+ * The injected script: set `ownsHost` on the page transport, creating the
44
+ * transport object only when the page carries none. A classic (non-module)
45
+ * inline script — parsed before the deferred module entry executes, which is
46
+ * where dsh-client-connection reads the flag once.
47
+ */
48
+ export const OWNS_HOST_SCRIPT = `<script ${OWNS_HOST_MARKER}>window.__DSH_TRANSPORT__ = window.__DSH_TRANSPORT__ || {}; window.__DSH_TRANSPORT__.ownsHost = true;</script>`;
49
+ /**
50
+ * Append the ownsHost script to a rendered index.html body, before
51
+ * `</body>` (falling back to the end when the body tag is missing — the
52
+ * html-to-html contract of `webServer.tapIndex` expects a pure transform).
53
+ * Idempotent: a body that already carries the marker is returned as-is, so
54
+ * double registration across an HMR reload can never double-inject.
55
+ * @param html - the rendered index.html body.
56
+ * @returns the body with the script in place.
57
+ */
58
+ export function injectOwnsHost(html) {
59
+ if (html.includes(OWNS_HOST_MARKER))
60
+ return html;
61
+ const closeBody = /<\/body\b[^>]*>/i.exec(html);
62
+ if (closeBody === null)
63
+ return html + OWNS_HOST_SCRIPT;
64
+ return html.slice(0, closeBody.index) + OWNS_HOST_SCRIPT + html.slice(closeBody.index);
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-web-auth",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
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",