dsh-plugin-weapp-gateway 0.8.23 → 0.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/PROTOCOL.md +1545 -1486
  3. package/README.md +503 -464
  4. package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
  5. package/bin/setup-ip.mjs +474 -474
  6. package/cordis.patch.yml +50 -50
  7. package/docs/agent-team-app-integration.md +945 -0
  8. package/docs/blog-mobile-gateway.md +542 -542
  9. package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
  10. package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
  11. package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
  12. package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
  13. package/docs/dsh-rc2-mobile-integration.md +155 -155
  14. package/docs/file-upload-app-integration.md +250 -0
  15. package/docs/multi-gateway-app-integration.md +141 -125
  16. package/docs/multi-gateway-phase1-acceptance.md +179 -179
  17. package/docs/multi-gateway-todo.md +137 -137
  18. package/docs/remote-gateway-refactor-plan.md +52 -52
  19. package/docs/runtime-architecture.architecture.json +264 -264
  20. package/docs/runtime-architecture.html +15001 -15001
  21. package/docs/runtime-architecture.visual-check.html +32 -32
  22. package/docs/runtime-architecture.visual-check.json +548 -548
  23. package/docs/session-agent-preset-app-integration.md +93 -93
  24. package/docs/turn-outline-app-integration.md +196 -0
  25. package/docs/typert-remote-gateway-feature-checklist.md +294 -294
  26. package/docs/voice-input-implementation.md +219 -219
  27. package/docs/workspace-changes-mobile-integration.md +32 -32
  28. package/helper/dsh_mobile_gateway_helper.py +227 -227
  29. package/lib/account-bridge.mjs +57 -57
  30. package/lib/client.js +1409 -1361
  31. package/lib/cloudflare-tunnel.mjs +314 -314
  32. package/lib/cloudflared-binary.mjs +135 -135
  33. package/lib/context-bridge.mjs +3338 -1715
  34. package/lib/cost-meter-bridge.mjs +794 -794
  35. package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
  36. package/lib/devices.js +273 -245
  37. package/lib/dsh-host-adapter.mjs +508 -426
  38. package/lib/file-upload.mjs +205 -0
  39. package/lib/gateway-probe.mjs +6 -6
  40. package/lib/gateway-state.mjs +57 -57
  41. package/lib/gateway-tools.mjs +36 -36
  42. package/lib/history-cache.mjs +66 -0
  43. package/lib/index.mjs +4390 -3803
  44. package/lib/plugin-management.mjs +251 -233
  45. package/lib/public-endpoint-check.mjs +61 -61
  46. package/lib/relay-connector.mjs +213 -181
  47. package/lib/relay-state.mjs +83 -76
  48. package/lib/session-follower.mjs +140 -136
  49. package/lib/session-resume-bridge.mjs +86 -0
  50. package/lib/session-target.mjs +29 -0
  51. package/lib/speech-input.mjs +192 -192
  52. package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
  53. package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
  54. package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
  55. package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
  56. package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
  57. package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
  58. package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
  59. package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
  60. package/lib/vendor/cost-meter/net.mjs +183 -183
  61. package/lib/vendor/cost-meter/opendesign.mjs +204 -204
  62. package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
  63. package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
  64. package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
  65. package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
  66. package/lib/web-restart.mjs +47 -47
  67. package/lib/wire-json.mjs +7 -7
  68. package/lib/workspace-changes-bridge.mjs +32 -32
  69. package/package.json +54 -54
  70. package/relay/agent-connector.js +588 -569
  71. package/relay/vendor/tweetnacl.js +2391 -2391
