dsh-feishu-auth 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -19,6 +19,7 @@ DSH(DeepSeek Harness)Web 界面的飞书 OAuth 登录网关。一个 Cordis
19
19
  | `lib/gate.js` | 网关引擎:分发拦截与层标记、OAuth 端点、拒绝策略、harness 两段式交接 |
20
20
  | `lib/feishu.js` | 飞书接口:授权 URL、换 user_access_token、读用户信息 |
21
21
  | `lib/session.js` | HMAC 签名 Cookie(会话 / state)、密钥文件读写 |
22
+ | `lib/client.js` | 浏览器半边:设置面板「退出登录」入口,注册进插槽 `settings.action` |
22
23
  | `lib/config.js` | 配置解析与 Cookie 名、TTL 常量 |
23
24
  | `lib/urls.js` | Host 规范化、回调地址推导、`next` 开放重定向防护、导航请求判定 |
24
25
  | `lib/pages.js` | 提示页(拒绝 / 错误 / 未就绪 / 已登出),全部内联样式 |
@@ -60,9 +61,10 @@ cd ~/.dsh/plugins/dsh-feishu-auth && node --test # 单元用例
60
61
  | --- | --- |
61
62
  | 启动日志 | `飞书登录已挂载 …` + `网关自检通过(未登录 → HTTP 401,已登录 → HTTP 404)`,无 `[error]` |
62
63
  | 未登录访问(浏览器式请求) | `302 → /feishu-auth/login?next=…`;`/api` 无 cookie 时是 `401` JSON |
63
- | 完整登录交接 | `GET /` → `303 /?token=…` → harness 下发 `dsh-auth-*` cookie → 再访问 `/` 得 `200` 且返回真实应用页 |
64
+ | 完整登录交接 | `GET /` → harness 回 401 时给 **200 同站重进页**(自动跳回 `/`)→ 再访问 `/` 得 `200` 真实应用页;harness 不回 401 时走 `303 /?token=…` → 下发 `dsh-auth-*` → 再访问 `/` 得 `200` |
64
65
  | 停用验证 | 探针应从 `302`(网关在岗)变成 `401`(harness 自己的门)——这是确认层真的被摘掉的唯一可靠信号 |
65
66
  | `/feishu-auth/status` | `{"gate":"enforce","authenticated":…}` |
67
+ | 设置页入口 | 打开设置面板:头部「关闭」左边出现「退出登录」;点击后落到「已退出登录」页,浏览器里本插件与 harness 的 Cookie 都清空 |
66
68
 
67
69
  ## 配置项的生效语义
68
70
 
@@ -89,7 +91,7 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
89
91
  | 现象 | 处理 |
90
92
  | --- | --- |
91
93
  | 所有请求 503 | 凭证不在进程环境里:确认 `~/.dsh/.env` 后重启 |
92
- | 登录成功后停在 harness 的 401 | 网关没拿到 harness 入口地址(日志有 error 行):检查 `ctx.inject(['connection'])` 是否仍被 dsh 支持 |
94
+ | 页面打不开(0.1.3 之前会停在 harness 的 401 页) | 网关自己收尾:先给同站重进页、再换新凭据,两轮都不行才给「还差一步」页并打 warn。若日志里完全没有恢复行、只有 `[error]` 说拿不到入口地址 → 检查 `ctx.inject(['connection'])` 是否仍被 dsh 支持 |
93
95
  | 飞书报 `redirect_uri unmatch` | 回调地址没登记/不一致;临时隧道换域名后必须补登记 |
94
96
  | 飞书报 `20010` | 账号不在应用可用范围,或应用版本未发布 |
95
97
  | 想立刻放行 | `disable.patch.yml` 启动一次,或给该行加 `disabled: true` 后重启 |
@@ -101,7 +103,8 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
101
103
  - **绝不用身份比较判断服务成员。** `ctx.webServer` 是 Cordis traceable 服务,成员读取每次都返回新的包装 Proxy:`server.match === 你的函数` 恒为 false。识别自己的层只能靠符号标记,解包靠 `Symbol.for('cordis.original')`。详见架构文档。
102
104
  - **挂载/卸载必须幂等。** 热重载时新层可能先于旧层的 disposer 挂上:只允许最新层卸载,发现遗留层要复用它的原始实现而不是往上叠。
103
105
  - **新增行为要补用例**,并确认「旧实现下该用例会红」——否则它没锁住任何东西。
104
- - **不改客户端资产、不注入 DOM**:拦截只发生在 HTTP 层。
106
+ - **不注入 DOM、不改 harness 的客户端资产**:要出现在界面上只走 dsh 的插槽(`lib/client.js`,见架构文档「客户端半边」),不要往页面里塞元素。
107
+ - **浏览器半边必须保持「注册工厂」形态**:`lib/client.js` 顶层只允许 `window.__ModuleLoader__.load({ id, factory })`;`require` 与一切副作用都必须进 factory,否则运行时直接抛。
105
108
  - **提示页只用内联样式**,不能依赖被自己保护的静态资源。
