后台/会话三诉求:子会话(一台设备一会话)+ 离线消息队列 + 被登出推送 + 后台唤醒层

- Req 1 子会话(iOS 多进程在用户视角=一台设备一会话,内部多条独立刷新逻辑会话):brokerclient 加 MintParams.Sub + SessionInfo.Sub + RevokeDeviceSessions(user,meta) 级联;device-session sub!="" 挂在主 device_id 之下、走 tier=clipboard 且不建第二个 device 行;handleSessionsList 按 meta 归并、隐藏 sub!=""(但保留“仅控件会话存活”的设备,不简单丢弃);revokeDevice 改走 meta 级联(移除设备连带吊控件子会话)。iOS provisionWidgetSessionIfNeeded 改用主 device_id + sub="widget"(弃用独立 dev_widget),控件令牌仍隔离持有、独立刷新——不碰引擎主会话,#7 隔离不变。蓝本 auth/docs/子会话方案.md(cdrop 提案 + Broker 评审接受 + cdrop 确认 6 点:用 sub、R1 cdrop 归并、scope 复用 tier=clipboard、级联 DELETE …?meta=)。
- Req 2a 离线消息队列:新增 pending_messages 表(0001_init + sqlc 查询);message.go 收件设备离线即入队(无论是否配推送都入队,恒 202),修“离线消息只随推送横幅一闪、不入收件列表”;GET /api/messages/pending 取即删(DELETE..RETURNING,单次投递);web hub.ts onOpen 每次 SSE 连接 / 重连即拉取补收、逐条 addMessage(全端受益,iOS 经桥推原生 + 累积未读);复用 login-request reaper 清 TTL。
- Req 3 被登出推送:push 加 KindSessionRevoked + 本地化文案,apns/web 双通道;revokeDevice 加 notifyRevoked 参(跨端 revoke=true、自登出=false),跨端移除时推送告知被踢设备;iOS AppDelegate 收 session:revoked 即清 Keychain 主会话 + 控件会话、发 .cdropSessionRevoked,前台经 AppRoot 即时回登录页、关闭态下次启动即登出。
- #6 后台唤醒层:apns apsEnvelope 加 content-available:1(服务端有消息时既弹可点横幅又短暂后台唤醒);iOS DeviceItem 加 Codable、presence 快照持久化(冷启即时显示设备列表,缓解“长时间重连”观感,SSE 一连即整组替换自校正);控件会话回前台补铸(scenePhase active)+ content-available 唤醒时刷新隔离的控件会话(剪贴板保活,绝不碰引擎主会话以免 #7 回归)。
- 测试:qr_test mock broker 加 sub 幂等键 + 级联吊销端点;bootstrap_test 期望表集加 pending_messages。
This commit is contained in:
2026-06-27 17:42:53 +08:00
parent 1b94df1604
commit a2cad11224
23 changed files with 790 additions and 105 deletions
+209
View File
@@ -0,0 +1,209 @@
# 子会话(subordinate session)— 提交 Auth Broker 评审
> 在现有“委派设备会话”(R1 列举 + R2 幂等铸造,`meta` 为稳定设备身份)之上,引入“子会话”:
> 一台设备下可有多条独立令牌的逻辑会话,但对外仍呈现为**一台设备、一条会话**。
> 状态:**待 Broker 评审**。关联:委派设备会话模型、cdrop `internal/httpapi/device_session.go` / `sessions.go`。
---
## 1. 背景与动机
cdrop 的 iOS 客户端天然是**多进程**:
- **主 app**:完整会话,跑 SSE 信令 / 传输 / presence。
- **控制中心控件 + 主屏小组件扩展**:独立 OS 进程,仅调云剪贴板 REST(`/api/clipboard`)。系统会在主 app 关闭 / 墓碑时单独拉起该扩展,故它必须能**脱离主 app 独立工作**。
这两个进程需要**各自独立的令牌生命周期**:单次轮换(single-use rotating)的 refresh token 若被两进程共用,会并发抢轮换——一方轮换后另一方持有的旧 refresh 立即失效,触发误登出。cdrop 已在主会话上踩过同类坑(冷启并发 401 抢轮换 → 误 forceLogout → 隔夜被迫重登,已修)。结论:**控件会话必须是与主会话隔离的第二条逻辑会话。**
但在**用户视角**,二者必须是**一台设备、一条会话**:
- 在用户**任何设备**的“设备 / 会话”列表里(该列表由 Broker R1 权威呈现,cdrop 仅叠加 type/online),iOS 只能出现**一次**。
- **登出该 iOS 设备**(在别处吊销)应**连带**登出其控件会话——不能留一条游离的控件会话还能读写剪贴板。
当前模型“一个 `meta` = 一台设备 = 一条会话”(R2 同 `meta` 即轮换替换那一条)无法表达“一台设备下多条逻辑会话”:若控件用同 `meta` 铸造,R2 会把主会话的令牌轮换掉(打断主 app);若控件用另一个 `meta`,它就成了**第二台设备**,违反“一个设备”诉求。
---
## 2. 诉求:最小扩展为“子会话”
在一条**父设备会话**之下铸一条**子会话**:
- **共享父的设备身份**(同一 `meta`)→ R1 里仍是同一台设备。
- **独立令牌对**access + refresh),**独立刷新**,与父互不抢轮换。
- 可带**缩减 scope**(如 `clipboard`),最小权限。
- **连带吊销**:吊销设备 / 父会话即吊销其全部子会话。
- 对外(R1 列表、任何 Broker 账户 UI)**不单独呈现**为设备 / 会话。
---
## 3. 数据模型与语义(建议)
### 3.1 会话增加子标识 `role`
- 会话表增可选列 `role`(或 `sub`),默认空串 `""` = 主会话。
- **R2 幂等键**从 `(user, meta)` 扩为 `(user, meta, role)`
- `(user, meta, "")` = 主会话(行为不变,老客户端零影响)。
- `(user, meta, "widget")` = 控件子会话,与主会话**并存**、各自独立令牌。
-`(user, meta, role)` 再铸 = 幂等轮换那一条(沿用现 R2 语义到 role 维度)。
### 3.2 scope 缩减
- 子会话铸造时可声明 `scope`(如 `clipboard`),Broker 在签发的令牌里带上;cdrop 路由层据此只放行剪贴板(cdrop 已有 `requireScope("clipboard")` 同款门,复用)。
### 3.3 列表 R1 归并
- R1 **按 `meta` 归并**:一台 `meta` 只呈现**一条**设备 / 会话(取主会话的 label / last_seen 等)。
- 子会话**不单列**。两种实现皆可,Broker 择一:
- (a) R1 直接隐藏 `role != ""` 的行;或
- (b) R1 仍返回但带 `role` / `parent_sid` 字段,由消费方(cdrop)归并隐藏。
- cdrop 倾向 (a)(权威端归并最干净,任何消费方都见一台)。
### 3.4 吊销级联
- 按**设备 `meta`** 吊销(cdrop 的“移除设备”/“登出”即按 device_id=`meta` 走)→ Broker 级联吊销该 `meta` 下**全部 role**(主 + 子)。
- 按单条 `sid` 吊销沿用现语义(可单独吊销某子会话,可选)。
### 3.5 刷新
- 每条会话按**自身** refresh token 独立轮换,无需区分主 / 子。**无跨进程抢轮换**——这正是隔离两条会话的目的。
---
## 4. API 形态(建议,二选一)
**方案甲(最小侵入)**:现有 device-session 铸造端点加两个可选参数 `role``scope`。缺省 `role=""` 即今日行为。
**方案乙(显式)**:新增 `POST /device/subsession { parent_meta, role, scope }`,语义同上。
刷新 / 撤销端点**无需改签名**:刷新按 refresh token;撤销按 sid 沿用,新增“按 meta 级联撤销”(cdrop 移除设备时用)。
---
## 5. cdrop / iOS 侧配合(Broker 支持后)
- iOS 控件会话改为“**主设备 `meta` + `role=widget` + `scope=clipboard`**”铸造,**弃用**独立 `dev_widget` device_id。
- cdrop `/api/sessions` 直接呈现 Broker 归并后的列表 → iOS 只出现一台。
- 移除设备 / 登出按 `meta` 级联 → 控件会话随之失效。
- 控件令牌泄露面缩到“仅剪贴板”。
改动量小且不触 cdrop 的主会话刷新热路径(不引入 #7 类风险)。
---
## 6. 备选与取舍
- **cdrop 侧过滤(不改 Broker**cdrop 在自己的 `/api/sessions` 里隐藏控件会话行。问题:
- 只修 cdrop 自己的列表视图;Broker **权威 R1** + 任何 Broker 账户 UI / 其他消费方仍见**两条**——违反“在用户任何设备的列表里都是一个”。
- 级联吊销需 cdrop **自行维护**父子映射并双吊销,Broker 不知二者关联,脆弱、易漏。
- 控件会话在 Broker 仍算一台“设备”,占设备数配额、被 Broker 自身管理面看见。
- **结论**:不干净,仅作 Broker 支持前的**临时回退**(若需提前上线,可先 cdrop 侧隐藏 + 按 user 维度级联,待 Broker 子会话就绪再切换)。
---
## 7. 安全要点
- **scope 最小化**:子会话仅 `clipboard`,缩小扩展进程(独立沙箱)令牌泄露面。
- **级联吊销**:保证“登出一台 iOS”彻底,含其全部子会话——不留游离凭证。
- **独立刷新**:各会话自轮换,无跨进程抢单次轮换 refresh(杜绝 #7 类误登出)。
- **老客户端零影响**`role=""` 即现行为,R2/R1/refresh/revoke 对既有会话语义不变。
---
## 8. 给 Broker 的一句话
> 在“委派设备会话”上,把 R2 幂等键由 `(user, meta)` 扩为 `(user, meta, role)`R1 按 `meta` 归并、按 `meta` 级联吊销,子会话可带缩减 scope——即可让 cdrop 的多进程 iOS 客户端在用户视角是“一台设备、一条会话”,而内部是各自独立刷新、互不抢轮换的两条逻辑会话。
---
## Broker 方评审与接口约定(2026-06-27
总评:诉求清晰、与“委派设备会话”正交、可最小扩展实现,**接受**。核心洞见正确——“一设备多逻辑会话、对外仍一台”靠在 `meta`(设备身份)之下加一维**会话判别子键**即可,且老客户端零影响。已对读 broker 实现(`internal.go` handleInternalMint/handleInternalList、`tokens.go` mintScopedSession、`store.go` sessionCols/Put、`verify.go` injectIdentity/scopeCoversApp、`authn.go` lockRefresh/lockMintIdent)。逐节回应并约定接口;有三处对建议做了收敛(命名、scope 复用、R1 归并位置),请确认。
### 一、命名:用 `sub`,不用 `role`
broker 侧 `role` 已被授权语义占用(Casdoor role、apps.json `required_role`、verify 角色校验)。会话判别键叫 `role` 会与鉴权角色混淆。**定为 `sub`**,与 `meta` 并列:`meta`=设备身份(归并/级联键),`sub`=设备内会话判别(幂等子键)。默认 `""`=主会话。
### 二、数据模型:新增 `sub` 槽(必要,无更省编码)
- `authcore.Session` 加通用不透明槽 `Sub`(与 `Meta` 同范式,broker 不解释、消费者定义)+ schema `sub TEXT NOT NULL DEFAULT ''` 幂等迁移(同 `meta` 列做法)+ store 列同步(17→18 列)。
- **为何不复用现字段**:① 折进 `meta`(如 `dev_abc#widget`)会破坏 meta 不透明、且 R1 归并/级联须解析 meta——拒;② 拿 `scope`/`tier` 当判别键不可行——主会话 tier 会随重配对 guest→full 在**原地变**(现 R2 明确支持原地改档),若 tier 入幂等键,tier 一变即新建出第二条主会话(重复)。故判别键必须**与 scope/tier 解耦且稳定**——这是 `sub` 必须独立于 `tier` 的根因。
- **R2 幂等键** `(user, app, meta)``(user, app, meta, sub)`:查既有过滤加 `&& ex.Sub==req.Sub``lockMintIdent` 键改 `user|app|meta|sub`。老客户端 `sub=""`、既有会话 `Sub=""`→照旧匹配轮换,零影响。
### 三、scope 缩减:复用现有 `tier`broker 无新增
你要的“子会话带缩减 scope(clipboard)”现机制已能给:`tier` 即 scope 后缀(token scope`app:cdrop:<tier>`cdrop 读末段)。控件铸造传 `tier="clipboard"`(或 `widget`)→ token scope `app:cdrop:clipboard`cdrop 现有 `requireScope("clipboard")` 照常判。**无需新增 `scope` 参数**。
注:`tier` 槽现承载“档 guest/full”,此处再承载“能力 clipboard”——对 broker 都是不透明后缀、透传即可,由 cdrop 统一解释末段。若你要把“档”与“能力”分两维,须另开 scope 字段;单一消费者下复用 `tier` 最省,**推荐复用**。
### 四、R1 归并:取 (b) broker 暴露 `sub`cdrop 归并(非你倾向的 (a) broker 隐藏)
- R1 响应加 `sub` 字段,**返回全部** Live machine 会话(主+子);cdrop 按 `meta` 归并、在自己设备列表里隐藏 `sub!=""`
- **为何 (b) 而非 (a)**:① **正确性**——(a) 朴素版“隐藏 sub!="" 行”会让“仅控件存活(主会话已过期、控件独立刷新着)”的设备从列表**消失**;(b) 下 cdrop 按 meta 归并,任一会话存活设备即在列。② **broker 保通用**——R1 是“列某 app 的机器会话”通用原语,“一设备一行”是 cdrop 产品视图,不该烙进 broker。③ cdrop 本就“仅叠加 type/online”后处理 R1,加“按 meta 归并+隐藏 sub”到同一步极廉价、归属也对。
- 你担心的“任何消费方都见一台”:R1 **当前唯一消费方是 cdrop**broker 自身管理 UI 用 handleListTokens=调用者自己的令牌,不走 R1),故 (b) 即满足“处处一台”。**待出现第二个 R1 消费方**再加 broker 侧归并(`?group=meta` 返回每 meta 一代表行、用代表选择处理“仅子存活”)——与“per-app key 待第二低信任 app 才做”同一“按需延后”原则。若你坚持现在就要 broker 归并,我加 `?group=meta`(默认仍 per-session)。
### 五、级联吊销:新增按 meta 端点
- 新增 `DELETE /internal/sessions?user_id=&app=&meta=`(内部守卫+`X-Broker-App: cdrop`)→ 吊销该 `(user, app, meta)` 下**全部 sub**(主+子)的 Live machine 会话,**每条各自 `lockRefresh(sid)`**(沿用 revoke-vs-rotate 复活修复,防级联中途被并发轮换复活)。返回 `200 {"revoked": N}`(幂等:已空→`{revoked:0}`,非 404)。`user_id` 必填(store 按 user 索引;`app`/`meta` 过滤)。
- 现有 `DELETE /internal/sessions/{sid}` 不变(仍可单吊一条子会话,即 §3.4 的“可选单吊”)。
- cdrop“移除设备/登出”按 device_id=`meta` 调此端点即彻底,不留游离控件会话。
### 六、铸造端点:取方案甲(扩参,非新端点)
- 复用 `POST /internal/sessions`,加可选 `sub`(默认 `""`=今日行为),校验同 `tier``[a-z0-9_-]{0,32}`)。响应不变(`issueResp`)。
- **不取方案乙**(新 `/device/subsession`):铸造本是同一“委派 mint”、只多一维,新端点徒增重复逻辑。刷新(按 refresh token)/单吊(按 sid)签名不变。
### 七、verify 注头:可选 `X-Auth-Sub`
sub 非空时 `/verify` 可注 `X-Auth-Sub`(同 X-Auth-Meta 范式),供 cdrop 日志/逻辑。**可选**——若 cdrop 仅靠 scope 末段判能力即够,可不消费;要的话我加(含 caddybroker CopyHeaders,与 X-Auth-Meta 同批,接边缘那次连带重建 caddy-custom)。请告知是否要。
### 八、安全要点回应(你 §7
- **独立刷新无抢轮换**:天然满足——主/子是独立 sid+独立 refresh 凭证,无共享单次轮换 refresh,杜绝你 #7 类误登出;broker 无需特殊处理。
- **scope 最小化**:控件 tier=clipboard,泄露面仅剪贴板。✓
- **级联彻底**:按 meta 吊全 sub,不留游离。✓
- **老客户端零影响**sub="" 即现行为,R1/R2/refresh/revoke 既有语义不变。✓
- **子会话数无 broker 侧上限**(信任 cdrop;正常 main+widget=2);如需护栏可加每 `(user,app,meta)` 的 sub 数上限,默认不加。
### 九、约定接口(汇总,确认后即实现 broker 侧)
```
# 铸造(主或子)
POST /internal/sessions (X-Internal-Key,直连内网,无 XFF)
{ user_id, app, meta, sub?="", tier?, access_ttl?, refresh_ttl?, sliding?, refresh?, label? }
幂等键 (user, app, meta, sub);命中即原地轮换(稳 sid)。
→ 200 { id, app, access, refresh, access_expires, refresh_expires }
# 列举(含 subcdrop 按 meta 归并、隐藏 sub!=""
GET /internal/sessions?user_id=&app= (X-Internal-Key,无 XFF)
→ 200 { sessions:[ { id, sub, scope, label, meta, created_at, last_used_at, expires_at } ] }
# 单吊一条会话(不变)
DELETE /internal/sessions/{sid} (X-Internal-Key, X-Broker-App)
→ 204 / 404
# 级联吊销一台设备全部 sub(新增)
DELETE /internal/sessions?user_id=&app=&meta= (X-Internal-Key, X-Broker-App)
→ 200 { revoked: N }
```
控件会话铸造示例:`{ user_id, app:"cdrop", meta:"<主设备同 meta>", sub:"widget", tier:"clipboard", refresh:true }`
### 待你确认(确认后:broker 侧我实现,cdrop 侧你实现)
1. 命名 `sub`(替 `role`)——认可?
2. R1 取 (b)broker 暴露 subcdrop 归并)——认可?还是要 broker 侧 `?group=meta`
3. scope 缩减复用 `tier`(控件 `tier="clipboard"`)——认可?还是要独立 scope 维?
4. 级联吊销端点形态 `DELETE …?user_id=&app=&meta=`——认可?(备选 `POST /internal/sessions/revoke {…}`
5. 要不要 `X-Auth-Sub` 注头(要则接边缘时连带重建 caddy-custom)?
6. `sub` 取值集(现仅 `"" / "widget"`?将来还有别的扩展进程→是否要 sub 数护栏)。
分工照旧:接口确认后 broker 侧由我实现(`Session.Sub`+迁移、R2 键扩展、R1 加 sub、级联吊销端点、可选 X-Auth-Sub),cdrop 侧由你实现(控件改 `meta+sub+tier` 铸造、`/api/sessions` 归并、移除设备走 meta 级联)。接边缘(若要 X-Auth-Sub)那次连带重建 caddy-custom。
---
## cdrop 方确认(2026-06-27
评审收敛得很到位,三处收敛全部接受,逐条确认如下——broker 侧可据此开工。
1. **命名 `sub`(替 `role`)——确认。** 与 broker 的 `role`Casdoor / `required_role` / verify 角色)解耦正确:`meta`=设备身份、`sub`=设备内会话判别。默认 `""`=主会话。
2. **R1 取 (b)broker 暴露 `sub` + cdrop 按 meta 归并隐藏 `sub!=""`)——确认。** 你的“仅子存活则设备不该消失”反例是决定性的:cdrop 按 `meta` 归并能让“任一会话存活即在列”,而 (a) 朴素隐藏会误删只剩控件会话的设备。R1 当前唯一消费方是 cdrop,(b) 即满足“处处一台”。**不要 `?group=meta`**——待出现第二个 R1 消费方再按需加(同你“按需延后”原则)。
3. **scope 缩减复用 `tier`(控件 `tier="clipboard"`)——确认。** token scope `app:cdrop:clipboard`cdrop 现有 clipboard scope 门照判(clipboard 路由放行、其余路由 reject)。不开独立 scope 维——单一消费者下复用 `tier` 最省,“档 / 能力”都由 cdrop 统一解释 scope 末段。cdrop 侧据此校验:clipboard 档会话仅 `/api/clipboard` 放行。
4. **级联吊销 `DELETE …?user_id=&app=&meta=` → `{revoked:N}`——确认。** 与既有 `DELETE /internal/sessions/{sid}` 同风格,优于 `POST /revoke``user_id` 必填我方满足(移除设备时 cdrop 持 user + device_id=meta)。每条各自 `lockRefresh(sid)` 防级联中途复活——同意。
5. **`X-Auth-Sub` 注头——暂不需要,不必为此重建 caddy-custom。** cdrop 判能力只靠 scope 末段(clipboard)即够;设备列表归并用的 `sub` 来自 R1 内部响应、非边缘头。控件只打 `/api/clipboard`、不入 presence / 传输,故请求期无需区分 main / widget。**待将来确有需要再加**(与 X-Auth-Sub 接边缘那次一并)。
6. **`sub` 取值集:现仅 `""`(主)/ `"widget"`(控制中心 + 主屏小组件,tier=clipboard)。** 命名空间预留给未来扩展进程(如 `"share"` 分享扩展、`"siri"`)。**暂不要 sub 数护栏**——cdrop 自控铸造、正常恒 2 条;若日后扩展进程增多再加每 `(user,app,meta)` 上限。
### cdrop 侧待办(broker 就绪后实施,非阻塞,可并行)
- 控件会话改铸:`POST /api/auth/device-session` 透传 `sub:"widget"` + `tier:"clipboard"``meta` 用**主设备同一 device_id**(弃用 `dev_widget` 独立 device_id + `widgetDeviceID()`)。
- `/api/sessions` + `/api/devices` 列表:按 `meta` 归并、隐藏 `sub!=""`(消费 R1 的 `sub` 字段)。
- 移除设备 / 登出:`revokeDevice` 改调级联端点 `DELETE …?meta=`(取代现按单 sid),保证连带吊控件会话。
- iOS:控件会话 provision 改用主 device_id + sub`WidgetSessionStore` 仍隔离持有控件令牌对(独立刷新不变,#7 隔离不动)。
> 在 broker 实现 `sub` 之前,cdrop 维持现状(控件用独立 `dev_widget` device_id,列表里多一台),不做 §6 的临时 cdrop 侧隐藏——等 broker 干净方案。
+9 -2
View File
@@ -54,6 +54,12 @@ type apsPayload struct {
type apsEnvelope struct { type apsEnvelope struct {
Alert apsAlert `json:"alert"` Alert apsAlert `json:"alert"`
Sound string `json:"sound"` Sound string `json:"sound"`
// ContentAvailable=1 gives the app a brief background wake (in addition to the
// visible banner) so it can warm up — refresh the isolated widget/clipboard
// session, re-establish state — without the user tapping. iOS rate-limits these,
// and they ride the existing offline alert (push-type stays "alert"), so a server
// message is the only trigger. The app never holds a persistent connection.
ContentAvailable int `json:"content-available,omitempty"`
} }
type apsAlert struct { type apsAlert struct {
@@ -171,8 +177,9 @@ func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n push.N
} }
payload := apsPayload{ payload := apsPayload{
APS: apsEnvelope{ APS: apsEnvelope{
Alert: apsAlert{Title: title, Body: body}, Alert: apsAlert{Title: title, Body: body},
Sound: "default", Sound: "default",
ContentAvailable: 1,
}, },
Type: n.Type, Type: n.Type,
URL: url, URL: url,
+34 -3
View File
@@ -57,6 +57,11 @@ type MintParams struct {
Sliding bool Sliding bool
Label string Label string
Meta string Meta string
// Sub is the intra-device session discriminator under one Meta (device): "" = the main
// session, a non-empty value (e.g. "widget") a subordinate session that shares the device
// identity but holds its own independently-refreshed tokens. The broker keys R2 idempotency
// on (user, app, meta, sub), so a sub session coexists with the main instead of rotating it.
Sub string
} }
// Session is a freshly minted delegated session. SID is the broker's session id — // Session is a freshly minted delegated session. SID is the broker's session id —
@@ -78,6 +83,7 @@ type mintReqWire struct {
Sliding bool `json:"sliding,omitempty"` Sliding bool `json:"sliding,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Meta string `json:"meta,omitempty"` Meta string `json:"meta,omitempty"`
Sub string `json:"sub,omitempty"`
} }
type sessionWire struct { type sessionWire struct {
@@ -94,7 +100,7 @@ func (c *Client) MintSession(ctx context.Context, p MintParams) (Session, error)
body := mintReqWire{ body := mintReqWire{
UserID: p.UserID, App: c.app, Tier: p.Tier, UserID: p.UserID, App: c.app, Tier: p.Tier,
AccessTTL: p.AccessTTL, RefreshTTL: p.RefreshTTL, Sliding: p.Sliding, AccessTTL: p.AccessTTL, RefreshTTL: p.RefreshTTL, Sliding: p.Sliding,
Label: p.Label, Meta: p.Meta, Label: p.Label, Meta: p.Meta, Sub: p.Sub,
} }
headers := map[string]string{"X-Internal-Key": c.internalKey} headers := map[string]string{"X-Internal-Key": c.internalKey}
var out sessionWire var out sessionWire
@@ -123,6 +129,29 @@ func (c *Client) RevokeSession(ctx context.Context, sid string) error {
return err return err
} }
// RevokeDeviceSessions cascade-revokes every session under one device meta — the main session
// and any subordinate (e.g. clipboard widget) sessions sharing that device
// (DELETE /internal/sessions?user_id=&app=&meta=). cdrop calls this when removing a device or
// logging it out, so no orphan sub-session survives to keep reading the clipboard. Returns the
// count revoked; revoked:0 (nothing to revoke) is idempotent success, not an error.
func (c *Client) RevokeDeviceSessions(ctx context.Context, userID, meta string) (int, error) {
q := url.Values{"user_id": {userID}, "app": {c.app}, "meta": {meta}}
headers := map[string]string{
"X-Internal-Key": c.internalKey,
"X-Broker-App": c.app,
}
var out struct {
Revoked int `json:"revoked"`
}
// The cascade endpoint always returns 200 {revoked:N} (revoked:0 when nothing matched), never
// 404 — so a 404 here means the endpoint is absent (a broker predating sub support) and must
// surface as an error rather than masquerade as success and silently leak the session.
if err := c.do(ctx, http.MethodDelete, "/internal/sessions?"+q.Encode(), headers, nil, http.StatusOK, &out); err != nil {
return 0, err
}
return out.Revoked, nil
}
// Refreshed is the result of rolling a session's access token. The broker rotates // Refreshed is the result of rolling a session's access token. The broker rotates
// the refresh credential, so the old one is now dead and the new one must be stored. // the refresh credential, so the old one is now dead and the new one must be stored.
type Refreshed struct { type Refreshed struct {
@@ -156,7 +185,8 @@ func (c *Client) RefreshSession(ctx context.Context, refresh string) (Refreshed,
// (the session<->device join key). The broker returns only kind==machine sessions for this // (the session<->device join key). The broker returns only kind==machine sessions for this
// app and never includes credentials. // app and never includes credentials.
type SessionInfo struct { type SessionInfo struct {
SID string SID string
Sub string // intra-device discriminator: "" = main; non-empty = subordinate (cdrop hides it)
Scope string Scope string
Label string Label string
Meta string Meta string
@@ -167,6 +197,7 @@ type SessionInfo struct {
type sessionInfoWire struct { type sessionInfoWire struct {
ID string `json:"id"` ID string `json:"id"`
Sub string `json:"sub"`
Scope string `json:"scope"` Scope string `json:"scope"`
Label string `json:"label"` Label string `json:"label"`
Meta string `json:"meta"` Meta string `json:"meta"`
@@ -192,7 +223,7 @@ func (c *Client) ListSessions(ctx context.Context, userID string) ([]SessionInfo
sessions := make([]SessionInfo, 0, len(out.Sessions)) sessions := make([]SessionInfo, 0, len(out.Sessions))
for _, s := range out.Sessions { for _, s := range out.Sessions {
sessions = append(sessions, SessionInfo{ sessions = append(sessions, SessionInfo{
SID: s.ID, Scope: s.Scope, Label: s.Label, Meta: s.Meta, SID: s.ID, Sub: s.Sub, Scope: s.Scope, Label: s.Label, Meta: s.Meta,
CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, ExpiresAt: s.ExpiresAt, CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, ExpiresAt: s.ExpiresAt,
}) })
} }
+1 -1
View File
@@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) {
t.Fatalf("bootstrap: %v", err) t.Fatalf("bootstrap: %v", err)
} }
want := []string{"clipboard_state", "devices", "login_requests", "push_subscriptions", "transfer_sessions"} want := []string{"clipboard_state", "devices", "login_requests", "pending_messages", "push_subscriptions", "transfer_sessions"}
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil { if err != nil {
t.Fatalf("query tables: %v", err) t.Fatalf("query tables: %v", err)
+19
View File
@@ -94,3 +94,22 @@ CREATE TABLE IF NOT EXISTS login_requests (
); );
CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at); CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at);
-- pending_messages:离线消息队列。设备间文本消息本是即时的(无 DB 行,仅经 SSE 转发);当收件
-- 设备页面 / app 关闭(无活 SSE)时,消息此前只随推送横幅一闪即逝、不入收件设备的消息列表。本表
-- 把离线消息入队,待收件设备唤醒 / 回前台经 GET /api/messages/pending 取走(单次投递:取即删,
-- DELETE...RETURNING)并累积未读。id 服务端生成,供客户端去重。按 (user_id, to_device) 取,与
-- hub.SendTo / push 一致按 device_name 定位收件设备。expires_at 短 TTLreaper 清陈旧未取走的。
CREATE TABLE IF NOT EXISTS pending_messages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
to_device TEXT NOT NULL,
from_device TEXT NOT NULL,
text TEXT NOT NULL,
sent_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pending_messages_user_device ON pending_messages (user_id, to_device);
CREATE INDEX IF NOT EXISTS idx_pending_messages_expires ON pending_messages (expires_at);
+11
View File
@@ -42,6 +42,17 @@ type LoginRequest struct {
ApprovedAt *int64 `json:"approved_at"` ApprovedAt *int64 `json:"approved_at"`
} }
type PendingMessage struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ToDevice string `json:"to_device"`
FromDevice string `json:"from_device"`
Text string `json:"text"`
SentAt int64 `json:"sent_at"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
}
type PushSubscription struct { type PushSubscription struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
+98
View File
@@ -0,0 +1,98 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: pending_messages.sql
package db
import (
"context"
)
const deleteExpiredPendingMessages = `-- name: DeleteExpiredPendingMessages :exec
DELETE FROM pending_messages
WHERE expires_at < ?
`
func (q *Queries) DeleteExpiredPendingMessages(ctx context.Context, expiresAt int64) error {
_, err := q.db.ExecContext(ctx, deleteExpiredPendingMessages, expiresAt)
return err
}
const insertPendingMessage = `-- name: InsertPendingMessage :exec
INSERT INTO pending_messages (id, user_id, to_device, from_device, text, sent_at, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
type InsertPendingMessageParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ToDevice string `json:"to_device"`
FromDevice string `json:"from_device"`
Text string `json:"text"`
SentAt int64 `json:"sent_at"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
}
// pending_messages: offline message queue. A text message to a device with no live
// SSE connection is queued here and delivered once when that device next polls.
// NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on multibyte
// runes in query files, corrupting the generated SQL.
func (q *Queries) InsertPendingMessage(ctx context.Context, arg InsertPendingMessageParams) error {
_, err := q.db.ExecContext(ctx, insertPendingMessage,
arg.ID,
arg.UserID,
arg.ToDevice,
arg.FromDevice,
arg.Text,
arg.SentAt,
arg.CreatedAt,
arg.ExpiresAt,
)
return err
}
const popPendingMessages = `-- name: PopPendingMessages :many
DELETE FROM pending_messages
WHERE user_id = ? AND to_device = ?
RETURNING id, user_id, to_device, from_device, text, sent_at, created_at, expires_at
`
type PopPendingMessagesParams struct {
UserID string `json:"user_id"`
ToDevice string `json:"to_device"`
}
func (q *Queries) PopPendingMessages(ctx context.Context, arg PopPendingMessagesParams) ([]PendingMessage, error) {
rows, err := q.db.QueryContext(ctx, popPendingMessages, arg.UserID, arg.ToDevice)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PendingMessage
for rows.Next() {
var i PendingMessage
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.ToDevice,
&i.FromDevice,
&i.Text,
&i.SentAt,
&i.CreatedAt,
&i.ExpiresAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+17
View File
@@ -0,0 +1,17 @@
-- pending_messages: offline message queue. A text message to a device with no live
-- SSE connection is queued here and delivered once when that device next polls.
-- NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on multibyte
-- runes in query files, corrupting the generated SQL.
-- name: InsertPendingMessage :exec
INSERT INTO pending_messages (id, user_id, to_device, from_device, text, sent_at, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
-- name: PopPendingMessages :many
DELETE FROM pending_messages
WHERE user_id = ? AND to_device = ?
RETURNING id, user_id, to_device, from_device, text, sent_at, created_at, expires_at;
-- name: DeleteExpiredPendingMessages :exec
DELETE FROM pending_messages
WHERE expires_at < ?;
+60 -15
View File
@@ -28,6 +28,12 @@ type deviceSessionReq struct {
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"` // browser | macos | windows | linux | ios DeviceType string `json:"device_type"` // browser | macos | windows | linux | ios
// Sub, when non-empty, mints a subordinate session under the SAME device (DeviceID is the
// main device's id) instead of a new device: it shares the device identity (one device in
// every list) but holds its own independently-refreshed tokens. cdrop uses "widget" for the
// clipboard control / home-screen widget extension (separate OS process), scoped to clipboard
// only. Requires a non-empty DeviceID (the main device to attach to).
Sub string `json:"sub"`
} }
type deviceSessionResp struct { type deviceSessionResp struct {
@@ -62,8 +68,20 @@ func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
return return
} }
// Sub != "" mints a subordinate session under an existing device (DeviceID is the main
// device's id), not a new device. It must attach to a concrete device_id (no auto-gen).
sub := strings.TrimSpace(req.Sub)
if sub != "" && !validSub(sub) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid sub"})
return
}
deviceID := strings.TrimSpace(req.DeviceID) deviceID := strings.TrimSpace(req.DeviceID)
if deviceID == "" { if deviceID == "" {
if sub != "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sub requires device_id"})
return
}
var err error var err error
if deviceID, err = newDeviceID(); err != nil { if deviceID, err = newDeviceID(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
@@ -81,12 +99,16 @@ func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
deviceType := qrDeviceType(req.DeviceType) deviceType := qrDeviceType(req.DeviceType)
// Mint at the caller's current trust tier: an SSO / device-authorize login is full, a // Mint at the caller's current trust tier: an SSO / device-authorize login is full, a
// restricted guest stays guest. This stops a borrowed (guest) browser from minting // restricted guest stays guest. This stops a borrowed (guest) browser from minting itself a
// itself a full device session. // full device session. A subordinate session instead carries a reduced capability scope
// (clipboard) — the widget extension can only read/write the clipboard, not act as the device.
tier := "full" tier := "full"
if claims.Guest() { if claims.Guest() {
tier = "guest" tier = "guest"
} }
if sub != "" {
tier = "clipboard"
}
accessTTL, refreshTTL := s.tierTTLs(tier) accessTTL, refreshTTL := s.tierTTLs(tier)
sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{ sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{
@@ -97,6 +119,7 @@ func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
Sliding: true, Sliding: true,
Label: deviceName, Label: deviceName,
Meta: deviceID, Meta: deviceID,
Sub: sub,
}) })
if err != nil { if err != nil {
slog.Error("device-session mint failed", "err", err, "user", claims.UserID) slog.Error("device-session mint failed", "err", err, "user", claims.UserID)
@@ -105,19 +128,24 @@ func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
} }
now := time.Now().Unix() now := time.Now().Unix()
if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{ // A subordinate session shares the main device's row — do NOT create a second device row
DeviceID: deviceID, // (it would collide on the device_id PK / surface as a duplicate device). The main device's
UserID: claims.UserID, // row already represents this device in every list; the sub is revoked via the meta cascade.
Name: deviceName, if sub == "" {
Type: deviceType, if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
Tier: tier, DeviceID: deviceID,
BrokerSid: sess.SID, UserID: claims.UserID,
CreatedAt: now, Name: deviceName,
LastSeen: now, Type: deviceType,
}); err != nil { Tier: tier,
// The session is minted and usable; a failed cache-row write only costs the local BrokerSid: sess.SID,
// type/presence overlay, so proceed rather than strand the device without tokens. CreatedAt: now,
slog.Error("device-session cache write failed", "err", err, "user", claims.UserID, "device", deviceID) LastSeen: now,
}); err != nil {
// The session is minted and usable; a failed cache-row write only costs the local
// type/presence overlay, so proceed rather than strand the device without tokens.
slog.Error("device-session cache write failed", "err", err, "user", claims.UserID, "device", deviceID)
}
} }
name := claims.Name name := claims.Name
@@ -140,6 +168,23 @@ func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
}) })
} }
// validSub accepts a subordinate-session discriminator: a short [a-z0-9_-] token (broker-safe,
// control-byte-free). Today cdrop only mints "widget" (clipboard control / home-screen widget);
// the format check reserves room for future extension processes without re-validating per value.
func validSub(sub string) bool {
if len(sub) == 0 || len(sub) > 32 {
return false
}
for _, c := range sub {
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '_', c == '-':
default:
return false
}
}
return true
}
// validDeviceID accepts a cdrop device_id: the "dev_" prefix plus pure [A-Za-z0-9_-], capped // validDeviceID accepts a cdrop device_id: the "dev_" prefix plus pure [A-Za-z0-9_-], capped
// in length. This both recognises cdrop's own ids (newDeviceID) and guarantees the value is // in length. This both recognises cdrop's own ids (newDeviceID) and guarantees the value is
// control-byte-free, so it is safe to pass to the broker as meta (echoed into X-Auth-Meta). // control-byte-free, so it is safe to pass to the broker as meta (echoed into X-Auth-Meta).
+1 -1
View File
@@ -58,7 +58,7 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"})
return return
} }
status, ok := s.revokeDevice(r, claims.UserID, deviceID) status, ok := s.revokeDevice(r, claims.UserID, deviceID, true)
if !ok { if !ok {
writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)}) writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)})
return return
+77 -14
View File
@@ -2,16 +2,25 @@ package httpapi
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"sort"
"time" "time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push" "commilitia.net/cdrop/internal/push"
) )
// pendingMessageTTL bounds how long an offline message waits in the queue before the
// reaper drops it. A day matches the push TTL — past that the message is stale anyway.
const pendingMessageTTL = 24 * 60 * 60
// 4 KB caps DoS-via-paste; longer payloads should use file transfer instead. // 4 KB caps DoS-via-paste; longer payloads should use file transfer instead.
const maxMessageBytes = 4 * 1024 const maxMessageBytes = 4 * 1024
@@ -79,26 +88,80 @@ func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
}, },
}) })
if !delivered { if !delivered {
// Peer's page is closed (no live SSE). The message has no DB row, so the // 收件设备页面 / app 关闭(无活 SSE):把消息入离线队列,待其唤醒 / 回前台经
// only delivery left is a push notification (Web Push or APNs) carrying // GET /api/messages/pending 取走入库并累积未读——无论是否配推送都入队,故消息不再「只随
// the text itself. If any push channel is enabled and may have a // 推送横幅一闪而过、不入收件列表」。同时发推送唤醒(横幅即时可见)。返回 202(已受理、离线投递)。
// subscription, send and report 202; otherwise the message is truly gone. sentAt := time.Now().Unix()
if err := s.queries.InsertPendingMessage(r.Context(), db.InsertPendingMessageParams{
ID: newMessageID(),
UserID: claims.UserID,
ToDevice: req.To,
FromDevice: from,
Text: req.Text,
SentAt: sentAt,
CreatedAt: sentAt,
ExpiresAt: sentAt + pendingMessageTTL,
}); err != nil {
slog.Error("queue offline message failed", "err", err, "user", claims.UserID, "to", req.To)
}
n := push.Notification{ n := push.Notification{
Type: push.KindMessage, Type: push.KindMessage,
Params: map[string]string{"sender": from, "text": req.Text}, Params: map[string]string{"sender": from, "text": req.Text},
} }
if s.push.Enabled() || s.apns.Enabled() { if s.push.Enabled() {
if s.push.Enabled() { go s.push.Notify(context.Background(), claims.UserID, req.To, n)
go s.push.Notify(context.Background(), claims.UserID, req.To, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), claims.UserID, req.To, n)
}
w.WriteHeader(http.StatusAccepted)
return
} }
writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"}) if s.apns.Enabled() {
go s.apns.Notify(context.Background(), claims.UserID, req.To, n)
}
w.WriteHeader(http.StatusAccepted)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// newMessageID returns a random opaque id for a queued message so the client can dedup it
// against the live SSE path (which assigns its own ids).
func newMessageID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand only fails if the OS RNG is broken; panicking is correct.
panic("crypto/rand: " + err.Error())
}
return hex.EncodeToString(b[:])
}
type pendingMessageWire struct {
ID string `json:"id"`
From string `json:"from"`
Text string `json:"text"`
SentAt int64 `json:"sent_at"`
}
// handlePendingMessages delivers (once) the messages queued for this device while it was
// offline, then deletes them (DELETE...RETURNING — at-most-once). The device polls this on wake
// / foreground, saves them locally, and accrues unread. Returns [] when none. Guests included
// (messaging is allowed for guest sessions). A device with no managed name gets [].
func (s *Server) handlePendingMessages(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
if device == "" {
writeJSON(w, http.StatusOK, []pendingMessageWire{})
return
}
rows, err := s.queries.PopPendingMessages(r.Context(), db.PopPendingMessagesParams{
UserID: claims.UserID,
ToDevice: device,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
// DELETE...RETURNING order is unspecified; present oldest-first for natural chat order.
sort.Slice(rows, func(i, j int) bool { return rows[i].SentAt < rows[j].SentAt })
out := make([]pendingMessageWire, 0, len(rows))
for _, m := range rows {
out = append(out, pendingMessageWire{ID: m.ID, From: m.FromDevice, Text: m.Text, SentAt: m.SentAt})
}
writeJSON(w, http.StatusOK, out)
}
+17 -5
View File
@@ -35,8 +35,8 @@ type mockBrokerState struct {
} }
type mockSession struct { type mockSession struct {
sid, userID, app, meta, label, scope string sid, userID, app, meta, sub, label, scope string
createdAt, lastUsedAt int64 createdAt, lastUsedAt int64
} }
// newMockBroker stands in for the Auth Broker's internal API: POST /internal/sessions // newMockBroker stands in for the Auth Broker's internal API: POST /internal/sessions
@@ -67,7 +67,7 @@ func newMockBroker(t *testing.T) (*brokerclient.Client, *mockBrokerState) {
sid := "" sid := ""
if meta != "" { if meta != "" {
for _, sess := range st.sessions { for _, sess := range st.sessions {
if !st.revoked[sess.sid] && sess.userID == userID && sess.app == app && sess.meta == meta { if !st.revoked[sess.sid] && sess.userID == userID && sess.app == app && sess.meta == meta && sess.sub == str(body, "sub") {
sid = sess.sid sid = sess.sid
break break
} }
@@ -81,7 +81,7 @@ func newMockBroker(t *testing.T) (*brokerclient.Client, *mockBrokerState) {
if existing, ok := st.sessions[sid]; ok { if existing, ok := st.sessions[sid]; ok {
created = existing.createdAt // rotation preserves CreatedAt created = existing.createdAt // rotation preserves CreatedAt
} }
st.sessions[sid] = &mockSession{sid: sid, userID: userID, app: app, meta: meta, label: label, scope: scope, createdAt: created, lastUsedAt: now} st.sessions[sid] = &mockSession{sid: sid, userID: userID, app: app, meta: meta, sub: str(body, "sub"), label: label, scope: scope, createdAt: created, lastUsedAt: now}
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": sid, "app": "cdrop", "id": sid, "app": "cdrop",
"access": "acc-" + sid, "refresh": "rtk-" + sid, "access": "acc-" + sid, "refresh": "rtk-" + sid,
@@ -101,12 +101,24 @@ func newMockBroker(t *testing.T) (*brokerclient.Client, *mockBrokerState) {
continue continue
} }
out = append(out, map[string]any{ out = append(out, map[string]any{
"id": sess.sid, "scope": sess.scope, "label": sess.label, "meta": sess.meta, "id": sess.sid, "sub": sess.sub, "scope": sess.scope, "label": sess.label, "meta": sess.meta,
"created_at": sess.createdAt, "last_used_at": sess.lastUsedAt, "created_at": sess.createdAt, "last_used_at": sess.lastUsedAt,
"expires_at": time.Now().Add(24 * time.Hour).Unix(), "expires_at": time.Now().Add(24 * time.Hour).Unix(),
}) })
} }
_ = json.NewEncoder(w).Encode(map[string]any{"sessions": out}) _ = json.NewEncoder(w).Encode(map[string]any{"sessions": out})
case r.Method == http.MethodDelete && r.URL.Path == "/internal/sessions":
q := r.URL.Query()
st.lastRevokeApp = r.Header.Get("X-Broker-App")
revoked := 0
for _, sess := range st.sessions {
if st.revoked[sess.sid] || sess.userID != q.Get("user_id") || sess.app != q.Get("app") || sess.meta != q.Get("meta") {
continue
}
st.revoked[sess.sid] = true
revoked += 1
}
_ = json.NewEncoder(w).Encode(map[string]any{"revoked": revoked})
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/internal/sessions/"): case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/internal/sessions/"):
st.revoked[strings.TrimPrefix(r.URL.Path, "/internal/sessions/")] = true st.revoked[strings.TrimPrefix(r.URL.Path, "/internal/sessions/")] = true
st.lastRevokeApp = r.Header.Get("X-Broker-App") st.lastRevokeApp = r.Header.Get("X-Broker-App")
+2
View File
@@ -147,6 +147,8 @@ func (s *Server) routes() {
r.Get("/hub/events", s.handleEvents) r.Get("/hub/events", s.handleEvents)
r.Post("/hub/signal", s.handleSignal) r.Post("/hub/signal", s.handleSignal)
r.Post("/message", s.handleMessage) r.Post("/message", s.handleMessage)
// 离线消息取件:设备唤醒 / 回前台时取走离线期间入队的消息(取即删),入库 + 累积未读。
r.Get("/messages/pending", s.handlePendingMessages)
r.Get("/devices", s.handleDevices) r.Get("/devices", s.handleDevices)
r.Get("/push/vapid-key", s.handlePushVAPIDKey) r.Get("/push/vapid-key", s.handlePushVAPIDKey)
r.Post("/push/subscribe", s.handlePushSubscribe) r.Post("/push/subscribe", s.handlePushSubscribe)
+6 -1
View File
@@ -68,11 +68,16 @@ func RunLoginRequestReaper(ctx context.Context, q *db.Queries) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
if n, err := q.DeleteExpiredLoginRequests(ctx, time.Now().Unix()); err != nil { now := time.Now().Unix()
if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil {
slog.Warn("login request reaper failed", "err", err) slog.Warn("login request reaper failed", "err", err)
} else if n > 0 { } else if n > 0 {
slog.Info("login requests reaped", "count", n) slog.Info("login requests reaped", "count", n)
} }
// 顺带清陈旧未取走的离线消息(同 1h 节律,复用本 goroutine)。
if err := q.DeleteExpiredPendingMessages(ctx, now); err != nil {
slog.Warn("pending message reaper failed", "err", err)
}
} }
} }
} }
+69 -53
View File
@@ -1,13 +1,16 @@
package httpapi package httpapi
import ( import (
"context"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
) )
// Session management. After the unified-session-model rework, every logged-in client — // Session management. After the unified-session-model rework, every logged-in client —
@@ -82,19 +85,33 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
} }
} }
out := make([]sessionView, 0, len(sessions)) // 按 meta 归并:一台设备(meta)只呈现一条。子会话(sub!="",如控件剪贴板会话)与主会话共享 meta
// ——优先用主会话作代表;但若某设备只剩子会话存活(主会话已过期、控件会话仍独立刷新着),仍按该
// meta 呈现一台,故不能简单丢弃 sub!="" 行(否则“仅控件存活”的设备会从列表消失,见 auth/docs/
// 子会话方案.md §四)。meta-less 会话是非 cdrop 托管的机器会话(桌面 device-authorize bootstrap
// 随即被代铸替换),无 device_id、不是托管设备,跳过。
rep := make(map[string]brokerclient.SessionInfo, len(sessions))
order := make([]string, 0, len(sessions))
for _, sess := range sessions { for _, sess := range sessions {
// A meta-less session is a non-cdrop-managed machine session — a desktop
// device-authorize bootstrap that the desktop replaces via 代铸 right away. It has
// no device_id, so it isn't a managed device and must not show as a phantom row.
if sess.Meta == "" { if sess.Meta == "" {
continue continue
} }
typ := typeByID[sess.Meta] if cur, ok := rep[sess.Meta]; !ok {
rep[sess.Meta] = sess
order = append(order, sess.Meta)
} else if cur.Sub != "" && sess.Sub == "" {
rep[sess.Meta] = sess // 主会话优先覆盖先到的子会话,作该设备的代表
}
}
out := make([]sessionView, 0, len(order))
for _, meta := range order {
sess := rep[meta]
typ := typeByID[meta]
if typ == "" { if typ == "" {
typ = "browser" typ = "browser"
} }
name := nameByID[sess.Meta] name := nameByID[meta]
if name == "" { if name == "" {
name = sess.Label name = sess.Label
} }
@@ -103,13 +120,13 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
scope = "guest" scope = "guest"
} }
out = append(out, sessionView{ out = append(out, sessionView{
ID: sess.Meta, ID: meta,
DeviceID: sess.Meta, DeviceID: meta,
DeviceName: name, DeviceName: name,
Kind: typ, Kind: typ,
Scope: scope, Scope: scope,
Current: sess.Meta == claims.DeviceID, Current: meta == claims.DeviceID,
Online: s.hub.Online(claims.UserID, sess.Meta), Online: s.hub.Online(claims.UserID, meta),
CreatedAt: sess.CreatedAt, CreatedAt: sess.CreatedAt,
LastUsedAt: sess.LastUsedAt, LastUsedAt: sess.LastUsedAt,
}) })
@@ -127,7 +144,7 @@ func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
return return
} }
status, ok := s.revokeDevice(r, claims.UserID, id) status, ok := s.revokeDevice(r, claims.UserID, id, true)
if !ok { if !ok {
writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)}) writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)})
return return
@@ -141,63 +158,62 @@ func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
// (they are the same operation). The broker session id is resolved cache-first (the local row // (they are the same operation). The broker session id is resolved cache-first (the local row
// holds the stable broker_sid) and falls back to R1 — the authoritative list — so a missing or // holds the stable broker_sid) and falls back to R1 — the authoritative list — so a missing or
// pruned cache row still revokes correctly and stays authorized to this user. // pruned cache row still revokes correctly and stays authorized to this user.
func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) { func (s *Server) revokeDevice(r *http.Request, userID, deviceID string, notifyRevoked bool) (int, bool) {
sid, name := "", "" // Resolve the device name (for the live SSE kick + the cross-device revoke push). Cache-first
// (the local row holds the live name, which a decoupled rename keeps current); fall back to the
// broker R1 label for a row-less session.
name := ""
localRowOwned := false localRowOwned := false
if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID { if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID {
sid = dev.BrokerSid
name = dev.Name name = dev.Name
localRowOwned = true localRowOwned = true
} }
if sid == "" { if name == "" {
sessions, err := s.broker.ListSessions(r.Context(), userID) if sessions, err := s.broker.ListSessions(r.Context(), userID); err == nil {
if err != nil { for _, sess := range sessions {
slog.Error("revoke: list sessions failed", "err", err, "user", userID) if sess.Meta == deviceID {
return http.StatusBadGateway, false name = sess.Label
} break
for _, sess := range sessions { }
if sess.Meta == deviceID {
sid = sess.SID
name = sess.Label
break
} }
} }
} }
if sid == "" {
// No broker session for this device_id. If we still own a local cache row, it is a stale / // Cascade-revoke the whole device by meta: the main session AND any subordinate (clipboard
// phantom entry — a synthetic test device (the Diag residue) or a row whose broker session // widget) sessions sharing this device_id, so no orphan sub-session survives to keep reading
// is long gone. Drop the local row + kick + republish so the user can always clear such a // the clipboard. Idempotent — revoked:0 when the device's sessions are already gone.
// device from their list; only a device_id we own nothing for is a genuine 404. revoked, err := s.broker.RevokeDeviceSessions(r.Context(), userID, deviceID)
if !localRowOwned { if err != nil {
return http.StatusNotFound, false slog.Error("broker cascade revoke failed", "err", err, "user", userID, "meta", deviceID)
}
if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
DeviceID: deviceID,
UserID: userID,
}); err != nil {
slog.Warn("delete phantom device cache failed", "err", err, "user", userID, "device", deviceID)
}
s.hub.Kick(userID, deviceID, name)
s.hub.PublishPresence(r.Context(), userID)
return http.StatusNoContent, true
}
// Revoke the broker session first so the device can't refresh; a 404 (already gone) is
// idempotent success inside RevokeSession.
if err := s.broker.RevokeSession(r.Context(), sid); err != nil {
slog.Error("broker revoke failed", "err", err, "user", userID, "sid", sid)
return http.StatusBadGateway, false return http.StatusBadGateway, false
} }
// Nothing revoked and we own no local row → a device_id we have nothing for is a genuine 404.
// (A phantom row with no broker session still owns a local row, so it falls through to cleanup
// below — the user can always clear such a stale entry from their list.)
if revoked == 0 && !localRowOwned {
return http.StatusNotFound, false
}
// 跨设备登出(非自登出):推送告知被踢设备,使其立即知晓并清本地登录态——即便其页面 / app 已关闭,
// 也不必等下次请求 401 才发现。用一次性 context(请求 ctx 会随响应取消),best-effort 异步发。
if notifyRevoked && name != "" {
n := push.Notification{Type: push.KindSessionRevoked, Tag: "session:revoked"}
if s.push.Enabled() {
go s.push.Notify(context.Background(), userID, name, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), userID, name, n)
}
}
// Drop the local cache row (idempotent; non-fatal — the sweeper / next list-prune also clean it).
if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
DeviceID: deviceID, DeviceID: deviceID,
UserID: userID, UserID: userID,
}); err != nil { }); err != nil {
// The session is already revoked; a failed cache delete is non-fatal (the sweeper
// and the next list-prune clean it). Report success so the client sees the logout.
slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID) slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID)
} }
// Kick by the stable device_id (the hub key); name rides along for the code-less fallback // Kick by the stable device_id (the hub key); name rides along for the code-less fallback path
// path inside Kick. This makes cross-device revoke land reliably (the prior name-keyed Kick // inside Kick, so a renamed device is still kicked reliably (the prior "移除失败" symptom).
// could miss a renamed device, leaving it able to keep refreshing — the "移除失败" symptom).
s.hub.Kick(userID, deviceID, name) s.hub.Kick(userID, deviceID, name)
s.hub.PublishPresence(r.Context(), userID) s.hub.PublishPresence(r.Context(), userID)
return http.StatusNoContent, true return http.StatusNoContent, true
@@ -214,7 +230,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
} }
claims, _ := jwtauth.ClaimsFromContext(r.Context()) claims, _ := jwtauth.ClaimsFromContext(r.Context())
if claims.DeviceID != "" { if claims.DeviceID != "" {
if status, ok := s.revokeDevice(r, claims.UserID, claims.DeviceID); !ok && status != http.StatusNotFound { if status, ok := s.revokeDevice(r, claims.UserID, claims.DeviceID, false); !ok && status != http.StatusNotFound {
slog.Warn("logout revoke failed", "status", status, "user", claims.UserID) slog.Warn("logout revoke failed", "status", status, "user", claims.UserID)
} }
} }
+12
View File
@@ -44,6 +44,10 @@ const (
KindTransferDone = "transfer:done" KindTransferDone = "transfer:done"
KindTransferFailed = "transfer:failed" KindTransferFailed = "transfer:failed"
KindMessage = "message" KindMessage = "message"
// KindSessionRevoked tells a device it was logged out by another device (cross-device
// revoke). The client clears its local session on receipt — immediate even when its page /
// app is closed, instead of waiting for the next request to 401.
KindSessionRevoked = "session:revoked"
) )
// Notification is an intent to notify; the sender localizes it per subscription. // Notification is an intent to notify; the sender localizes it per subscription.
@@ -206,18 +210,24 @@ var notifyStrings = map[string]map[string]string{
"incoming.body": "%s 发来 %s", "incoming.body": "%s 发来 %s",
"done.title": "传输完成", "done.title": "传输完成",
"failed.title": "传输失败", "failed.title": "传输失败",
"revoked.title": "已退出登录",
"revoked.body": "此设备已被其他设备移除,需重新登录",
}, },
"zh-TW": { "zh-TW": {
"incoming.title": "收到檔案", "incoming.title": "收到檔案",
"incoming.body": "%s 傳來 %s", "incoming.body": "%s 傳來 %s",
"done.title": "傳輸完成", "done.title": "傳輸完成",
"failed.title": "傳輸失敗", "failed.title": "傳輸失敗",
"revoked.title": "已登出",
"revoked.body": "此裝置已被其他裝置移除,需重新登入",
}, },
"en-US": { "en-US": {
"incoming.title": "Incoming file", "incoming.title": "Incoming file",
"incoming.body": "%s is sending %s", "incoming.body": "%s is sending %s",
"done.title": "Transfer complete", "done.title": "Transfer complete",
"failed.title": "Transfer failed", "failed.title": "Transfer failed",
"revoked.title": "Signed out",
"revoked.body": "This device was removed by another device; sign in again",
}, },
} }
@@ -254,6 +264,8 @@ func Localize(typ string, params map[string]string, locale string) (title, body
return t["done.title"], params["filename"] return t["done.title"], params["filename"]
case KindTransferFailed: case KindTransferFailed:
return t["failed.title"], params["filename"] return t["failed.title"], params["filename"]
case KindSessionRevoked:
return t["revoked.title"], t["revoked.body"]
default: default:
return params["title"], params["body"] return params["title"], params["body"]
} }
+26
View File
@@ -38,4 +38,30 @@ enum WidgetSessionStore
guard let url = fileURL() else { return } guard let url = fileURL() else { return }
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
} }
// refreshAccess refresh token accessPOST /api/auth/refresh
// app content-available / 使
// refresh#7 no-opCAS-before-clear
// 401 refresh
static func refreshAccess() async
{
guard let session = load() else { return }
var req = URLRequest(url: URL(string: CDropAPI.base + "/api/auth/refresh")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: [ "refresh_token": session.refreshToken ])
guard let (data, resp) = try? await URLSession.shared.data(for: req) else { return }
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 200,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let access = obj["access_token"] as? String
{
let newRefresh = (obj["refresh_token"] as? String) ?? session.refreshToken
save(WidgetSession(accessToken: access, refreshToken: newRefresh, userId: session.userId))
}
else if code == 401, load()?.refreshToken == session.refreshToken
{
clear()
}
}
} }
+36
View File
@@ -32,4 +32,40 @@ final class AppDelegate: NSObject, UIApplicationDelegate
{ {
// Push // Push
} }
// content-available apns/sender.go ~30s
// / access使 /
//
// #7
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
)
{
// session:revoked Keychain +
// UI / / Keychain
// NotificationCenter 401
// push.KindSessionRevoked Go
if (userInfo["type"] as? String) == "session:revoked"
{
AuthManager.clearPersistedSession()
WidgetSessionStore.clear()
NotificationCenter.default.post(name: .cdropSessionRevoked, object: nil)
completionHandler(.newData)
return
}
// content-available #7
Task
{
await WidgetSessionStore.refreshAccess()
completionHandler(.newData)
}
}
}
extension Notification.Name
{
// UI AppRoot.onReceive
static let cdropSessionRevoked = Notification.Name("cdrop.sessionRevoked")
} }
+8
View File
@@ -195,6 +195,14 @@ final class AuthManager
Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) Keychain.delete(service: Self.keychainService, account: Self.keychainAccount)
} }
// AppDelegate live AuthManager
// logout() Keychain init
// .cdropSessionRevoked logout()
static func clearPersistedSession()
{
Keychain.delete(service: keychainService, account: keychainAccount)
}
// ---- Broker / / ---- // ---- Broker / / ----
private struct AuthConfig { let brokerURL: String; let brokerApp: String } private struct AuthConfig { let brokerURL: String; let brokerApp: String }
+16
View File
@@ -6,6 +6,7 @@ import SwiftUI
struct CDropApp: App struct CDropApp: App
{ {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@State private var auth = AuthManager() @State private var auth = AuthManager()
@State private var engine = EngineController() @State private var engine = EngineController()
@@ -15,6 +16,15 @@ struct CDropApp: App
{ {
AppRoot(auth: auth, engine: engine) AppRoot(auth: auth, engine: engine)
} }
.onChange(of: scenePhase)
{ _, phase in
// / ready
//
if phase == .active, auth.session != nil
{
engine.provisionWidgetSessionIfNeeded()
}
}
} }
} }
@@ -55,5 +65,11 @@ struct AppRoot: View
guard url.scheme == "cdrop", url.host == "share" else { return } guard url.scheme == "cdrop", url.host == "share" else { return }
engine.loadPendingShares() engine.loadPendingShares()
} }
// AppDelegate +
.onReceive(NotificationCenter.default.publisher(for: .cdropSessionRevoked))
{ _ in
engine.reset()
auth.logout()
}
} }
} }
@@ -7,7 +7,7 @@ import WebKit
// Decodable JS WKWebView bool NSNumber // Decodable JS WKWebView bool NSNumber
// / JSONDecoder // / JSONDecoder
// web hub.ts handlePresence // web hub.ts handlePresence
struct DeviceItem: Identifiable, Equatable struct DeviceItem: Identifiable, Equatable, Codable
{ {
// deviceID broker meta / / // deviceID broker meta / /
// name deviceID presence name // name deviceID presence name
@@ -166,6 +166,9 @@ final class EngineController: NSObject
// / // /
history = RecordsStore.load([TransferItem].self, "history") ?? [] history = RecordsStore.load([TransferItem].self, "history") ?? []
messages = RecordsStore.load([MessageItem].self, "messages") ?? [] messages = RecordsStore.load([MessageItem].self, "messages") ?? []
// presence / SSE
// SSE presence 线
devices = RecordsStore.load([DeviceItem].self, "devices") ?? []
} }
// makeWebView WebView __CDROP_BOOT__device_type:"ios" // makeWebView WebView __CDROP_BOOT__device_type:"ios"
@@ -232,6 +235,7 @@ final class EngineController: NSObject
// //
RecordsStore.clear("history") RecordsStore.clear("history")
RecordsStore.clear("messages") RecordsStore.clear("messages")
RecordsStore.clear("devices")
status = t("ios.engine.disconnected") status = t("ios.engine.disconnected")
deviceName = "" deviceName = ""
PushRegistry.shared.reset() PushRegistry.shared.reset()
@@ -431,9 +435,16 @@ final class EngineController: NSObject
func provisionWidgetSessionIfNeeded() func provisionWidgetSessionIfNeeded()
{ {
guard engineReady, WidgetSessionStore.load() == nil else { return } guard engineReady, WidgetSessionStore.load() == nil else { return }
let id = CDropAPI.widgetDeviceID() // meta = device_id+ sub="widget" +
let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ]) // tier=clipboard dev_widget device_id
sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ]) // ClipboardClient#7
let id = selfDeviceID
guard !id.isEmpty else { return }
sendCommand("provisionWidgetSession", payload: [
"device_id": id,
"device_name": currentDeviceName(),
"sub": "widget",
])
} }
// JSwindow.__cdropEngineEvent // JSwindow.__cdropEngineEvent
@@ -629,6 +640,7 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let raw = p["devices"] as? [Any] if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
{ {
devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) } devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
RecordsStore.save(devices, "devices") // presence
} }
case "transfers": case "transfers":
if let p = payload as? [String: Any], let raw = p["active"] as? [Any] if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
+8 -6
View File
@@ -309,9 +309,9 @@ function bindCommands(): void
// (独立 device_id),回交原生存 App Group 供控件自用 / 自刷,不与引擎抢 refresh 轮换。 // (独立 device_id),回交原生存 App Group 供控件自用 / 自刷,不与引擎抢 refresh 轮换。
onNativeEvent("provisionWidgetSession", (payload) => onNativeEvent("provisionWidgetSession", (payload) =>
{ {
const p = payload as { device_id?: string; device_name?: string }; const p = payload as { device_id?: string; device_name?: string; sub?: string };
if (!p.device_id) { return; } if (!p.device_id) { return; }
void provisionWidgetSession(p.device_id, p.device_name ?? ""); void provisionWidgetSession(p.device_id, p.device_name ?? "", p.sub ?? "");
}); });
onNativeEvent("shutdown", () => onNativeEvent("shutdown", () =>
{ {
@@ -339,16 +339,18 @@ async function registerPush(token: string, locale: string): Promise<void>
} }
} }
// provisionWidgetSession:经 /api/auth/device-session 给控件铸一条独立委派会话(device_type // provisionWidgetSession:经 /api/auth/device-session 给控件铸一条「子会话」(sub="widget",挂在主
// ios),把 access / refresh 回交原生(存 App Group)。控件据此独立调 /api/clipboard 并自刷。 // 设备 device_id 之下,后端给 tier=clipboard),把 access / refresh 回交原生(存 App Group)。控件据此
async function provisionWidgetSession(deviceId: string, deviceName: string): Promise<void> // 独立调 /api/clipboard 并自刷——令牌隔离、独立刷新(不碰引擎主会话,#7 隔离不变),但在用户视角是同一
// 台设备一条会话(子会话方案,broker 按 (user,meta,sub) 幂等、按 meta 归并/级联)。
async function provisionWidgetSession(deviceId: string, deviceName: string, sub: string): Promise<void>
{ {
try try
{ {
const r = await apiFetch("/api/auth/device-session", { const r = await apiFetch("/api/auth/device-session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: deviceId, device_name: deviceName, device_type: "ios" }), body: JSON.stringify({ device_id: deviceId, device_name: deviceName, device_type: "ios", sub }),
}); });
if (!r.ok) { return; } if (!r.ok) { return; }
const d = (await r.json()) as { access_token: string; refresh_token: string; user_id: string }; const d = (await r.json()) as { access_token: string; refresh_token: string; user_id: string };
+38
View File
@@ -98,6 +98,44 @@ function sleep(ms: number, signal: AbortSignal): Promise<void>
function onOpen(): void function onOpen(): void
{ {
markConnected(); markConnected();
// 连接(含每次重连)即取走离线期间入队的消息:拉 /api/messages/pending(服务端取即删 / 单次
// 投递),逐条入 store——与实时 message 同路(addMessage → 引擎桥推原生 + 累积未读)。修「离线
// 消息只随推送横幅一闪、不入收件列表」。best-effort:失败 / 未取走的下次连接重试。
void fetchPendingMessages();
}
interface PendingMessage
{
id?: string;
from?: string;
text?: string;
sent_at?: number;
}
async function fetchPendingMessages(): Promise<void>
{
let list: PendingMessage[];
try
{
const r = await apiFetch("/api/messages/pending");
if (!r.ok) { return; }
list = (await r.json()) as PendingMessage[];
}
catch { return; }
if (!Array.isArray(list)) { return; }
// 服务端按 sent_at 升序返回;逐条前插即得最新在前。各条带服务端 id,原生 / store 按 id 去重,
// 不与可能的实时重复冲突。这里不再额外弹桌面通知(入队时已发推送),仅补进列表 + 累积未读。
for (const m of list)
{
if (!m.id || !m.from || typeof m.text !== "string") { continue; }
useAppStore.getState().addMessage({
id: m.id,
direction: "incoming",
peerName: m.from,
text: m.text,
sentAt: typeof m.sent_at === "number" ? m.sent_at : Math.floor(Date.now() / 1000),
});
}
} }
function markConnected(): void function markConnected(): void