AI Passport

锚点层 (Cloudflare Worker)

一个「云端中转站」。卡片和大脑都主动连它,它负责鉴权、配对、原样转发。

Cloudflare Worker + Durable Objects · 部署在 card.jxccwx.com

说人话版: 锚点就像一个「总机话务员」。卡片打电话进来说「我是 3 号卡片」,大脑打电话进来说「我要找 3 号卡片」,话务员把这两根线接起来,之后他们说的话话务员只是原样传递、自己不去理解。为什么要专门有个话务员?因为卡片和大脑互相找不到对方,得有一个公共的、大家都找得到的中间点。

#它做什么 / 不做什么

做 ✅不做 ❌
鉴权(设备令牌 / 网关令牌分开)不解析音频,二进制帧原样转发
一设备一房间,同时挂两个 WebSocket 互转不理解 ask/answer/status 的业务含义
控制消息原样转发不做 STT / TTS / Agent 推理(那些在网关上)
卡片离线时把网关的 say 排队不为卡片操作排队(过期动作比丢失更糟)
自己回答 ping(判活)不重连、不退避(那是卡片侧的责任)

这个「做窄边界」是有意为之——锚点、固件、宿主三套软件有各自的发布节奏,锚点理解得越少,三者越容易独立升级。

#端点

方法路径鉴权用途
GET/card/v1/health无连通性探测,返回 {ok,ver,ts,caps}
GET/card/v1/ws?role=device|gw&id=设备号有WebSocket 升级
GET/card/v1/room?id=设备号网关令牌房间状态快照(排障用)

鉴权方式:Authorization: Bearer <token> 或 ?token=(为 ESP32 / Node 内置 WebSocket 这类不方便设 header 的实现留的口子)。 卡片用 DEVICE_TOKEN、网关用 GATEWAY_TOKEN,两个不同的 secret。

#文件与函数级明细

▶ index.js Worker 入口:路由、鉴权、WS 升级 4.8 KB
31function safeEqual(a, b)定长循环比较,防时序侧信道
41function presentedToken(request, url)从 header 或 query 取令牌
49function authenticate(request, url, env)按 role 区分 DEVICE_TOKEN / GATEWAY_TOKEN
62function fail(status, message)
67async fetch(request, env)主入口:/health、/ws、/room 路由
▶ session.js SessionRoom Durable Object:配对、互转、离线队列 15.3 KB

核心。每个设备对应一个 DO 实例(房间)。房间里同时挂两张 WebSocket(卡片 + 网关),把收到的消息互转给对方。用 Hibernation API 省空闲成本。

35class SessionRoom
36constructor(ctx, env)
115async fetch(request)房间入口:验证后升级 WS
246async webSocketMessage(ws, message)收到消息 → 判类型 → 转给另一端
360async webSocketClose(ws, code, reason)
373async webSocketError(ws)
379async alarm()定时器:清理死连接、离线队列超时
▶ protocol.js 协议常量与编解码(镜像固件侧 wb_proto.c) 5.4 KB
17export const VER = 1
23export const AUDIO_FRAME_PAYLOAD = 240
24export const AUDIO_FRAME_SIZE = 243
33-55E_* / C_* 事件与命令常量
79export function encode(obj)
88export function decode(text)
111export function anchorMsg({gw, pending, deviceId})锚点自有的状态消息
118export function blockedStatus(detail)
123export function resumedStatus(detail)
127export function pong(nonce)
131export function anchorHello(sys)
135export function bye(reason)
▶ version.js 构建标识(源码指纹,自动写入) 1.0 KB

记录「部署的到底是哪一份代码」。SOURCE_DIGEST 是对 src 下所有 .js + wrangler.toml 逐文件 sha256 再摘要得到,用于核对部署版本是否一致。

-BUILD_ID = "0.1.0"
-SOURCE_DIGEST(前 12 位 = SOURCE_SHORT)
-BUILT_AT(构建时间)

#链路状态:为什么多一个 anchor 消息

原始需求里最硬的一条是「不允许转圈等待」——用户必须一眼分清「网络坏了」「大脑没上线」「任务在跑」。这三者需要一个卡片能读的明确信号。

// 锚点自有的状态消息(不是网关的语义)
{ "t": "anchor", "ver": 1, "gw": "online" | "offline", "pending": 0, "id": "WB-3A7C" }

因为 status 是「网关」的语义,锚点若借用它,卡片就无法区分「大脑说任务被阻塞」和「大脑根本没上线」。 所以协议 V1 加了一个锚点自有消息类型 anchor,卡片用它渲染连接指示器。

情况卡片收到什么
锚点不可达什么都收不到 —— WS 建不起来,按传输层错误处理
锚点在、网关不在anchor{gw:"offline"} + status{state:"blocked",detail:"大脑未上线"}

#部署配置

# anchor/wrangler.toml
name = "card-anchor"
main = "src/index.js"
compatibility_date = "2026-09-01"
account_id = "67a19f55851c5090d43fdbc8119317d2"
workers_dev = false          # workers.dev 国内被 DNS 污染,关掉
routes = [ { pattern = "card.jxccwx.com", custom_domain = true } ]

[[durable_objects.bindings]]
name = "SESSION"
class_name = "SessionRoom"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["SessionRoom"]

令牌 DEVICE_TOKEN / GATEWAY_TOKEN 走 wrangler secret put,不写进配置文件。

为什么不用 workers.dev? 因为 workers.dev 域名在国内被 DNS 污染,会解析到错误的 IP、TLS 建不起来。所以显式关掉 workers_dev,只走自有域名 card.jxccwx.com(custom_domain 模式,deploy 时 Cloudflare 自动建 DNS 记录和证书)。