106
109
  - 配置解析永不抛错:除凭证外的问题降级为默认值并记录;凭证缺失走故障关闭。
107
110
 
@@ -116,13 +119,15 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
116
119
 
117
120
  ## 与 dsh 版本的耦合点
118
121
 
119
- 升级 dsh 后优先复核这四处,任何一处变了都要同步适配:
122
+ 升级 dsh 后优先复核这几处,任何一处变了都要同步适配:
120
123
 
121
124
  | 依赖 | 用途 | 失效表现 |
122
125
  | --- | --- | --- |
123
126
  | `webServer.match(pathname)` 分发点 | 唯一的拦截缝隙 | 插件**拒绝启动**并报错(不会静默放过) |
124
- | `ctx.inject(['connection'])` | 取 harness 入口地址(两段式交接) | 日志 error;登录后停在 harness 的 401 页 |
127
+ | `ctx.inject(['connection'])` | 取 harness 入口地址(两段式交接) | 日志 error;只剩同站重进这一步可救,跨站链那类场景会落到「还差一步」页 |
125
128
  | `dsh-auth-<authority>` Cookie 前缀 | 登出时清掉 harness 自己的 Cookie | 登出后可能被 harness 直接放回 |
126
129
  | `/?token=<launch token>` 兑换约定 | 交接第二段 | 同上 |
130
+ | `dsh.client` + `exports["./client"]` 契约 | 浏览器半边的发现与托管(`/plugins/??<id>/client.js`) | 设置页里没有「退出登录」入口,`/plugins/…` 404 |
131
+ | 插槽名 `settings.action`(`ctx.slots`) | 入口在设置面板里的位置 | 入口不出现;需换槽名并核对 `settings.*` 插槽目录 |
127
132
 
128
133
  另见架构文档「已知边界与风险」。
package/README.md CHANGED
@@ -64,6 +64,12 @@ dsh web --no-open --host 0.0.0.0 --port 3080 --trusted-host <你的隧道域名>
64
64
 
65
65
  `allowedUsers` 该填什么:被拒的人会在页面上看到**他自己**的 `open_id`,抄进去保存即可——配置项热生效,不用重启。
66
66
 
67
+ ## 退出登录
68
+
69
+ **设置面板右上角有一个「退出登录」**(`关闭` 按钮左边)。它调 `/feishu-auth/logout`,会清掉本插件的会话 Cookie **和** harness 自己的 `dsh-auth-*`——后者是关键,只清前者的话 harness 会立刻把人放回去。
70
+
71
+ 标签页停在旧的 harness 报错页上时,按一次刷新即可恢复:网关会自己把浏览器接回来(同站重进 → 必要时换一份新凭据),**不需要重新登录**。
72
+
67
73
  ## 常见问题
68
74
 
69
75
  | 现象 | 处理 |
@@ -14,16 +14,26 @@ flowchart TB
14
14
  CFG -->|"是"| FC["503 未就绪页<br/>(故障关闭)"]
15
15
  CFG -->|"否"| AU{"有效会话 Cookie?"}
16
16
  AU -->|"否"| DN["导航: 302 → 飞书授权页<br/>其它: 401 JSON"]
17
- AU -->|"是"| HO{"需要 harness 交接?"}
18
- HO -->|"是"| EX["303 → /?token=…<br/>+ 20s 交接标记"]
19
- HO -->|"否"| PS["交给 harness 原分发逻辑"]
20
- PS -->|"harness 回 401<br/>(旧 dsh-auth-* 已作废)"| EX
17
+ AU -->|"是"| HO{"需要 harness 交接?<br/>(请求前判定)"}
18
+ HO -->|"是"| EX["303 → /?token=…<br/>+ 阶梯 0"]
19
+ HO -->|"否"| PS["交给 harness 原分发逻辑<br/>并盯住它的回答"]
20
+ PS -->|"harness 回 401"| LD{"恢复阶梯"}
21
+ LD -->|"阶梯 0"| EN["200 同站重进页<br/>+ 阶梯 1"]
22
+ LD -->|"阶梯 1"| EX2["303 → /?token=…<br/>+ 阶梯 2"]
23
+ LD -->|"阶梯 2"| ST["200「还差一步」页<br/>+ 清阶梯"]
24
+ EN --> PS
25
+ EX2 --> PS
21
26
  ```
22
27
 
23
28
  「需要 harness 交接」有两条触发路径:
24
29
 
25
- 1. **请求前判定**:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有交接标记(见下)。
26
- 2. **响应后判定**:判定 1 只能看到 `dsh-auth-*` **存不存在**,但 harness 只在「根请求携带本进程启动令牌」时签发它——harness 一重启,浏览器手里那份旧 Cookie 就作废了,而它的存在反而压住了交接,把用户送上 harness 那张没有出口的 401 页。所以页面入口的请求交给 harness 之后还要看它的回答:**回 401 就再交接一次**(同样受交接标记约束)。
30
+ 1. **请求前判定**:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有阶梯 Cookie。够用即走 `/?token=…`。
31
+ 2. **响应后判定(恢复阶梯)**:判定 1 只能看到 `dsh-auth-*` **存不存在**,看不到浏览器到底交没交上来。有两种状态会让已登录的页面请求仍被 harness 打回 401
32
+
33
+ - **凭据作废**:harness 只在「根请求携带本进程启动令牌」时签发 `dsh-auth-*`,进程一重启,浏览器手里那份签名就验不过了;
34
+ - **凭据被扣下**:harness 那张 Cookie 是 `SameSite=Strict`,而浏览器若正沿一条**跨站跳转链**走(飞书 OAuth 回调就是,且这条链上的后续跳转都留在链里),链内所有请求都不会带上它——尽管它已经存好了。
35
+
36
+ 两种状态的共同可靠信号就是 harness 自己的 401,所以页面入口的响应被 401 打回时,不把它转给用户,而是按阶梯走一步(阶梯值记在 `dsh-feishu-handoff` 里,见「两段式交接与恢复阶梯」)。
27
37
 
28
38
  ## 拦截层
29
39
 
@@ -61,33 +71,44 @@ sequenceDiagram
61
71
  L->>G2: dispose(是最新层 → 还原 original)
62
72
  ```
