yoto-nodejs-client 0.0.2 → 0.0.4

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 (75) hide show
  1. package/README.md +523 -30
  2. package/bin/auth.js +36 -46
  3. package/bin/content.js +0 -0
  4. package/bin/device-model.d.ts +3 -0
  5. package/bin/device-model.d.ts.map +1 -0
  6. package/bin/device-model.js +360 -0
  7. package/bin/device-tui.TODO.md +125 -0
  8. package/bin/device-tui.d.ts +31 -0
  9. package/bin/device-tui.d.ts.map +1 -0
  10. package/bin/device-tui.js +1123 -0
  11. package/bin/devices.js +166 -28
  12. package/bin/groups.js +0 -0
  13. package/bin/icons.js +0 -0
  14. package/bin/lib/cli-helpers.d.ts +1 -1
  15. package/bin/lib/cli-helpers.d.ts.map +1 -1
  16. package/bin/lib/cli-helpers.js +5 -5
  17. package/bin/refresh-token.js +6 -6
  18. package/bin/token-info.js +3 -3
  19. package/index.d.ts +4 -585
  20. package/index.d.ts.map +1 -1
  21. package/index.js +11 -689
  22. package/lib/api-client.d.ts +576 -0
  23. package/lib/api-client.d.ts.map +1 -0
  24. package/lib/api-client.js +681 -0
  25. package/lib/api-endpoints/auth.d.ts +199 -8
  26. package/lib/api-endpoints/auth.d.ts.map +1 -1
  27. package/lib/api-endpoints/auth.js +224 -7
  28. package/lib/api-endpoints/auth.test.js +54 -2
  29. package/lib/api-endpoints/constants.d.ts +14 -8
  30. package/lib/api-endpoints/constants.d.ts.map +1 -1
  31. package/lib/api-endpoints/constants.js +17 -10
  32. package/lib/api-endpoints/content.test.js +1 -1
  33. package/lib/api-endpoints/devices.d.ts +405 -117
  34. package/lib/api-endpoints/devices.d.ts.map +1 -1
  35. package/lib/api-endpoints/devices.js +114 -52
  36. package/lib/api-endpoints/devices.test.js +1 -1
  37. package/lib/api-endpoints/{test-helpers.d.ts → endpoint-test-helpers.d.ts} +1 -1
  38. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
  39. package/lib/api-endpoints/family-library-groups.test.js +1 -1
  40. package/lib/api-endpoints/family.test.js +1 -1
  41. package/lib/api-endpoints/icons.test.js +1 -1
  42. package/lib/helpers/power-state.d.ts +53 -0
  43. package/lib/helpers/power-state.d.ts.map +1 -0
  44. package/lib/helpers/power-state.js +73 -0
  45. package/lib/helpers/power-state.test.js +100 -0
  46. package/lib/helpers/temperature.d.ts +24 -0
  47. package/lib/helpers/temperature.d.ts.map +1 -0
  48. package/lib/helpers/temperature.js +61 -0
  49. package/lib/helpers/temperature.test.js +58 -0
  50. package/lib/helpers/typed-keys.d.ts +7 -0
  51. package/lib/helpers/typed-keys.d.ts.map +1 -0
  52. package/lib/helpers/typed-keys.js +8 -0
  53. package/lib/mqtt/client.d.ts +348 -22
  54. package/lib/mqtt/client.d.ts.map +1 -1
  55. package/lib/mqtt/client.js +213 -31
  56. package/lib/mqtt/factory.d.ts +22 -4
  57. package/lib/mqtt/factory.d.ts.map +1 -1
  58. package/lib/mqtt/factory.js +27 -5
  59. package/lib/mqtt/mqtt.test.js +85 -28
  60. package/lib/mqtt/topics.d.ts +41 -13
  61. package/lib/mqtt/topics.d.ts.map +1 -1
  62. package/lib/mqtt/topics.js +54 -20
  63. package/lib/pkg.d.cts +8 -0
  64. package/lib/token.d.ts +21 -6
  65. package/lib/token.d.ts.map +1 -1
  66. package/lib/token.js +30 -23
  67. package/lib/yoto-account.d.ts +163 -0
  68. package/lib/yoto-account.d.ts.map +1 -0
  69. package/lib/yoto-account.js +340 -0
  70. package/lib/yoto-device.d.ts +656 -0
  71. package/lib/yoto-device.d.ts.map +1 -0
  72. package/lib/yoto-device.js +2850 -0
  73. package/package.json +21 -15
  74. package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
  75. /package/lib/api-endpoints/{test-helpers.js → endpoint-test-helpers.js} +0 -0
