dsh-feishu-auth 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -24,6 +24,7 @@ DSH(DeepSeek Harness)Web 界面的飞书 OAuth 登录网关。一个 Cordis
24
24
  | `lib/pages.js` | 提示页(拒绝 / 错误 / 未就绪 / 已登出),全部内联样式 |
25
25
  | `enable.patch.yml` / `disable.patch.yml` | 启用 / 停用 overlay |
26
26
  | `test/` | `node --test`,零依赖,替身自建 |
27
+ | `docs/architecture.md` / `docs/release.md` | 内部设计(拦截层身份、两段式交接)与发版流程(staged + trusted publishing) |
27
28
 
28
29
  零运行时依赖:只用 node 内置模块,所以在没 `pnpm install` 过的 profile 里也能直接引用。
29
30
 
@@ -110,6 +111,8 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
110
111
  2. 在本机 `dsh web` 实测:启动自检 + 未登录 302 + 完整交接 200;涉及卸载/重载的改动要额外验「停用 → 401、再启用 → 302」。
111
112
  3. 提交并推送 `git push origin main`(仓库 `jianghuifr/dsh-feishu-auth`,带 `dsh-plugin` topic)。
112
113
  4. 影响用户可见行为或配置语义的改动,同步更新 [README.md](README.md) 和 [AGENTS.md](AGENTS.md)(本文)以及架构文档中的对应事实。
114
+ 5. 发版:`npm version patch` → 推 tag → CI 走 `npm stage publish`(OIDC,无 token)→ 在 npmjs 批准后上线。详见 [docs/release.md](docs/release.md)。
115
+ 6. 合并改动 `.github/workflows/` 的 PR 时,执行合并的凭据必须带 `workflow` scope(GitHub 对 OAuth App / PAT 的硬限制,与改动内容无关):用 gh CLI 就先 `gh auth refresh -s workflow` 补授权,或改用网页合并,或本地应用同样改动后直接推 `main`。
113
116
 
114
117
  ## 与 dsh 版本的耦合点
115
118
 
package/README.md CHANGED
@@ -48,7 +48,7 @@ dsh web --no-open --host 0.0.0.0 --port 3080 --trusted-host <你的隧道域名>
48
48
 
49
49
  装完后 profile 的 `dsh.profile.bundles` 会多一行,启动日志出现 `飞书登录已挂载` 和 `网关自检通过` 就绪。
50
50
 