63
73
 
64
- ## 两段式交接
74
+ ## 两段式交接与恢复阶梯
65
75
 
66
76
  飞书登录只签发本插件自己的会话 Cookie。harness 另有一层签名 Cookie(`dsh-auth-<authority>`),只在一个**根请求带上本进程启动令牌**时签发。所以「能打开页面」需要两段都完成:
67
77
 
68
78
  1. 网关把浏览器跳到 `connection.authenticatedUrl()` 给出的 `/?token=<launch token>`;
69
79
  2. harness 校验令牌、下发 `dsh-auth-*`,再跳回干净的 `/`。
70
80
 
71
- `connection` 服务**只能**经 `ctx.inject(['connection'], cb)` 取(`ctx.get` 返回 undefined,属性访问直接抛错),所以在插件挂载时捕获成 `entryUrlProvider`,每次请求时调用。取不到时打一行 error,并在交接判定里退化为直接放行给 harness(让它自己的 401 页成为终点,避免无休止往返)。
81
+ `connection` 服务**只能**经 `ctx.inject(['connection'], cb)` 取(`ctx.get` 返回 undefined,属性访问直接抛错),所以在插件挂载时捕获成 `entryUrlProvider`,每次请求时调用。取不到时打一行 error,交接判定退化为「不交接」。
82
+
83
+ ### 阶梯
84
+
85
+ **`dsh-feishu-handoff`**(20 秒)记的是这台浏览器已经花掉的恢复步数,而不是一个 0/1 标记:
86
+
87
+ | 值 | 含义 | 这一层的回答 |
88
+ | --- | --- | --- |
89
+ | 无 | 还没试过 | harness 回 401 → **同站重进页**(200,`location.replace`),记 1 |
90
+ | `0` | 只做过请求前交接 | 同上(同站重进) |
91
+ | `1` | 已同站重进 | harness 回 401 → **`303 /?token=…`**,记 2 |
92
+ | `2` | 重进 + 交接都试过 | harness 回 401 → **「还差一步」页**(200,给按钮与原因),并清掉阶梯 |
93
+
94
+ 为什么要「同站重进」这一步:harness 的 `dsh-auth-<authority>` 带 `SameSite=Strict`,而飞书 OAuth 回调落在浏览器眼里是一条**跨站链**——链上所有请求(包括回调后 303 到 `/?token=…`、harness 再 303 回 `/`)都不带 Strict Cookie。于是在 `/` 这一跳被 harness 打回 401,尽管 Cookie 已经存好。此时从**本站域内的文档**发起一次跳转(我们的重进页就是),导航的同站属性成立,Cookie 就带上了——一次跳转、无需重新登录。实测:harness 的墙页面上执行 `location.replace('/')` 即返回应用页。
72
95
 
73
- **交接标记**(`dsh-feishu-handoff`,20 秒)是死循环的兜底:浏览器拒绝存 harness Cookie 时,`/` 与 `/?token=…` 之间只会来回一次。
96
+ 作废旧凭据(harness 重启)走的是下一步:`/?token=…` 会重新签发一张能验过的 Cookie;这条链从同站重进之后出发,因此也在同站上下文里,新 Cookie 立刻可用。
74
97
 
