latchkey 3.3.0 → 3.4.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.
Files changed (35) hide show
  1. package/dist/skills/generic/latchkey/SKILL.md +1 -1
  2. package/dist/skills/openclaw/latchkey/SKILL.md +1 -1
  3. package/dist/src/apiCredentials/serialization.d.ts +21 -0
  4. package/dist/src/apiCredentials/serialization.d.ts.map +1 -1
  5. package/dist/src/apiCredentials/serialization.js +7 -0
  6. package/dist/src/apiCredentials/serialization.js.map +1 -1
  7. package/dist/src/serviceRegistry.d.ts.map +1 -1
  8. package/dist/src/serviceRegistry.js +2 -1
  9. package/dist/src/serviceRegistry.js.map +1 -1
  10. package/dist/src/services/aws.d.ts.map +1 -1
  11. package/dist/src/services/aws.js +1 -2
  12. package/dist/src/services/aws.js.map +1 -1
  13. package/dist/src/services/index.d.ts +1 -0
  14. package/dist/src/services/index.d.ts.map +1 -1
  15. package/dist/src/services/index.js +1 -0
  16. package/dist/src/services/index.js.map +1 -1
  17. package/dist/src/services/ngrok.d.ts +38 -0
  18. package/dist/src/services/ngrok.d.ts.map +1 -0
  19. package/dist/src/services/ngrok.js +129 -0
  20. package/dist/src/services/ngrok.js.map +1 -0
  21. package/dist/src/services/zoom.d.ts +118 -5
  22. package/dist/src/services/zoom.d.ts.map +1 -1
  23. package/dist/src/services/zoom.js +692 -7
  24. package/dist/src/services/zoom.js.map +1 -1
  25. package/dist/src/version.d.ts +1 -1
  26. package/dist/src/version.js +1 -1
  27. package/dist/tests/apiCredentials.test.js +5 -0
  28. package/dist/tests/apiCredentials.test.js.map +1 -1
  29. package/dist/tests/servicesAgainstRecordings.test.js +8 -2
  30. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  31. package/dist/tests/zoom.test.d.ts +2 -0
  32. package/dist/tests/zoom.test.d.ts.map +1 -0
  33. package/dist/tests/zoom.test.js +110 -0
  34. package/dist/tests/zoom.test.js.map +1 -0
  35. package/package.json +2 -2
@@ -1,33 +1,718 @@
1
+ /**
2
+ * Zoom service implementation.
3
+ *
4
+ * Browser login creates a Server-to-Server OAuth app in the Zoom App
5
+ * Marketplace. Such an app has no user context: its credentials are an account
6
+ * id plus a client id/secret, which are exchanged for a short-lived access
7
+ * token with the `account_credentials` grant whenever the previous token
8
+ * expires.
9
+ */
10
+ import { z } from 'zod';
11
+ import { ApiCredentialsUsageError } from '../apiCredentials/base.js';
1
12
  import { fetchAccountFromEndpoint, tryParseJson } from '../apiCredentials/account.js';
