后台/会话三诉求:子会话(一台设备一会话)+ 离线消息队列 + 被登出推送 + 后台唤醒层
- 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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user