75
- 2 步的前提是「根请求带本进程的启动令牌」,所以 **harness 每次重启都会让浏览器里那份 `dsh-auth-*` 作废**。仅凭「Cookie 存不存在」判断交接会漏掉这种情况(旧 Cookie 还在,交接被压住,用户卡在 harness 的 401 页),因此交接判定同时看 harness 的回答:页面入口拿到 401 就再交接一次——老浏览器无需重新登录即可恢复。同理,交接后的 401 若再出现,交接标记会让它止步,把 harness 的 401 页作为终点而不是无尽往返。
98
+ 阶梯尽头(用户浏览器连续两次都不交出凭据,例如无痕窗口或拦截扩展)由插件自己的页面收尾并打 warn——**harness 的 401 页任何时候都不会被直接转给用户**,因为那张页面只写着一个对用户毫无意义的内部 URL。
76
99
 
77
100
  ```mermaid
78
101
  sequenceDiagram
79
102
  participant B as 浏览器
80
103
  participant G as 网关
81
104
  participant H as harness
82
- B->>G: GET /
83
- G->>B: 302 /feishu-auth/login
84
- B->>G: GET /feishu-auth/login
85
- G->>B: 302 飞书授权页(+ state Cookie)
86
- B->>G: GET /feishu-auth/callback?code=…&state=…
87
- G->>B: 303 /?token=…(+ 会话 Cookie + 交接标记)
88
- B->>H: GET /?token=…
89
- H->>B: 303 /(+ dsh-auth-* Cookie)
90
- B->>G: GET /(会话 + dsh-auth-*)
105
+ B->>H: GET /?token=…(跨站链内)
106
+ H->>B: 303 /(+ dsh-auth-* Strict Cookie,链内被扣下)
107
+ B->>G: GET /(会话有,Strict Cookie 没带上)
108
+ G->>H: 放行
109
+ H->>B: 401 认证墙
110
+ G->>B: 200 同站重进页(吞掉 401,阶梯 1)
111
+ B->>G: GET /(同站导航 → 带上 Strict Cookie)
91
112
  G->>H: 放行
92
113
  H->>B: 200 应用页
93
114
  ```
@@ -100,12 +121,21 @@ sequenceDiagram
100
121
  | --- | --- | --- |
101
122
  | `dsh-feishu-session` | `sessionMaxAgeDays`(默认 14 天) | `kind=session`、`sub`(open_id)、`name`、`tenant`、`iat`、`exp` |
102
123
  | `dsh-feishu-state` | 10 分钟 | `kind=state`、`nonce`、`next`、`redirectUri`、`iat`、`exp` |
103
- | `dsh-feishu-handoff` | 20 秒 | 交接标记,防往返 |
124
+ | `dsh-feishu-handoff` | 20 秒 | 恢复阶梯步数(`1` / `2`),防往返 |
104
125
 
105
126
  载荷统一是 `v1.<base64url(JSON)>.<base64url(HMAC-SHA256)>`,签名密钥是 `$DSH_HOME/feishu-auth/session-secret`(首次启动生成 32 字节、0600、原子写入;重启不变,所以登录态能跨重启存活)。校验用 `timingSafeEqual`,`state` 用常量时间比较防 CSRF。
106
127
 
107
128
  被拒的账号会看到**他自己**的 `open_id`(便于运维填 `allowedUsers`),不泄露他人信息;所有插值经 `escapeHtml`。
108
129
 
130
+ ## 客户端半边
131
+
132
+ 设置面板里的「退出登录」入口由 `lib/client.js` 提供——插件唯一的浏览器侧代码,同时消费两个外部契约:
133
+
134
+ - **dsh 的客户端插件机制**(`@deepseek-ai/dsh-client-modules`):包在 `package.json` 里声明 `dsh.client`(`platform: web`、`immediately`),产物挂在 `exports["./client"]`。服务端扫描**所有**这样声明的包(不限于 `@deepseek-ai/*`),按模块图顺序合成 combo 脚本,浏览器侧由 `window.__ModuleLoader__` 惰性执行。产物必须是**注册工厂形态**:文件顶层只调用 `window.__ModuleLoader__.load({ id, factory })`,不得有任何副作用或 `require`(运行时会抛 `requested external … before the module system existed`)。本插件手写这个包装,所以仓库仍然零依赖、零构建。
135
+ - **插槽 API**(`ctx.slots`):`inject(name, …)` 等槽出现后再 `register`。用的是 `settings.action`——「content-column header, before Close」,`kind: list`、`replaceRisk: none`,因此我们的条目是**加**进去的,不替换任何既有 UI。`settings.action` 由设置面板条目在挂载时声明,所以必须先 `inject` 再注册。
136
+
137
+ 登出本身仍归服务端:入口只是一个指向 `/feishu-auth/logout` 的普通链接,由网关清掉自己的会话 Cookie **和** harness 的 `dsh-auth-*`——少了后者,harness 会当场上门把人放回去。
138
+
109
139
  ## 失败模式
110
140
 
