dsh-feishu-auth 0.1.2 → 0.1.3

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.
@@ -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 标记:
72
86
 
73
- **交接标记**(`dsh-feishu-handoff`,20 秒)是死循环的兜底:浏览器拒绝存 harness Cookie 时,`/` `/?token=…` 之间只会来回一次。
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('/')` 即返回应用页。
74
95
 
75
- 第 2 步的前提是「根请求带本进程的启动令牌」,所以 **harness 每次重启都会让浏览器里那份 `dsh-auth-*` 作废**。仅凭「Cookie 存不存在」判断交接会漏掉这种情况(旧 Cookie 还在,交接被压住,用户卡在 harness 的 401 页),因此交接判定同时看 harness 的回答:页面入口拿到 401 就再交接一次——老浏览器无需重新登录即可恢复。同理,交接后的 401 若再出现,交接标记会让它止步,把 harness 的 401 页作为终点而不是无尽往返。
96
+ 作废旧凭据(harness 重启)走的是下一步:`/?token=…` 会重新签发一张能验过的 Cookie;这条链从同站重进之后出发,因此也在同站上下文里,新 Cookie 立刻可用。
97
+
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,7 +121,7 @@ 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
 
@@ -113,8 +134,10 @@ sequenceDiagram
113
134
  | 缺 `appId` / `appSecret` | 故障关闭:所有请求 503「未就绪」页,日志 `[error]` 说明缺什么 |
114
135
  | 会话密钥文件不可读写 | 同上(内存里用临时密钥,重启即失效) |
115
136
  | `webServer.match` 不存在 | 挂载抛错,插件拒启动——无保护状态不允许运行 |
116
- | `connection` 服务取不到 | 记 error,交接退化为放行给 harness 401 |
117
- | harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 自动再交接一次(用户无感);已带交接标记时不再重试,401 页成为终点 |
137
+ | `connection` 服务取不到 | 记 error;不交接,页面入口的 401 由恢复阶梯兜住(同站重进 插件自己的页面) |
138
+ | harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 阶梯(同站重进 再交接一次)→ 用户无感恢复 |
139
+ | 浏览器沿跨站链到达(飞书 OAuth 回调) | 同一条阶梯的第一步就是为此设计的:同站重进一次即带上 `SameSite=Strict` 的 `dsh-auth-*` |
140
+ | 浏览器两次都不交凭据(无痕窗口 / 拦截扩展) | 阶梯走完 → 插件自己的「还差一步」页 + 一行 warn,不把 harness 的 401 页转给用户 |
118
141
  | 启动自检失败 | `[error]` 明确报出:未登录请求未被拦,或持有效会话仍被拒 |
119
142
  | 配置项(`allowedUsers` / `sessionMaxAgeDays`)非法 | `allowedUsers` 非法 → 致命(避免悄悄放宽到全员);`sessionMaxAgeDays` 非法 → 回落默认值 |
120
143
 
package/docs/release.md CHANGED
@@ -19,7 +19,7 @@ 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,45 个用例
23
23
  npm run verify # lint + test —— CI 与 prepublishOnly 跑的就是它
24
24
  npm pack --dry-run # 检查发布产物内容(15 个文件)
25
25
  ```
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.1.3",
4
4
  "description": "Feishu (Lark) OAuth login gate for the DeepSeek Harness web GUI.",
5
5
  "keywords": [
6
6
  "dsh",