@@ -1,32 +1,48 @@
1
+ /**
2
+ * OAuth2 response type for authorization requests
3
+ * @typedef {'code' | 'token' | 'id_token' | 'code token' | 'code id_token' | 'token id_token' | 'code token id_token'} YotoOAuthResponseType
4
+ */
5
+ /**
6
+ * OAuth2 prompt parameter for authorization requests
7
+ * @typedef {'none' | 'login' | 'consent' | 'select_account'} YotoOAuthPromptType
8
+ */
9
+ /**
10
+ * PKCE code challenge method for authorization requests
11
+ * @typedef {'S256' | 'plain'} YotoOAuthCodeChallengeMethod
12
+ */
13
+ /**
14
+ * OAuth2 grant type for token exchange requests
15
+ * @typedef {'authorization_code' | 'refresh_token' | 'client_credentials' | typeof DEVICE_CODE_GRANT_TYPE} YotoOAuthGrantType
16
+ */
1
17
  /**
2
18
  * Redirects the user to Yoto's login page to begin the OAuth2 Authorization Code flow.
3
19
  * @see https://yoto.dev/api/get-authorize/
4
20
  * @param {object} options
5
21
  * @param {string} [options.audience='https://api.yotoplay.com'] Audience for the token
6
22
  * @param {string} [options.scope='openid profile offline_access'] Requested scopes
7
- * @param {'code' | 'token' | 'id_token' | 'code token' | 'code id_token' | 'token id_token' | 'code token id_token'} options.responseType Required response type
23
+ * @param {YotoOAuthResponseType} options.responseType Required response type
8
24
  * @param {string} options.clientId Required client ID
9
25
  * @param {string} options.redirectUri Required redirect URI
10
26
  * @param {string} options.state Required opaque value for preventing CSRF attacks
11
27
  * @param {string} [options.nonce] String value to prevent replay attacks
12
- * @param {'none' | 'login' | 'consent' | 'select_account'} [options.prompt] Authorization server prompt behavior
28
+ * @param {YotoOAuthPromptType} [options.prompt] Authorization server prompt behavior
13
29
  * @param {number} [options.maxAge] Maximum authentication age in seconds
14
30
  * @param {string} [options.codeChallenge] PKCE code challenge
15
- * @param {'S256' | 'plain'} [options.codeChallengeMethod] PKCE code challenge method
31
+ * @param {YotoOAuthCodeChallengeMethod} [options.codeChallengeMethod] PKCE code challenge method
16
32
  * @return {string} The authorization URL to redirect the user to
17
33
  */
18
34
  export function getAuthorizeUrl({ audience, scope, responseType, clientId, redirectUri, state, nonce, prompt, maxAge, codeChallenge, codeChallengeMethod }: {
19
35
  audience?: string | undefined;
20
36
  scope?: string | undefined;
21
- responseType: "code" | "token" | "id_token" | "code token" | "code id_token" | "token id_token" | "code token id_token";
37
+ responseType: YotoOAuthResponseType;
22
38
  clientId: string;
23
39
  redirectUri: string;
24
40
  state: string;
25
41
  nonce?: string | undefined;
26
- prompt?: "none" | "login" | "consent" | "select_account" | undefined;
42
+ prompt?: YotoOAuthPromptType | undefined;
27
43
  maxAge?: number | undefined;
28
44
  codeChallenge?: string | undefined;
29
- codeChallengeMethod?: "S256" | "plain" | undefined;
45
+ codeChallengeMethod?: YotoOAuthCodeChallengeMethod | undefined;
30
46
  }): string;
31
47
  /**
32
48
  * @see https://yoto.dev/api/post-oauth-token/
@@ -43,7 +59,7 @@ export function getAuthorizeUrl({ audience, scope, responseType, clientId, redir
43
59
  * Exchange authorization code or refresh token for access tokens.
44
60
  * @see https://yoto.dev/api/post-oauth-token/
45
61
  * @param {object} options
46
- * @param {'authorization_code' | 'refresh_token' | 'client_credentials' | 'urn:ietf:params:oauth:grant-type:device_code'} options.grantType Required grant type
62
+ * @param {YotoOAuthGrantType} options.grantType Required grant type
47
63
  * @param {string} [options.code] Authorization code (required for authorization_code grant)
48
64
  * @param {string} [options.redirectUri] Redirect URI (required for authorization_code grant if used in authorize request)
49
65
  * @param {string} [options.refreshToken] Refresh token (required for refresh_token grant)
@@ -58,7 +74,7 @@ export function getAuthorizeUrl({ audience, scope, responseType, clientId, redir
58
74
  * @return {Promise<YotoTokenResponse>} Token response
59
75
  */
60
76
  export function exchangeToken({ grantType, code, redirectUri, refreshToken, clientId, clientSecret, scope, codeVerifier, deviceCode, audience, userAgent, requestOptions }: {
61
- grantType: "authorization_code" | "refresh_token" | "client_credentials" | "urn:ietf:params:oauth:grant-type:device_code";
77
+ grantType: YotoOAuthGrantType;
62
78
  code?: string | undefined;
63
79
  redirectUri?: string | undefined;
64
80
  refreshToken?: string | undefined;
@@ -103,6 +119,137 @@ export function requestDeviceCode({ clientId, scope, audience, userAgent, reques
103
119
  dispatcher?: import("undici").Dispatcher;
104
120
  } & Omit<import("undici").Dispatcher.RequestOptions<unknown>, "origin" | "path" | "method"> & Partial<Pick<import("undici").Dispatcher.RequestOptions<null>, "method">>) | undefined;
105
121
  }): Promise<YotoDeviceCodeResponse>;
