认证传输层:零密钥设计与部署陷阱
agentdock auth 从「直连 PostgREST + 公开 anon key」切到「provider HTTP 端点」的实现模式,以及切换前必须验证生产环境的教训。
认证传输层:零密钥设计与部署陷阱
agentdock auth login/logout/status 的后端交互经历过一次传输层重写:从 CLI 直连
PostgREST(POST {supabaseUrl}/rest/v1/rpc/consume_device_auth,带公开 anon key)
切换为 CLI 只认一个 provider base URL(POST {webUrl}/api/device-auth/consume,
零密钥)。这页记录两件事:这次重写复用的设计模式,以及重写过程中实测到的一个
部署陷阱。
设计模式:客户端零密钥,靠"唯一后端触点函数"换传输层
问题
CLI 要连后端,两种做法各有代价:
- 直连数据库层(PostgREST/Supabase RPC):CLI 需要知道 URL + key + RPC 名 +
schema 头——provider 抽象被撑大成四个字段;key 轮换即坏;打包进 npm 包会触发
secrets门(即使 anon key 本身是公开设计,"新增暴露类别"仍是审查点)。 - 走 provider 自己的 HTTP 端点:CLI 只认一个 base URL,服务端内部去调 RPC。CLI 侧零密钥,provider 抽象缩成一个字段,服务端还能在不发 CLI 新版的 情况下演进流程。
后者更好,但往往不是一步到位——cli-auth 这次先落地了直连版本(design.md §2.2
本身就写了"终局在此,但本刀先不做,因为需要跨仓协作")。关键是当时怎么写代码,
决定了后续切换是重构还是一行改动。
解法:把唯一的后端调用收在一个函数里
packages/cli/src/core/auth.ts 从第一版起就把所有网络交互收敛进
consumeDeviceAuth() 这一个函数——不是"大部分逻辑在这里",是唯一一处:
/**
* The ONE backend touch point. Swapping the transport is what this function
* being a single function makes possible.
*/
export async function consumeDeviceAuth(
provider: AuthProvider,
deviceCode: string,
fetchImpl: FetchLike = globalThis.fetch,
): Promise<ConsumeResult | { error: string }> {
const url = `${provider.webUrl.replace(/\/+$/, '')}/api/device-auth/consume`
// ...
}切换传输层时,diff 范围是:
AuthProvider接口从{ name, webUrl, supabaseUrl, anonKey }收敛成{ name, webUrl }consumeDeviceAuth()内部换了一个 URL、一个 body 形状、去掉了两个 headerresolveProvider()少解析两个字段- 调用方(
pollForSession、两个 adapter)一行都没有改动——它们只认consumeDeviceAuth()的返回值形状,不知道底下打的是 RPC 还是 REST 端点
没有出现"要不要把 fetch 调用抽出去做成 interface"式的重构,因为它从一开始就 只有一处。
可迁移的原则
给客户端加后端集成时,先问:这个能力会不会有第二种传输实现(直连 vs 网关、
REST vs GraphQL、v1 vs v2)?如果有可能,把所有对外请求收进一个具名函数(不是一个
模块、不是一层抽象类,就是一个函数),让它成为唯一touch point。判断标准很
具体:grep 整个模块,fetch/fetchImpl 调用只出现一次。这比预先设计一层可插拔
adapter 便宜得多——早期不知道第二种实现长什么样,过度抽象反而会抽错维度。
部署陷阱:端点合进 main ≠ 生产上有
现象
cli-auth-via-endpoint 这次切换的目标端点(thefoolai 仓的
POST /api/device-auth/consume)在 PR #199 里已经合并进 main,proposal 里
也这么写了。但 2026-08-19 第一次对生产地址发起验证请求时:
curl -X POST https://www.fujia.site/api/device-auth/consume \
-d '{"device_code":"11111111-2222-3333-4444-555555555555"}'
# → 404如果没有做这一步 curl,直接按 proposal 把 CLI 的 consumeDeviceAuth() 切过去,
结果是把所有用户的登录一起指向一个生产上根本不存在的地址——而 CI、代码审查、
甚至读 PR diff 都不会告诉你这件事,因为代码本身是对的,缺的是"这份代码有没有
真的跑在你要打的那个地址上"。
根因
main 分支合并只代表代码进了主干;thefoolai 侧 web 应用走的是独立的
release-web 发布分支,只有该分支推送才会触发生产部署。"合进 main" 和"生产
可用"之间隔着一次没做的发布——这个模式在 thefoolai 项目里复发过不止五次
(详见其内部 wiki「发布分支流程」),本刀是同一根因在跨仓协作场景下的又一次
命中:作为下游消费方(agentdock CLI),完全没有可见性知道上游是否已经发版,
只能靠实测确认。
解法:切换前,用生产地址本身做判据
不看:
- PR 是否已合并
- CI 是否绿
- proposal 文档是否写着"已合并 main"
只看:对生产 URL 发一次真实请求,看响应形状(如本例中的 404 vs
200 + {"status":"not_found"})。这条判据比任何间接信号都可靠,因为它问的
正是要问的问题:"我要打的这个地址,现在能用吗"。
验证
本刀实际执行前重新验证了一次(2026-08-19,前置条件已由构建者确认修复):
curl -X POST https://www.fujia.site/api/device-auth/consume \
-d '{"device_code":"11111111-2222-3333-4444-555555555555"}'
# → 200 {"status":"not_found"}
curl -X POST https://www.fujia.site/api/device-auth/consume \
-d '{"device_code":"not-a-uuid"}'
# → 400 {"error":"invalid_request","message":"device_code must be a UUID"}两个响应都符合预期形状,才动手改 consumeDeviceAuth()。同样的原则也适用于跨仓
依赖里"还没上生产"的字段——这次的 display_name 字段(thefoolai PR #201)在
CLI 侧代码里就必须写成容错读取(存在则用,不存在则退回旧字段),而不是假设它已经
在生产响应里。