2
- import { Service } from './core/base.js';
13
+ import { runCapturedAsync } from '../curl.js';
14
+ import { typeLikeHuman } from '../playwrightUtils.js';
15
+ import { BrowserFollowupServiceSession, FollowupWork, isBrowserClosedError, LoginCancelledError, LoginFailedError, Service, } from './core/base.js';
16
+ const ZOOM_TOKEN_ENDPOINT = 'https://zoom.us/oauth/token';
17
+ // Sign-in happens on Zoom itself rather than on the App Marketplace, which
18
+ // bounces visitors through a less predictable chain of pages.
19
+ const ZOOM_SIGN_IN_URL = 'https://zoom.us/signin#/login';
20
+ // Where Zoom sends the user once signed in: their home page, served by whichever
21
+ // regional host runs their account (https://us05web.zoom.us/myhome, ...) and
22
+ // possibly with a query or a subpath behind it. The redirect there names the
23
+ // page either as an absolute URL or as a path.
24
+ const SIGNED_IN_HOME_URL_PATTERN = /^(https:\/\/([\w-]+\.)*zoom\.us)?\/myhome\b/i;
25
+ /**
26
+ * Whether a URL names the home page Zoom sends a user to once they are signed
27
+ * in. Also accepts the bare path, the form a redirect to it may take.
28
+ */
29
+ export function isZoomSignedInHomeUrl(url) {
30
+ return SIGNED_IN_HOME_URL_PATTERN.test(url);
31
+ }
32
+ // The Marketplace page that lists the user's apps and carries the "Develop"
33
+ // menu the app creation flow starts from.
34
+ const ZOOM_MARKETPLACE_BUILD_URL = 'https://marketplace.zoom.us/user/build';
35
+ // Zoom's access tokens live for an hour; renew slightly early so a token does
36
+ // not expire between the check and the request that uses it.
37
+ const ACCESS_TOKEN_EXPIRY_MARGIN_MS = 60_000;
38
+ const DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
39
+ export class ZoomCredentialError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'ZoomCredentialError';
43
+ }
44
+ }
45
+ /**
46
+ * Raised when the signed-in user is not allowed to create apps in their Zoom
47
+ * account. Extends {@link LoginFailedError} so the CLI reports the message on
48
+ * stderr instead of a stack trace.
49
+ */
50
+ export class ZoomAppCreationNotPermittedError extends LoginFailedError {
51
+ constructor() {
52
+ super('Your Zoom user is not allowed to create apps in the Zoom App Marketplace. Ask an administrator ' +
53
+ 'of your Zoom account to grant you the developer privilege, then try again.');
54
+ this.name = 'ZoomAppCreationNotPermittedError';
55
+ }
56
+ }
57
+ /**
58
+ * Credentials of a Zoom Server-to-Server OAuth app: the long-lived triple that
59
+ * identifies the app, plus the short-lived access token minted from it.
60
+ */
61
+ export const ZoomServerToServerCredentialsSchema = z.object({
62
+ objectType: z.literal('zoomServerToServer'),
63
+ accountId: z.string(),
64
+ clientId: z.string(),
65
+ clientSecret: z.string(),
66
+ accessToken: z.string().optional(),
67
+ accessTokenExpiresAt: z.string().optional(),
68
+ });
69
+ export class ZoomServerToServerCredentials {
70
+ objectType = 'zoomServerToServer';
71
+ accountId;
72
+ clientId;
73
+ clientSecret;
74
+ accessToken;
75
+ accessTokenExpiresAt;
76
+ accountEmail;
77
+ constructor(accountId, clientId, clientSecret, accessToken, accessTokenExpiresAt, accountEmail) {
78
+ this.accountId = accountId;
79
+ this.clientId = clientId;
80
+ this.clientSecret = clientSecret;
81
+ this.accessToken = accessToken;
82
+ this.accessTokenExpiresAt = accessTokenExpiresAt;
83
+ this.accountEmail = accountEmail;
84
+ }
85
+ withAccessToken(accessToken, accessTokenExpiresAt) {
86
+ return new ZoomServerToServerCredentials(this.accountId, this.clientId, this.clientSecret, accessToken, accessTokenExpiresAt, this.accountEmail);
87
+ }
88
+ injectIntoCurlCall(curlArguments) {
89
+ if (this.accessToken === undefined) {
90
+ throw new ApiCredentialsUsageError('Zoom server-to-server credentials have no access token yet. ' +
91
+ 'Run a Zoom request again to have one minted, or re-run the login.');
92
+ }
93
+ return Promise.resolve(['-H', `Authorization: Bearer ${this.accessToken}`, ...curlArguments]);
94
+ }
95
+ /**
96
+ * Missing tokens count as expired: the client id and secret are enough to
97
+ * mint a new one, so refreshing is always the right response.
98
+ */
99
+ isExpired() {
100
+ if (this.accessToken === undefined || this.accessTokenExpiresAt === undefined) {
101
+ return true;
102
+ }
103
+ const expiresAt = new Date(this.accessTokenExpiresAt).getTime();
104
+ return Date.now() >= expiresAt - ACCESS_TOKEN_EXPIRY_MARGIN_MS;
105
+ }
106
+ toJSON() {
107
+ return {
108
+ objectType: this.objectType,
109
+ accountId: this.accountId,
110
+ clientId: this.clientId,
111
+ clientSecret: this.clientSecret,
112
+ accessToken: this.accessToken,
113
+ accessTokenExpiresAt: this.accessTokenExpiresAt,
114
+ };
115
+ }
116
+ static fromJSON(data) {
117
+ return new ZoomServerToServerCredentials(data.accountId, data.clientId, data.clientSecret, data.accessToken, data.accessTokenExpiresAt);
118
+ }
119
+ }
120
+ /**
121
+ * Exchange the app's client id/secret for an account-level access token.
122
+ * Returns null when Zoom refuses to issue one (for instance while the app is
123
+ * not activated yet).
124
+ */
125
+ async function mintAccessToken(credentials) {
126
+ const body = new URLSearchParams({
127
+ grant_type: 'account_credentials',
128
+ account_id: credentials.accountId,
129
+ });
130
+ const result = await runCapturedAsync([
131
+ '-s',
132
+ '-X',
133
+ 'POST',
134
+ '-u',
135
+ `${credentials.clientId}:${credentials.clientSecret}`,
136
+ '-H',
137
+ 'Content-Type: application/x-www-form-urlencoded',
138
+ '-d',
139
+ body.toString(),
140
+ ZOOM_TOKEN_ENDPOINT,
141
+ ], 30);
142
+ if (result.returncode !== 0) {
143
+ return null;
144
+ }
145
+ const response = tryParseJson(result.stdout);
146
+ if (response?.access_token === undefined) {
147
+ return null;
148
+ }
149
+ const lifetimeSeconds = response.expires_in ?? DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS;
150
+ return credentials.withAccessToken(response.access_token, new Date(Date.now() + lifetimeSeconds * 1000).toISOString());
151
+ }
152
+ // --- Browser automation ------------------------------------------------------
153
+ const DEFAULT_TIMEOUT_MS = 8000;
154
+ // The app pages are a single-page app: steps change the URL without a document
155
+ // load, so progress is observed by polling rather than by navigation events.
156
+ const URL_POLL_INTERVAL_MS = 200;
157
+ const APP_CREATION_TIMEOUT_MS = 30_000;
158
+ // Time given to a dialog or page to render after a click.
159
+ const PAGE_SETTLE_MS = 1000;
160
+ // Time given to a dialog or menu to finish animating into place.
161
+ const ANIMATION_SETTLE_MS = 300;
162
+ // The "Build app" dialog may be preceded by the API terms of use, which the
163
+ // user has to accept before Zoom lets the flow continue, so the click may have
164
+ // to be repeated.
165
+ const BUILD_APP_ATTEMPTS = 4;
166
+ // Time allowed for the user to accept Zoom's API License and Terms of Use.
167
+ const TERMS_OF_USE_USER_INTERACTION_TIMEOUT_MS = 10 * 60 * 1000;
168
+ // Zoom walks the app setup through several pages (credentials, information,
169
+ // feature, scopes), each behind its own "Continue" button, and rejects
170
+ // incomplete pages by staying put.
171
+ const MAX_CONTINUE_CLICKS = 8;
172
+ // A freshly activated app takes a moment to become usable for token minting.
173
+ const TOKEN_MINTING_ATTEMPTS = 5;
174
+ const TOKEN_MINTING_RETRY_DELAY_MS = 2000;
175
+ // Marketplace pages expose the signed-in user, their privileges and their
176
+ // account in a global object, which is both more reliable and more
177
+ // language-independent than reading the rendered page.
178
+ const ZoomMarketplaceUserInfoSchema = z.object({
179
+ email: z.string().optional(),
180
+ userName: z.string().optional(),
181
+ canBuildServerToServerOAuthApp: z.boolean().optional(),
182
+ });
183
+ const APP_CREDENTIALS_URL_PATTERN = /\/develop\/apps\/([^/?#]+)\/credentials/;
184
+ // The two controls that carry nothing but their label: the app kind radio
185
+ // buttons all have an empty value, and the "Build app" menu item has neither an
186
+ // id nor a test attribute. Everything else is addressed through `data-ta`
187
+ // attributes, input names, icons or structural classes, none of which depend on
188
+ // the interface language.
189
+ const SERVER_TO_SERVER_APP_KIND_PATTERN = /server\s*to\s*server/i;
190
+ const BUILD_APP_MENU_ITEM_PATTERN = /build\s*app/i;
191
+ // The "Add Scopes" button and the "Select All" menu of the scope dialog have no
192
+ // attributes to go by either, but they are the only elements on their page drawn
193
+ // with a plus icon respectively a drop-down triangle.
194
+ const ADD_SCOPES_BUTTON_SELECTOR = 'button:has(svg mask[id^="icon_PlusSmallOutline"])';
195
+ const SELECT_ALL_SCOPES_GLYPH = '\u25be';
196
+ // The dialogs of the two design systems Zoom's Marketplace mixes mark their
197
+ // confirming button ("Create", "Done") as the primary one, which avoids having
198
+ // to match its label.
199
+ const ENABLED_PRIMARY_BUTTON_SELECTOR = 'button:is(.MuiButton-primary, .ui-Button-primary):not([disabled]):not([aria-disabled="true"])';
200
+ const ENABLED_PRIMARY_DIALOG_BUTTON_SELECTOR = `[role="dialog"]:visible ${ENABLED_PRIMARY_BUTTON_SELECTOR}`;
201
+ // The scope dialog counts the selection next to the button that confirms it;
202
+ // only the number is read, so the wording does not matter.
203
+ const SELECTED_SCOPE_COUNT_PATTERN = /\d+/;
204
+ // How long to give the dialog to work a group's scopes into the selection.
205
+ const SCOPE_SELECTION_TIMEOUT_MS = 5000;
206
+ // The scope dialog groups the scopes by product, one tab each, and its "Select
207
+ // All" only ever reaches the group on display — so every group worth having is
208
+ // visited in turn. The tabs are named after Zoom's products, which is all there
209
+ // is to tell them apart; anchoring the names keeps "Contacts" from picking
210
+ // "Contact Center". Groups an account does not have are simply absent.
211
+ const SCOPE_GROUP_PATTERNS = [
212
+ /^Team Chat$/i,
213
+ /^Meetings$/i,
214
+ /^Recording$/i,
215
+ /^User$/i,
216
+ /^Calendar$/i,
217
+ /^ZOOM$/i,
218
+ /^Dashboard$/i,
219
+ /^Contacts$/i,
220
+ /^Message$/i,
221
+ ];
222
+ const DEVELOP_MENU_BUTTON_SELECTOR = 'button[data-ta="develop"]';
223
+ // Menus are modals: they cover the page with an invisible backdrop that
224
+ // swallows pointer events until the menu is dismissed.
225
+ const OPEN_MENU_BACKDROP_SELECTOR = '.MuiMenu-root .MuiModal-backdrop:visible';
226
+ // The API terms dialog is recognized by its link to the license document, which
227
+ // does not depend on the interface language. The app kind dialog is the one
228
+ // asking to pick between radio buttons.
229
+ const API_TERMS_DIALOG_SELECTOR = '[role="dialog"]:visible:has(a[href*="zoom_api_license_and_tou"])';
230
+ const APP_KIND_DIALOG_SELECTOR = '[role="dialog"]:visible:has(input[type="radio"])';
231
+ // The inputs of the app information page are named after the fields of Zoom's
232
+ // app model (as are those of the credentials page). Matching the name as a
233
+ // substring absorbs the spellings Zoom uses for the developer contact across
234
+ // versions of the page, and covers pages that ask for a second contact.
235
+ const COMPANY_NAME_INPUT_SELECTOR = 'input[name*="company" i]';
236
+ const DEVELOPER_NAME_INPUT_SELECTOR = 'input[name*="developername" i], input[name*="contactname" i], input[name*="supportname" i]';
237
+ const DEVELOPER_EMAIL_INPUT_SELECTOR = 'input[name*="email" i]';
238
+ function visibleDialog(page) {
239
+ // The Marketplace keeps dialogs of pages already visited in the DOM, so the
240
+ // one to act on is the last visible one.
241
+ return page.locator('[role="dialog"]:visible').last();
242
+ }
243
+ /**
244
+ * Wait for the Marketplace header to be there and settle: it hydrates after the
245
+ * page load and re-renders shortly after. Clicking into it before that opens a
246
+ * menu whose backdrop then stays behind, blocking every later click.
247
+ */
248
+ async function waitForMarketplaceHeader(page) {
249
+ await page.locator(DEVELOP_MENU_BUTTON_SELECTOR).waitFor({ timeout: DEFAULT_TIMEOUT_MS });
250
+ await page.waitForTimeout(PAGE_SETTLE_MS);
251
+ }
252
+ /**
253
+ * Read the signed-in user, their privileges and their account off a Marketplace
254
+ * page. Returns null when the page carries no user, which is how a Marketplace
255
+ * page rendered for a visitor who is not (or no longer) signed in looks.
256
+ */
257
+ async function readMarketplaceUserInfo(page) {
258
+ const rawUserInfo = await page.evaluate(() => {
259
+ const appConf = globalThis.appConf;
260
+ return appConf?.userInfo ?? null;
261
+ });
262
+ const parsed = ZoomMarketplaceUserInfoSchema.safeParse(rawUserInfo);
263
+ if (!parsed.success) {
264
+ return null;
265
+ }
266
+ const userInfo = parsed.data;
267
+ return userInfo.email === undefined && userInfo.userName === undefined ? null : userInfo;
268
+ }
269
+ async function clickEnabledPrimaryDialogButton(page) {
270
+ await clickControl(page.locator(ENABLED_PRIMARY_DIALOG_BUTTON_SELECTOR).last());
271
+ }
272
+ /**
273
+ * Click a control of the Marketplace's interface.
274
+ *
275
+ * Its buttons and menu entries swap modal layers in and out, and a backdrop
276
+ * appearing while the mouse button is still down is what Playwright reads as
277
+ * its own click having been intercepted: it discards the click and retries —
278
+ * only now the backdrop that the first click brought up blocks every attempt,
279
+ * until the action times out with a menu or dialog sitting open. Forcing the
280
+ * click sends the same mouse events without that hit-target check, which is
281
+ * why every click of this flow goes through here.
282
+ */
283
+ async function clickControl(locator) {
284
+ await locator.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
285
+ // A forced click skips the wait for the element to stop moving, so a dialog
286
+ // or menu still animating into place gets its time here instead.
287
+ await locator.page().waitForTimeout(ANIMATION_SETTLE_MS);
288
+ await locator.click({ force: true });
289
+ }
290
+ /**
291
+ * Close a menu that is (still) open. A menu covers the whole page with an
292
+ * invisible backdrop that swallows every click, so one left open blocks the
293
+ * rest of the flow — including a second attempt at opening it.
294
+ */
295
+ async function dismissOpenMenu(page) {
296
+ const menuBackdrop = page.locator(OPEN_MENU_BACKDROP_SELECTOR).first();
297
+ if ((await menuBackdrop.count()) === 0) {
298
+ return;
299
+ }
300
+ // Escape rather than a click on the backdrop, which would land on whatever is
301
+ // underneath should the menu close on its own in the meantime.
302
+ await page.keyboard.press('Escape');
303
+ await menuBackdrop.waitFor({ state: 'hidden', timeout: DEFAULT_TIMEOUT_MS });
304
+ }
305
+ /**
306
+ * Ask for the app creation dialog through the header's "Develop" menu.
307
+ *
308
+ * The menu opens on hover, so the button is hovered rather than clicked: a click
309
+ * arrives at a menu that is already open and closes it again, which is why
310
+ * clicking here gets nowhere. Its entry is then chosen with Enter, since the
311
+ * menu takes the focus as it opens — that is what leaves its first entry
312
+ * highlighted — and a keypress needs no coordinates, unlike a click, which the
313
+ * invisible backdrop MUI spreads beside the menu can swallow.
314
+ */
315
+ async function chooseDevelopBuildApp(page) {
316
+ await dismissOpenMenu(page);
317
+ const developMenuButton = page.locator(DEVELOP_MENU_BUTTON_SELECTOR);
318
+ await developMenuButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
319
+ await developMenuButton.hover({ force: true });
320
+ const buildAppEntry = page.getByRole('menuitem', { name: BUILD_APP_MENU_ITEM_PATTERN }).first();
321
+ await buildAppEntry.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
322
+ // Let the menu settle and take the focus before the keypress.
323
+ await page.waitForTimeout(ANIMATION_SETTLE_MS);
324
+ await page.keyboard.press('Enter');
325
+ await page.waitForTimeout(ANIMATION_SETTLE_MS);
326
+ if (await buildAppEntry.isVisible()) {
327
+ // The menu stayed open, so the focus was elsewhere: click the entry after
328
+ // all, and clear the menu if that leaves it behind.
329
+ await clickControl(buildAppEntry);
330
+ await dismissOpenMenu(page);
331
+ }
332
+ }
333
+ async function selectServerToServerAppKind(appKindDialog) {
334
+ if ((await appKindDialog.locator('input[type="radio"]:not([disabled])').count()) === 0) {
335
+ throw new ZoomAppCreationNotPermittedError();
336
+ }
337
+ const serverToServerOption = appKindDialog
338
+ .locator('label')
339
+ .filter({ hasText: SERVER_TO_SERVER_APP_KIND_PATTERN })
340
+ .locator('input[type="radio"]')
341
+ .first();
342
+ if ((await serverToServerOption.count()) === 0) {
343
+ throw new LoginFailedError('Zoom does not offer the "Server to Server OAuth" app type to your user. ' +
344
+ 'Ask an administrator of your Zoom account to enable it for you.');
345
+ }
346
+ if (!(await serverToServerOption.isEnabled())) {
347
+ throw new ZoomAppCreationNotPermittedError();
348
+ }
349
+ await serverToServerOption.check();
350
+ }
351
+ async function nameAndCreateApp(page, appName) {
352
+ const appNameInput = page.locator('input[data-ta="app-name"]');
353
+ await appNameInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
354
+ await typeLikeHuman(page, appNameInput, appName);
355
+ await clickEnabledPrimaryDialogButton(page);
356
+ }
357
+ /**
358
+ * Wait until Zoom opens the credentials page of the new app and return the app
359
+ * id taken from its URL.
360
+ */
361
+ async function waitForCreatedAppId(page) {
362
+ const deadline = Date.now() + APP_CREATION_TIMEOUT_MS;
363
+ while (Date.now() < deadline) {
364
+ const appId = APP_CREDENTIALS_URL_PATTERN.exec(page.url())?.[1];
365
+ if (appId !== undefined) {
366
+ return appId;
367
+ }
368
+ await page.waitForTimeout(URL_POLL_INTERVAL_MS);
369
+ }
370
+ throw new LoginFailedError(`Zoom did not open the credentials page of the new app (last URL: ${page.url()}).`);
371
+ }
372
+ async function readCredentialField(page, fieldName) {
373
+ const input = page.locator(`input[name="${fieldName}"]`);
374
+ await input.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
375
+ const value = await input.inputValue();
376
+ if (value === '') {
377
+ throw new LoginFailedError(`Zoom left the app credential field '${fieldName}' empty.`);
378
+ }
379
+ return value;
380
+ }
381
+ async function readAppCredentials(page, accountEmail) {
382
+ return new ZoomServerToServerCredentials(await readCredentialField(page, 'devAccountId'), await readCredentialField(page, 'devClientId'), await readCredentialField(page, 'devClientSecret'), undefined, undefined, accountEmail);
383
+ }
384
+ /**
385
+ * Type the value into every input the selector matches that is still empty and
386
+ * open for editing, so fields Zoom prefilled (or the user filled) are left as
387
+ * they are. Matching nothing is fine: which fields a page asks for varies.
388
+ */
389
+ async function fillEmptyInputs(page, selector, value) {
390
+ for (const input of await page.locator(selector).all()) {
391
+ if (!(await input.isVisible()) || !(await input.isEditable())) {
392
+ continue;
393
+ }
394
+ if ((await input.inputValue()) !== '') {
395
+ continue;
396
+ }
397
+ await typeLikeHuman(page, input, value);
398
+ }
399
+ }
400
+ /**
401
+ * Fill the developer contact details Zoom requires before an app can go live:
402
+ * the signed-in user's own name and e-mail address, and as the company the name
403
+ * the app is created under, which says where the app came from.
404
+ */
405
+ async function fillDeveloperInformation(page, userInfo, companyName) {
406
+ await fillEmptyInputs(page, COMPANY_NAME_INPUT_SELECTOR, companyName);
407
+ if (userInfo.userName !== undefined) {
408
+ await fillEmptyInputs(page, DEVELOPER_NAME_INPUT_SELECTOR, userInfo.userName);
409
+ }
410
+ if (userInfo.email !== undefined) {
411
+ await fillEmptyInputs(page, DEVELOPER_EMAIL_INPUT_SELECTOR, userInfo.email);
412
+ }
413
+ }
414
+ async function clickContinueButton(page) {
415
+ await clickControl(page.locator('button[data-ta="continue-button"]'));
416
+ }
417
+ /**
418
+ * Walk the app setup pages from the credentials page to the scopes page,
419
+ * filling in the developer information Zoom asks for along the way. Zoom
420
+ * refuses to move on from an incomplete page, so each step retries until the
421
+ * scopes page is reached.
422
+ */
423
+ async function continueToScopesPage(page, appId, userInfo, companyName) {
424
+ const scopesPath = `/develop/apps/${appId}/scope`;
425
+ for (let attempt = 0; attempt < MAX_CONTINUE_CLICKS; attempt++) {
426
+ if (new URL(page.url()).pathname === scopesPath) {
427
+ return;
428
+ }
429
+ await fillDeveloperInformation(page, userInfo, companyName);
430
+ await clickContinueButton(page);
431
+ await page.waitForTimeout(PAGE_SETTLE_MS);
432
+ }
433
+ throw new LoginFailedError(`Zoom's app setup did not reach the scopes page (stuck at ${page.url()}). ` +
434
+ 'Complete the app setup in the browser window to finish the login.');
435
+ }
436
+ /**
437
+ * Grant the app every admin scope its account offers, so a single set of
438
+ * credentials can serve any Zoom API call the account is entitled to.
439
+ */
440
+ /**
441
+ * How many scopes the dialog reports as selected, or null when it shows no
442
+ * count at all.
443
+ */
444
+ async function readSelectedScopeCount(scopesDialog) {
445
+ // The count sits beside the confirming button, in the footer of the dialog.
446
+ const footer = scopesDialog.locator(ENABLED_PRIMARY_BUTTON_SELECTOR).last().locator('xpath=..');
447
+ const countMatch = SELECTED_SCOPE_COUNT_PATTERN.exec((await footer.textContent()) ?? '');
448
+ return countMatch === null ? null : Number(countMatch[0]);
449
+ }
450
+ /**
451
+ * Wait for the dialog's count of selected scopes to move on from what it was,
452
+ * which working a group into the selection takes it a moment to do. A group
453
+ * that holds no scopes for this account never moves it, so running out of time
454
+ * is not an error.
455
+ */
456
+ async function waitForSelectedScopeCountChange(page, scopesDialog, previousCount) {
457
+ const deadline = Date.now() + SCOPE_SELECTION_TIMEOUT_MS;
458
+ while (Date.now() < deadline) {
459
+ if ((await readSelectedScopeCount(scopesDialog)) !== previousCount) {
460
+ return;
461
+ }
462
+ await page.waitForTimeout(URL_POLL_INTERVAL_MS);
463
+ }
464
+ }
465
+ /**
466
+ * Add every scope of the group on display to the selection. The entries of the
467
+ * "Select All" menu widen from account level outwards ("Select All (Admin)",
468
+ * then "(Master)", then "(All)"), so the account-level one is the first.
469
+ */
470
+ async function selectAllScopesOfGroup(page, scopesDialog) {
471
+ const previousCount = await readSelectedScopeCount(scopesDialog);
472
+ await clickControl(scopesDialog.getByText(SELECT_ALL_SCOPES_GLYPH).first());
473
+ // The menu opens outside the dialog, so it is looked up on the page. Unlike
474
+ // the header's, this menu opens on click and its entries take one.
475
+ await clickControl(page.locator('[role="menu"]:visible').last().locator('[role="menuitem"]').first());
476
+ await waitForSelectedScopeCountChange(page, scopesDialog, previousCount);
477
+ }
478
+ /**
479
+ * Grant the app the scopes of every product group worth having, so a single set
480
+ * of credentials can serve the Zoom APIs an account is entitled to.
481
+ *
482
+ * Confirming a selection that stayed empty would leave the app created,
483
+ * activated and unable to call a single API, so that is refused outright.
484
+ */
485
+ async function addAllScopes(page) {
486
+ await clickControl(page.locator(ADD_SCOPES_BUTTON_SELECTOR).first());
487
+ const scopesDialog = visibleDialog(page);
488
+ for (const groupPattern of SCOPE_GROUP_PATTERNS) {
489
+ const groupTab = scopesDialog.getByRole('tab', { name: groupPattern }).first();
490
+ if ((await groupTab.count()) === 0) {
491
+ continue;
492
+ }
493
+ await clickControl(groupTab);
494
+ await selectAllScopesOfGroup(page, scopesDialog);
495
+ }
496
+ const selectedCount = await readSelectedScopeCount(scopesDialog);
497
+ if (selectedCount === 0) {
498
+ throw new LoginFailedError('Zoom selected no scopes for the app, which would leave it unable to call ' +
499
+ 'any API. Add the scopes in the browser window, or ask an administrator of ' +
500
+ 'your Zoom account for the privileges to do so.');
501
+ }
502
+ // Confirm the selection ("Done").
503
+ await clickEnabledPrimaryDialogButton(page);
504
+ await page.waitForTimeout(PAGE_SETTLE_MS);
505
+ }
506
+ async function activateApp(page) {
507
+ await clickControl(page.locator('button[data-ta="activeAppBtn"]'));
508
+ await page.waitForTimeout(PAGE_SETTLE_MS);
509
+ // Some accounts have to confirm the activation in a dialog.
510
+ if ((await page.locator(ENABLED_PRIMARY_DIALOG_BUTTON_SELECTOR).count()) > 0) {
511
+ await clickEnabledPrimaryDialogButton(page);
512
+ await page.waitForTimeout(PAGE_SETTLE_MS);
513
+ }
514
+ }
515
+ /**
516
+ * Mint the first access token of the new app, retrying because activation takes
517
+ * a moment to take effect.
518
+ */
519
+ async function mintFirstAccessToken(page, credentials) {
520
+ for (let attempt = 0; attempt < TOKEN_MINTING_ATTEMPTS; attempt++) {
521
+ const withToken = await mintAccessToken(credentials);
522
+ if (withToken !== null) {
523
+ return withToken;
524
+ }
525
+ await page.waitForTimeout(TOKEN_MINTING_RETRY_DELAY_MS);
526
+ }
527
+ throw new LoginFailedError('Zoom did not issue an access token for the new app. Check in the browser ' +
528
+ 'window whether the app was activated, and try again.');
529
+ }
530
+ class ZoomServiceSession extends BrowserFollowupServiceSession {
531
+ followupWork = FollowupWork.CreateApp;
532
+ isSignedIn = false;
533
+ /**
534
+ * Sign-in is over once Zoom sends the user to their home page, which it does
535
+ * after the last step of the sign-in (whatever two-factor or consent steps
536
+ * came before). Being sent there is enough — waiting for the home page to
537
+ * answer, let alone to render, would only cost a round trip, since the flow
538
+ * navigates away from it to the Marketplace anyway.
539
+ */
540
+ onResponse(response) {
541
+ if (this.isSignedIn) {
542
+ return;
543
+ }
544
+ if (!response.request().isNavigationRequest()) {
545
+ return;
546
+ }
547
+ const redirectTarget = response.headers().location;
548
+ if (isZoomSignedInHomeUrl(response.request().url()) ||
549
+ (redirectTarget !== undefined && isZoomSignedInHomeUrl(redirectTarget))) {
550
+ this.isSignedIn = true;
551
+ }
552
+ }
553
+ isLoginComplete() {
554
+ return this.isSignedIn;
555
+ }
556
+ /**
557
+ * Accounts that have not accepted Zoom's API License and Terms of Use yet see
558
+ * a dialog blocking the app creation. Accepting the terms is the user's
559
+ * decision, so the page is surfaced and the flow waits until the dialog is
560
+ * gone rather than clicking "Agree" on the user's behalf.
561
+ */
562
+ async waitForApiTermsOfUseDecision(page) {
563
+ const termsDialog = page.locator(API_TERMS_DIALOG_SELECTOR).first();
564
+ if ((await termsDialog.count()) === 0) {
565
+ return;
566
+ }
567
+ try {
568
+ await page.bringToFront();
569
+ // Either button of the dialog closes it: declining leaves the terms
570
+ // unsigned, which shows the dialog again on the next attempt.
571
+ await termsDialog.waitFor({
572
+ state: 'hidden',
573
+ timeout: TERMS_OF_USE_USER_INTERACTION_TIMEOUT_MS,
574
+ });
575
+ }
576
+ catch (error) {
577
+ if (error instanceof Error && isBrowserClosedError(error)) {
578
+ throw new LoginCancelledError();
579
+ }
580
+ throw error;
581
+ }
582
+ await this.spinnerPage?.bringToFront();
583
+ await page.waitForTimeout(PAGE_SETTLE_MS);
584
+ }
585
+ /**
586
+ * Open the dialog that asks what kind of app to create, letting the user
587
+ * accept the API terms of use on the way if the account has not signed them
588
+ * yet.
589
+ */
590
+ async openAppKindDialog(page) {
591
+ const appKindDialog = page.locator(APP_KIND_DIALOG_SELECTOR).last();
592
+ for (let attempt = 0; attempt < BUILD_APP_ATTEMPTS; attempt++) {
593
+ await this.waitForApiTermsOfUseDecision(page);
594
+ if (await appKindDialog.isVisible()) {
595
+ return appKindDialog;
596
+ }
597
+ await chooseDevelopBuildApp(page);
598
+ // Zoom fetches the dialog rather than having it ready, so wait for
599
+ // whichever it puts up: the app kinds, or the terms of use that have to
600
+ // be accepted first.
601
+ await page
602
+ .locator(`${APP_KIND_DIALOG_SELECTOR}, ${API_TERMS_DIALOG_SELECTOR}`)
603
+ .first()
604
+ .waitFor({ timeout: DEFAULT_TIMEOUT_MS })
605
+ .catch(() => {
606
+ // Neither showed up; the next attempt asks for the dialog again.
607
+ });
608
+ }
609
+ throw new LoginFailedError('Zoom\'s "Build app" dialog did not open. Zoom asks for its API License ' +
610
+ 'and Terms of Use to be accepted before an app can be created; without ' +
611
+ 'that, the app has to be created manually.');
612
+ }
613
+ async performBrowserFollowup(context, _oldCredentials) {
614
+ const page = context.pages()[0];
615
+ if (!page) {
616
+ throw new LoginFailedError('No page available in browser context.');
617
+ }
618
+ // Fail fast on anything unreachable: without this, a click blocked by a
619
+ // leftover modal backdrop retries for Playwright's default half minute
620
+ // before it reports a timeout.
621
+ page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
622
+ // Sign-in happened on Zoom itself; the app is created on the Marketplace,
623
+ // which the same session carries over to.
624
+ await page.goto(ZOOM_MARKETPLACE_BUILD_URL);
625
+ await waitForMarketplaceHeader(page);
626
+ const userInfo = await readMarketplaceUserInfo(page);
627
+ if (userInfo === null) {
628
+ throw new LoginFailedError('The Zoom App Marketplace did not recognize the signed-in user. ' +
629
+ 'Sign in to https://marketplace.zoom.us/ in the browser window and try again.');
630
+ }
631
+ if (userInfo.canBuildServerToServerOAuthApp === false) {
632
+ throw new ZoomAppCreationNotPermittedError();
633
+ }
634
+ const appKindDialog = await this.openAppKindDialog(page);
635
+ await selectServerToServerAppKind(appKindDialog);
636
+ await clickEnabledPrimaryDialogButton(page);
637
+ await nameAndCreateApp(page, this.generateAppName());
638
+ const appId = await waitForCreatedAppId(page);
639
+ const credentials = await readAppCredentials(page, userInfo.email);
640
+ await continueToScopesPage(page, appId, userInfo, this.appNamePrefix);
641
+ await addAllScopes(page);
642
+ await clickContinueButton(page);
643
+ await activateApp(page);
644
+ const credentialsWithToken = await mintFirstAccessToken(page, credentials);
645
+ await page.close();
646
+ return credentialsWithToken;
647
+ }
648
+ }
3
649
  export class Zoom extends Service {
4
650
  name = 'zoom';
5
651
  displayName = 'Zoom';
6
652
  baseApiUrls = ['https://api.zoom.us/v2/'];
7
- loginUrl = 'https://zoom.us/signin';
8
- info = 'https://developers.zoom.us/docs/api/.';
653
+ loginUrl = ZOOM_SIGN_IN_URL;
654
+ info = 'https://developers.zoom.us/docs/api/. ' +
655
+ 'Browser login creates a Server-to-Server OAuth app with all admin scopes; ' +
656
+ 'its access token is renewed automatically.';
9
657
  credentialCheckCurlArguments = [
10
658
  '-H',
11
659
  'Content-Type: application/json',
12
660
  'https://api.zoom.us/v2/users?page_size=1',
13
661
  ];
14
662
  setCredentialsExample(serviceName) {
15
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
663
+ return `latchkey auth set-nocurl ${serviceName} <account-id> <client-id> <client-secret>`;
664
+ }
665
+ /**
666
+ * Accept the credentials of a Server-to-Server OAuth app the user created
667
+ * themselves, in the order the Zoom App Marketplace shows them.
668
+ */
669
+ getCredentialsNoCurl(arguments_) {
670
+ const [accountId, clientId, clientSecret] = arguments_;
671
+ if (arguments_.length !== 3 ||
672
+ accountId === undefined ||
673
+ clientId === undefined ||
674
+ clientSecret === undefined) {
675
+ throw new ZoomCredentialError('Expected exactly three arguments: <account-id> <client-id> <client-secret>.\n' +
676
+ 'They are the credentials of a Zoom Server-to-Server OAuth app.\n' +
677
+ `Example: ${this.setCredentialsExample(this.name)}`);
678
+ }
679
+ return new ZoomServerToServerCredentials(accountId, clientId, clientSecret);
680
+ }
681
+ getSession(appNamePrefix) {
682
+ return new ZoomServiceSession(this, appNamePrefix);
16
683
  }
17
684
  /**
18
685
  * The account comes from /users/me rather than the user-list endpoint used
19
686
  * by the credential check, which carries no identity. Server-to-server
20
687
  * tokens have no user context and error on /users/me, in which case the
21
- * account stays undetermined.
688
+ * account is named after the user the app was created by, and failing that
689
+ * by the app's (opaque) Zoom account id.
22
690
  */
23
- getAccount(apiCredentials) {
24
- return fetchAccountFromEndpoint(apiCredentials, ['https://api.zoom.us/v2/users/me'], (responseBody) => {
691
+ async getAccount(apiCredentials) {
692
+ const account = await fetchAccountFromEndpoint(apiCredentials, ['https://api.zoom.us/v2/users/me'], (responseBody) => {
25
693
  const data = tryParseJson(responseBody);
26
694
  if (data === null || data.code !== undefined) {
27
695
  return null;
28
696
  }
29
697
  return data.email ?? data.id ?? null;
30
698
  });
699
+ if (account !== null) {
700
+ return account;
701
+ }
702
+ if (!(apiCredentials instanceof ZoomServerToServerCredentials)) {
703
+ return null;
704
+ }
705
+ return apiCredentials.accountEmail ?? apiCredentials.accountId;
706
+ }
707
+ /**
708
+ * Server-to-server credentials carry no refresh token: a fresh access token
709
+ * is minted from the client id and secret whenever the old one expires.
710
+ */
711
+ async refreshCredentials(apiCredentials) {
712
+ if (!(apiCredentials instanceof ZoomServerToServerCredentials)) {
713
+ return null;
714
+ }
715
+ return await mintAccessToken(apiCredentials);
31
716
  }
32
717
  }
33
718
  export const ZOOM = new Zoom();