122
+ /**
123
+ * Poll result when authorization is still pending
124
+ * @typedef {Object} YotoDevicePollPending
125
+ * @property {'pending'} status - Indicates polling should continue
126
+ * @property {number} interval - Current polling interval in milliseconds
127
+ */
128
+ /**
129
+ * Poll result when polling needs to slow down
130
+ * @typedef {Object} YotoDevicePollSlowDown
131
+ * @property {'slow_down'} status - Indicates polling interval should be increased
132
+ * @property {number} interval - New polling interval in milliseconds
133
+ */
134
+ /**
135
+ * Poll result when authorization is successful
136
+ * @typedef {Object} YotoDevicePollSuccess
137
+ * @property {'success'} status - Indicates successful authorization
138
+ * @property {YotoTokenResponse} tokens - OAuth tokens
139
+ */
140
+ /**
141
+ * Poll result for device authorization flow
142
+ * @typedef {YotoDevicePollPending | YotoDevicePollSlowDown | YotoDevicePollSuccess} YotoDevicePollResult
143
+ */
144
+ /**
145
+ * Poll for device authorization completion with automatic error handling (single poll attempt).
146
+ * This function handles common polling errors (authorization_pending, slow_down)
147
+ * and only throws for unrecoverable errors (expired_token, access_denied, etc).
148
+ *
149
+ * Non-blocking - returns immediately with poll result. Suitable for:
150
+ * - Manual polling loops in CLI applications
151
+ * - Server-side endpoints that poll on behalf of clients (e.g., Homebridge UI server)
152
+ * - Custom UI implementations with specific polling behavior
153
+ *
154
+ * For the simplest approach (automatic polling loop), use waitForDeviceAuthorization() instead.
155
+ *
156
+ * @see https://yoto.dev/api/post-oauth-token/
157
+ * @param {object} options
158
+ * @param {string} options.deviceCode - Device code from requestDeviceCode()
159
+ * @param {string} options.clientId - OAuth client ID
160
+ * @param {string} [options.audience='https://api.yotoplay.com'] - Audience for the token
161
+ * @param {number} [options.currentInterval=5000] - Current polling interval in milliseconds
162
+ * @param {string} [options.userAgent] - Optional user agent string
163
+ * @param {RequestOptions} [options.requestOptions] - Additional undici request options
164
+ * @returns {Promise<YotoDevicePollResult>} Poll result with status and data
165
+ * @throws {YotoAPIError} For unrecoverable errors (expired_token, access_denied, invalid_grant, etc)
166
+ *
167
+ */
168
+ export function pollForDeviceToken({ deviceCode, clientId, audience, currentInterval, userAgent, requestOptions }: {
169
+ deviceCode: string;
170
+ clientId: string;
171
+ audience?: string | undefined;
172
+ currentInterval?: number | undefined;
173
+ userAgent?: string | undefined;
174
+ requestOptions?: ({
175
+ dispatcher?: import("undici").Dispatcher;
176
+ } & Omit<import("undici").Dispatcher.RequestOptions<unknown>, "origin" | "path" | "method"> & Partial<Pick<import("undici").Dispatcher.RequestOptions<null>, "method">>) | undefined;
177
+ }): Promise<YotoDevicePollResult>;
178
+ /**
179
+ * Wait for device authorization to complete with automatic polling.
180
+ * This function wraps the entire polling loop - just call it and await the result.
181
+ * It handles all polling logic internally including interval adjustments.
182
+ *
183
+ * Designed for CLI usage where you want to block until authorization completes.
184
+ * For UI implementations with progress feedback, use pollForDeviceToken() directly.
185
+ *
186
+ * @see https://yoto.dev/api/post-oauth-token/
187
+ * @param {object} options
188
+ * @param {string} options.deviceCode - Device code from requestDeviceCode()
189
+ * @param {string} options.clientId - OAuth client ID
190
+ * @param {string} [options.audience='https://api.yotoplay.com'] - Audience for the token
191
+ * @param {number} [options.initialInterval=5000] - Initial polling interval in milliseconds
192
+ * @param {number} [options.expiresIn] - Seconds until device code expires (for timeout calculation)
193
+ * @param {string} [options.userAgent] - Optional user agent string
194
+ * @param {RequestOptions} [options.requestOptions] - Additional undici request options
195
+ * @param {(result: YotoDevicePollResult) => void} [options.onPoll] - Optional callback invoked after each poll attempt
196
+ * @returns {Promise<YotoTokenResponse>} Token response on successful authorization
197
+ * @throws {YotoAPIError} For unrecoverable errors (expired_token, access_denied, invalid_grant, etc)
198
+ * @throws {Error} If device code expires (timeout)
199
+ *
200
+ * @example
201
+ * // Simple usage - just wait for tokens
202
+ * const deviceAuth = await requestDeviceCode({ clientId })
203
+ * console.log(`Visit: ${deviceAuth.verification_uri_complete}`)
204
+ *
205
+ * const tokens = await waitForDeviceAuthorization({
206
+ * deviceCode: deviceAuth.device_code,
207
+ * clientId,
208
+ * initialInterval: deviceAuth.interval * 1000,
209
+ * expiresIn: deviceAuth.expires_in
210
+ * })
211
+ *
212
+ * console.log('Got tokens:', tokens)
213
+ *
214
+ * @example
215
+ * // With progress callback
216
+ * const tokens = await waitForDeviceAuthorization({
217
+ * deviceCode: deviceAuth.device_code,
218
+ * clientId,
219
+ * onPoll: (result) => {
220
+ * if (result.status === 'pending') process.stdout.write('.')
221
+ * if (result.status === 'slow_down') console.log('\nSlowing down...')
222
+ * }
223
+ * })
224
+ */
225
+ export function waitForDeviceAuthorization({ deviceCode, clientId, audience, initialInterval, expiresIn, userAgent, requestOptions, onPoll }: {
226
+ deviceCode: string;
227
+ clientId: string;
228
+ audience?: string | undefined;
229
+ initialInterval?: number | undefined;
230
+ expiresIn?: number | undefined;
231
+ userAgent?: string | undefined;
232
+ requestOptions?: ({
233
+ dispatcher?: import("undici").Dispatcher;
234
+ } & Omit<import("undici").Dispatcher.RequestOptions<unknown>, "origin" | "path" | "method"> & Partial<Pick<import("undici").Dispatcher.RequestOptions<null>, "method">>) | undefined;
235
+ onPoll?: ((result: YotoDevicePollResult) => void) | undefined;
236
+ }): Promise<YotoTokenResponse>;
237
+ /**
238
+ * OAuth2 response type for authorization requests
239
+ */
240
+ export type YotoOAuthResponseType = "code" | "token" | "id_token" | "code token" | "code id_token" | "token id_token" | "code token id_token";
241
+ /**
242
+ * OAuth2 prompt parameter for authorization requests
243
+ */
244
+ export type YotoOAuthPromptType = "none" | "login" | "consent" | "select_account";
245
+ /**
246
+ * PKCE code challenge method for authorization requests
247
+ */
248
+ export type YotoOAuthCodeChallengeMethod = "S256" | "plain";
249
+ /**
250
+ * OAuth2 grant type for token exchange requests
251
+ */
252
+ export type YotoOAuthGrantType = "authorization_code" | "refresh_token" | "client_credentials" | typeof DEVICE_CODE_GRANT_TYPE;
106
253
  export type YotoTokenResponse = {
107
254
  access_token: string;
108
255
  token_type: string;
@@ -138,4 +285,48 @@ export type YotoDeviceCodeResponse = {
138
285
  */
139
286
  interval: number;
140
287
  };
288
+ /**
289
+ * Poll result when authorization is still pending
290
+ */
291
+ export type YotoDevicePollPending = {
292
+ /**
293
+ * - Indicates polling should continue
294
+ */
295
+ status: "pending";
296
+ /**
297
+ * - Current polling interval in milliseconds
298
+ */
299
+ interval: number;
300
+ };
301
+ /**
302
+ * Poll result when polling needs to slow down
303
+ */
304
+ export type YotoDevicePollSlowDown = {
305
+ /**
306
+ * - Indicates polling interval should be increased
307
+ */
308
+ status: "slow_down";
309
+ /**
310
+ * - New polling interval in milliseconds
311
+ */
312
+ interval: number;
313
+ };
314
+ /**
315
+ * Poll result when authorization is successful
316
+ */
317
+ export type YotoDevicePollSuccess = {
318
+ /**
319
+ * - Indicates successful authorization
320
+ */
321
+ status: "success";
322
+ /**
323
+ * - OAuth tokens
324
+ */
325
+ tokens: YotoTokenResponse;
326
+ };
327
+ /**
328
+ * Poll result for device authorization flow
329
+ */
330
+ export type YotoDevicePollResult = YotoDevicePollPending | YotoDevicePollSlowDown | YotoDevicePollSuccess;
331
+ import { DEVICE_CODE_GRANT_TYPE } from './constants.js';
141
332
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["auth.js"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;;;;GAgBG;AACH,4JAbG;IAA0B,QAAQ;IACR,KAAK;IAC6F,YAAY,EAA/H,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,YAAY,GAAG,eAAe,GAAG,gBAAgB,GAAG,qBAAqB;IACzF,QAAQ,EAAxB,MAAM;IACU,WAAW,EAA3B,MAAM;IACU,KAAK,EAArB,MAAM;IACW,KAAK;IACoC,MAAM;IAC/C,MAAM;IACN,aAAa;IACH,mBAAmB;CACvD,GAAS,MAAM,CA+BjB;AAED;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,4KAdG;IAAiI,SAAS,EAAjI,oBAAoB,GAAG,eAAe,GAAG,oBAAoB,GAAG,8CAA8C;IAC7F,IAAI;IACJ,WAAW;IACX,YAAY;IACZ,QAAQ;IACR,YAAY;IACZ,KAAK;IACL,YAAY;IACZ,UAAU;IACV,QAAQ;IACR,SAAS;IACD,cAAc;;;CAChD,GAAS,OAAO,CAAC,iBAAiB,CAAC,CAoErC;AAED;;;;;;;;;GASG;AAEH;;;;;;;;;;GAUG;AACH,4FAPG;IAAyB,QAAQ,EAAxB,MAAM;IACW,KAAK;IACL,QAAQ;IACR,SAAS;IACD,cAAc;;;CAChD,GAAS,OAAO,CAAC,sBAAsB,CAAC,CA+B1C;;kBAlJa,MAAM;gBACN,MAAM;gBACN,MAAM;oBACN,MAAM;YACN,MAAM;eACN,MAAM;iBACN,MAAM;;;;;;iBA4FN,MAAM;;;;eACN,MAAM;;;;sBACN,MAAM;;;;gCACN,MAAM;;;;gBACN,MAAM;;;;cACN,MAAM"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["auth.js"],"names":[],"mappings":"AAWA;;;GAGG;AAEH;;;GAGG;AAEH;;;GAGG;AAEH;;;GAGG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,4JAbG;IAA0B,QAAQ;IACR,KAAK;IACS,YAAY,EAA3C,qBAAqB;IACL,QAAQ,EAAxB,MAAM;IACU,WAAW,EAA3B,MAAM;IACU,KAAK,EAArB,MAAM;IACW,KAAK;IACQ,MAAM;IACnB,MAAM;IACN,aAAa;IACS,mBAAmB;CACnE,GAAS,MAAM,CA+BjB;AAED;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,4KAdG;IAAqC,SAAS,EAArC,kBAAkB;IACD,IAAI;IACJ,WAAW;IACX,YAAY;IACZ,QAAQ;IACR,YAAY;IACZ,KAAK;IACL,YAAY;IACZ,UAAU;IACV,QAAQ;IACR,SAAS;IACD,cAAc;;;CAChD,GAAS,OAAO,CAAC,iBAAiB,CAAC,CAoErC;AAED;;;;;;;;;GASG;AAEH;;;;;;;;;;GAUG;AACH,4FAPG;IAAyB,QAAQ,EAAxB,MAAM;IACW,KAAK;IACL,QAAQ;IACR,SAAS;IACD,cAAc;;;CAChD,GAAS,OAAO,CAAC,sBAAsB,CAAC,CA+B1C;AAED;;;;;GAKG;AAEH;;;;;GAKG;AAEH;;;;;GAKG;AAEH;;;GAGG;AAEH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,mHAVG;IAAwB,UAAU,EAA1B,MAAM;IACU,QAAQ,EAAxB,MAAM;IACW,QAAQ;IACR,eAAe;IACf,SAAS;IACD,cAAc;;;CAC/C,GAAU,OAAO,CAAC,oBAAoB,CAAC,CAuDzC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,8IArCG;IAAwB,UAAU,EAA1B,MAAM;IACU,QAAQ,EAAxB,MAAM;IACW,QAAQ;IACR,eAAe;IACf,SAAS;IACT,SAAS;IACD,cAAc;;;IACU,MAAM,aAA9C,oBAAoB,KAAK,IAAI;CAC9C,GAAU,OAAO,CAAC,iBAAiB,CAAC,CA0EtC;;;;oCA5ZY,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,YAAY,GAAG,eAAe,GAAG,gBAAgB,GAAG,qBAAqB;;;;kCAKzG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,gBAAgB;;;;2CAK/C,MAAM,GAAG,OAAO;;;;iCAKhB,oBAAoB,GAAG,eAAe,GAAG,oBAAoB,GAAG,OAAO,sBAAsB;;kBAsD5F,MAAM;gBACN,MAAM;gBACN,MAAM;oBACN,MAAM;YACN,MAAM;eACN,MAAM;iBACN,MAAM;;;;;;iBA4FN,MAAM;;;;eACN,MAAM;;;;sBACN,MAAM;;;;gCACN,MAAM;;;;gBACN,MAAM;;;;cACN,MAAM;;;;;;;;;YAgDN,SAAS;;;;cACT,MAAM;;;;;;;;;YAMN,WAAW;;;;cACX,MAAM;;;;;;;;;YAMN,SAAS;;;;YACT,iBAAiB;;;;;mCAKlB,qBAAqB,GAAG,sBAAsB,GAAG,qBAAqB;uCAxPK,gBAAgB"}
@@ -3,27 +3,47 @@
3
3
  */
4
4
  import { request } from 'undici'
5
5
  import { defaultHeaders, handleBadResponse, mergeRequestOptions, YotoAPIError } from './helpers.js'
6
- import { DEFAULT_SCOPE, DEFAULT_AUDIENCE, YOTO_LOGIN_URL } from './constants.js'
6
+ import { DEFAULT_SCOPE, DEFAULT_AUDIENCE, YOTO_LOGIN_URL, DEVICE_CODE_GRANT_TYPE } from './constants.js'
7
7
 
8
8
  // ============================================================================
9
9
  // Authentication: Authentication endpoints for browser-based and device flows
10
10
  // ============================================================================
11
11
 
12
+ /**
13
+ * OAuth2 response type for authorization requests
14
+ * @typedef {'code' | 'token' | 'id_token' | 'code token' | 'code id_token' | 'token id_token' | 'code token id_token'} YotoOAuthResponseType
15
+ */
16
+
17
+ /**
18
+ * OAuth2 prompt parameter for authorization requests
19
+ * @typedef {'none' | 'login' | 'consent' | 'select_account'} YotoOAuthPromptType
20
+ */
21
+
22
+ /**
23
+ * PKCE code challenge method for authorization requests
24
+ * @typedef {'S256' | 'plain'} YotoOAuthCodeChallengeMethod
25
+ */
26
+
27
+ /**
28
+ * OAuth2 grant type for token exchange requests
29
+ * @typedef {'authorization_code' | 'refresh_token' | 'client_credentials' | typeof DEVICE_CODE_GRANT_TYPE} YotoOAuthGrantType
30
+ */
31
+
12
32
  /**
13
33
  * Redirects the user to Yoto's login page to begin the OAuth2 Authorization Code flow.
14
34
  * @see https://yoto.dev/api/get-authorize/
15
35
  * @param {object} options
16
36
  * @param {string} [options.audience='https://api.yotoplay.com'] Audience for the token
17
37
  * @param {string} [options.scope='openid profile offline_access'] Requested scopes
18
- * @param {'code' | 'token' | 'id_token' | 'code token' | 'code id_token' | 'token id_token' | 'code token id_token'} options.responseType Required response type
38
+ * @param {YotoOAuthResponseType} options.responseType Required response type
19
39
  * @param {string} options.clientId Required client ID
20
40
  * @param {string} options.redirectUri Required redirect URI
21
41
  * @param {string} options.state Required opaque value for preventing CSRF attacks
22
42
  * @param {string} [options.nonce] String value to prevent replay attacks
23
- * @param {'none' | 'login' | 'consent' | 'select_account'} [options.prompt] Authorization server prompt behavior
43
+ * @param {YotoOAuthPromptType} [options.prompt] Authorization server prompt behavior
24
44
  * @param {number} [options.maxAge] Maximum authentication age in seconds
25
45
  * @param {string} [options.codeChallenge] PKCE code challenge
26
- * @param {'S256' | 'plain'} [options.codeChallengeMethod] PKCE code challenge method
46
+ * @param {YotoOAuthCodeChallengeMethod} [options.codeChallengeMethod] PKCE code challenge method
27
47
  * @return {string} The authorization URL to redirect the user to
28
48
  */
29
49
  export function getAuthorizeUrl ({
@@ -73,7 +93,7 @@ export function getAuthorizeUrl ({
73
93
  * Exchange authorization code or refresh token for access tokens.
74
94
  * @see https://yoto.dev/api/post-oauth-token/
75
95
  * @param {object} options
76
- * @param {'authorization_code' | 'refresh_token' | 'client_credentials' | 'urn:ietf:params:oauth:grant-type:device_code'} options.grantType Required grant type
96
+ * @param {YotoOAuthGrantType} options.grantType Required grant type
77
97
  * @param {string} [options.code] Authorization code (required for authorization_code grant)
78
98
  * @param {string} [options.redirectUri] Redirect URI (required for authorization_code grant if used in authorize request)
79
99
  * @param {string} [options.refreshToken] Refresh token (required for refresh_token grant)
@@ -114,7 +134,7 @@ export async function exchangeToken ({
114
134
  } else if (grantType === 'refresh_token') {
115
135
  if (!refreshToken) throw new Error('refreshToken is required for refresh_token grant')
116
136
  formData.set('refresh_token', refreshToken)
117
- } else if (grantType === 'urn:ietf:params:oauth:grant-type:device_code') {
137
+ } else if (grantType === DEVICE_CODE_GRANT_TYPE) {
118
138
  if (!deviceCode) throw new Error('deviceCode is required for device_code grant')
119
139
  formData.set('device_code', deviceCode)
120
140
  }
@@ -137,7 +157,7 @@ export async function exchangeToken ({
137
157
 
138
158
  // For device_code grant, always parse JSON first so error details are available
139
159
  // 403 errors with authorization_pending/slow_down are expected during polling
140
- if (grantType === 'urn:ietf:params:oauth:grant-type:device_code') {
160
+ if (grantType === DEVICE_CODE_GRANT_TYPE) {
141
161
  /** @type {any} */
142
162
  const rawResponse = await response.body.json()
143
163
 
@@ -207,3 +227,200 @@ export async function requestDeviceCode ({
207
227
  const responseBody = /** @type {YotoDeviceCodeResponse} */ (await response.body.json())
208
228
  return responseBody
209
229
  }
230
+
231
+ /**
232
+ * Poll result when authorization is still pending
233
+ * @typedef {Object} YotoDevicePollPending
234
+ * @property {'pending'} status - Indicates polling should continue
235
+ * @property {number} interval - Current polling interval in milliseconds
236
+ */
237
+
238
+ /**
239
+ * Poll result when polling needs to slow down
240
+ * @typedef {Object} YotoDevicePollSlowDown
241
+ * @property {'slow_down'} status - Indicates polling interval should be increased
242
+ * @property {number} interval - New polling interval in milliseconds
243
+ */
244
+
245
+ /**
246
+ * Poll result when authorization is successful
247
+ * @typedef {Object} YotoDevicePollSuccess
248
+ * @property {'success'} status - Indicates successful authorization
249
+ * @property {YotoTokenResponse} tokens - OAuth tokens
250
+ */
251
+
252
+ /**
253
+ * Poll result for device authorization flow
254
+ * @typedef {YotoDevicePollPending | YotoDevicePollSlowDown | YotoDevicePollSuccess} YotoDevicePollResult
255
+ */
256
+
257
+ /**
258
+ * Poll for device authorization completion with automatic error handling (single poll attempt).
259
+ * This function handles common polling errors (authorization_pending, slow_down)
260
+ * and only throws for unrecoverable errors (expired_token, access_denied, etc).
261
+ *
262
+ * Non-blocking - returns immediately with poll result. Suitable for:
263
+ * - Manual polling loops in CLI applications
264
+ * - Server-side endpoints that poll on behalf of clients (e.g., Homebridge UI server)
265
+ * - Custom UI implementations with specific polling behavior
266
+ *
267
+ * For the simplest approach (automatic polling loop), use waitForDeviceAuthorization() instead.
268
+ *
269
+ * @see https://yoto.dev/api/post-oauth-token/
270
+ * @param {object} options
271
+ * @param {string} options.deviceCode - Device code from requestDeviceCode()
272
+ * @param {string} options.clientId - OAuth client ID
273
+ * @param {string} [options.audience='https://api.yotoplay.com'] - Audience for the token
274
+ * @param {number} [options.currentInterval=5000] - Current polling interval in milliseconds
275
+ * @param {string} [options.userAgent] - Optional user agent string
276
+ * @param {RequestOptions} [options.requestOptions] - Additional undici request options
277
+ * @returns {Promise<YotoDevicePollResult>} Poll result with status and data
278
+ * @throws {YotoAPIError} For unrecoverable errors (expired_token, access_denied, invalid_grant, etc)
279
+ *
280
+ */
281
+ export async function pollForDeviceToken ({
282
+ deviceCode,
283
+ clientId,
284
+ audience = DEFAULT_AUDIENCE,
285
+ currentInterval = 5000,
286
+ userAgent,
287
+ requestOptions
288
+ }) {
289
+ try {
290
+ const tokens = await exchangeToken({
291
+ grantType: DEVICE_CODE_GRANT_TYPE,
292
+ deviceCode,
293
+ clientId,
294
+ audience,
295
+ userAgent,
296
+ requestOptions
297
+ })
298
+
299
+ // Success - return tokens
300
+ return {
301
+ status: 'success',
302
+ tokens
303
+ }
304
+ } catch (err) {
305
+ const error = /** @type {any} */ (err)
306
+ const errorCode = error.body?.error
307
+
308
+ // Handle recoverable polling states
309
+ if (errorCode === 'authorization_pending') {
310
+ // User hasn't authorized yet - continue polling
311
+ return {
312
+ status: 'pending',
313
+ interval: currentInterval
314
+ }
315
+ }
316
+
317
+ if (errorCode === 'slow_down') {
318
+ // Polling too fast - increase interval by 5 seconds
319
+ return {
320
+ status: 'slow_down',
321
+ interval: currentInterval + 5000
322
+ }
323
+ }
324
+
325
+ // All other errors are unrecoverable - throw them
326
+ // Common unrecoverable errors:
327
+ // - expired_token: Device code expired
328
+ // - access_denied: User denied authorization
329
+ // - invalid_grant: Invalid device code or other grant issue
330
+ throw error
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Wait for device authorization to complete with automatic polling.
336
+ * This function wraps the entire polling loop - just call it and await the result.
337
+ * It handles all polling logic internally including interval adjustments.
338
+ *
339
+ * Designed for CLI usage where you want to block until authorization completes.
340
+ * For UI implementations with progress feedback, use pollForDeviceToken() directly.
341
+ *
342
+ * @see https://yoto.dev/api/post-oauth-token/
343
+ * @param {object} options
344
+ * @param {string} options.deviceCode - Device code from requestDeviceCode()
345
+ * @param {string} options.clientId - OAuth client ID
346
+ * @param {string} [options.audience='https://api.yotoplay.com'] - Audience for the token
347
+ * @param {number} [options.initialInterval=5000] - Initial polling interval in milliseconds
348
+ * @param {number} [options.expiresIn] - Seconds until device code expires (for timeout calculation)
349
+ * @param {string} [options.userAgent] - Optional user agent string
350
+ * @param {RequestOptions} [options.requestOptions] - Additional undici request options
351
+ * @param {(result: YotoDevicePollResult) => void} [options.onPoll] - Optional callback invoked after each poll attempt
352
+ * @returns {Promise<YotoTokenResponse>} Token response on successful authorization
353
+ * @throws {YotoAPIError} For unrecoverable errors (expired_token, access_denied, invalid_grant, etc)
354
+ * @throws {Error} If device code expires (timeout)
355
+ *
356
+ * @example
357
+ * // Simple usage - just wait for tokens
358
+ * const deviceAuth = await requestDeviceCode({ clientId })
359
+ * console.log(`Visit: ${deviceAuth.verification_uri_complete}`)
360
+ *
361
+ * const tokens = await waitForDeviceAuthorization({
362
+ * deviceCode: deviceAuth.device_code,
363
+ * clientId,
364
+ * initialInterval: deviceAuth.interval * 1000,
365
+ * expiresIn: deviceAuth.expires_in
366
+ * })
367
+ *
368
+ * console.log('Got tokens:', tokens)
369
+ *
370
+ * @example
371
+ * // With progress callback
372
+ * const tokens = await waitForDeviceAuthorization({
373
+ * deviceCode: deviceAuth.device_code,
374
+ * clientId,
375
+ * onPoll: (result) => {
376
+ * if (result.status === 'pending') process.stdout.write('.')
377
+ * if (result.status === 'slow_down') console.log('\nSlowing down...')
378
+ * }
379
+ * })
380
+ */
381
+ export async function waitForDeviceAuthorization ({
382
+ deviceCode,
383
+ clientId,
384
+ audience = DEFAULT_AUDIENCE,
385
+ initialInterval = 5000,
386
+ expiresIn,
387
+ userAgent,
388
+ requestOptions,
389
+ onPoll
390
+ }) {
391
+ let interval = initialInterval
392
+ const startTime = Date.now()
393
+ const expiresAt = expiresIn ? startTime + (expiresIn * 1000) : null
394
+
395
+ while (true) {
396
+ // Check if we've exceeded the expiration time
397
+ if (expiresAt && Date.now() >= expiresAt) {
398
+ throw new Error('Device code has expired')
399
+ }
400
+
401
+ const result = await pollForDeviceToken({
402
+ deviceCode,
403
+ clientId,
404
+ audience,
405
+ currentInterval: interval,
406
+ userAgent,
407
+ requestOptions
408
+ })
409
+
410
+ // Invoke callback if provided
411
+ if (onPoll) {
412
+ onPoll(result)
413
+ }
414
+
415
+ if (result.status === 'success') {
416
+ return result.tokens
417
+ }
418
+
419
+ if (result.status === 'slow_down') {
420
+ interval = result.interval
421
+ }
422
+
423
+ // Wait before next poll
424
+ await new Promise(resolve => setTimeout(resolve, interval))
425
+ }
426
+ }
@@ -1,8 +1,8 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert'
3
- import { exchangeToken } from './auth.js'
3
+ import { exchangeToken, pollForDeviceToken } from './auth.js'
4
4
  import { YotoAPIError } from './helpers.js'
5
- import { loadTestTokens } from './test-helpers.js'
5
+ import { loadTestTokens } from './endpoint-test-helpers.js'
6
6
 
7
7
  const { clientId } = loadTestTokens()
8
8
 
@@ -25,3 +25,55 @@ test('exchangeToken - refresh flow', async (t) => {
25
25
  )
26
26
  })
27
27
  })
28
+
29
+ test('pollForDeviceToken', async (t) => {
30
+ await t.test('should throw invalid_grant for invalid device code', async () => {
31
+ // Invalid device codes throw invalid_grant error (unrecoverable)
32
+ await assert.rejects(
33
+ async () => {
34
+ await pollForDeviceToken({
35
+ deviceCode: 'invalid-device-code',
36
+ clientId,
37
+ currentInterval: 5000
38
+ })
39
+ },
40
+ (err) => {
41
+ assert.ok(err instanceof YotoAPIError, 'Should throw YotoAPIError')
42
+ // @ts-expect-error
43
+ assert.strictEqual(err.body?.error, 'invalid_grant', 'Should be invalid_grant error')
44
+ assert.ok(err.statusCode, 'Error should have statusCode')
45
+ return true
46
+ }
47
+ )
48
+ })
49
+
50
+ await t.test('should throw for expired_token error', async () => {
51
+ // The function should throw for expired_token (unrecoverable)
52
+ // We can't easily test this without a real flow, but we verify the error handling works
53
+ await assert.rejects(
54
+ async () => {
55
+ await pollForDeviceToken({
56
+ deviceCode: 'expired-device-code-xyz',
57
+ clientId,
58
+ currentInterval: 5000
59
+ })
60
+ },
61
+ (err) => {
62
+ assert.ok(err instanceof YotoAPIError, 'Should throw YotoAPIError')
63
+ // Will get invalid_grant for malformed codes
64
+ // @ts-expect-error
65
+ assert.ok(err.body?.error, 'Should have error code')
66
+ return true
67
+ }
68
+ )
69
+ })
70
+
71
+ // Note: Testing authorization_pending and slow_down states requires a real device flow
72
+ // These tests would need to:
73
+ // 1. Call requestDeviceCode() to get a valid device_code
74
+ // 2. Poll immediately (should get authorization_pending)
75
+ // 3. Poll rapidly (might get slow_down)
76
+ // 4. Complete auth in browser (would get success)
77
+ // This is too complex and flaky for unit tests, so we skip testing those paths
78
+ // The implementation is verified by the CLI tool usage in bin/auth.js
79
+ })
@@ -1,9 +1,20 @@
1
1
  /**
2
2
  * Default OAuth client ID for testing and development
3
- * This is a public client ID provided by Yoto for development purposes
3
+ * This is a public client ID for the app "yoto-nodejs-client".
4
+ * Generate a new one at https://dashboard.yoto.dev or use the default.
4
5
  * @constant {string}
5
6
  */
6
7
  export const DEFAULT_CLIENT_ID: "ix91Qy0B4uA8187JhI0tQbQQ5I5nUKYh";
8
+ /**
9
+ * Yoto API base URL
10
+ * @constant {string}
11
+ */
12
+ export const YOTO_API_URL: "https://api.yotoplay.com";
13
+ /**
14
+ * Yoto login/authentication base URL
15
+ * @constant {string}
16
+ */
17
+ export const YOTO_LOGIN_URL: "https://login.yotoplay.com";
7
18
  /**
8
19
  * Default OAuth audience for Yoto API
9
20
  * @constant {string}
@@ -16,13 +27,8 @@ export const DEFAULT_AUDIENCE: "https://api.yotoplay.com";
16
27
  */
17
28
  export const DEFAULT_SCOPE: "openid profile offline_access";
18
29
  /**
19
- * Yoto API base URL
20
- * @constant {string}
21
- */
22
- export const YOTO_API_URL: "https://api.yotoplay.com";
23
- /**
24
- * Yoto login/authentication base URL
30
+ * OAuth device code grant type
25
31
  * @constant {string}
26
32
  */
27
- export const YOTO_LOGIN_URL: "https://login.yotoplay.com";
33
+ export const DEVICE_CODE_GRANT_TYPE: "urn:ietf:params:oauth:grant-type:device_code";
28
34
  //# sourceMappingURL=constants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["constants.js"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,gCAAiC,kCAAkC,CAAA;AAEnE;;;GAGG;AACH,+BAAgC,0BAA0B,CAAA;AAE1D;;;;GAIG;AACH,4BAA6B,+BAA+B,CAAA;AAE5D;;;GAGG;AACH,2BAA4B,0BAA0B,CAAA;AAEtD;;;GAGG;AACH,6BAA8B,4BAA4B,CAAA"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["constants.js"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,gCAAiC,kCAAkC,CAAA;AAEnE;;;GAGG;AACH,2BAA4B,0BAA0B,CAAA;AAEtD;;;GAGG;AACH,6BAA8B,4BAA4B,CAAA;AAE1D;;;GAGG;AACH,0DAA4C;AAE5C;;;;GAIG;AACH,4BAA6B,+BAA+B,CAAA;AAE5D;;;GAGG;AACH,qCAAsC,8CAA8C,CAAA"}