import type { IncomingMessage, ServerResponse } from 'node:http'; import { type ControlAuditSink } from './control-audit.js'; import { type ControlRequestHeadersLike } from './control-csrf.js'; /** * owner 工作台内「自取常驻链接」(`GET /api/workbench/standing-link`)。 * * ─── 为什么要有这条端点 ──────────────────────────────────────────────────── * botmux 自部署用户就是自己这台实例的 owner,但拿一条能收藏进书签的常驻工作台 * 入口,此前只有一条路:SSH 上服务器跑 `botmux dashboard`。飞书卡片那条按钮给的 * 是 30 分钟短票(P2-1),到期即死——这是刻意的红线:**长期 token 不得进聊天 * 记录**(卡片历史、转发、截图都比首次投递活得久)。这条红线原样保留,这里只是 * 把「自取」搬进 owner 已经登录的工作台页面本身:页面是私人上下文,响应 * `no-store`,链接既不落盘也不进任何持久化载体。 * * ─── 谁能取 ──────────────────────────────────────────────────────────────── * 只有**本机完整管理身份**(legacy owner cookie —— `?t=` 种的,或短票兑换出的 * 那一枚)。两层门禁,彼此独立、谁都不依赖对方: * * 1. **路由级**:这条路径既不在 `workbenchH5Capability` 的能力表里,也不在 * `PUBLIC_READ_PATHS` 里,所以 `decideWorkbenchH5Auth` / `decideDashboardAuth` * 对 workbench-only(飞书 H5)、平台 owner/teammate/guest、匿名一律 deny401 * —— 请求根本到不了这里。新增路由默认私有,这一层是构造性的。 * 2. **处理器级**:本文件再自己判一次 `kind === 'legacy-dashboard'`,不满足直接 * 404。平台身份即使角色叫 owner 也在此止步——它证明的是「机器跳板 + 平台角色」, * 不是本机管理权限(与 dashboard.ts 里 `authed = legacyAuthed` 同一口径)。 * * ─── 其余约束 ────────────────────────────────────────────────────────────── * - **同源**:跨站页面拿着 owner 浏览器里的 cookie 打这条 GET 一律 403,判定复用 * control-csrf 的归一化(`Sec-Fetch-Site` / `Origin`,两者都缺席时 `Referer` * 兜底,全无信号 fail closed)。 * - **no-store**:响应体里就是长期凭证,任何中间缓存都不该留。 * - **取用必留痕**:每发一次写一条 `auth.standing_link_issued`(含身份,不含 * token 与链接)。审计写不进去就不发链接——这是唯一一处「审计失败即拒绝」, * 因为这条端点交出去的是常驻凭证,留不下痕就等于凭空多一份无人知晓的副本。 * - **token 只有一个出处**:`activeToken()` 每次现读落盘的活跃 token,所以 * `botmux dashboard rotate` 之后这里自然返回新 token 拼的链接,无需任何同步。 */ /** 端点路径。auth.ts 的能力表 / 公开白名单都**不**包含它(fail closed 的默认)。 */ export declare const WORKBENCH_STANDING_LINK_PATH = "/api/workbench/standing-link"; /** 处理器只关心身份的这两个切面(结构上兼容 dashboard.ts 的 * `DashboardRequestIdentity`)。 */ export interface StandingLinkIdentityLike { kind: string; userId?: string; } export interface WorkbenchStandingLinkDeps { /** 本请求已解析出的身份(`resolveDashboardIdentity` 的结论),匿名为 null。 */ identity: StandingLinkIdentityLike | null; /** 当前活跃的 legacy 管理 token(单一出处:落盘文件)。 */ activeToken: () => string | null; /** token → 常驻工作台入口 URL(`/workbench?t=`)。base 的解析复用 * core/dashboard-url.ts 那一份;不可解析时返回 null,调用方据此 503 而不是拼 * 出半截链接。 */ standingLinkUrl: (token: string) => string | null; /** 审计落点(dashboard.ts 传的是与 takeover / 登录同一个 sink)。 */ audit: ControlAuditSink; now?: () => Date; } /** * 这次请求是否来自本源页面。 * * `controlRequestOriginState` 明确判 `foreign` 就是跨站,**不给 Referer 翻案的 * 机会**;只有两个来源信号都缺席(`unknown`)时才用 Referer 兜底,把老浏览器上 * 的真 owner 救回来(见 {@link refererMatchesHost} 的注释)。全无信号 = 拒绝。 */ export declare function standingLinkSameOrigin(headers: ControlRequestHeadersLike): boolean; /** * `GET /api/workbench/standing-link` 处理器。命中返回 true(响应已写完),未命中 * 返回 false 交还路由(非 GET / 别的路径一律不自己兜底)。 * * 成功:`200 { ok: true, url }`;无权:`404 { ok: false, error: 'not_found' }`; * 跨站:`403 control_origin_forbidden`;没有活跃 token / 链接拼不出 / 审计落不下: * `503 standing_link_unavailable`。失败分支一律不回显 token、不回显链接。 */ export declare function handleWorkbenchStandingLink(req: IncomingMessage, res: ServerResponse, url: URL, deps: WorkbenchStandingLinkDeps): boolean; //# sourceMappingURL=standing-link.d.ts.map