wkgeo-cli 0.2.0

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.
package/lib/device.mjs ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * 设备码授权(RFC 8628)—— CLI 侧的完整实现
3
+ * ===========================================================================
4
+ * wkgeo login --device 申请短码 → 打印 → 轮询 → 写凭据
5
+ * wkgeo login --device --no-wait 只申请 + 打印就退出(给 AI 端用,见下)
6
+ * wkgeo login --device-continue 续跑上一次没等完的设备码
7
+ *
8
+ * ## 为什么要有这条路(老板决定 AB 都做)
9
+ * 本地回调模式要求"CLI 所在机器能起一个回环监听端口,并且**浏览器能把 code 送回
10
+ * 127.0.0.1**"。这在下面这些场景里做不到:
11
+ * * CLI 跑在远程服务器 / 容器里,用户在另一台机器上点浏览器;
12
+ * * 用户环境有防火墙/安全软件拦本地监听;
13
+ * * ★ **用户开着系统/全局代理,且代理没绕过 127.0.0.1** —— 回环请求被代理接管,
14
+ * CLI 的本地服务永远收不到 code(2026-10-02 老板报的就是这个)。
15
+ * 设备码模式**只需要一次出网请求 + 轮询**,不需要任何回调地址,因此对代理天然免疫。
16
+ *
17
+ * ## 流程(严格按 RFC 8628)
18
+ * 1. `POST {device_authorization_endpoint}`(表单:`client_id`、`scope`)
19
+ * → `{device_code, user_code, verification_uri, verification_uri_complete?,
20
+ * expires_in, interval}`
21
+ * 2. CLI 打印「请打开 <网址> 并确认授权;短码:XXXX-XXXX」,必要时自动打开浏览器
22
+ * 3. CLI 以 `interval` 秒为间隔轮询 `POST /api/oauth/token`:
23
+ * `grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=…`
24
+ * 中间态(**不是错误**,要继续轮询):
25
+ * * `authorization_pending` —— 用户还没确认
26
+ * * `slow_down` —— 轮询太快,`interval += 5`
27
+ * * `expired_token` —— 设备码过期(终止)
28
+ * * `access_denied` —— 用户拒绝(终止)
29
+ * 4. 成功 → 拿 `access_token` → 写本地凭据(0600,与回调模式完全同一份文件/格式)
30
+ *
31
+ * ## ⚠️ 安全边界(写进 README/SKILL,别让 AI 端误解自己的权限)
32
+ * "用户确认"这一步(`POST /api/oauth/device_confirm`)**必须由用户自己的浏览器会话完成**
33
+ * (它要求平台登录态;AI 端/CLI 都没有、也不该有用户的网页会话 Cookie)。
34
+ * 所以"用户把短码发到 AI 对话框"之后,AI 端能做的是**把网址 + 短码清楚地转达给用户、
35
+ * 引导他点确认**,**不能**替用户点同意。CLI 在这里只负责"申请码 + 等结果"。
36
+ *
37
+ * ## 实现结构(三个步骤拆开,是为了 `--no-wait` / `--device-continue`)
38
+ * requestDeviceCode() —— 只管申请(失败就按失败报,绝不假装成功)
39
+ * announceDeviceCode() —— 只管把网址/短码交给用户(人读 + 机读 + 开浏览器)
40
+ * pollDeviceToken() —— 只管轮询 + 写凭据
41
+ * runDeviceLogin() —— 上面三步串起来(原有的一口气跑完的入口,测试与兼容用)
42
+ *
43
+ * ## 服务端现状(2026-10-02 线上实测)
44
+ * `POST /api/oauth/device_authorization` **已实现**,metadata 里也声明了
45
+ * `device_authorization_endpoint` 与 `grant_types_supported` 含 device_code。
46
+ * 但"端点不存在"这条分支**必须保留**(历史版本 / 别的环境可能还没有),
47
+ * 命中时给"服务端缺口 + 改用回调模式"的明确错误,而不是含糊失败。
48
+ */
49
+
50
+ import { createApi } from './api.mjs';
51
+ import { writeCredentials, credentialsFromToken } from './credentials.mjs';
52
+ import { withTrailingSlash } from './config.mjs';
53
+ import { CliError, EXIT, loginHint } from './errors.mjs';
54
+ import { defaultOpenBrowser, resolveBrowserIntent, browserOpenedText, browserFailedHintLines } from './login.mjs';
55
+
56
+ /** RFC 8628 规定的 urn */
57
+ export const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
58
+ export const DEFAULT_DEVICE_PATH = '/api/oauth/device_authorization';
59
+ export const DEFAULT_DEVICE_INTERVAL_SEC = 5;
60
+ export const DEFAULT_DEVICE_EXPIRES_SEC = 600;
61
+
62
+ const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
63
+
64
+ /** 端点不存在 / 未实现 的判定(404、或统一信封里的"未知操作"、或 HTML 404 页) */
65
+ function looksNotImplemented(res) {
66
+ if (!res) return false;
67
+ if (res.status === 404 || res.status === 405 || res.status === 501) return true;
68
+ const body = res.body;
69
+ if (body && typeof body === 'object') {
70
+ const msg = String(body.message || body.error_description || body.error || '');
71
+ if (/未知操作|未在权限表中登记|unknown action|not_?found|unsupported_grant_type/i.test(msg)) return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * 第 1 步:申请设备码(**不发浏览器、不打印、不轮询**)。
78
+ *
79
+ * @returns {Promise<object>} 归一化后的设备码对象:
80
+ * { device_code, user_code, verification_uri, verification_uri_complete, open_url,
81
+ * expires_in, interval, expires_at, requested_at, endpoint_path, metadata }
82
+ */
83
+ export async function requestDeviceCode({ config, deps = {} }) {
84
+ const fetchImpl = deps.fetchImpl || globalThis.fetch;
85
+ const now = deps.now || (() => Date.now());
86
+ const api = createApi({ config, fetchImpl });
87
+
88
+ /* ---- 0. 发现端点:优先用 metadata 声明的;没有就用默认路径 ---- */
89
+ let metadata = deps.metadata || null;
90
+ if (!metadata) {
91
+ try {
92
+ metadata = await api.metadata();
93
+ } catch {
94
+ /* metadata 拿不到不是致命问题,用默认路径试 */
95
+ }
96
+ }
97
+ const endpointPath =
98
+ metadata && metadata.device_authorization_endpoint
99
+ ? String(metadata.device_authorization_endpoint).replace(config.base, '')
100
+ : DEFAULT_DEVICE_PATH;
101
+
102
+ /* ---- 1. 申请设备码 ---- */
103
+ const req = await api.oauthForm(endpointPath, { client_id: config.clientId, scope: config.scope });
104
+ if (req.networkError) {
105
+ throw new CliError(`申请设备码失败:${req.networkError}`, {
106
+ kind: 'network',
107
+ hint: '检查网络后重试;也可以改用本地回调模式 `wkgeo login`',
108
+ });
109
+ }
110
+ if (looksNotImplemented(req)) {
111
+ throw new CliError(
112
+ `服务端未实现设备码授权(POST ${endpointPath} → HTTP ${req.status}${req.body && req.body.message ? ',' + req.body.message : ''})`,
113
+ {
114
+ kind: 'not_implemented',
115
+ code: req.status,
116
+ hint:
117
+ '这是**服务端能力缺口**(不是命令用错):请改用本地回调模式 `wkgeo login`;' +
118
+ '服务端补端点的最小方案见 cli/docs/设备码授权-服务端最小方案.md',
119
+ exitCode: EXIT.ERROR,
120
+ },
121
+ );
122
+ }
123
+ const body = req.body || {};
124
+ if (!body.device_code || !body.user_code) {
125
+ // 端点存在但返回的形状不对(比如返回了统一信封)→ 如实报错,别猜
126
+ const cls =
127
+ body.code !== undefined && body.code !== 0
128
+ ? `服务端返回 code=${body.code}:${body.message || ''}`
129
+ : `服务端返回的形状不符合 RFC 8628(缺少 device_code / user_code):${JSON.stringify(body).slice(0, 200)}`;
130
+ throw new CliError(`设备码申请失败:${cls}`, {
131
+ kind: 'device_authorization_failed',
132
+ code: body.code,
133
+ hint: '可改用本地回调模式 `wkgeo login` 完成授权',
134
+ });
135
+ }
136
+
137
+ const userCode = String(body.user_code);
138
+ /**
139
+ * ⚠️ 兜底地址**必须带尾斜杠**:`${基址}/oauth/device`(不带斜杠)会踩同一个坑 ——
140
+ * 托管侧对目录路径做 302 且**丢掉 query**,`?user_code=…` 预填参数会被剥光,
141
+ * 用户就得自己手输短码(甚至显示"授权请求不完整")。
142
+ * 拼法统一走 `withTrailingSlash()`(cli/lib 里唯一入口,见 config.mjs)。
143
+ * 服务端 metadata 给的 `verification_uri` 也一样过一道:不能假设它永远配得对。
144
+ * ★ 2026-10-02(三站拆分):兜底基址改用**授权站子域** `config.oauthWebBase`。
145
+ */
146
+ const verificationUri = withTrailingSlash(
147
+ String(body.verification_uri || `${config.oauthWebBase}/oauth/device`),
148
+ );
149
+ /**
150
+ * `verification_uri_complete`(带短码预填的地址):服务端给了就用它的,
151
+ * **没给就自己拼一个**。
152
+ *
153
+ * ⚠️ 为什么不再"服务端没给就留空":
154
+ * 这条流程的整个卖点是"用户只点一下、不用手输短码"(老板要的友好路径)。
155
+ * 留空的话用户得自己把 `ABCD-1234` 敲进页面,而我们**明明能拼**——
156
+ * RFC 8628 §3.3.1 就把 `verification_uri_complete` 定义为"已经带好用户码的地址"。
157
+ * 参数名必须与确认页一致(`admin-web/src/pages/device/index.tsx` 读 `user_code`),
158
+ * 所以这里拼的是 `?user_code=<码>`;地址本身已经过 `withTrailingSlash()`
159
+ * (尾斜杠必须在 `?` **之前**,否则托管侧 302 会把 query 丢掉)。
160
+ */
161
+ const verificationUriComplete = body.verification_uri_complete
162
+ ? withTrailingSlash(String(body.verification_uri_complete))
163
+ : buildVerificationUriComplete(verificationUri, userCode);
164
+ const expiresInSec = Number(body.expires_in) > 0 ? Number(body.expires_in) : DEFAULT_DEVICE_EXPIRES_SEC;
165
+ const intervalSec = Number(body.interval) > 0 ? Number(body.interval) : DEFAULT_DEVICE_INTERVAL_SEC;
166
+
167
+ return {
168
+ device_code: String(body.device_code),
169
+ user_code: userCode,
170
+ verification_uri: verificationUri,
171
+ verification_uri_complete: verificationUriComplete,
172
+ open_url: verificationUriComplete || verificationUri,
173
+ expires_in: expiresInSec,
174
+ interval: intervalSec,
175
+ /**
176
+ * ★ 绝对到期时刻(毫秒)。**必须落成绝对时间**(而不是"还能等 N 秒"):
177
+ * `--no-wait` 会把这份状态写到磁盘,用户可能 3 分钟后才回来续跑,
178
+ * 相对秒数在那种情况下会算错(变成"又能等 600 秒"),绝对时刻不会。
179
+ */
180
+ expires_at: now() + expiresInSec * 1000,
181
+ requested_at: now(),
182
+ endpoint_path: endpointPath,
183
+ metadata,
184
+ };
185
+ }
186
+
187
+ /** 用户可直接复制的那句话(AI 端拿到 `paste_text` 就能原样转达,不用自己组织语言) */
188
+ export function devicePasteText(device) {
189
+ return `请打开 ${device.open_url} 并完成「同意授权」(短码:${device.user_code})。`;
190
+ }
191
+
192
+ /**
193
+ * 由 `verification_uri` + 短码拼出"单击即用"的确认地址。
194
+ *
195
+ * ⚠️ 用 `URL` 而不是字符串拼接,是为了让 **fragment / 已有 query** 都落到正确位置
196
+ * (手拼时 `#frag` 之后的内容对服务器无意义,还可能拼出两个 `?`)。
197
+ * ⚠️ 参数名**必须**是 `user_code`:确认页 `admin-web/src/pages/device/index.tsx`
198
+ * 就是读这个名字(改这里 = 改前后端契约,两边必须同时改)。
199
+ * ⚠️ 解析失败(地址不合法)也不能抛错:这是"锦上添花"的地址,抛错会连累整条授权流程。
200
+ */
201
+ export function buildVerificationUriComplete(verificationUri, userCode) {
202
+ try {
203
+ const u = new URL(verificationUri);
204
+ u.searchParams.set('user_code', String(userCode));
205
+ return u.toString();
206
+ } catch {
207
+ const sep = String(verificationUri).includes('?') ? '&' : '?';
208
+ return `${verificationUri}${sep}user_code=${encodeURIComponent(String(userCode))}`;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 第 2 步:把网址/短码交给用户(人读一段 + 机器读一份 + 必要时自动打开浏览器)。
214
+ *
215
+ * @param {object} args
216
+ * @param {object} args.device requestDeviceCode() 的返回值
217
+ * @param {object} [args.extra] 额外合进 `onDeviceCode(info)` 的字段(例如 --no-wait 的 resume_command)
218
+ */
219
+ export function announceDeviceCode({ config, log = () => {}, deps = {}, device, extra = {} }) {
220
+ const pasteText = devicePasteText(device);
221
+ log('');
222
+ log('请在浏览器中完成授权(设备码模式,不需要任何回调地址):');
223
+ log('');
224
+ log(` 1) 打开:${device.open_url}`);
225
+ /**
226
+ * ⚠️ 短码**始终**打印一遍(哪怕地址里已经预填了):页面万一没读到 `?user_code=`,
227
+ * 用户还能照着敲;这也是用户"复制给 AI 端"时最需要的那串字符。
228
+ */
229
+ log(` 2) 如果页面没有自动填好短码,请输入:${device.user_code}`);
230
+ log('');
231
+ /**
232
+ * ★ 这一行是给"用户把信息复制到 AI 对话框"这个用法准备的(老板明确要的形态):
233
+ * 一整句、带完整网址和短码,选中复制就能用,不需要用户自己拼。
234
+ */
235
+ log(`📋 可以直接复制这段发给 AI 端或转给用户:${pasteText}`);
236
+ log('');
237
+ if (typeof deps.onDeviceCode === 'function') {
238
+ deps.onDeviceCode({
239
+ user_code: device.user_code,
240
+ verification_uri: device.verification_uri,
241
+ verification_uri_complete: device.verification_uri_complete,
242
+ expires_in: device.expires_in,
243
+ interval: device.interval,
244
+ open_url: device.open_url,
245
+ paste_text: pasteText,
246
+ ...extra,
247
+ });
248
+ }
249
+ /**
250
+ * ★ 走**同一个闸门**(`resolveBrowserIntent`,与 login 回调模式、钱包充值完全同一份逻辑)。
251
+ *
252
+ * ★ 老板最终拍板走**传统路线**:**默认总是尝试打开**系统浏览器(设备码模式同样如此,
253
+ * 人在终端与 AI 场景都一样);唯一的"不打开"开关是 `--no-browser`。
254
+ * (历史:2026-10-01 曾改成"只有人在终端才弹",那版已按老板最终决定回退。)
255
+ */
256
+ const browserIntent = resolveBrowserIntent({
257
+ isTTY: deps.isTTY === true,
258
+ json: deps.json === true,
259
+ forceOpen: deps.forceBrowser === true,
260
+ noBrowser: deps.openBrowser === false || deps.noBrowser === true,
261
+ });
262
+ if (browserIntent.allow) {
263
+ // 传入函数时用注入的(测试/自定义);否则用**真·打开浏览器**
264
+ const opener = typeof deps.openBrowser === 'function' ? deps.openBrowser : defaultOpenBrowser(deps, log);
265
+ /**
266
+ * 打开失败**绝不中断授权**:地址已经完整打印在上面了,而且失败提示里会**再带一遍完整 URL**
267
+ * (见 login.mjs 的 browserFailedHintLines;Windows 上 `&` 被 cmd 当命令分隔符吃掉的坑
268
+ * 见 login.mjs 顶部对 BROWSER_STRATEGY_TABLE 的说明)。
269
+ * ⚠️ 同步版 `defaultOpenBrowser` 只能判断"进程有没有被创建出来":
270
+ * 异步失败(emit error)会由它自己的 onError 再打一段同样的提示,这里不重复。
271
+ */
272
+ const opened = opener(device.open_url);
273
+ if (opened) {
274
+ log(browserOpenedText({ kind: 'auth' }));
275
+ } else {
276
+ for (const line of browserFailedHintLines(device.open_url, { kind: 'auth', agent: browserIntent.mode === 'agent' })) {
277
+ log(line);
278
+ }
279
+ }
280
+ } else if (browserIntent.skipped_reason === 'no_browser') {
281
+ // 用户主动关掉的,不该报"失败"
282
+ log('(已按 --no-browser 跳过自动打开)');
283
+ }
284
+ return pasteText;
285
+ }
286
+
287
+ /**
288
+ * 第 3 步:轮询换票,成功后写本地凭据。
289
+ *
290
+ * @param {object} args.device requestDeviceCode() 的返回值(或从 `--device-continue` 读回的状态)
291
+ * @param {object} [args.deps]
292
+ * ★ `deps.shouldStop()` —— 可选。返回 true 时**立刻停止轮询**并抛 `kind = 'cancelled'`。
293
+ * 用途只有一个:`commands/auth.mjs` 里"回调 / 设备码**两条路并存**"时,回调那条路先拿到令牌,
294
+ * 设备码这条就不该再对同一个授权做第二次换票(也不该继续占着进程)。
295
+ * ⚠️ 每个循环的**睡醒之后**也要再查一次:`sleep` 可能是几秒的定时器,醒来时另一条路可能已经赢了。
296
+ * @returns {Promise<object>} {credentials, account, mode, metadata, mode_name, user_code,
297
+ * verification_uri, poll_attempts}
298
+ */
299
+ export async function pollDeviceToken({ config, log = () => {}, deps = {}, device }) {
300
+ const fetchImpl = deps.fetchImpl || globalThis.fetch;
301
+ const sleep = deps.sleep || sleepDefault;
302
+ const now = deps.now || (() => Date.now());
303
+ /** ★ 另一条路赢了就停(见上面 deps.shouldStop 的说明) */
304
+ const shouldStop = typeof deps.shouldStop === 'function' ? deps.shouldStop : null;
305
+ const stopped = () =>
306
+ new CliError('设备码轮询已停止(另一条路已经完成登录)', {
307
+ kind: 'cancelled',
308
+ hint: '不需要做任何事:登录已经完成',
309
+ exitCode: EXIT.AUTH,
310
+ });
311
+ const api = createApi({ config, fetchImpl });
312
+
313
+ const expiresAt = Number(device.expires_at) > 0 ? Number(device.expires_at) : now() + device.expires_in * 1000;
314
+ let intervalMs = (Number(device.interval) > 0 ? Number(device.interval) : DEFAULT_DEVICE_INTERVAL_SEC) * 1000;
315
+ const expiresInSec = Math.max(0, Math.round((expiresAt - now()) / 1000));
316
+
317
+ log(`等待你在浏览器里完成授权…(每 ${Math.round(intervalMs / 1000)} 秒轮询一次,最多 ${expiresInSec} 秒)`);
318
+
319
+ let attempts = 0;
320
+ for (;;) {
321
+ if (shouldStop && shouldStop()) throw stopped();
322
+ if (now() >= expiresAt) {
323
+ throw new CliError(`设备码已过期(等待超过 ${device.expires_in} 秒)`, {
324
+ kind: 'expired',
325
+ hint: `重新运行 \`${config.cmdName || 'wkgeo'} login --device\` 获取新的短码`,
326
+ exitCode: EXIT.AUTH,
327
+ });
328
+ }
329
+ await sleep(intervalMs);
330
+ // ★ 睡醒后必须再查一次:这一觉可能睡了几秒,期间另一条路可能已经拿到令牌了
331
+ if (shouldStop && shouldStop()) throw stopped();
332
+ attempts++;
333
+
334
+ const res = await api.oauthForm('/api/oauth/token', {
335
+ grant_type: DEVICE_GRANT_TYPE,
336
+ device_code: device.device_code,
337
+ client_id: config.clientId,
338
+ });
339
+ if (res.networkError) {
340
+ // 网络抖一下不该让整个授权作废:记一笔继续轮询(直到过期)
341
+ log(`⚠️ 第 ${attempts} 次轮询网络失败(${res.networkError}),继续重试…`);
342
+ continue;
343
+ }
344
+ const rb = res.body || {};
345
+ if (res.status === 200 && rb.access_token) {
346
+ let account = null;
347
+ try {
348
+ account = await api.userinfo(rb.access_token);
349
+ } catch {
350
+ /* 身份信息拿不到不影响已登录这个事实 */
351
+ }
352
+ const credentials = credentialsFromToken({
353
+ clientId: config.clientId,
354
+ tokenResponse: rb,
355
+ account,
356
+ scope: config.scope,
357
+ now: now(),
358
+ });
359
+ const mode = writeCredentials(config.credentialsFile, credentials, { now: now() });
360
+ log('✅ 授权成功,凭据已保存到本机。');
361
+ return {
362
+ credentials,
363
+ account,
364
+ mode,
365
+ metadata: device.metadata || null,
366
+ mode_name: 'device',
367
+ user_code: device.user_code,
368
+ verification_uri: device.verification_uri,
369
+ /** 再给一份"可直接复制给 AI 端"的话(成功回执里也带上,方便用户补发) */
370
+ paste_text: devicePasteText(device),
371
+ poll_attempts: attempts,
372
+ };
373
+ }
374
+
375
+ const err = String(rb.error || '');
376
+ if (err === 'authorization_pending') {
377
+ log(` …等待用户确认(第 ${attempts} 次轮询)`);
378
+ continue;
379
+ }
380
+ if (err === 'slow_down') {
381
+ intervalMs += 5000; // RFC 8628:被要求放慢就 +5 秒
382
+ log(` 服务端要求放慢轮询,间隔改为 ${Math.round(intervalMs / 1000)} 秒`);
383
+ continue;
384
+ }
385
+ if (err === 'access_denied') {
386
+ throw new CliError('用户在浏览器里拒绝了本次授权', {
387
+ kind: 'denied',
388
+ code: 'access_denied',
389
+ hint: '没有写入任何凭据;需要时重新运行 `wkgeo login --device`',
390
+ exitCode: EXIT.AUTH,
391
+ });
392
+ }
393
+ if (err === 'expired_token') {
394
+ throw new CliError('设备码已过期', {
395
+ kind: 'expired',
396
+ code: 'expired_token',
397
+ hint: '重新运行 `wkgeo login --device` 获取新的短码',
398
+ exitCode: EXIT.AUTH,
399
+ });
400
+ }
401
+ if (looksNotImplemented(res)) {
402
+ throw new CliError(`服务端不支持设备码换票(HTTP ${res.status})`, {
403
+ kind: 'not_implemented',
404
+ code: res.status,
405
+ hint: '服务端缺口:见 cli/docs/设备码授权-服务端最小方案.md;可改用 `wkgeo login`',
406
+ });
407
+ }
408
+ // 其它错误:保留原始 error / error_description,绝不吞
409
+ const msg = String(rb.error_description || rb.message || rb.error || `HTTP ${res.status}`);
410
+ throw new CliError(`换取令牌失败:${msg}`, {
411
+ kind: 'token_exchange',
412
+ code: rb.error || res.status,
413
+ hint: `原始响应:${JSON.stringify(rb).slice(0, 200)};${loginHint(config.cmdName || 'wkgeo')}`,
414
+ });
415
+ }
416
+ }
417
+
418
+ /**
419
+ * 一口气跑完:申请 → 交给用户 → 轮询 → 写凭据。
420
+ * (命令层为了支持 `--no-wait` / `--device-continue` 会自己按顺序调上面三步;
421
+ * 这个入口保留给"就是要一次跑完"的调用方与既有单测。)
422
+ */
423
+ export async function runDeviceLogin({ config, log = () => {}, deps = {} }) {
424
+ const device = await requestDeviceCode({ config, deps });
425
+ announceDeviceCode({ config, log, deps, device });
426
+ return pollDeviceToken({ config, log, deps, device });
427
+ }