111
141
  | 情形 | 行为 |
@@ -113,8 +143,11 @@ sequenceDiagram
113
143
  | 缺 `appId` / `appSecret` | 故障关闭:所有请求 503「未就绪」页,日志 `[error]` 说明缺什么 |
114
144
  | 会话密钥文件不可读写 | 同上(内存里用临时密钥,重启即失效) |
115
145
  | `webServer.match` 不存在 | 挂载抛错,插件拒启动——无保护状态不允许运行 |
116
- | `connection` 服务取不到 | 记 error,交接退化为放行给 harness 401 |
117
- | harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 自动再交接一次(用户无感);已带交接标记时不再重试,401 页成为终点 |
146
+ | `connection` 服务取不到 | 记 error;不交接,页面入口的 401 由恢复阶梯兜住(同站重进 插件自己的页面) |
147
+ | harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 阶梯(同站重进 再交接一次)→ 用户无感恢复 |
148
+ | 浏览器沿跨站链到达(飞书 OAuth 回调) | 同一条阶梯的第一步就是为此设计的:同站重进一次即带上 `SameSite=Strict` 的 `dsh-auth-*` |
149
+ | 浏览器两次都不交凭据(无痕窗口 / 拦截扩展) | 阶梯走完 → 插件自己的「还差一步」页 + 一行 warn,不把 harness 的 401 页转给用户 |
150
+ | 客户端 bundle 没被服务(`/plugins/…` 404) | 设置面板里当然也没有入口:核对 `package.json` 的 `dsh.client` + `exports["./client"]`、文件存在,以及件里注册的 `id` 与包名一致(运行时会对不上就抛) |
118
151
  | 启动自检失败 | `[error]` 明确报出:未登录请求未被拦,或持有效会话仍被拒 |
119
152
  | 配置项(`allowedUsers` / `sessionMaxAgeDays`)非法 | `allowedUsers` 非法 → 致命(避免悄悄放宽到全员);`sessionMaxAgeDays` 非法 → 回落默认值 |
120
153
 
package/docs/release.md CHANGED
@@ -19,11 +19,13 @@ npm 路径靠 `package.json` 的 `dsh.bundle.patch` 声明自己是组合包,`
19
19
  ```bash
20
20
  npm ci # 只装 devDependencies(eslint);运行时零依赖
21
21
  npm run lint
22
- npm test # node --test,39 个用例
22
+ npm test # node --test,50 个用例
23
23
  npm run verify # lint + test —— CI 与 prepublishOnly 跑的就是它
