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

- 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
+60 -15
View File
@@ -28,6 +28,12 @@ type deviceSessionReq struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
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 {
@@ -62,8 +68,20 @@ func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) {
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)
if deviceID == "" {
if sub != "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sub requires device_id"})
return
}
var err error
if deviceID, err = newDeviceID(); err != nil {
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)
// 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
// itself a full device session.
// restricted guest stays guest. This stops a borrowed (guest) browser from minting itself a
// 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"
if claims.Guest() {
tier = "guest"
}
if sub != "" {
tier = "clipboard"
}
accessTTL, refreshTTL := s.tierTTLs(tier)
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,
Label: deviceName,
Meta: deviceID,
Sub: sub,
})
if err != nil {
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()
if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
DeviceID: deviceID,
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
Tier: tier,
BrokerSid: sess.SID,
CreatedAt: now,
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)
// A subordinate session shares the main device's row — do NOT create a second device row
// (it would collide on the device_id PK / surface as a duplicate device). The main device's
// row already represents this device in every list; the sub is revoked via the meta cascade.
if sub == "" {
if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
DeviceID: deviceID,
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
Tier: tier,
BrokerSid: sess.SID,
CreatedAt: now,
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
@@ -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
// 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).