51
- > npm 上还没有这个版本、或想跑本地源码?手工放一份仓库到 `~/.dsh/plugins/dsh-feishu-auth/`,再照 [AGENTS.md 的安装与激活](AGENTS.md#安装与激活) 在 profile 里插一行即可。tarball 离线安装也用 `dsh plugin ... add ./dsh-feishu-auth-0.1.0.tgz`。
51
+ > 想跑本地源码(要改代码、或离线环境)?手工放一份仓库到 `~/.dsh/plugins/dsh-feishu-auth/`,再照 [AGENTS.md 的安装与激活](AGENTS.md#安装与激活) 在 profile 里插一行。tarball 离线安装用 `dsh plugin ... add ./dsh-feishu-auth-0.1.0.tgz`。
52
52
 
53
53
  权限(scope)不用申请,`open_id`、`union_id`、`tenant_key`、姓名直接可读。
54
54
 
@@ -17,9 +17,13 @@ flowchart TB
17
17
  AU -->|"是"| HO{"需要 harness 交接?"}
18
18
  HO -->|"是"| EX["303 → /?token=…<br/>+ 20s 交接标记"]
19
19
  HO -->|"否"| PS["交给 harness 原分发逻辑"]
20
+ PS -->|"harness 回 401<br/>(旧 dsh-auth-* 已作废)"| EX
20
21
  ```
21
22
 
22
- 「需要 harness 交接」的判定:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有交接标记(见下)。
23
+ 「需要 harness 交接」有两条触发路径:
24
+
25
+ 1. **请求前判定**:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有交接标记(见下)。
26
+ 2. **响应后判定**:判定 1 只能看到 `dsh-auth-*` **存不存在**,但 harness 只在「根请求携带本进程启动令牌」时签发它——harness 一重启,浏览器手里那份旧 Cookie 就作废了,而它的存在反而压住了交接,把用户送上 harness 那张没有出口的 401 页。所以页面入口的请求交给 harness 之后还要看它的回答:**回 401 就再交接一次**(同样受交接标记约束)。
23
27
 
24
28
  ## 拦截层
25
29
 
@@ -68,6 +72,8 @@ sequenceDiagram
68
72
 
69
73
  **交接标记**(`dsh-feishu-handoff`,20 秒)是死循环的兜底:浏览器拒绝存 harness Cookie 时,`/` 与 `/?token=…` 之间只会来回一次。
70
74
 
75
+ 第 2 步的前提是「根请求带本进程的启动令牌」,所以 **harness 每次重启都会让浏览器里那份 `dsh-auth-*` 作废**。仅凭「Cookie 存不存在」判断交接会漏掉这种情况(旧 Cookie 还在,交接被压住,用户卡在 harness 的 401 页),因此交接判定同时看 harness 的回答:页面入口拿到 401 就再交接一次——老浏览器无需重新登录即可恢复。同理,交接后的 401 若再出现,交接标记会让它止步,把 harness 的 401 页作为终点而不是无尽往返。
76
+
71
77
  ```mermaid
72
78
  sequenceDiagram
73
79
  participant B as 浏览器
@@ -108,6 +114,7 @@ sequenceDiagram
108
114
  | 会话密钥文件不可读写 | 同上(内存里用临时密钥,重启即失效) |
109
115
  | `webServer.match` 不存在 | 挂载抛错,插件拒启动——无保护状态不允许运行 |
110
116
  | `connection` 服务取不到 | 记 error,交接退化为放行给 harness 的 401 页 |
117
+ | harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 自动再交接一次(用户无感);已带交接标记时不再重试,401 页成为终点 |
111
118
  | 启动自检失败 | `[error]` 明确报出:未登录请求未被拦,或持有效会话仍被拒 |
112
119
  | 配置项(`allowedUsers` / `sessionMaxAgeDays`)非法 | `allowedUsers` 非法 → 致命(避免悄悄放宽到全员);`sessionMaxAgeDays` 非法 → 回落默认值 |
113
120
 
package/docs/release.md CHANGED
@@ -48,7 +48,7 @@ git push --follow-tags
48
48
  随后批准上线,二选一:
49
49
 
50
50
  - 网页:npmjs.com → 你的账号 → **Staged Packages** → 选中版本 → Approve(提示 2FA)
51
- - CLI:`npm stage list` 拿 stage id → `npm stage approve <stage-id>`(需 2FA
51
+ - CLI:`npm stage list` 拿 stage id → `npm stage approve <stage-id>`(需 2FA;npm 会要求到 `https://www.npmjs.com/auth/cli/…` 做一次浏览器认证,链接一次性且约几分钟过期,过期就重跑命令拿新链接)
52
52
 
53
53
  批准前可以验货:`npm stage download <stage-id>` 把 tarball 拉下来看,`npm stage reject <stage-id>` 丢弃。
54
54
 
@@ -62,22 +62,24 @@ git push --follow-tags
62
62
 
63
63
  配成 stage-only 后,该 workflow 发起的 `npm publish` 会被 registry 拒绝,只有 `npm stage publish` 被接受。
64
64
 
65
- ### 首次发布(只需一次,手工)
65
+ ### 首次发布(已完成:0.1.0 于 2026-09-13 手工发布)
66
+
67
+ `stage` 不支持全新包,所以建包这一次必须手工做,之后一律走上面的 staged 流程。
66
68
 
67
69
  ```bash
68
- cd ~/.dsh/plugins/dsh-feishu-auth
70
+ # 在你自己的仓库副本里执行
69
71
  npm login --registry https://registry.npmjs.org
70
72
  npm publish --access public --registry https://registry.npmjs.org
71
73
  ```
72
74
 
73
- - 必须显式指定 registry:本机 npm 默认源是镜像站,不加会发到镜像。
75
+ - 若你的 npm 默认源是镜像(国内常见配置),必须显式带 `--registry https://registry.npmjs.org`,否则会发到镜像上。
74
76
  - 手工发布的 0.1.0 不带 provenance(provenance 需要 CI 的 OIDC);从 0.1.1 起走流水线自动带。
75
77
 
76
78
  ## 版本号与 npm CLI
77
79
 
78
- `npm stage` 需要 npm CLI ≥ 11.15,本机是 11.6.2,所以本机要用 `npx npm@latest stage ...`;`ci.yml` 与 `release.yml` 里都显式 `npm install -g npm@latest`,不受 runner 自带版本影响。
80
+ `npm stage` 需要 npm CLI ≥ 11.15:本地版本不够时用 `npx npm@latest stage ...`。`ci.yml` 与 `release.yml` 里都显式 `npm install -g npm@latest`,不受 runner 自带版本影响。
79
81
 
80
- 包名 `dsh-feishu-auth` 未被占用(2026-09-13 registry 返回 404)。首次发布前确认 npm 账号已开 2FA
82
+ 包名 `dsh-feishu-auth` 已发布(`0.1.0`,2026-09-13;此前查官方 registry 404 即未占用)。此后发版一律走 staged 流程,账号需保持 2FA 开启。
81
83
 
82
84
  ## 依赖维护
83
85
 
package/lib/gate.js CHANGED
@@ -177,6 +177,21 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
177
177
  return names.some((name) => name.startsWith('dsh-auth-')) !== true;
178
178
  }
179
179
 
180
+ /**
181
+ * Send the browser through the harness's launch-token exchange.
182
+ *
183
+ * The marker bounds retries: a browser that refuses to store the harness
184
+ * cookie would otherwise bounce between `/` and `/?token=…` forever.
185
+ */
186
+ function handoffToHarness(req, res, target) {
187
+ redirect(res, 303, target, {
188
+ 'set-cookie': serializeCookie(HANDOFF_COOKIE, '1', {
189
+ maxAgeSeconds: HANDOFF_TTL_SECONDS,
190
+ secure: isSecure(requestBaseUrl(req)),
191
+ }),
192
+ });
193
+ }
194
+
180
195
  /** Deliver the request to the harness exactly as it would have been delivered. */
181
196
  async function passthrough(req, res, route, server) {
182
197
  const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
@@ -188,6 +203,76 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
188
203
  await target(req, res);
189
204
  }
190
205
 
206
+ /** Whether this request is the harness's own page entry point. */
207
+ function isPageNavigation(req, url) {
208
+ if (url.pathname !== '/') return false;
209
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
210
+ return isNavigationRequest(req) === true;
211
+ }
212
+
213
+ /**
214
+ * Deliver a page navigation to the harness while watching for its auth wall.
215
+ *
216
+ * {@link needsHarnessHandoff} can only observe that a `dsh-auth-*` cookie
217
+ * *exists*, but the harness mints that cookie solely through its per-process
218
+ * launch-token exchange — so after a harness restart the cookie a browser
219
+ * still holds is worthless, and its presence suppresses the handoff, landing
220
+ * the user on the harness's terminal 401 page with no way forward.
221
+ *
222
+ * The harness's own answer is the dependable signal, so a 401 on a page
223
+ * navigation is answered with one more handoff. The exchange is left alone
224
+ * when there is nowhere to hand off to (no resolver), when this very request
225
+ * carries the marker (a browser that will not store the cookie), or when the
226
+ * harness answers anything other than 401 (that answer is forwarded as is).
227
+ */
228
+ async function passthroughWatchingForAuthWall(req, res, route, server, requested) {
229
+ const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
230
+ if (typeof target !== 'function') {
231
+ res.writeHead(404);
232
+ res.end();
233
+ return;
234
+ }
235
+ const entryTarget = entryLocation(req, requested);
236
+ const canHandOff =
237
+ entryTarget !== requested &&
238
+ cookieNames(req?.headers?.cookie).includes(HANDOFF_COOKIE) !== true;
239
+ if (canHandOff !== true) {
240
+ await target(req, res);
241
+ return;
242
+ }
243
+
244
+ // Swallow the auth wall before any of it reaches the socket, so the 303 can
245
+ // still be written on the very same response.
246
+ const originalWriteHead = res.writeHead;
247
+ const originalWrite = res.write;
248
+ const originalEnd = res.end;
249
+ let walled = false;
250
+ const restore = () => {
251
+ res.writeHead = originalWriteHead;
252
+ res.write = originalWrite;
253
+ res.end = originalEnd;
254
+ };
255
+ res.writeHead = function (status, ...rest) {
256
+ if (status === 401) {
257
+ walled = true;
258
+ return this;
259
+ }
260
+ return originalWriteHead.apply(this, [status, ...rest]);
261
+ };
262
+ res.write = function (...args) {
263
+ return walled === true ? true : originalWrite.apply(this, args);
264
+ };
265
+ res.end = function (...args) {
266
+ return walled === true ? this : originalEnd.apply(this, args);
267
+ };
268
+ try {
269
+ await target(req, res);
270
+ } finally {
271
+ restore();
272
+ }
273
+ if (walled === true) handoffToHarness(req, res, entryTarget);
274
+ }
275
+
191
276
  /** Refuse an unauthenticated request: redirect browsers, 401 everything else. */
192
277
  function deny(req, res, url, principal) {
193
278
  if (mode === 'misconfigured') {
@@ -417,22 +502,23 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
417
502
  deny(req, res, url, principal);
418
503
  return;
419
504
  }
505
+ const requested = `${url.pathname}${url.search}`;
420
506
  if (needsHarnessHandoff(req, url)) {
421
- const requested = `${url.pathname}${url.search}`;
422
507
  const target = entryLocation(req, requested);
423
508
  // A resolver that is missing or throws degrades to the requested path.
424
509
  // Redirecting to it would repeat this exact state forever, so fall
425
510
  // through to the harness instead: its own 401 page is terminal.
426
511
  if (target !== requested) {
427
- redirect(res, 303, target, {
428
- 'set-cookie': serializeCookie(HANDOFF_COOKIE, '1', {
429
- maxAgeSeconds: HANDOFF_TTL_SECONDS,
430
- secure: isSecure(requestBaseUrl(req)),
431
- }),
432
- });
512
+ handoffToHarness(req, res, target);
433
513
  return;
434
514
  }
435
515
  }
516
+ // A stale `dsh-auth-*` cookie keeps needsHarnessHandoff quiet, so the page
517
+ // entry point also checks what the harness actually answers.
518
+ if (isPageNavigation(req, url)) {
519
+ await passthroughWatchingForAuthWall(req, res, route, server, requested);
520
+ return;
521
+ }
436
522
  await passthrough(req, res, route, server);
437
523
  }
438
524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-feishu-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Feishu (Lark) OAuth login gate for the DeepSeek Harness web GUI.",
5
5
  "keywords": [
6
6
  "dsh",