latchkey 0.1.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 (176) hide show
  1. package/.nvmrc +1 -0
  2. package/.pre-commit-config.yaml +22 -0
  3. package/.prettierignore +4 -0
  4. package/.prettierrc +7 -0
  5. package/CLAUDE.md +13 -0
  6. package/LICENSE +7 -0
  7. package/README.md +167 -0
  8. package/dist/scripts/cryptFile.d.ts +21 -0
  9. package/dist/scripts/cryptFile.d.ts.map +1 -0
  10. package/dist/scripts/cryptFile.js +106 -0
  11. package/dist/scripts/cryptFile.js.map +1 -0
  12. package/dist/scripts/encryptFile.d.ts +21 -0
  13. package/dist/scripts/encryptFile.d.ts.map +1 -0
  14. package/dist/scripts/encryptFile.js +101 -0
  15. package/dist/scripts/encryptFile.js.map +1 -0
  16. package/dist/scripts/recordBrowserSession.d.ts +18 -0
  17. package/dist/scripts/recordBrowserSession.d.ts.map +1 -0
  18. package/dist/scripts/recordBrowserSession.js +213 -0
  19. package/dist/scripts/recordBrowserSession.js.map +1 -0
  20. package/dist/src/apiCredentialStore.d.ts +19 -0
  21. package/dist/src/apiCredentialStore.d.ts.map +1 -0
  22. package/dist/src/apiCredentialStore.js +65 -0
  23. package/dist/src/apiCredentialStore.js.map +1 -0
  24. package/dist/src/apiCredentials.d.ts +134 -0
  25. package/dist/src/apiCredentials.d.ts.map +1 -0
  26. package/dist/src/apiCredentials.js +139 -0
  27. package/dist/src/apiCredentials.js.map +1 -0
  28. package/dist/src/browserConfig.d.ts +90 -0
  29. package/dist/src/browserConfig.d.ts.map +1 -0
  30. package/dist/src/browserConfig.js +259 -0
  31. package/dist/src/browserConfig.js.map +1 -0
  32. package/dist/src/browserState.d.ts +8 -0
  33. package/dist/src/browserState.d.ts.map +1 -0
  34. package/dist/src/browserState.js +21 -0
  35. package/dist/src/browserState.js.map +1 -0
  36. package/dist/src/cli.d.ts +6 -0
  37. package/dist/src/cli.d.ts.map +1 -0
  38. package/dist/src/cli.js +25 -0
  39. package/dist/src/cli.js.map +1 -0
  40. package/dist/src/cliCommands.d.ts +29 -0
  41. package/dist/src/cliCommands.d.ts.map +1 -0
  42. package/dist/src/cliCommands.js +264 -0
  43. package/dist/src/cliCommands.js.map +1 -0
  44. package/dist/src/config.d.ts +35 -0
  45. package/dist/src/config.d.ts.map +1 -0
  46. package/dist/src/config.js +96 -0
  47. package/dist/src/config.js.map +1 -0
  48. package/dist/src/curl.d.ts +29 -0
  49. package/dist/src/curl.d.ts.map +1 -0
  50. package/dist/src/curl.js +53 -0
  51. package/dist/src/curl.js.map +1 -0
  52. package/dist/src/encryptedStorage.d.ts +39 -0
  53. package/dist/src/encryptedStorage.d.ts.map +1 -0
  54. package/dist/src/encryptedStorage.js +128 -0
  55. package/dist/src/encryptedStorage.js.map +1 -0
  56. package/dist/src/encryption.d.ts +28 -0
  57. package/dist/src/encryption.d.ts.map +1 -0
  58. package/dist/src/encryption.js +86 -0
  59. package/dist/src/encryption.js.map +1 -0
  60. package/dist/src/index.d.ts +14 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +17 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/keychain.d.ts +33 -0
  65. package/dist/src/keychain.d.ts.map +1 -0
  66. package/dist/src/keychain.js +94 -0
  67. package/dist/src/keychain.js.map +1 -0
  68. package/dist/src/playwrightUtils.d.ts +27 -0
  69. package/dist/src/playwrightUtils.d.ts.map +1 -0
  70. package/dist/src/playwrightUtils.js +122 -0
  71. package/dist/src/playwrightUtils.js.map +1 -0
  72. package/dist/src/registry.d.ts +12 -0
  73. package/dist/src/registry.d.ts.map +1 -0
  74. package/dist/src/registry.js +30 -0
  75. package/dist/src/registry.js.map +1 -0
  76. package/dist/src/services/base.d.ts +98 -0
  77. package/dist/src/services/base.d.ts.map +1 -0
  78. package/dist/src/services/base.js +137 -0
  79. package/dist/src/services/base.js.map +1 -0
  80. package/dist/src/services/discord.d.ts +20 -0
  81. package/dist/src/services/discord.d.ts.map +1 -0
  82. package/dist/src/services/discord.js +55 -0
  83. package/dist/src/services/discord.js.map +1 -0
  84. package/dist/src/services/dropbox.d.ts +23 -0
  85. package/dist/src/services/dropbox.d.ts.map +1 -0
  86. package/dist/src/services/dropbox.js +136 -0
  87. package/dist/src/services/dropbox.js.map +1 -0
  88. package/dist/src/services/github.d.ts +23 -0
  89. package/dist/src/services/github.d.ts.map +1 -0
  90. package/dist/src/services/github.js +110 -0
  91. package/dist/src/services/github.js.map +1 -0
  92. package/dist/src/services/index.d.ts +12 -0
  93. package/dist/src/services/index.d.ts.map +1 -0
  94. package/dist/src/services/index.js +11 -0
  95. package/dist/src/services/index.js.map +1 -0
  96. package/dist/src/services/linear.d.ts +23 -0
  97. package/dist/src/services/linear.d.ts.map +1 -0
  98. package/dist/src/services/linear.js +110 -0
  99. package/dist/src/services/linear.js.map +1 -0
  100. package/dist/src/services/slack.d.ts +21 -0
  101. package/dist/src/services/slack.d.ts.map +1 -0
  102. package/dist/src/services/slack.js +67 -0
  103. package/dist/src/services/slack.js.map +1 -0
  104. package/dist/tests/apiCredentialStore.test.d.ts +2 -0
  105. package/dist/tests/apiCredentialStore.test.d.ts.map +1 -0
  106. package/dist/tests/apiCredentialStore.test.js +130 -0
  107. package/dist/tests/apiCredentialStore.test.js.map +1 -0
  108. package/dist/tests/apiCredentials.test.d.ts +2 -0
  109. package/dist/tests/apiCredentials.test.d.ts.map +1 -0
  110. package/dist/tests/apiCredentials.test.js +169 -0
  111. package/dist/tests/apiCredentials.test.js.map +1 -0
  112. package/dist/tests/cli.test.d.ts +2 -0
  113. package/dist/tests/cli.test.d.ts.map +1 -0
  114. package/dist/tests/cli.test.js +584 -0
  115. package/dist/tests/cli.test.js.map +1 -0
  116. package/dist/tests/encryptedStorage.test.d.ts +2 -0
  117. package/dist/tests/encryptedStorage.test.d.ts.map +1 -0
  118. package/dist/tests/encryptedStorage.test.js +126 -0
  119. package/dist/tests/encryptedStorage.test.js.map +1 -0
  120. package/dist/tests/encryption.test.d.ts +2 -0
  121. package/dist/tests/encryption.test.d.ts.map +1 -0
  122. package/dist/tests/encryption.test.js +121 -0
  123. package/dist/tests/encryption.test.js.map +1 -0
  124. package/dist/tests/lint.test.d.ts +2 -0
  125. package/dist/tests/lint.test.d.ts.map +1 -0
  126. package/dist/tests/lint.test.js +18 -0
  127. package/dist/tests/lint.test.js.map +1 -0
  128. package/dist/tests/registry.test.d.ts +2 -0
  129. package/dist/tests/registry.test.d.ts.map +1 -0
  130. package/dist/tests/registry.test.js +85 -0
  131. package/dist/tests/registry.test.js.map +1 -0
  132. package/dist/tests/servicesAgainstRecordings.test.d.ts +20 -0
  133. package/dist/tests/servicesAgainstRecordings.test.d.ts.map +1 -0
  134. package/dist/tests/servicesAgainstRecordings.test.js +157 -0
  135. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -0
  136. package/dist/tests/typecheck.test.d.ts +2 -0
  137. package/dist/tests/typecheck.test.d.ts.map +1 -0
  138. package/dist/tests/typecheck.test.js +18 -0
  139. package/dist/tests/typecheck.test.js.map +1 -0
  140. package/docs/development.md +94 -0
  141. package/eslint.config.js +30 -0
  142. package/integrations/SKILL.md +62 -0
  143. package/package.json +68 -0
  144. package/scripts/cryptFile.ts +123 -0
  145. package/scripts/recordBrowserSession.ts +280 -0
  146. package/scripts/tsconfig.json +10 -0
  147. package/src/apiCredentialStore.ts +87 -0
  148. package/src/apiCredentials.ts +180 -0
  149. package/src/cli.ts +32 -0
  150. package/src/cliCommands.ts +321 -0
  151. package/src/config.ts +115 -0
  152. package/src/curl.ts +78 -0
  153. package/src/encryptedStorage.ts +161 -0
  154. package/src/encryption.ts +106 -0
  155. package/src/index.ts +65 -0
  156. package/src/keychain.ts +105 -0
  157. package/src/playwrightUtils.ts +143 -0
  158. package/src/registry.ts +35 -0
  159. package/src/services/base.ts +234 -0
  160. package/src/services/discord.ts +73 -0
  161. package/src/services/dropbox.ts +173 -0
  162. package/src/services/github.ts +139 -0
  163. package/src/services/index.ts +13 -0
  164. package/src/services/linear.ts +134 -0
  165. package/src/services/slack.ts +85 -0
  166. package/tests/apiCredentialStore.test.ts +162 -0
  167. package/tests/apiCredentials.test.ts +195 -0
  168. package/tests/cli.test.ts +798 -0
  169. package/tests/encryptedStorage.test.ts +173 -0
  170. package/tests/encryption.test.ts +169 -0
  171. package/tests/lint.test.ts +19 -0
  172. package/tests/registry.test.ts +103 -0
  173. package/tests/servicesAgainstRecordings.test.ts +230 -0
  174. package/tests/typecheck.test.ts +19 -0
  175. package/tsconfig.json +24 -0
  176. package/vitest.config.ts +13 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Playwright utility functions for browser automation.