package/lib/devices.js CHANGED
@@ -1,245 +1,273 @@
1
- // Persistent paired-device registry for weapp-gateway authentication.
2
- //
3
- // Long-lived device tokens are generated with 256 bits of entropy and are
4
- // never written to disk. Only their SHA-256 digests are persisted. Pairing
5
- // codes are one-time, short-lived, and memory-only, so a restart invalidates
6
- // every outstanding QR code without affecting already paired devices.
7
- 'use strict'
8
-
9
- const fs = require('node:fs')
10
- const crypto = require('node:crypto')
11
- const path = require('node:path')
12
-
13
- const STORE_VERSION = 3
14
- const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
15
-
16
- function digest(secret) {
17
- return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
18
- }
19
-
20
- function safeEqualHex(left, right) {
21
- if (typeof left !== 'string' || typeof right !== 'string') return false
22
- const a = Buffer.from(left, 'hex')
23
- const b = Buffer.from(right, 'hex')
24
- return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
25
- }
26
-
27
- function publicDevice(device, connections) {
28
- return {
29
- id: device.id,
30
- name: device.name,
31
- createdAt: device.createdAt,
32
- lastSeenAt: device.lastSeenAt || null,
33
- online: connections > 0,
34
- connections,
35
- }
36
- }
37
-
38
- function normalizeClientDeviceId(value) {
39
- if (typeof value !== 'string') return undefined
40
- const normalized = value.trim()
41
- return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
42
- }
43
-
44
- function createRegistry(file, options = {}) {
45
- const pairingTtlMs = Number.isSafeInteger(options.pairingTtlMs) && options.pairingTtlMs > 0
46
- ? options.pairingTtlMs
47
- : DEFAULT_PAIRING_TTL_MS
48
- let devices = []
49
- const pairings = new Map()
50
- const online = new Map()
51
-
52
- const save = () => {
53
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
54
- const tmp = `${file}.${process.pid}.tmp`
55
- fs.writeFileSync(tmp, JSON.stringify({ version: STORE_VERSION, devices }, null, 2), { mode: 0o600 })
56
- fs.chmodSync(tmp, 0o600)
57
- fs.renameSync(tmp, file)
58
- }
59
-
60
- const load = () => {
61
- try {
62
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
63
- if (!Array.isArray(parsed.devices)) return
64
- let migrated = false
65
- devices = parsed.devices.flatMap((row) => {
66
- if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return []
67
- let tokenHash = typeof row.tokenHash === 'string' ? row.tokenHash : undefined
68
- // Migrate the v1 development format without invalidating an existing
69
- // device. The plaintext is removed on the next atomic save.
70
- if (!tokenHash && typeof row.token === 'string' && row.token !== '') {
71
- tokenHash = digest(row.token)
72
- migrated = true
73
- }
74
- if (!tokenHash || !/^[a-f0-9]{64}$/.test(tokenHash)) return []
75
- return [{
76
- id: row.id,
77
- name: row.name.slice(0, 80),
78
- clientDeviceId: normalizeClientDeviceId(row.clientDeviceId),
79
- tokenHash,
80
- createdAt: Number.isFinite(row.createdAt) ? row.createdAt : Date.now(),
81
- lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null,
82
- revokedAt: Number.isFinite(row.revokedAt) ? row.revokedAt : (row.revoked ? Date.now() : null),
83
- }]
84
- })
85
- if (migrated || parsed.version !== STORE_VERSION) save()
86
- else fs.chmodSync(file, 0o600)
87
- } catch (error) {
88
- if (error && error.code !== 'ENOENT') throw new Error(`failed to load device registry: ${error.message}`, { cause: error })
89
- }
90
- }
91
-
92
- const prunePairings = (now = Date.now()) => {
93
- for (const [codeHash, pairing] of pairings) {
94
- if (pairing.expiresAt <= now) pairings.delete(codeHash)
95
- }
96
- }
97
-
98
- load()
99
-
100
- return {
101
- list() {
102
- const active = devices.filter((device) => !device.revokedAt)
103
- const counts = new Map()
104
- const indices = new Map()
105
- for (const device of active) counts.set(device.name, (counts.get(device.name) || 0) + 1)
106
- return active.map((device) => {
107
- const index = (indices.get(device.name) || 0) + 1
108
- indices.set(device.name, index)
109
- return {
110
- ...publicDevice(device, online.get(device.id) || 0),
111
- displayName: counts.get(device.name) > 1 ? `${device.name} (${index})` : device.name,
112
- }
113
- })
114
- },
115
-
116
- count() {
117
- return devices.filter((device) => !device.revokedAt).length
118
- },
119
-
120
- // Ids of pairing codes that are still redeemable. The management panel
121
- // polls this to close the QR dialog the moment its code is consumed (a
122
- // successful scan removes it) instead of waiting for the 5-minute TTL.
123
- pendingPairings() {
124
- prunePairings()
125
- return [...pairings.values()].map((pairing) => pairing.id)
126
- },
127
-
128
- createPairing(name) {
129
- prunePairings()
130
- const code = crypto.randomBytes(32).toString('base64url')
131
- const pairing = {
132
- id: crypto.randomUUID(),
133
- name: typeof name === 'string' && name.trim() !== '' ? name.trim().slice(0, 80) : '未命名设备',
134
- expiresAt: Date.now() + pairingTtlMs,
135
- }
136
- pairings.set(digest(code), pairing)
137
- return { ...pairing, code }
138
- },
139
-
140
- hasPairing(code) {
141
- prunePairings()
142
- return typeof code === 'string' && pairings.has(digest(code))
143
- },
144
-
145
- claimPairing(code, clientDeviceId, clientDeviceName) {
146
- prunePairings()
147
- if (typeof code !== 'string' || code === '') return undefined
148
- const codeHash = digest(code)
149
- const pairing = pairings.get(codeHash)
150
- if (!pairing) return undefined
151
- // Delete before doing any further work: claiming is single-use even if a
152
- // later socket upgrade fails.
153
- pairings.delete(codeHash)
154
- const token = crypto.randomBytes(32).toString('base64url')
155
- const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
156
- let device = normalizedClientDeviceId
157
- ? devices.find((candidate) => !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
158
- : undefined
159
- if (device) {
160
- // A valid one-time pairing code authorizes credential rotation. Keep
161
- // the stable server-side device record while replacing its token, so
162
- // re-pairing one iOS installation never creates duplicate rows.
163
- device.name = typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备'
164
- device.tokenHash = digest(token)
165
- device.clientDeviceId = normalizedClientDeviceId
166
- } else {
167
- device = {
168
- id: pairing.id,
169
- name: typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备',
170
- clientDeviceId: normalizedClientDeviceId,
171
- tokenHash: digest(token),
172
- createdAt: Date.now(),
173
- lastSeenAt: null,
174
- revokedAt: null,
175
- }
176
- devices.push(device)
177
- }
178
- save()
179
- return { device: publicDevice(device, 0), token }
180
- },
181
-
182
- authenticate(token, clientDeviceId, clientDeviceName) {
183
- if (typeof token !== 'string' || token === '') return undefined
184
- const tokenHash = digest(token)
185
- const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
186
- const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
187
- if (device && normalizedClientDeviceId && !device.clientDeviceId) {
188
- // Migration path for devices paired before installation IDs existed:
189
- // a valid long-lived token proves ownership of this record, so it is
190
- // safe to bind the client's stable ID without creating a new device.
191
- const conflict = devices.some((candidate) => candidate !== device && !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
192
- if (!conflict) {
193
- device.clientDeviceId = normalizedClientDeviceId
194
- save()
195
- }
196
- }
197
- if (device && typeof clientDeviceName === 'string' && clientDeviceName.trim()) {
198
- const name = clientDeviceName.trim().slice(0, 80)
199
- if (device.name !== name) {
200
- device.name = name
201
- save()
202
- }
203
- }
204
- return device ? publicDevice(device, online.get(device.id) || 0) : undefined
205
- },
206
-
207
- connected(deviceId) {
208
- const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
209
- if (!device) return false
210
- online.set(deviceId, (online.get(deviceId) || 0) + 1)
211
- device.lastSeenAt = Date.now()
212
- save()
213
- return true
214
- },
215
-
216
- disconnected(deviceId) {
217
- const count = online.get(deviceId) || 0
218
- if (count <= 1) online.delete(deviceId)
219
- else online.set(deviceId, count - 1)
220
- },
221
-
222
- revoke(deviceId) {
223
- const index = devices.findIndex((candidate) => candidate.id === deviceId && !candidate.revokedAt)
224
- if (index < 0) return false
225
- devices.splice(index, 1)
226
- online.delete(deviceId)
227
- save()
228
- return true
229
- },
230
-
231
- // A paired device can be relabelled later: the relay connector renames its
232
- // entry once the phone reports its own model, so the trusted-device list
233
- // shows "Xiaomi 14 Ultra" instead of the host running the connector.
234
- rename(deviceId, name) {
235
- const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
236
- if (!device) return null
237
- if (typeof name !== 'string' || name.trim() === '') return null
238
- device.name = name.trim().slice(0, 80)
239
- save()
240
- return publicDevice(device, online.get(device.id) || 0)
241
- },
242
- }
243
- }
244
-
245
- module.exports = { createRegistry, digest }
1
+ // Persistent paired-device registry for weapp-gateway authentication.
2
+ //
3
+ // Long-lived device tokens are generated with 256 bits of entropy and are
4
+ // never written to disk. Only their SHA-256 digests are persisted. Pairing
5
+ // codes are one-time, short-lived, and memory-only, so a restart invalidates
6
+ // every outstanding QR code without affecting already paired devices.
7
+ 'use strict'
8
+
9
+ const fs = require('node:fs')
10
+ const crypto = require('node:crypto')
11
+ const path = require('node:path')
12
+
13
+ const STORE_VERSION = 3
14
+ const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
15
+
16
+ // How a device's live connections reach this gateway. The trusted-device list
17
+ // shows one badge per active route so the operator can see whether the phone
18
+ // currently sits on the LAN, a public entry point, or the Cloudflare tunnel.
19
+ // lan — the private-network-only LAN listener
20
+ // public — the main listener (public server, reverse proxy, or the
21
+ // bundled relay connector dialling loopback on its behalf)
22
+ // cloudflare — the tunnel's dedicated loopback origin listener
23
+ const CONNECTION_ROUTES = ['lan', 'public', 'cloudflare']
24
+
25
+ function normalizeRoute(route) {
26
+ return CONNECTION_ROUTES.includes(route) ? route : 'public'
27
+ }
28
+
29
+ function digest(secret) {
30
+ return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
31
+ }
32
+
33
+ function safeEqualHex(left, right) {
34
+ if (typeof left !== 'string' || typeof right !== 'string') return false
35
+ const a = Buffer.from(left, 'hex')
36
+ const b = Buffer.from(right, 'hex')
37
+ return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
38
+ }
39
+
40
+ // routes: Map<route, connectionCount> for the device's live sockets. Derived
41
+ // totals keep one source of truth for online/connections/routes.
42
+ function publicDevice(device, routes) {
43
+ let connections = 0
44
+ for (const count of routes.values()) connections += count
45
+ return {
46
+ id: device.id,
47
+ name: device.name,
48
+ createdAt: device.createdAt,
49
+ lastSeenAt: device.lastSeenAt || null,
50
+ online: connections > 0,
51
+ connections,
52
+ routes: [...routes.keys()],
53
+ }
54
+ }
55
+
56
+ function normalizeClientDeviceId(value) {
57
+ if (typeof value !== 'string') return undefined
58
+ const normalized = value.trim()
59
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
60
+ }
61
+
62
+ function createRegistry(file, options = {}) {
63
+ const pairingTtlMs = Number.isSafeInteger(options.pairingTtlMs) && options.pairingTtlMs > 0
64
+ ? options.pairingTtlMs
65
+ : DEFAULT_PAIRING_TTL_MS
66
+ let devices = []
67
+ const pairings = new Map()
68
+ // deviceId -> Map<route, connectionCount>
69
+ const online = new Map()
70
+
71
+ const routesOf = (deviceId) => online.get(deviceId) || new Map()
72
+
73
+ const save = () => {
74
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
75
+ const tmp = `${file}.${process.pid}.tmp`
76
+ fs.writeFileSync(tmp, JSON.stringify({ version: STORE_VERSION, devices }, null, 2), { mode: 0o600 })
77
+ fs.chmodSync(tmp, 0o600)
78
+ fs.renameSync(tmp, file)
79
+ }
80
+
81
+ const load = () => {
82
+ try {
83
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
84
+ if (!Array.isArray(parsed.devices)) return
85
+ let migrated = false
86
+ devices = parsed.devices.flatMap((row) => {
87
+ if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return []
88
+ let tokenHash = typeof row.tokenHash === 'string' ? row.tokenHash : undefined
89
+ // Migrate the v1 development format without invalidating an existing
90
+ // device. The plaintext is removed on the next atomic save.
91
+ if (!tokenHash && typeof row.token === 'string' && row.token !== '') {
92
+ tokenHash = digest(row.token)
93
+ migrated = true
94
+ }
95
+ if (!tokenHash || !/^[a-f0-9]{64}$/.test(tokenHash)) return []
96
+ return [{
97
+ id: row.id,
98
+ name: row.name.slice(0, 80),
99
+ clientDeviceId: normalizeClientDeviceId(row.clientDeviceId),
100
+ tokenHash,
101
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : Date.now(),
102
+ lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null,
103
+ revokedAt: Number.isFinite(row.revokedAt) ? row.revokedAt : (row.revoked ? Date.now() : null),
104
+ }]
105
+ })
106
+ if (migrated || parsed.version !== STORE_VERSION) save()
107
+ else fs.chmodSync(file, 0o600)
108
+ } catch (error) {
109
+ if (error && error.code !== 'ENOENT') throw new Error(`failed to load device registry: ${error.message}`, { cause: error })
110
+ }
111
+ }
112
+
113
+ const prunePairings = (now = Date.now()) => {
114
+ for (const [codeHash, pairing] of pairings) {
115
+ if (pairing.expiresAt <= now) pairings.delete(codeHash)
116
+ }
117
+ }
118
+
119
+ load()
120
+
121
+ return {
122
+ list() {
123
+ const active = devices.filter((device) => !device.revokedAt)
124
+ const counts = new Map()
125
+ const indices = new Map()
126
+ for (const device of active) counts.set(device.name, (counts.get(device.name) || 0) + 1)
127
+ return active.map((device) => {
128
+ const index = (indices.get(device.name) || 0) + 1
129
+ indices.set(device.name, index)
130
+ return {
131
+ ...publicDevice(device, routesOf(device.id)),
132
+ displayName: counts.get(device.name) > 1 ? `${device.name} (${index})` : device.name,
133
+ }
134
+ })
135
+ },
136
+
137
+ count() {
138
+ return devices.filter((device) => !device.revokedAt).length
139
+ },
140
+
141
+ // Ids of pairing codes that are still redeemable. The management panel
142
+ // polls this to close the QR dialog the moment its code is consumed (a
143
+ // successful scan removes it) instead of waiting for the 5-minute TTL.
144
+ pendingPairings() {
145
+ prunePairings()
146
+ return [...pairings.values()].map((pairing) => pairing.id)
147
+ },
148
+
149
+ createPairing(name) {
150
+ prunePairings()
151
+ const code = crypto.randomBytes(32).toString('base64url')
152
+ const pairing = {
153
+ id: crypto.randomUUID(),
154
+ name: typeof name === 'string' && name.trim() !== '' ? name.trim().slice(0, 80) : '未命名设备',
155
+ expiresAt: Date.now() + pairingTtlMs,
156
+ }
157
+ pairings.set(digest(code), pairing)
158
+ return { ...pairing, code }
159
+ },
160
+
161
+ hasPairing(code) {
162
+ prunePairings()
163
+ return typeof code === 'string' && pairings.has(digest(code))
164
+ },
165
+
166
+ claimPairing(code, clientDeviceId, clientDeviceName) {
167
+ prunePairings()
168
+ if (typeof code !== 'string' || code === '') return undefined
169
+ const codeHash = digest(code)
170
+ const pairing = pairings.get(codeHash)
171
+ if (!pairing) return undefined
172
+ // Delete before doing any further work: claiming is single-use even if a
173
+ // later socket upgrade fails.
174
+ pairings.delete(codeHash)
175
+ const token = crypto.randomBytes(32).toString('base64url')
176
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
177
+ let device = normalizedClientDeviceId
178
+ ? devices.find((candidate) => !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
179
+ : undefined
180
+ if (device) {
181
+ // A valid one-time pairing code authorizes credential rotation. Keep
182
+ // the stable server-side device record while replacing its token, so
183
+ // re-pairing one iOS installation never creates duplicate rows.
184
+ device.name = typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备'
185
+ device.tokenHash = digest(token)
186
+ device.clientDeviceId = normalizedClientDeviceId
187
+ } else {
188
+ device = {
189
+ id: pairing.id,
190
+ name: typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备',
191
+ clientDeviceId: normalizedClientDeviceId,
192
+ tokenHash: digest(token),
193
+ createdAt: Date.now(),
194
+ lastSeenAt: null,
195
+ revokedAt: null,
196
+ }
197
+ devices.push(device)
198
+ }
199
+ save()
200
+ return { device: publicDevice(device, new Map()), token }
201
+ },
202
+
203
+ authenticate(token, clientDeviceId, clientDeviceName) {
204
+ if (typeof token !== 'string' || token === '') return undefined
205
+ const tokenHash = digest(token)
206
+ const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
207
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
208
+ if (device && normalizedClientDeviceId && !device.clientDeviceId) {
209
+ // Migration path for devices paired before installation IDs existed:
210
+ // a valid long-lived token proves ownership of this record, so it is
211
+ // safe to bind the client's stable ID without creating a new device.
212
+ const conflict = devices.some((candidate) => candidate !== device && !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
213
+ if (!conflict) {
214
+ device.clientDeviceId = normalizedClientDeviceId
215
+ save()
216
+ }
217
+ }
218
+ if (device && typeof clientDeviceName === 'string' && clientDeviceName.trim()) {
219
+ const name = clientDeviceName.trim().slice(0, 80)
220
+ if (device.name !== name) {
221
+ device.name = name
222
+ save()
223
+ }
224
+ }
225
+ return device ? publicDevice(device, routesOf(device.id)) : undefined
226
+ },
227
+
228
+ connected(deviceId, route) {
229
+ const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
230
+ if (!device) return false
231
+ const key = normalizeRoute(route)
232
+ const routes = online.get(deviceId) || new Map()
233
+ routes.set(key, (routes.get(key) || 0) + 1)
234
+ online.set(deviceId, routes)
235
+ device.lastSeenAt = Date.now()
236
+ save()
237
+ return true
238
+ },
239
+
240
+ disconnected(deviceId, route) {
241
+ const routes = online.get(deviceId)
242
+ if (!routes) return
243
+ const key = normalizeRoute(route)
244
+ const count = routes.get(key) || 0
245
+ if (count <= 1) routes.delete(key)
246
+ else routes.set(key, count - 1)
247
+ if (routes.size === 0) online.delete(deviceId)
248
+ },
249
+
250
+ revoke(deviceId) {
251
+ const index = devices.findIndex((candidate) => candidate.id === deviceId && !candidate.revokedAt)
252
+ if (index < 0) return false
253
+ devices.splice(index, 1)
254
+ online.delete(deviceId)
255
+ save()
256
+ return true
257
+ },
258
+
259
+ // A paired device can be relabelled later: the relay connector renames its
260
+ // entry once the phone reports its own model, so the trusted-device list
261
+ // shows "Xiaomi 14 Ultra" instead of the host running the connector.
262
+ rename(deviceId, name) {
263
+ const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
264
+ if (!device) return null
265
+ if (typeof name !== 'string' || name.trim() === '') return null
266
+ device.name = name.trim().slice(0, 80)
267
+ save()
268
+ return publicDevice(device, routesOf(device.id))
269
+ },
270
+ }
271
+ }
272
+
273
+ module.exports = { createRegistry, digest, CONNECTION_ROUTES }