24
- npm pack --dry-run # 检查发布产物内容(15 个文件)
24
+ npm pack --dry-run # 检查发布产物内容(16 个文件)
25
25
  ```
26
26
 
27
+ `lib/client.js`(浏览器半边)是**手写**的 loader 包装,不是构建产物:文件顶层只允许 `window.__ModuleLoader__.load({ id, factory })`,任何顶层 `require` 或副作用都会被运行时拒绝。改动它的规范见 [docs/architecture.md 的「客户端半边」](architecture.md#客户端半边)。
28
+
27
29
  ## CI
28
30
 
29
31
  `.github/workflows/ci.yml`,在 push 到 `main`、PR、手动触发时跑:
package/lib/client.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Browser half of the gate: one「退出登录」entry in the settings panel.
3
+ *
4
+ * The server half owns `/feishu-auth/logout`, which clears this gate's session
5
+ * cookie AND the harness's own `dsh-auth-*`, so this half only has to put a link
6
+ * to it in front of the user.
7
+ *
8
+ * Two contracts come from dsh, and both are load-bearing:
9
+ *
10
+ * - The file is a **client bundle**: it registers a factory through
11
+ * `window.__ModuleLoader__.load({ id, factory })` and does nothing else at the
12
+ * top level. `@deepseek-ai/dsh-client-modules` serves it at
13
+ * `/plugins/dsh-feishu-auth/client.js` (it discovers any package declaring
14
+ * `dsh.client` plus `exports["./client"]`, not just `@deepseek-ai/*`), and the
15
+ * runtime rejects a bundle that requests an external before the module system
16
+ * exists. Hence: no top-level `require`, no top-level side effects.
17
+ * - The seat is the slot API's, not ours: `ctx.slots.inject(name, …)` waits for
18
+ * the slot to exist — `settings.action` is declared by the settings panel
19
+ * entry, so it only exists while that panel is mounted — and `register` adds
20
+ * an entry beside the shipped ones (`id` of our own, so nothing is replaced).
21
+ *
22
+ * Styles stay inline: a client bundle may inject CSS from inside its factory
23
+ * closure, but one button does not justify a stylesheet.
24
+ * @module dsh-feishu-auth/client
25
+ */
26
+
27
+ window.__ModuleLoader__.load({
28
+ id: 'dsh-feishu-auth',
29
+ factory: (require) => {
30
+ const module = { exports: {} };
31
+ const exports = module.exports;
32
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
33
+ const React = require('react');
34
+
35
+ /** The seat: content-column header actions, rendered before the Close button. */
36
+ const SLOT = 'settings.action';
37
+ /** This entry's id inside that slot — ours, so the shipped entries stay. */
38
+ const ENTRY_ID = 'feishu-logout';
39
+ /** Where the server half signs this browser out. */
40
+ const LOGOUT_PATH = '/feishu-auth/logout';
41
+
42
+ /** Resting and hover looks, kept close to the shipped header actions. */
43
+ const REST = {
44
+ color: 'var(--dsw-alias-label-secondary, #6b6b70)',
45
+ background: 'transparent',
46
+ };
47
+ const HOVER = {
48
+ color: 'var(--dsw-alias-label-primary, #1d1d1f)',
49
+ background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.12))',
50
+ };
51
+
52
+ /**
53
+ * The entry itself: a plain link, so a middle-click or a reload-free
54
+ * navigation both reach the server half.
55
+ * @returns the anchor element.
56
+ */
57
+ function LogoutAction() {
58
+ const [state, setState] = React.useState(REST);
59
+ return React.createElement(
60
+ 'a',
61
+ {
62
+ href: LOGOUT_PATH,
63
+ title: '清掉这台浏览器上的登录凭据',
64
+ style: {
65
+ ...state,
66
+ display: 'inline-flex',
67
+ alignItems: 'center',
68
+ height: '28px',
69
+ padding: '0 10px',
70
+ border: '0',
71
+ borderRadius: '8px',
72
+ font: 'inherit',
73
+ fontSize: '13px',
74
+ lineHeight: '18px',
75
+ textDecoration: 'none',
76
+ whiteSpace: 'nowrap',
77
+ cursor: 'pointer',
78
+ transition: 'color .12s, background .12s',
79
+ },
80
+ onMouseEnter: () => setState(HOVER),
81
+ onMouseLeave: () => setState(REST),
82
+ },
83
+ '退出登录',
84
+ );
85
+ }
86
+
87
+ const inject = ['slots'];
88
+
89
+ /**
90
+ * Register the entry, waiting for the settings panel to declare the slot.
91
+ * @param ctx - client plugin context.
92
+ */
93
+ function apply(ctx) {
94
+ ctx.slots.inject(SLOT, () =>
95
+ ctx.slots.register({ name: SLOT, id: ENTRY_ID, order: 100, label: '退出登录' }, LogoutAction),
96
+ );
97
+ }
98
+
99
+ exports.apply = apply;
100
+ exports.inject = inject;
101
+ return module.exports;
102
+ },
103
+ });
package/lib/gate.js CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  STATE_TTL_MS,
27
27
  } from './config.js';
28
28
  import { buildAuthorizeUrl, exchangeCode, fetchUserInfo } from './feishu.js';
29
- import { renderDenied, renderError, renderLoggedOut, renderMisconfigured } from './pages.js';
29
+ import { renderDenied, renderEntering, renderError, renderLoggedOut, renderMisconfigured, renderRecoveryStuck } from './pages.js';
30
30
  import { cookieNames, expiredCookie, generateToken, readCookie, safeEqual, serializeCookie, signPayload, verifyPayload } from './session.js';
31
31
  import { clientAddress, isNavigationRequest, normalizeAuthority, requestBaseUrl, sanitizeNext } from './urls.js';
32
32
 
@@ -163,9 +163,7 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
163
163
  * otherwise land on the harness's 401 page with no way forward.
164
164
  *
165
165
  * The `token` guard keeps the exchange request itself from looping, and the
166
- * handoff marker bounds the attempt when a browser refuses to store the
167
- * harness cookie — without it, such a browser would bounce between `/` and
168
- * `/?token=…` forever.
166
+ * ladder cookie bounds the attempt (see {@link passthroughWatchingForAuthWall}).
169
167
  */
170
168
  function needsHarnessHandoff(req, url) {
171
169
  if (url.pathname !== '/') return false;
@@ -178,20 +176,39 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
178
176
  }
179
177
 
180
178
  /**
181
- * Send the browser through the harness's launch-token exchange.
179
+ * How many recovery steps this browser has already spent, from the ladder
180
+ * cookie: 0 when nothing has been tried (or only the pre-flight handoff),
181
+ * 1 after the same-site re-entry, 2 after a handoff that followed it.
182
182
  *
183
- * The marker bounds retries: a browser that refuses to store the harness
184
- * cookie would otherwise bounce between `/` and `/?token=…` forever.
183
+ * An unparsable value (for example the `1` a browser kept from an older
184
+ * release) counts as one step spent, which only makes the ladder move on
185
+ * sooner — never a loop.
185
186
  */