3
+ */
4
+
5
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import type { Browser, BrowserContext, Page, Locator } from 'playwright';
9
+ import { EncryptedStorage } from './encryptedStorage.js';
10
+
11
+ export interface BrowserWithContext {
12
+ readonly browser: Browser;
13
+ readonly context: BrowserContext;
14
+ }
15
+
16
+ /**
17
+ * Run a callback with a browser context initialized from encrypted storage state.
18
+ * After the callback completes, persists browser state back to encrypted storage.
19
+ */
20
+ export async function withTempBrowserContext<T>(
21
+ encryptedStorage: EncryptedStorage,
22
+ browserStatePath: string,
23
+ callback: (state: BrowserWithContext) => Promise<T>
24
+ ): Promise<T> {
25
+ const tempDir = mkdtempSync(join(tmpdir(), 'latchkey-browser-state-'));
26
+ const tempFilePath = join(tempDir, 'browser_state.json');
27
+
28
+ let initialStorageState: string | undefined;
29
+ if (existsSync(browserStatePath)) {
30
+ const content = encryptedStorage.readFile(browserStatePath);
31
+ if (content !== null) {
32
+ writeFileSync(tempFilePath, content, { encoding: 'utf-8', mode: 0o600 });
33
+ initialStorageState = tempFilePath;
34
+ }
35
+ }
36
+
37
+ const { chromium: chromiumBrowser } = await import('playwright');
38
+ const browser = await chromiumBrowser.launch({ headless: false });
39
+
40
+ try {
41
+ const contextOptions: { storageState?: string } = {};
42
+ if (initialStorageState !== undefined) {
43
+ contextOptions.storageState = initialStorageState;
44
+ }
45
+ const context = await browser.newContext(contextOptions);
46
+
47
+ const result = await callback({ browser, context });
48
+
49
+ // Persist browser state back to encrypted storage
50
+ await context.storageState({ path: tempFilePath });
51
+ const content = readFileSync(tempFilePath, 'utf-8');
52
+ encryptedStorage.writeFile(browserStatePath, content);
53
+
54
+ return result;
55
+ } finally {
56
+ await browser.close();
57
+ try {
58
+ rmSync(tempDir, { recursive: true, force: true });
59
+ } catch {
60
+ // Ignore cleanup errors
61
+ }
62
+ }
63
+ }
64
+
65
+ // Typing delay range in milliseconds (min, max) to simulate human-like typing
66
+ const TYPING_DELAY_MIN_MS = 30;
67
+ const TYPING_DELAY_MAX_MS = 100;
68
+
69
+ /**
70
+ * Type text character by character with random delays to simulate human typing.
71
+ *
72
+ * This triggers proper JavaScript input events that some websites require,
73
+ * unlike fill() which sets the value directly.
74
+ */
75
+ export async function typeLikeHuman(page: Page, locator: Locator, text: string): Promise<void> {
76
+ await locator.click();
77
+ for (const character of text) {
78
+ await locator.pressSequentially(character);
79
+ const delay =
80
+ Math.floor(Math.random() * (TYPING_DELAY_MAX_MS - TYPING_DELAY_MIN_MS + 1)) +
81
+ TYPING_DELAY_MIN_MS;
82
+ await page.waitForTimeout(delay);
83
+ }
84
+ }
85
+
86
+ // Script that creates the spinner overlay, designed to run in browser context
87
+ function createSpinnerOverlayScript(serviceName: string): string {
88
+ return `
89
+ (() => {
90
+ if (document.getElementById('latchkey-spinner-overlay')) return;
91
+ const overlay = document.createElement('div');
92
+ overlay.id = 'latchkey-spinner-overlay';
93
+ overlay.innerHTML = \`
94
+ <style>
95
+ #latchkey-spinner-overlay {
96
+ position: fixed;
97
+ top: 0;
98
+ left: 0;
99
+ width: 100%;
100
+ height: 100%;
101
+ background: #f5f5f5;
102
+ display: flex;
103
+ flex-direction: column;
104
+ justify-content: center;
105
+ align-items: center;
106
+ z-index: 2147483647;
107
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
108
+ pointer-events: none;
109
+ }
110
+ #latchkey-spinner-overlay .spinner {
111
+ width: 50px;
112
+ height: 50px;
113
+ border: 4px solid #e0e0e0;
114
+ border-top-color: #007bff;
115
+ border-radius: 50%;
116
+ animation: latchkey-spin 1s linear infinite;
117
+ }
118
+ #latchkey-spinner-overlay .message {
119
+ margin-top: 20px;
120
+ color: #555;
121
+ font-size: 16px;
122
+ }
123
+ @keyframes latchkey-spin {
124
+ to { transform: rotate(360deg); }
125
+ }
126
+ </style>
127
+ <div class="spinner"></div>
128
+ <div class="message">Finalizing ${serviceName} login...</div>
129
+ \`;
130
+ document.body.appendChild(overlay);
131
+ })()
132
+ `;
133
+ }
134
+
135
+ /**
136
+ * Show a spinner overlay that hides page content from the user.
137
+ * The overlay persists across page navigations within the browser context.
138
+ */
139
+ export async function showSpinnerPage(context: BrowserContext, serviceName: string): Promise<void> {
140
+ const spinnerPage = await context.newPage();
141
+ await spinnerPage.evaluate(createSpinnerOverlayScript(serviceName));
142
+ await spinnerPage.bringToFront();
143
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Service registry for looking up services by name or URL.
3
+ */
4
+
5
+ import { Service, SLACK, DISCORD, DROPBOX, GITHUB, LINEAR } from './services/index.js';
6
+
7
+ export class Registry {
8
+ readonly services: readonly Service[];
9
+
10
+ constructor(services: readonly Service[]) {
11
+ this.services = services;
12
+ }
13
+
14
+ getByName(name: string): Service | null {
15
+ for (const service of this.services) {
16
+ if (service.name === name) {
17
+ return service;
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+
23
+ getByUrl(url: string): Service | null {
24
+ for (const service of this.services) {
25
+ for (const baseApiUrl of service.baseApiUrls) {
26
+ if (url.startsWith(baseApiUrl)) {
27
+ return service;
28
+ }
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export const REGISTRY = new Registry([SLACK, DISCORD, DROPBOX, GITHUB, LINEAR]);
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Base classes and interfaces for service implementations.
3
+ */
4
+
5
+ import type { Browser, BrowserContext, Page, Response } from 'playwright';
6
+ import { ApiCredentialStatus, ApiCredentials } from '../apiCredentials.js';
7
+ import { EncryptedStorage } from '../encryptedStorage.js';
8
+ import { showSpinnerPage, withTempBrowserContext } from '../playwrightUtils.js';
9
+
10
+ export class LoginCancelledError extends Error {
11
+ constructor(message = 'Login was cancelled because the browser was closed.') {
12
+ super(message);
13
+ this.name = 'LoginCancelledError';
14
+ }
15
+ }
16
+
17
+ export class LoginFailedError extends Error {
18
+ constructor(message = 'Login failed: no credentials were extracted.') {
19
+ super(message);
20
+ this.name = 'LoginFailedError';
21
+ }
22
+ }
23
+
24
+ function isBrowserClosedError(error: Error): boolean {
25
+ const message = error.message.toLowerCase();
26
+ return (
27
+ message.includes('target closed') ||
28
+ message.includes('browser closed') ||
29
+ message.includes('browser has been closed') ||
30
+ message.includes('context has been closed') ||
31
+ message.includes('page has been closed')
32
+ );
33
+ }
34
+
35
+ function isTimeoutError(error: Error): boolean {
36
+ return error.name === 'TimeoutError';
37
+ }
38
+
39
+ /**
40
+ * Base interface for a service that latchkey can authenticate with.
41
+ */
42
+ export interface Service {
43
+ readonly name: string;
44
+ readonly baseApiUrls: readonly string[];
45
+ readonly loginUrl: string;
46
+
47
+ /**
48
+ * Check if the given API credentials are valid for this service.
49
+ */
50
+ checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus;
51
+
52
+ /**
53
+ * Return curl arguments for checking credentials (excluding auth headers).
54
+ */
55
+ readonly credentialCheckCurlArguments: readonly string[];
56
+
57
+ /**
58
+ * Get a new session for the login flow.
59
+ */
60
+ getSession(): ServiceSession;
61
+ }
62
+
63
+ /**
64
+ * Base class for service sessions that handle the login flow.
65
+ */
66
+ export abstract class ServiceSession {
67
+ readonly service: Service;
68
+
69
+ constructor(service: Service) {
70
+ this.service = service;
71
+ }
72
+
73
+ /**
74
+ * Handle a response during the headful login phase.
75
+ */
76
+ abstract onResponse(response: Response): void;
77
+
78
+ /**
79
+ * Check if the login phase is complete.
80
+ */
81
+ protected abstract isLoginComplete(): boolean;
82
+
83
+ /**
84
+ * Finalize credentials after the headful login phase.
85
+ * Receives the browser and context from the login phase, which are still open.
86
+ */
87
+ protected abstract finalizeCredentials(
88
+ browser: Browser,
89
+ context: BrowserContext
90
+ ): Promise<ApiCredentials | null>;
91
+
92
+ /**
93
+ * Wait until the browser login phase is complete.
94
+ */
95
+ private async waitForLoginComplete(page: Page): Promise<void> {
96
+ while (!this.isLoginComplete()) {
97
+ await page.waitForTimeout(100);
98
+ }
99
+ }
100
+
101
+
102
+ /**
103
+ * Optionally diagnose a timeout error that occurred during credential finalization.
104
+ *
105
+ * Services can override this to inspect the page state and return a more
106
+ * specific error (e.g., checking for permission denied messages).
107
+ * If this returns an error, it will be thrown instead of the generic
108
+ * LoginFailedError. If it returns null, the original timeout error message is used.
109
+ */
110
+ protected diagnoseTimeoutError(
111
+ _context: BrowserContext,
112
+ _originalError: Error
113
+ ): Promise<Error | null> {
114
+ return Promise.resolve(null);
115
+ }
116
+
117
+ /**
118
+ * Perform the login flow and return the extracted credentials.
119
+ */
120
+ async login(
121
+ encryptedStorage: EncryptedStorage,
122
+ browserStatePath: string
123
+ ): Promise<ApiCredentials> {
124
+ return withTempBrowserContext(
125
+ encryptedStorage,
126
+ browserStatePath,
127
+ async ({ browser, context }) => {
128
+ const page = await context.newPage();
129
+
130
+ page.on('response', (response) => {
131
+ this.onResponse(response);
132
+ });
133
+
134
+ try {
135
+ await page.goto(this.service.loginUrl);
136
+ await this.waitForLoginComplete(page);
137
+ } catch (error: unknown) {
138
+ if (error instanceof Error && isBrowserClosedError(error)) {
139
+ throw new LoginCancelledError();
140
+ }
141
+ throw error;
142
+ }
143
+
144
+ let apiCredentials: ApiCredentials | null;
145
+ try {
146
+ apiCredentials = await this.finalizeCredentials(browser, context);
147
+ } catch (error: unknown) {
148
+ if (error instanceof Error && isBrowserClosedError(error)) {
149
+ throw new LoginCancelledError();
150
+ }
151
+ if (error instanceof Error && isTimeoutError(error)) {
152
+ const diagnosedError = await this.diagnoseTimeoutError(context, error);
153
+ if (diagnosedError !== null) {
154
+ throw diagnosedError;
155
+ }
156
+ throw new LoginFailedError(`Login failed: ${error.message}`);
157
+ }
158
+ throw error;
159
+ }
160
+
161
+ if (apiCredentials === null) {
162
+ throw new LoginFailedError();
163
+ }
164
+
165
+ return apiCredentials;
166
+ }
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Simple service session where credentials are extracted by observing requests during login.
173
+ */
174
+ export abstract class SimpleServiceSession extends ServiceSession {
175
+ protected apiCredentials: ApiCredentials | null = null;
176
+
177
+ /**
178
+ * Extract API credentials from a response during the headful login phase.
179
+ */
180
+ protected abstract getApiCredentialsFromResponse(
181
+ response: Response
182
+ ): Promise<ApiCredentials | null>;
183
+
184
+ onResponse(response: Response): void {
185
+ if (this.apiCredentials !== null) {
186
+ return;
187
+ }
188
+ this.getApiCredentialsFromResponse(response)
189
+ .then((credentials) => {
190
+ if (this.apiCredentials === null && credentials !== null) {
191
+ this.apiCredentials = credentials;
192
+ }
193
+ })
194
+ .catch(() => {
195
+ // Ignore errors extracting credentials
196
+ });
197
+ }
198
+
199
+ protected isLoginComplete(): boolean {
200
+ return this.apiCredentials !== null;
201
+ }
202
+
203
+ protected finalizeCredentials(
204
+ _browser: Browser,
205
+ _context: BrowserContext
206
+ ): Promise<ApiCredentials | null> {
207
+ return Promise.resolve(this.apiCredentials);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Service session that requires a browser followup to finalize credentials.
213
+ *
214
+ * The login phase captures login state. After login completes,
215
+ * the same browser session is reused to perform additional actions
216
+ * (e.g., navigating to settings and creating an API key).
217
+ */
218
+ export abstract class BrowserFollowupServiceSession extends ServiceSession {
219
+ /**
220
+ * Perform actions in the browser to finalize and extract API credentials.
221
+ * This runs in the same browser session used for login.
222
+ */
223
+ protected abstract performBrowserFollowup(
224
+ context: BrowserContext
225
+ ): Promise<ApiCredentials | null>;
226
+
227
+ protected override async finalizeCredentials(
228
+ _browser: Browser,
229
+ context: BrowserContext
230
+ ): Promise<ApiCredentials | null> {
231
+ await showSpinnerPage(context, this.service.name);
232
+ return this.performBrowserFollowup(context);
233
+ }
234
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Discord service implementation.
3
+ */
4
+
5
+ import type { Response } from 'playwright';
6
+ import { ApiCredentialStatus, ApiCredentials, AuthorizationBare } from '../apiCredentials.js';
7
+ import { runCaptured } from '../curl.js';
8
+ import { Service, SimpleServiceSession } from './base.js';
9
+
10
+ class DiscordServiceSession extends SimpleServiceSession {
11
+ protected async getApiCredentialsFromResponse(
12
+ response: Response
13
+ ): Promise<ApiCredentials | null> {
14
+ const request = response.request();
15
+ const url = request.url();
16
+
17
+ if (!url.startsWith('https://discord.com/api/')) {
18
+ return null;
19
+ }
20
+
21
+ // Require 2XX response to ensure the session is valid (not expired)
22
+ const status = response.status();
23
+ if (status < 200 || status >= 300) {
24
+ return null;
25
+ }
26
+
27
+ const headers = await request.allHeaders();
28
+ const authorization = headers.authorization;
29
+ if (authorization !== undefined && authorization.trim() !== '') {
30
+ return new AuthorizationBare(authorization);
31
+ }
32
+
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export class Discord implements Service {
38
+ readonly name = 'discord';
39
+ readonly baseApiUrls = ['https://discord.com/api/'] as const;
40
+ readonly loginUrl = 'https://discord.com/login';
41
+
42
+ readonly credentialCheckCurlArguments = ['https://discord.com/api/v9/users/@me'] as const;
43
+
44
+ getSession(): DiscordServiceSession {
45
+ return new DiscordServiceSession(this);
46
+ }
47
+
48
+ checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus {
49
+ if (!(apiCredentials instanceof AuthorizationBare)) {
50
+ return ApiCredentialStatus.Invalid;
51
+ }
52
+
53
+ const result = runCaptured(
54
+ [
55
+ '-s',
56
+ '-o',
57
+ '/dev/null',
58
+ '-w',
59
+ '%{http_code}',
60
+ ...apiCredentials.asCurlArguments(),
61
+ ...this.credentialCheckCurlArguments,
62
+ ],
63
+ 10
64
+ );
65
+
66
+ if (result.stdout === '200') {
67
+ return ApiCredentialStatus.Valid;
68
+ }
69
+ return ApiCredentialStatus.Invalid;
70
+ }
71
+ }
72
+
73
+ export const DISCORD = new Discord();
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Dropbox service implementation.
3
+ */
4
+
5
+ import { randomUUID } from 'node:crypto';
6
+ import type { Response, BrowserContext } from 'playwright';
7
+ import { ApiCredentialStatus, ApiCredentials, AuthorizationBearer } from '../apiCredentials.js';
8
+ import { runCaptured } from '../curl.js';
9
+ import { typeLikeHuman } from '../playwrightUtils.js';
10
+ import { Service, BrowserFollowupServiceSession, LoginFailedError } from './base.js';
11
+
12
+ const DEFAULT_TIMEOUT_MS = 8000;
13
+
14
+ class DropboxServiceSession extends BrowserFollowupServiceSession {
15
+ private isLoggedIn = false;
16
+
17
+ onResponse(response: Response): void {
18
+ if (this.isLoggedIn) {
19
+ return;
20
+ }
21
+
22
+ const request = response.request();
23
+ const url = request.url();
24
+ if (!url.startsWith('https://www.dropbox.com/')) {
25
+ return;
26
+ }
27
+
28
+ // Require 2XX response to ensure the session is valid (not expired)
29
+ const status = response.status();
30
+ if (status < 200 || status >= 300) {
31
+ return;
32
+ }
33
+
34
+ const headers = request.headers();
35
+ const uidHeader = headers['x-dropbox-uid'];
36
+ if (uidHeader === undefined || uidHeader === '-1') {
37
+ return;
38
+ }
39
+
40
+ this.isLoggedIn = true;
41
+ }
42
+
43
+ protected isLoginComplete(): boolean {
44
+ return this.isLoggedIn;
45
+ }
46
+
47
+ protected async performBrowserFollowup(context: BrowserContext): Promise<ApiCredentials | null> {
48
+ const page = context.pages()[0];
49
+ if (!page) {
50
+ throw new LoginFailedError('No page available in browser context.');
51
+ }
52
+
53
+ await page.goto('https://www.dropbox.com/developers/apps/create');
54
+
55
+ const scopedInput = page.locator('input#scoped');
56
+ await scopedInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
57
+ await scopedInput.click();
58
+
59
+ const fullPermissionsInput = page.locator('input#full_permissions');
60
+ await fullPermissionsInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
61
+ await fullPermissionsInput.click();
62
+
63
+ const appName = `Latchkey-${randomUUID().slice(0, 8)}`;
64
+ const appNameInput = page.locator('input#app-name');
65
+ await appNameInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
66
+ await typeLikeHuman(page, appNameInput, appName);
67
+
68
+ const createButton = page.getByRole('button', { name: 'Create app' });
69
+ await createButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
70
+ await createButton.click();
71
+
72
+ await page.waitForURL(/https:\/\/www\.dropbox\.com\/developers\/apps\/info\//, {
73
+ timeout: DEFAULT_TIMEOUT_MS,
74
+ });
75
+
76
+ // Configure permissions before generating token
77
+ const permissionsTab = page.locator('a.c-tabs__label[data-hash="permissions"]');
78
+ await permissionsTab.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
79
+ await permissionsTab.click();
80
+
81
+ // Enable all necessary permissions
82
+ const permissionIds = [
83
+ 'files.metadata.write',
84
+ 'files.content.write',
85
+ 'files.content.read',
86
+ 'sharing.write',
87
+ 'file_requests.write',
88
+ 'contacts.write',
89
+ ];
90
+
91
+ for (const permissionId of permissionIds) {
92
+ const escapedPermissionId = permissionId.replace(/\./g, '\\.');
93
+ const checkbox = page.locator(`input#${escapedPermissionId}`);
94
+ await checkbox.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
95
+ await checkbox.click();
96
+ }
97
+
98
+ // Submit permissions
99
+ const submitButton = page.locator('button.permissions-submit-button');
100
+ await submitButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
101
+ await submitButton.click();
102
+
103
+ // Wait for permissions to be saved
104
+ await page.waitForTimeout(512);
105
+
106
+ // Return to Settings tab to generate token
107
+ const settingsTab = page.locator('a.c-tabs__label[data-hash="settings"]');
108
+ await settingsTab.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
109
+ await settingsTab.click();
110
+
111
+ const generateButton = page.locator('input#generate-token-button');
112
+ await generateButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
113
+ await generateButton.click();
114
+
115
+ const tokenInput = page.locator('input#generated-token[data-token]');
116
+ await tokenInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
117
+
118
+ const token = await tokenInput.getAttribute('data-token');
119
+ if (token === null || token === '') {
120
+ throw new LoginFailedError('Failed to extract token from Dropbox.');
121
+ }
122
+
123
+ await page.close();
124
+
125
+ return new AuthorizationBearer(token);
126
+ }
127
+ }
128
+
129
+ export class Dropbox implements Service {
130
+ readonly name = 'dropbox';
131
+ readonly baseApiUrls = [
132
+ 'https://api.dropboxapi.com/',
133
+ 'https://content.dropboxapi.com/',
134
+ 'https://notify.dropboxapi.com/',
135
+ ] as const;
136
+ readonly loginUrl = 'https://www.dropbox.com/login';
137
+
138
+ readonly credentialCheckCurlArguments = [
139
+ '-X',
140
+ 'POST',
141
+ 'https://api.dropboxapi.com/2/users/get_current_account',
142
+ ] as const;
143
+
144
+ getSession(): DropboxServiceSession {
145
+ return new DropboxServiceSession(this);
146
+ }
147
+
148
+ checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus {
149
+ if (!(apiCredentials instanceof AuthorizationBearer)) {
150
+ return ApiCredentialStatus.Invalid;
151
+ }
152
+
153
+ const result = runCaptured(
154
+ [
155
+ '-s',
156
+ '-o',
157
+ '/dev/null',
158
+ '-w',
159
+ '%{http_code}',
160
+ ...apiCredentials.asCurlArguments(),
161
+ ...this.credentialCheckCurlArguments,
162
+ ],
163
+ 10
164
+ );
165
+
166
+ if (result.stdout === '200') {
167
+ return ApiCredentialStatus.Valid;
168
+ }
169
+ return ApiCredentialStatus.Invalid;
170
+ }
171
+ }
172
+
173
+ export const DROPBOX = new Dropbox();