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,139 @@
1
+ /**
2
+ * GitHub 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
+ // URL for creating a new personal access token (also used as login URL to trigger sudo)
15
+ const GITHUB_NEW_TOKEN_URL = 'https://github.com/settings/tokens/new';
16
+
17
+ // GitHub personal access token scopes to enable
18
+ const GITHUB_TOKEN_SCOPES = [
19
+ 'repo',
20
+ 'workflow',
21
+ 'write:packages',
22
+ 'delete:packages',
23
+ 'gist',
24
+ 'notifications',
25
+ 'admin:org',
26
+ 'admin:repo_hook',
27
+ 'admin:org_hook',
28
+ 'user',
29
+ 'delete_repo',
30
+ 'write:discussion',
31
+ 'admin:enterprise',
32
+ 'read:audit_log',
33
+ 'codespace',
34
+ 'copilot',
35
+ 'write:network_configurations',
36
+ 'project',
37
+ ] as const;
38
+
39
+ class GithubServiceSession extends BrowserFollowupServiceSession {
40
+ private isLoggedIn = false;
41
+
42
+ onResponse(response: Response): void {
43
+ if (this.isLoggedIn) {
44
+ return;
45
+ }
46
+
47
+ const request = response.request();
48
+ // Detect login (and github's sudo) by seeing if github allows us to access the new token page.
49
+ if (request.url() === GITHUB_NEW_TOKEN_URL) {
50
+ if (response.status() === 200) {
51
+ this.isLoggedIn = true;
52
+ }
53
+ }
54
+ }
55
+
56
+ protected isLoginComplete(): boolean {
57
+ return this.isLoggedIn;
58
+ }
59
+
60
+ protected async performBrowserFollowup(context: BrowserContext): Promise<ApiCredentials | null> {
61
+ const page = context.pages()[0];
62
+ if (!page) {
63
+ throw new LoginFailedError('No page available in browser context.');
64
+ }
65
+
66
+ await page.goto(GITHUB_NEW_TOKEN_URL);
67
+
68
+ // Add a note for the token
69
+ const noteInput = page.locator('//*[@id="oauth_access_description"]');
70
+ await noteInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
71
+ await typeLikeHuman(page, noteInput, `Latchkey-${randomUUID().slice(0, 8)}`);
72
+
73
+ // Enable all necessary scopes
74
+ for (const scope of GITHUB_TOKEN_SCOPES) {
75
+ const checkbox = page.locator(`input[name="oauth_access[scopes][]"][value="${scope}"]`);
76
+ if (await checkbox.isVisible()) {
77
+ await checkbox.check();
78
+ }
79
+ }
80
+
81
+ // Click the Generate Token button
82
+ const generateButton = page.locator(
83
+ 'button[type="submit"].btn-primary:has-text("Generate token")'
84
+ );
85
+ await generateButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
86
+ await generateButton.click();
87
+
88
+ // Wait for the page to load and retrieve the generated token
89
+ const tokenElement = page.locator('//*[@id="new-oauth-token"]');
90
+ await tokenElement.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
91
+
92
+ const token = await tokenElement.textContent();
93
+ if (token === null || token === '') {
94
+ throw new LoginFailedError('Failed to extract token from GitHub.');
95
+ }
96
+
97
+ await page.close();
98
+
99
+ return new AuthorizationBearer(token);
100
+ }
101
+ }
102
+
103
+ export class Github implements Service {
104
+ readonly name = 'github';
105
+ readonly baseApiUrls = ['https://api.github.com/'] as const;
106
+ readonly loginUrl = GITHUB_NEW_TOKEN_URL;
107
+
108
+ readonly credentialCheckCurlArguments = ['https://api.github.com/user'] as const;
109
+
110
+ getSession(): GithubServiceSession {
111
+ return new GithubServiceSession(this);
112
+ }
113
+
114
+ checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus {
115
+ if (!(apiCredentials instanceof AuthorizationBearer)) {
116
+ return ApiCredentialStatus.Invalid;
117
+ }
118
+
119
+ const result = runCaptured(
120
+ [
121
+ '-s',
122
+ '-o',
123
+ '/dev/null',
124
+ '-w',
125
+ '%{http_code}',
126
+ ...apiCredentials.asCurlArguments(),
127
+ ...this.credentialCheckCurlArguments,
128
+ ],
129
+ 10
130
+ );
131
+
132
+ if (result.stdout === '200') {
133
+ return ApiCredentialStatus.Valid;
134
+ }
135
+ return ApiCredentialStatus.Invalid;
136
+ }
137
+ }
138
+
139
+ export const GITHUB = new Github();
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Re-export all services.
3
+ */
4
+
5
+ export type { Service } from './base.js';
6
+ export { ServiceSession, SimpleServiceSession, BrowserFollowupServiceSession } from './base.js';
7
+ export { LoginCancelledError, LoginFailedError } from './base.js';
8
+
9
+ export { Slack, SLACK } from './slack.js';
10
+ export { Discord, DISCORD } from './discord.js';
11
+ export { Github, GITHUB } from './github.js';
12
+ export { Dropbox, DROPBOX } from './dropbox.js';
13
+ export { Linear, LINEAR } from './linear.js';
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Linear service implementation.
3
+ */
4
+
5
+ import { randomUUID } from 'node:crypto';
6
+ import type { Response, BrowserContext } from 'playwright';
7
+ import { ApiCredentialStatus, ApiCredentials, AuthorizationBare } 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
+ // URL for creating a new personal API key (also used as login URL)
15
+ const LINEAR_NEW_API_KEY_URL = 'https://linear.app/imbue/settings/account/security/api-keys/new';
16
+
17
+ class LinearServiceSession extends BrowserFollowupServiceSession {
18
+ private isLoggedIn = false;
19
+
20
+ onResponse(response: Response): void {
21
+ if (this.isLoggedIn) {
22
+ return;
23
+ }
24
+
25
+ const request = response.request();
26
+ // Empirically, Linear always sends this request. When not logged in,
27
+ // the response only contains "data.organizationMeta". Otherwise it can
28
+ // contain different things based on how exactly the user authenticated.
29
+ if (request.url() === 'https://client-api.linear.app/graphql' && request.method() === 'POST') {
30
+ if (response.status() === 200) {
31
+ try {
32
+ // Note: response.json() returns a Promise in Playwright
33
+ response
34
+ .json()
35
+ .then((jsonData: unknown) => {
36
+ const data = (jsonData as { data?: Record<string, unknown> }).data ?? {};
37
+ if (Object.keys(data).some((key) => key !== 'organizationMeta')) {
38
+ this.isLoggedIn = true;
39
+ }
40
+ })
41
+ .catch(() => {
42
+ // Ignore JSON parse errors
43
+ });
44
+ } catch {
45
+ // Ignore errors
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ protected isLoginComplete(): boolean {
52
+ return this.isLoggedIn;
53
+ }
54
+
55
+ protected async performBrowserFollowup(context: BrowserContext): Promise<ApiCredentials | null> {
56
+ const page = context.pages()[0];
57
+ if (!page) {
58
+ throw new LoginFailedError('No page available in browser context.');
59
+ }
60
+
61
+ await page.goto(LINEAR_NEW_API_KEY_URL);
62
+
63
+ // Fill in the key name
64
+ const keyName = `Latchkey-${randomUUID().slice(0, 8)}`;
65
+ const keyNameInput = page.getByRole('textbox', { name: 'Key name' });
66
+ await keyNameInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
67
+ await typeLikeHuman(page, keyNameInput, keyName);
68
+
69
+ // Click the Create button
70
+ const createButton = page.getByRole('button', { name: 'Create' });
71
+ await createButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
72
+ await createButton.click();
73
+
74
+ // Wait for and extract the token from span element containing lin_api_ prefix
75
+ const tokenElement = page.locator("span:text-matches('^lin_api_')");
76
+ await tokenElement.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
77
+
78
+ const token = await tokenElement.textContent();
79
+ if (token === null || token === '') {
80
+ throw new LoginFailedError('Failed to extract token from Linear.');
81
+ }
82
+
83
+ await page.close();
84
+
85
+ return new AuthorizationBare(token);
86
+ }
87
+ }
88
+
89
+ export class Linear implements Service {
90
+ readonly name = 'linear';
91
+ readonly baseApiUrls = ['https://api.linear.app/'] as const;
92
+ readonly loginUrl = LINEAR_NEW_API_KEY_URL;
93
+
94
+ readonly credentialCheckCurlArguments = [
95
+ '-X',
96
+ 'POST',
97
+ '-H',
98
+ 'Content-Type: application/json',
99
+ '-d',
100
+ '{"query": "{ viewer { id } }"}',
101
+ 'https://api.linear.app/graphql',
102
+ ] as const;
103
+
104
+ getSession(): LinearServiceSession {
105
+ return new LinearServiceSession(this);
106
+ }
107
+
108
+ checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus {
109
+ if (!(apiCredentials instanceof AuthorizationBare)) {
110
+ return ApiCredentialStatus.Invalid;
111
+ }
112
+
113
+ // Linear uses GraphQL API - check credentials with a simple query
114
+ const result = runCaptured(
115
+ [
116
+ '-s',
117
+ '-o',
118
+ '/dev/null',
119
+ '-w',
120
+ '%{http_code}',
121
+ ...apiCredentials.asCurlArguments(),
122
+ ...this.credentialCheckCurlArguments,
123
+ ],
124
+ 10
125
+ );
126
+
127
+ if (result.stdout === '200') {
128
+ return ApiCredentialStatus.Valid;
129
+ }
130
+ return ApiCredentialStatus.Invalid;
131
+ }
132
+ }
133
+
134
+ export const LINEAR = new Linear();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Slack service implementation.
3
+ */
4
+
5
+ import type { Response } from 'playwright';
6
+ import { ApiCredentialStatus, ApiCredentials, SlackApiCredentials } from '../apiCredentials.js';
7
+ import { runCaptured } from '../curl.js';
8
+ import { Service, SimpleServiceSession } from './base.js';
9
+
10
+ class SlackServiceSession extends SimpleServiceSession {
11
+ private pendingDCookie: string | null = null;
12
+
13
+ protected async getApiCredentialsFromResponse(
14
+ response: Response
15
+ ): Promise<ApiCredentials | null> {
16
+ const request = response.request();
17
+ const url = request.url();
18
+
19
+ // Check if the domain is under slack.com
20
+ if (!/^https:\/\/([a-z0-9-]+\.)?slack\.com\//.test(url)) {
21
+ return null;
22
+ }
23
+
24
+ const headers = await request.allHeaders();
25
+ const cookieHeader = headers.cookie;
26
+ if (cookieHeader === undefined) {
27
+ return null;
28
+ }
29
+
30
+ const cookieMatch = /\bd=([^;]+)/.exec(cookieHeader);
31
+ if (!cookieMatch?.[1]) {
32
+ return null;
33
+ }
34
+ const dCookie = cookieMatch[1];
35
+ this.pendingDCookie = dCookie;
36
+
37
+ // Extract token from response body (JSON embedded in HTML or raw JSON)
38
+ try {
39
+ const responseBody = await response.text();
40
+ const tokenMatch = /"api_token":"(xoxc-[a-zA-Z0-9-]+)"/.exec(responseBody);
41
+ if (tokenMatch?.[1]) {
42
+ return new SlackApiCredentials(tokenMatch[1], dCookie);
43
+ }
44
+ } catch {
45
+ // Ignore errors reading response body
46
+ }
47
+
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export class Slack implements Service {
53
+ readonly name = 'slack';
54
+ readonly baseApiUrls = ['https://slack.com/api/'] as const;
55
+ readonly loginUrl = 'https://slack.com/signin';
56
+
57
+ readonly credentialCheckCurlArguments = ['https://slack.com/api/auth.test'] as const;
58
+
59
+ getSession(): SlackServiceSession {
60
+ return new SlackServiceSession(this);
61
+ }
62
+
63
+ checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus {
64
+ if (!(apiCredentials instanceof SlackApiCredentials)) {
65
+ return ApiCredentialStatus.Invalid;
66
+ }
67
+
68
+ const result = runCaptured(
69
+ ['-s', ...apiCredentials.asCurlArguments(), ...this.credentialCheckCurlArguments],
70
+ 10
71
+ );
72
+
73
+ try {
74
+ const data = JSON.parse(result.stdout) as { ok?: boolean };
75
+ if (data.ok) {
76
+ return ApiCredentialStatus.Valid;
77
+ }
78
+ return ApiCredentialStatus.Invalid;
79
+ } catch {
80
+ return ApiCredentialStatus.Invalid;
81
+ }
82
+ }
83
+ }
84
+
85
+ export const SLACK = new Slack();
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { ApiCredentialStore } from '../src/apiCredentialStore.js';
6
+ import {
7
+ AuthorizationBearer,
8
+ AuthorizationBare,
9
+ SlackApiCredentials,
10
+ } from '../src/apiCredentials.js';
11
+ import { EncryptedStorage } from '../src/encryptedStorage.js';
12
+ import { generateKey } from '../src/encryption.js';
13
+
14
+ describe('ApiCredentialStore', () => {
15
+ let tempDir: string;
16
+ let storePath: string;
17
+ let encryptedStorage: EncryptedStorage;
18
+
19
+ beforeEach(() => {
20
+ tempDir = mkdtempSync(join(tmpdir(), 'latchkey-test-'));
21
+ storePath = join(tempDir, 'credentials.json');
22
+ encryptedStorage = new EncryptedStorage({ encryptionKeyOverride: generateKey() });
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(tempDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('get', () => {
30
+ it('should return null for non-existent store file', () => {
31
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
32
+ expect(store.get('slack')).toBeNull();
33
+ });
34
+
35
+ it('should return null for non-existent service', () => {
36
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
37
+ store.save('discord', new AuthorizationBare('token'));
38
+ expect(store.get('slack')).toBeNull();
39
+ });
40
+
41
+ it('should retrieve saved AuthorizationBearer credentials', () => {
42
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
43
+ const credentials = new AuthorizationBearer('test-token');
44
+ store.save('github', credentials);
45
+
46
+ const retrieved = store.get('github');
47
+ expect(retrieved).toBeInstanceOf(AuthorizationBearer);
48
+ expect((retrieved as AuthorizationBearer).token).toBe('test-token');
49
+ });
50
+
51
+ it('should retrieve saved AuthorizationBare credentials', () => {
52
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
53
+ const credentials = new AuthorizationBare('discord-token');
54
+ store.save('discord', credentials);
55
+
56
+ const retrieved = store.get('discord');
57
+ expect(retrieved).toBeInstanceOf(AuthorizationBare);
58
+ expect((retrieved as AuthorizationBare).token).toBe('discord-token');
59
+ });
60
+
61
+ it('should retrieve saved SlackApiCredentials', () => {
62
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
63
+ const credentials = new SlackApiCredentials('xoxc-token', 'd-cookie');
64
+ store.save('slack', credentials);
65
+
66
+ const retrieved = store.get('slack');
67
+ expect(retrieved).toBeInstanceOf(SlackApiCredentials);
68
+ expect((retrieved as SlackApiCredentials).token).toBe('xoxc-token');
69
+ expect((retrieved as SlackApiCredentials).dCookie).toBe('d-cookie');
70
+ });
71
+ });
72
+
73
+ describe('save', () => {
74
+ it('should create the store file if it does not exist', () => {
75
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
76
+ store.save('github', new AuthorizationBearer('token'));
77
+ expect(existsSync(storePath)).toBe(true);
78
+ });
79
+
80
+ it('should create parent directories if they do not exist', () => {
81
+ const nestedPath = join(tempDir, 'nested', 'deep', 'credentials.json');
82
+ const store = new ApiCredentialStore(nestedPath, encryptedStorage);
83
+ store.save('github', new AuthorizationBearer('token'));
84
+ expect(existsSync(nestedPath)).toBe(true);
85
+ });
86
+
87
+ it('should overwrite existing credentials for the same service', () => {
88
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
89
+ store.save('github', new AuthorizationBearer('old-token'));
90
+ store.save('github', new AuthorizationBearer('new-token'));
91
+
92
+ const retrieved = store.get('github');
93
+ expect((retrieved as AuthorizationBearer).token).toBe('new-token');
94
+ });
95
+
96
+ it('should preserve other services when saving', () => {
97
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
98
+ store.save('github', new AuthorizationBearer('github-token'));
99
+ store.save('discord', new AuthorizationBare('discord-token'));
100
+
101
+ expect(store.get('github')).not.toBeNull();
102
+ expect(store.get('discord')).not.toBeNull();
103
+ });
104
+
105
+ it('should write valid JSON (encrypted)', () => {
106
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
107
+ store.save('github', new AuthorizationBearer('token'));
108
+
109
+ const content = readFileSync(storePath, 'utf-8');
110
+ // When encrypted, content starts with prefix, followed by encrypted JSON
111
+ expect(content.startsWith('LATCHKEY_ENCRYPTED:')).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe('delete', () => {
116
+ it('should return false for non-existent service', () => {
117
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
118
+ expect(store.delete('github')).toBe(false);
119
+ });
120
+
121
+ it('should return false for non-existent store file', () => {
122
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
123
+ expect(store.delete('github')).toBe(false);
124
+ });
125
+
126
+ it('should delete existing credentials and return true', () => {
127
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
128
+ store.save('github', new AuthorizationBearer('token'));
129
+ expect(store.delete('github')).toBe(true);
130
+ expect(store.get('github')).toBeNull();
131
+ });
132
+
133
+ it('should preserve other services when deleting', () => {
134
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
135
+ store.save('github', new AuthorizationBearer('github-token'));
136
+ store.save('discord', new AuthorizationBare('discord-token'));
137
+
138
+ store.delete('github');
139
+
140
+ expect(store.get('github')).toBeNull();
141
+ expect(store.get('discord')).not.toBeNull();
142
+ });
143
+ });
144
+
145
+ describe('multiple credential types', () => {
146
+ it('should store and retrieve different credential types', () => {
147
+ const store = new ApiCredentialStore(storePath, encryptedStorage);
148
+
149
+ store.save('github', new AuthorizationBearer('github-token'));
150
+ store.save('discord', new AuthorizationBare('discord-token'));
151
+ store.save('slack', new SlackApiCredentials('slack-token', 'slack-cookie'));
152
+
153
+ const github = store.get('github');
154
+ const discord = store.get('discord');
155
+ const slack = store.get('slack');
156
+
157
+ expect(github).toBeInstanceOf(AuthorizationBearer);
158
+ expect(discord).toBeInstanceOf(AuthorizationBare);
159
+ expect(slack).toBeInstanceOf(SlackApiCredentials);
160
+ });
161
+ });
162
+ });