186
- function handoffToHarness(req, res, target) {
187
+ function recoveryStep(req) {
188
+ const raw = readCookie(req?.headers?.cookie, HANDOFF_COOKIE);
189
+ if (typeof raw !== 'string') return 0;
190
+ const step = Number.parseInt(raw, 10);
191
+ return Number.isFinite(step) === true && step > 0 ? step : 0;
192
+ }
193
+
194
+ /** Send the browser through the harness's launch-token exchange. */
195
+ function handoffToHarness(req, res, target, step) {
187
196
  redirect(res, 303, target, {
188
- 'set-cookie': serializeCookie(HANDOFF_COOKIE, '1', {
197
+ 'set-cookie': serializeCookie(HANDOFF_COOKIE, String(step), {
189
198
  maxAgeSeconds: HANDOFF_TTL_SECONDS,
190
199
  secure: isSecure(requestBaseUrl(req)),
191
200
  }),
192
201
  });
193
202
  }
194
203
 
204
+ /** Record a spent step without answering: used by the same-site re-entry page. */
205
+ function entryCookie(req, step) {
206
+ return serializeCookie(HANDOFF_COOKIE, String(step), {
207
+ maxAgeSeconds: HANDOFF_TTL_SECONDS,
208
+ secure: isSecure(requestBaseUrl(req)),
209
+ });
210
+ }
211
+
195
212
  /** Deliver the request to the harness exactly as it would have been delivered. */
196
213
  async function passthrough(req, res, route, server) {
197
214
  const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
@@ -214,16 +231,24 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
214
231
  * Deliver a page navigation to the harness while watching for its auth wall.
215
232
  *
216
233
  * {@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.
234
+ * *exists*; whether the browser actually hands it over is another matter.
235
+ * Two states produce the harness's wall on an authenticated page request:
236
+ *
237
+ * 1. The cookie is stale it was minted by a previous harness process, so
238
+ * its signature no longer verifies.
239
+ * 2. The cookie is fine but withheld: the harness mints it with
240
+ * `SameSite=Strict`, and a browser that is following a cross-site chain
241
+ * (the Feishu OAuth redirect, and every hop after it in that chain) does
242
+ * not send it for the rest of that chain.
221
243
  *
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).
244
+ * The wall itself is the dependable signal for both, so it is swallowed and
245
+ * answered with one recovery step, never forwarded: serving the harness's
246
+ * page would show the user an error that names an internal URL they cannot
247
+ * act on. Step 1 re-enters from this origin (a same-site navigation, which
248
+ * sends the Strict cookie this alone fixes case 2), step 2 runs the
249
+ * launch-token exchange (which mints a fresh cookie — case 1), and a browser
250
+ * that still walls after both gets a page that explains itself instead of a
251
+ * third round.
227
252
  */
228
253
  async function passthroughWatchingForAuthWall(req, res, route, server, requested) {
229
254
  const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
@@ -233,16 +258,10 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
233
258
  return;
234
259
  }
235
260
  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
- }
261
+ const step = recoveryStep(req);
243
262
 
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.
263
+ // Swallow the auth wall before any of it reaches the socket, so the
264
+ // recovery answer can still be written on the very same response.
246
265
  const originalWriteHead = res.writeHead;
247
266
  const originalWrite = res.write;
248
267
  const originalEnd = res.end;
@@ -270,7 +289,26 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
270
289
  } finally {
271
290
  restore();
272
291
  }
273
- if (walled === true) handoffToHarness(req, res, entryTarget);
292
+ if (walled !== true) return;
293
+
294
+ const canHandOff = entryTarget !== requested;
295
+ if (step <= 0) {
296
+ log.info(`harness 拒绝了页面请求(凭据未送达或被判失效),让浏览器从本站重新进入 ${requested}`);
297
+ sendHtml(res, 200, renderEntering({ target: requested }), { 'set-cookie': entryCookie(req, 1) });
298
+ return;
299
+ }
300
+ if (step === 1 && canHandOff === true) {
301
+ log.info('重新进入后仍被拒绝,改走 harness 的入口地址换取新凭据。');
302
+ handoffToHarness(req, res, entryTarget, 2);
303
+ return;
304
+ }
305
+ log.warn(
306
+ `这台浏览器连续两次都没把 DSH 的凭据交上来(${requested}):请检查是否处于无痕窗口或 Cookie 被拦截;` +
307
+ '登录态本身有效,换个普通窗口即可访问。',
308
+ );
309
+ sendHtml(res, 200, renderRecoveryStuck({ target: requested, loginPath: `${prefix}${LOGIN_PATH}` }), {
310
+ 'set-cookie': expiredCookie(HANDOFF_COOKIE, { secure: isSecure(requestBaseUrl(req)) }),
311
+ });
274
312
  }
275
313
 
276
314
  /** Refuse an unauthenticated request: redirect browsers, 401 everything else. */
@@ -505,16 +543,17 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
505
543
  const requested = `${url.pathname}${url.search}`;
506
544
  if (needsHarnessHandoff(req, url)) {
507
545
  const target = entryLocation(req, requested);
508
- // A resolver that is missing or throws degrades to the requested path.
509
- // Redirecting to it would repeat this exact state forever, so fall
510
- // through to the harness instead: its own 401 page is terminal.
546
+ // A resolver that is missing or throws degrades to the requested path:
547
+ // hand off only when there is somewhere to hand off to.
511
548
  if (target !== requested) {
512
- handoffToHarness(req, res, target);
549
+ handoffToHarness(req, res, target, 0);
513
550
  return;
514
551
  }
515
552
  }
516
- // A stale `dsh-auth-*` cookie keeps needsHarnessHandoff quiet, so the page
517
- // entry point also checks what the harness actually answers.
553
+ // A `dsh-auth-*` cookie in the request proves nothing about what the
554
+ // harness will accept, and a cookie the browser stores but withholds (see
555
+ // passthroughWatchingForAuthWall) is invisible from here — so the page
556
+ // entry point always checks what the harness actually answers.
518
557
  if (isPageNavigation(req, url)) {
519
558
  await passthroughWatchingForAuthWall(req, res, route, server, requested);
520
559
  return;
package/lib/pages.js CHANGED
@@ -80,6 +80,55 @@ function facts(rows) {
80
80
  return items === '' ? '' : `<dl>${items}</dl>`;
81
81
  }
82
82
 
83
+ /**
84
+ * Quote a value for a `<script>` context, so a URL built from the request can
85
+ * never close the tag or start a new statement.
86
+ * @param value - untrusted text.
87
+ * @returns a JavaScript string literal.
88
+ */
89
+ function jsString(value) {
90
+ return JSON.stringify(String(value ?? '')).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
91
+ }
92
+
93
+ /**
94
+ * The page that re-enters the harness from this origin.
95
+ *
96
+ * The harness mints `dsh-auth-<authority>` with `SameSite=Strict`. A browser
97
+ * that arrives through a cross-site chain — the Feishu OAuth redirect is one,
98
+ * and the redirects that follow it stay in that chain — therefore does not send
99
+ * that cookie on the later hops of the same chain, and the harness answers its
100
+ * auth wall although the cookie is already stored. A navigation started from a
101
+ * document on this origin is same-site, so one extra hop carries the cookie.
102
+ * @param options - the path to re-enter.
103
+ * @returns the HTML document.
104
+ */
105
+ export function renderEntering({ target }) {
106
+ const body = `
107
+ <p>已通过飞书验证,正在进入…</p>
108
+ <p class="note">如果没有自动跳转,<a href="${escapeHtml(target)}">点这里继续</a>。</p>
109
+ <script>location.replace(${jsString(target)});</script>`;
110
+ return renderPage({ title: '正在进入 · DSH', heading: '正在进入 DSH', body });
111
+ }
112
+
113
+ /**
114
+ * The page shown when the recovery ladder is exhausted: the browser stored
115
+ * neither this gate's cookie nor the harness's, so no redirect can help and the
116
+ * harness's own wall (which names an internal URL the operator would have to
117
+ * dig out) is replaced with an actionable page.
118
+ * @param options - the path to retry and the login path.
119
+ * @returns the HTML document.
120
+ */
121
+ export function renderRecoveryStuck({ target, loginPath }) {
122
+ const body = `
123
+ <p>这台浏览器没有把 DSH 自己的凭据交给服务器,自动恢复已经试过两轮。</p>
124
+ <p class="note">常见原因:无痕/隐私模式、拦下本站 Cookie 的拦截扩展、或浏览器把这次访问当成第三方上下文。换个普通窗口通常即可。</p>
125
+ <div class="actions">
126
+ <a class="button" href="${escapeHtml(target)}">再试一次</a>
127
+ <a class="button" href="${escapeHtml(loginPath)}">重新登录</a>
128
+ </div>`;
129
+ return renderPage({ title: '还差一步 · DSH', heading: '还差一步', body, tone: 'error' });
130
+ }
131
+
83
132
  /**
84
133
  * The page an authenticated-but-unauthorized account sees: its own identifiers
85
134
  * and nothing about anyone else, so the operator can allowlist the right value.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-feishu-auth",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Feishu (Lark) OAuth login gate for the DeepSeek Harness web GUI.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -18,6 +18,7 @@
18
18
  "main": "lib/index.js",
19
19
  "exports": {
20
20
  ".": "./lib/index.js",
21
+ "./client": "./lib/client.js",
21
22
  "./package.json": "./package.json"
22
23
  },
23
24
  "files": [
@@ -40,6 +41,11 @@
40
41
  "dsh": {
41
42
  "bundle": {
42
43
  "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "inject": [],
47
+ "platform": "web",
48
+ "immediately": true
43
49
  }
44
50
  },
45
51
  "scripts": {