latchkey 2.6.0 → 2.7.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 (177) hide show
  1. package/README.md +63 -7
  2. package/dist/scripts/recordBrowserSession.js +3 -3
  3. package/dist/scripts/recordBrowserSession.js.map +1 -1
  4. package/dist/src/{apiCredentials.d.ts → apiCredentials/base.d.ts} +6 -6
  5. package/dist/src/apiCredentials/base.d.ts.map +1 -0
  6. package/dist/src/{apiCredentials.js → apiCredentials/base.js} +5 -5
  7. package/dist/src/apiCredentials/base.js.map +1 -0
  8. package/dist/src/{apiCredentialsSerialization.d.ts → apiCredentials/serialization.d.ts} +5 -5
  9. package/dist/src/apiCredentials/serialization.d.ts.map +1 -0
  10. package/dist/src/{apiCredentialsSerialization.js → apiCredentials/serialization.js} +9 -9
  11. package/dist/src/apiCredentials/serialization.js.map +1 -0
  12. package/dist/src/{apiCredentialStore.d.ts → apiCredentials/store.d.ts} +3 -3
  13. package/dist/src/apiCredentials/store.d.ts.map +1 -0
  14. package/dist/src/{apiCredentialStore.js → apiCredentials/store.js} +2 -2
  15. package/dist/src/apiCredentials/store.js.map +1 -0
  16. package/dist/src/apiCredentials/utils.d.ts +13 -0
  17. package/dist/src/apiCredentials/utils.d.ts.map +1 -0
  18. package/dist/src/apiCredentials/utils.js +27 -0
  19. package/dist/src/apiCredentials/utils.js.map +1 -0
  20. package/dist/src/cli.js +42 -39
  21. package/dist/src/cli.js.map +1 -1
  22. package/dist/src/cliCommands.d.ts +6 -3
  23. package/dist/src/cliCommands.d.ts.map +1 -1
  24. package/dist/src/cliCommands.js +243 -190
  25. package/dist/src/cliCommands.js.map +1 -1
  26. package/dist/src/config.d.ts +36 -2
  27. package/dist/src/config.d.ts.map +1 -1
  28. package/dist/src/config.js +112 -17
  29. package/dist/src/config.js.map +1 -1
  30. package/dist/src/configDataStore.d.ts +44 -0
  31. package/dist/src/configDataStore.d.ts.map +1 -1
  32. package/dist/src/configDataStore.js +27 -0
  33. package/dist/src/configDataStore.js.map +1 -1
  34. package/dist/src/curl.d.ts +41 -8
  35. package/dist/src/curl.d.ts.map +1 -1
  36. package/dist/src/curl.js +80 -75
  37. package/dist/src/curl.js.map +1 -1
  38. package/dist/src/curlInjection.d.ts +46 -0
  39. package/dist/src/curlInjection.d.ts.map +1 -0
  40. package/dist/src/curlInjection.js +99 -0
  41. package/dist/src/curlInjection.js.map +1 -0
  42. package/dist/src/errorMessages.d.ts +14 -0
  43. package/dist/src/errorMessages.d.ts.map +1 -0
  44. package/dist/src/errorMessages.js +22 -0
  45. package/dist/src/errorMessages.js.map +1 -0
  46. package/dist/src/gateway/client.d.ts +32 -0
  47. package/dist/src/gateway/client.d.ts.map +1 -0
  48. package/dist/src/gateway/client.js +89 -0
  49. package/dist/src/gateway/client.js.map +1 -0
  50. package/dist/src/gateway/gatewayEndpoint.d.ts +43 -0
  51. package/dist/src/gateway/gatewayEndpoint.d.ts.map +1 -0
  52. package/dist/src/gateway/gatewayEndpoint.js +297 -0
  53. package/dist/src/gateway/gatewayEndpoint.js.map +1 -0
  54. package/dist/src/gateway/latchkeyEndpoint.d.ts +105 -0
  55. package/dist/src/gateway/latchkeyEndpoint.d.ts.map +1 -0
  56. package/dist/src/gateway/latchkeyEndpoint.js +144 -0
  57. package/dist/src/gateway/latchkeyEndpoint.js.map +1 -0
  58. package/dist/src/gateway/server.d.ts +20 -0
  59. package/dist/src/gateway/server.d.ts.map +1 -0
  60. package/dist/src/gateway/server.js +90 -0
  61. package/dist/src/gateway/server.js.map +1 -0
  62. package/dist/src/index.d.ts +4 -4
  63. package/dist/src/index.d.ts.map +1 -1
  64. package/dist/src/index.js +5 -5
  65. package/dist/src/index.js.map +1 -1
  66. package/dist/src/permissions.d.ts +2 -1
  67. package/dist/src/permissions.d.ts.map +1 -1
  68. package/dist/src/permissions.js +8 -4
  69. package/dist/src/permissions.js.map +1 -1
  70. package/dist/src/{registry.d.ts → serviceRegistry.d.ts} +4 -4
  71. package/dist/src/serviceRegistry.d.ts.map +1 -0
  72. package/dist/src/{registry.js → serviceRegistry.js} +4 -4
  73. package/dist/src/serviceRegistry.js.map +1 -0
  74. package/dist/src/services/aws.d.ts +2 -2
  75. package/dist/src/services/aws.d.ts.map +1 -1
  76. package/dist/src/services/aws.js +17 -10
  77. package/dist/src/services/aws.js.map +1 -1
  78. package/dist/src/services/core/base.d.ts +2 -2
  79. package/dist/src/services/core/base.d.ts.map +1 -1
  80. package/dist/src/services/core/base.js +3 -3
  81. package/dist/src/services/core/base.js.map +1 -1
  82. package/dist/src/services/core/registered.d.ts +2 -2
  83. package/dist/src/services/core/registered.d.ts.map +1 -1
  84. package/dist/src/services/core/registered.js +2 -2
  85. package/dist/src/services/core/registered.js.map +1 -1
  86. package/dist/src/services/discord.d.ts +1 -1
  87. package/dist/src/services/discord.d.ts.map +1 -1
  88. package/dist/src/services/discord.js +1 -1
  89. package/dist/src/services/discord.js.map +1 -1
  90. package/dist/src/services/dropbox.d.ts +1 -1
  91. package/dist/src/services/dropbox.d.ts.map +1 -1
  92. package/dist/src/services/dropbox.js +1 -1
  93. package/dist/src/services/dropbox.js.map +1 -1
  94. package/dist/src/services/github.d.ts +1 -1
  95. package/dist/src/services/github.d.ts.map +1 -1
  96. package/dist/src/services/github.js +1 -1
  97. package/dist/src/services/github.js.map +1 -1
  98. package/dist/src/services/google/base.d.ts +2 -2
  99. package/dist/src/services/google/base.d.ts.map +1 -1
  100. package/dist/src/services/google/base.js +3 -3
  101. package/dist/src/services/google/base.js.map +1 -1
  102. package/dist/src/services/google/directions.d.ts +1 -1
  103. package/dist/src/services/google/directions.d.ts.map +1 -1
  104. package/dist/src/services/linear.d.ts +1 -1
  105. package/dist/src/services/linear.d.ts.map +1 -1
  106. package/dist/src/services/linear.js +1 -1
  107. package/dist/src/services/linear.js.map +1 -1
  108. package/dist/src/services/notion.d.ts +1 -1
  109. package/dist/src/services/notion.d.ts.map +1 -1
  110. package/dist/src/services/notion.js +1 -1
  111. package/dist/src/services/notion.js.map +1 -1
  112. package/dist/src/services/sentry.d.ts +2 -2
  113. package/dist/src/services/sentry.d.ts.map +1 -1
  114. package/dist/src/services/sentry.js +6 -3
  115. package/dist/src/services/sentry.js.map +1 -1
  116. package/dist/src/services/slack.d.ts +3 -3
  117. package/dist/src/services/slack.d.ts.map +1 -1
  118. package/dist/src/services/slack.js +5 -5
  119. package/dist/src/services/slack.js.map +1 -1
  120. package/dist/src/services/telegram.d.ts +2 -2
  121. package/dist/src/services/telegram.d.ts.map +1 -1
  122. package/dist/src/services/telegram.js +2 -2
  123. package/dist/src/services/telegram.js.map +1 -1
  124. package/dist/src/sharedOperations.d.ts +44 -0
  125. package/dist/src/sharedOperations.d.ts.map +1 -0
  126. package/dist/src/sharedOperations.js +131 -0
  127. package/dist/src/sharedOperations.js.map +1 -0
  128. package/dist/src/version.d.ts +2 -0
  129. package/dist/src/version.d.ts.map +1 -0
  130. package/dist/src/version.js +4 -0
  131. package/dist/src/version.js.map +1 -0
  132. package/dist/tests/apiCredentialStore.test.js +2 -2
  133. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  134. package/dist/tests/apiCredentials.test.js +37 -36
  135. package/dist/tests/apiCredentials.test.js.map +1 -1
  136. package/dist/tests/cli.test.js +241 -55
  137. package/dist/tests/cli.test.js.map +1 -1
  138. package/dist/tests/config.test.d.ts +2 -0
  139. package/dist/tests/config.test.d.ts.map +1 -0
  140. package/dist/tests/config.test.js +150 -0
  141. package/dist/tests/config.test.js.map +1 -0
  142. package/dist/tests/gateway.test.d.ts +2 -0
  143. package/dist/tests/gateway.test.d.ts.map +1 -0
  144. package/dist/tests/gateway.test.js +566 -0
  145. package/dist/tests/gateway.test.js.map +1 -0
  146. package/dist/tests/gatewayClient.test.d.ts +2 -0
  147. package/dist/tests/gatewayClient.test.d.ts.map +1 -0
  148. package/dist/tests/gatewayClient.test.js +85 -0
  149. package/dist/tests/gatewayClient.test.js.map +1 -0
  150. package/dist/tests/latchkeyEndpoint.test.d.ts +2 -0
  151. package/dist/tests/latchkeyEndpoint.test.d.ts.map +1 -0
  152. package/dist/tests/latchkeyEndpoint.test.js +385 -0
  153. package/dist/tests/latchkeyEndpoint.test.js.map +1 -0
  154. package/dist/tests/permissions.test.js +18 -3
  155. package/dist/tests/permissions.test.js.map +1 -1
  156. package/dist/tests/serviceRegistry.test.d.ts +2 -0
  157. package/dist/tests/serviceRegistry.test.d.ts.map +1 -0
  158. package/dist/tests/{registry.test.js → serviceRegistry.test.js} +17 -17
  159. package/dist/tests/serviceRegistry.test.js.map +1 -0
  160. package/dist/tests/servicesAgainstRecordings.test.js +3 -3
  161. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  162. package/dist/tests/sharedOperations.test.d.ts +2 -0
  163. package/dist/tests/sharedOperations.test.d.ts.map +1 -0
  164. package/dist/tests/sharedOperations.test.js +264 -0
  165. package/dist/tests/sharedOperations.test.js.map +1 -0
  166. package/package.json +8 -2
  167. package/dist/src/apiCredentialStore.d.ts.map +0 -1
  168. package/dist/src/apiCredentialStore.js.map +0 -1
  169. package/dist/src/apiCredentials.d.ts.map +0 -1
  170. package/dist/src/apiCredentials.js.map +0 -1
  171. package/dist/src/apiCredentialsSerialization.d.ts.map +0 -1
  172. package/dist/src/apiCredentialsSerialization.js.map +0 -1
  173. package/dist/src/registry.d.ts.map +0 -1
  174. package/dist/src/registry.js.map +0 -1
  175. package/dist/tests/registry.test.d.ts +0 -2
  176. package/dist/tests/registry.test.d.ts.map +0 -1
  177. package/dist/tests/registry.test.js.map +0 -1
@@ -5,12 +5,12 @@ import { tmpdir } from 'node:os';
5
5
  import { execSync } from 'node:child_process';
6
6
  import { Command } from 'commander';
7
7
  import { registerCommands } from '../src/cliCommands.js';
8
- import { extractUrlFromCurlArguments } from '../src/curl.js';
8
+ import { CurlParseError, extractUrlFromCurlArguments } from '../src/curl.js';
9
9
  import { hasGraphicalEnvironment } from '../src/playwrightUtils.js';
10
10
  import { EncryptedStorage } from '../src/encryptedStorage.js';
11
11
  import { Config } from '../src/config.js';
12
- import { Registry } from '../src/registry.js';
13
- import { ApiCredentialStatus } from '../src/apiCredentials.js';
12
+ import { ServiceRegistry } from '../src/serviceRegistry.js';
13
+ import { ApiCredentialStatus } from '../src/apiCredentials/base.js';
14
14
  import { SlackApiCredentials } from '../src/services/slack.js';
15
15
  import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/core/base.js';
16
16
  import { RegisteredService } from '../src/services/core/registered.js';
@@ -18,7 +18,7 @@ import { GITLAB } from '../src/services/gitlab.js';
18
18
  import { GITHUB } from '../src/services/github.js';
19
19
  import { TELEGRAM } from '../src/services/telegram.js';
20
20
  import { deleteRegisteredService, loadRegisteredServices, saveRegisteredService, } from '../src/configDataStore.js';
21
- import { loadRegisteredServicesIntoRegistry } from '../src/registry.js';
21
+ import { loadRegisteredServicesIntoServiceRegistry } from '../src/serviceRegistry.js';
22
22
  // Use a fixed test key for deterministic test behavior (32 bytes = 256 bits, base64 encoded)
23
23
  const TEST_ENCRYPTION_KEY = 'dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=';
24
24
  async function writeSecureFile(path, content) {
@@ -98,13 +98,13 @@ describe('extractUrlFromCurlArguments', () => {
98
98
  const arguments_ = ['--header', 'Authorization: Bearer token', 'https://api.example.com'];
99
99
  expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
100
100
  });
101
- it('should return null when no URL is present', () => {
101
+ it('should throw CurlParseError when no URL is present', () => {
102
102
  const arguments_ = ['-X', 'POST', '-H', 'Content-Type: application/json'];
103
- expect(extractUrlFromCurlArguments(arguments_)).toBeNull();
103
+ expect(() => extractUrlFromCurlArguments(arguments_)).toThrow(CurlParseError);
104
104
  });
105
- it('should return null for empty arguments', () => {
105
+ it('should throw CurlParseError for empty arguments', () => {
106
106
  const arguments_ = [];
107
- expect(extractUrlFromCurlArguments(arguments_)).toBeNull();
107
+ expect(() => extractUrlFromCurlArguments(arguments_)).toThrow(CurlParseError);
108
108
  });
109
109
  it('should handle verbose flag', () => {
110
110
  let arguments_ = ['-v', 'https://api.example.com'];
@@ -118,6 +118,16 @@ describe('extractUrlFromCurlArguments', () => {
118
118
  const arguments_ = ['-k', '--compressed', '-s', '-i', 'https://api.example.com'];
119
119
  expect(extractUrlFromCurlArguments(arguments_)).toBe('https://api.example.com');
120
120
  });
121
+ it('should return the raw arg for schemeless URLs (curl defaults to http://)', () => {
122
+ expect(extractUrlFromCurlArguments(['www.seznam.cz'])).toBe('www.seznam.cz');
123
+ expect(extractUrlFromCurlArguments(['-X', 'POST', 'api.example.com/path'])).toBe('api.example.com/path');
124
+ });
125
+ it('should return null for non-http(s) schemes', () => {
126
+ expect(extractUrlFromCurlArguments(['ftp://example.com/'])).toBeNull();
127
+ });
128
+ it('should propagate CurlParseError for malformed arguments', () => {
129
+ expect(() => extractUrlFromCurlArguments(['-H', 'no-colon-here'])).toThrow(CurlParseError);
130
+ });
121
131
  });
122
132
  describe('hasGraphicalEnvironment', () => {
123
133
  const originalPlatform = process.platform;
@@ -257,6 +267,11 @@ describe('CLI commands with dependency injection', () => {
257
267
  accountName: overrides.accountName ?? defaultConfig.accountName,
258
268
  browserDisabled: overrides.browserDisabled ?? false,
259
269
  countingDisabled: overrides.countingDisabled ?? false,
270
+ permissionsDoNotUseBuiltinSchemas: overrides.permissionsDoNotUseBuiltinSchemas ?? false,
271
+ passthroughUnknown: overrides.passthroughUnknown ?? false,
272
+ gatewayUrl: overrides.gatewayUrl ?? null,
273
+ gatewayListenHost: overrides.gatewayListenHost ?? 'localhost',
274
+ gatewayListenPort: overrides.gatewayListenPort ?? 1989,
260
275
  checkSensitiveFilePermissions: () => undefined,
261
276
  checkSystemPrerequisites: () => undefined,
262
277
  };
@@ -269,7 +284,7 @@ describe('CLI commands with dependency injection', () => {
269
284
  loginUrl: 'https://slack.com/signin',
270
285
  info: 'Test info for Slack service.',
271
286
  credentialCheckCurlArguments: ['https://slack.com/api/auth.test'],
272
- checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
287
+ checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Valid),
273
288
  setCredentialsExample(serviceName) {
274
289
  return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
275
290
  },
@@ -280,7 +295,7 @@ describe('CLI commands with dependency injection', () => {
280
295
  login: vi.fn().mockResolvedValue(new SlackApiCredentials('xoxc-test-token', 'test-cookie')),
281
296
  }),
282
297
  };
283
- const mockRegistry = new Registry([mockSlackService]);
298
+ const mockRegistry = new ServiceRegistry([mockSlackService]);
284
299
  return {
285
300
  registry: mockRegistry,
286
301
  config: createMockConfig(),
@@ -288,6 +303,7 @@ describe('CLI commands with dependency injection', () => {
288
303
  capturedArgs.push(...args);
289
304
  return { returncode: 0, stdout: '', stderr: '' };
290
305
  },
306
+ runCurlAsync: () => Promise.resolve({ returncode: 0, stdout: Buffer.from(''), stderr: '' }),
291
307
  checkPermission: () => Promise.resolve(true),
292
308
  confirm: () => Promise.resolve(true),
293
309
  exit: (code) => {
@@ -300,6 +316,7 @@ describe('CLI commands with dependency injection', () => {
300
316
  errorLog: (message) => {
301
317
  errorLogs.push(message);
302
318
  },
319
+ version: '0.0.0-test',
303
320
  ...overrides,
304
321
  };
305
322
  }
@@ -399,7 +416,7 @@ describe('CLI commands with dependency injection', () => {
399
416
  loginUrl: 'https://nologin.example.com',
400
417
  info: 'A service without browser login support.',
401
418
  credentialCheckCurlArguments: [],
402
- checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Missing),
419
+ checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Missing),
403
420
  setCredentialsExample(serviceName) {
404
421
  return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
405
422
  },
@@ -408,7 +425,7 @@ describe('CLI commands with dependency injection', () => {
408
425
  },
409
426
  };
410
427
  const deps = createMockDependencies({
411
- registry: new Registry([noLoginService]),
428
+ registry: new ServiceRegistry([noLoginService]),
412
429
  });
413
430
  await runCommand(['services', 'list', '--viable'], deps);
414
431
  expect(logs).toHaveLength(1);
@@ -563,7 +580,7 @@ describe('CLI commands with dependency injection', () => {
563
580
  loginUrl: 'https://nologin.example.com',
564
581
  info: 'A service without browser login support.',
565
582
  credentialCheckCurlArguments: [],
566
- checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Missing),
583
+ checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Missing),
567
584
  setCredentialsExample(serviceName) {
568
585
  return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
569
586
  },
@@ -572,7 +589,7 @@ describe('CLI commands with dependency injection', () => {
572
589
  },
573
590
  };
574
591
  const deps = createMockDependencies({
575
- registry: new Registry([noLoginService]),
592
+ registry: new ServiceRegistry([noLoginService]),
576
593
  });
577
594
  await runCommand(['services', 'info', 'nologin'], deps);
578
595
  const info = JSON.parse(logs[0] ?? '');
@@ -743,7 +760,7 @@ describe('CLI commands with dependency injection', () => {
743
760
  const storePath = join(tempDir, 'credentials.json');
744
761
  await writeSecureFile(storePath, '{}');
745
762
  const deps = createMockDependencies({
746
- registry: new Registry([TELEGRAM]),
763
+ registry: new ServiceRegistry([TELEGRAM]),
747
764
  });
748
765
  await runCommand(['auth', 'set-nocurl', 'telegram', '123456:ABC-DEF'], deps);
749
766
  expect(logs).toContain('Credentials stored.');
@@ -765,14 +782,14 @@ describe('CLI commands with dependency injection', () => {
765
782
  });
766
783
  it('should return error when telegram token is missing', async () => {
767
784
  const deps = createMockDependencies({
768
- registry: new Registry([TELEGRAM]),
785
+ registry: new ServiceRegistry([TELEGRAM]),
769
786
  });
770
787
  await runCommand(['auth', 'set-nocurl', 'telegram'], deps);
771
788
  expect(exitCode).toBe(1);
772
789
  });
773
790
  it('should return error when telegram token format is invalid', async () => {
774
791
  const deps = createMockDependencies({
775
- registry: new Registry([TELEGRAM]),
792
+ registry: new ServiceRegistry([TELEGRAM]),
776
793
  });
777
794
  await runCommand(['auth', 'set-nocurl', 'telegram', 'not-a-valid-token'], deps);
778
795
  expect(exitCode).toBe(1);
@@ -848,6 +865,38 @@ describe('CLI commands with dependency injection', () => {
848
865
  await runCommand(['curl', 'https://unknown-api.example.com'], deps);
849
866
  expect(exitCode).toBe(1);
850
867
  });
868
+ it('should pass through unknown service when passthroughUnknown is enabled', async () => {
869
+ const deps = createMockDependencies({
870
+ config: createMockConfig({ passthroughUnknown: true }),
871
+ });
872
+ await runCommand(['curl', 'https://unknown-api.example.com/test'], deps);
873
+ expect(exitCode).toBe(0);
874
+ expect(capturedArgs).toEqual(['https://unknown-api.example.com/test']);
875
+ expect(errorLogs).toHaveLength(0);
876
+ });
877
+ it('should pass through missing credentials when passthroughUnknown is enabled', async () => {
878
+ const storePath = join(tempDir, 'credentials.json');
879
+ await writeSecureFile(storePath, '{}');
880
+ const deps = createMockDependencies({
881
+ config: createMockConfig({ passthroughUnknown: true }),
882
+ });
883
+ await runCommand(['curl', 'https://slack.com/api/test'], deps);
884
+ expect(exitCode).toBe(0);
885
+ expect(capturedArgs).toEqual(['https://slack.com/api/test']);
886
+ expect(errorLogs).toHaveLength(0);
887
+ });
888
+ it('should still inject credentials for known services when passthroughUnknown is enabled', async () => {
889
+ const storePath = join(tempDir, 'credentials.json');
890
+ await writeSecureFile(storePath, JSON.stringify({
891
+ slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
892
+ }));
893
+ const deps = createMockDependencies({
894
+ config: createMockConfig({ passthroughUnknown: true }),
895
+ });
896
+ await runCommand(['curl', 'https://slack.com/api/test'], deps);
897
+ expect(exitCode).toBe(0);
898
+ expect(capturedArgs).toContain('Authorization: Bearer stored-token');
899
+ });
851
900
  it('should read credentials from store and not call login', async () => {
852
901
  const storePath = join(tempDir, 'credentials.json');
853
902
  await writeSecureFile(storePath, JSON.stringify({
@@ -871,7 +920,7 @@ describe('CLI commands with dependency injection', () => {
871
920
  getSession: vi.fn().mockReturnValue({ login: mockLogin }),
872
921
  };
873
922
  const deps = createMockDependencies({
874
- registry: new Registry([mockSlackService]),
923
+ registry: new ServiceRegistry([mockSlackService]),
875
924
  });
876
925
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
877
926
  expect(mockLogin).not.toHaveBeenCalled();
@@ -890,7 +939,7 @@ describe('CLI commands with dependency injection', () => {
890
939
  telegram: { objectType: 'telegramBot', token: '123456:ABC-DEF' },
891
940
  }));
892
941
  const deps = createMockDependencies({
893
- registry: new Registry([TELEGRAM]),
942
+ registry: new ServiceRegistry([TELEGRAM]),
894
943
  });
895
944
  await runCommand(['curl', 'https://api.telegram.org/getMe'], deps);
896
945
  expect(capturedArgs).toEqual(['https://api.telegram.org/bot123456:ABC-DEF/getMe']);
@@ -908,7 +957,7 @@ describe('CLI commands with dependency injection', () => {
908
957
  loginUrl: 'https://nologin.example.com',
909
958
  info: 'A service without browser login support.',
910
959
  credentialCheckCurlArguments: [],
911
- checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
960
+ checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Valid),
912
961
  setCredentialsExample(serviceName) {
913
962
  return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
914
963
  },
@@ -918,7 +967,7 @@ describe('CLI commands with dependency injection', () => {
918
967
  // No getSession - service doesn't support browser login
919
968
  };
920
969
  const deps = createMockDependencies({
921
- registry: new Registry([noLoginService]),
970
+ registry: new ServiceRegistry([noLoginService]),
922
971
  });
923
972
  await runCommand(['curl', 'https://nologin.example.com/api/test'], deps);
924
973
  expect(exitCode).toBe(0);
@@ -985,7 +1034,7 @@ describe('CLI commands with dependency injection', () => {
985
1034
  // No getSession - service doesn't support browser login
986
1035
  };
987
1036
  const deps = createMockDependencies({
988
- registry: new Registry([noLoginService]),
1037
+ registry: new ServiceRegistry([noLoginService]),
989
1038
  });
990
1039
  await runCommand(['auth', 'browser', 'nologin'], deps);
991
1040
  expect(exitCode).toBe(1);
@@ -1042,7 +1091,7 @@ describe('CLI commands with dependency injection', () => {
1042
1091
  // No getSession - service doesn't support browser login
1043
1092
  };
1044
1093
  const deps = createMockDependencies({
1045
- registry: new Registry([nocurlService]),
1094
+ registry: new ServiceRegistry([nocurlService]),
1046
1095
  });
1047
1096
  await runCommand(['auth', 'browser', 'nocurl-only'], deps);
1048
1097
  expect(exitCode).toBe(1);
@@ -1051,7 +1100,7 @@ describe('CLI commands with dependency injection', () => {
1051
1100
  describe('services register command', () => {
1052
1101
  it('should register a new service', async () => {
1053
1102
  const deps = createMockDependencies({
1054
- registry: new Registry([GITLAB]),
1103
+ registry: new ServiceRegistry([GITLAB]),
1055
1104
  });
1056
1105
  await runCommand([
1057
1106
  'services',
@@ -1071,7 +1120,7 @@ describe('CLI commands with dependency injection', () => {
1071
1120
  });
1072
1121
  it('should persist registration to config.json', async () => {
1073
1122
  const deps = createMockDependencies({
1074
- registry: new Registry([GITLAB]),
1123
+ registry: new ServiceRegistry([GITLAB]),
1075
1124
  });
1076
1125
  await runCommand([
1077
1126
  'services',
@@ -1091,7 +1140,7 @@ describe('CLI commands with dependency injection', () => {
1091
1140
  });
1092
1141
  it('should reject unknown service family', async () => {
1093
1142
  const deps = createMockDependencies({
1094
- registry: new Registry([GITLAB]),
1143
+ registry: new ServiceRegistry([GITLAB]),
1095
1144
  });
1096
1145
  await runCommand([
1097
1146
  'services',
@@ -1107,7 +1156,7 @@ describe('CLI commands with dependency injection', () => {
1107
1156
  });
1108
1157
  it('should reject duplicate service name', async () => {
1109
1158
  const deps = createMockDependencies({
1110
- registry: new Registry([GITLAB]),
1159
+ registry: new ServiceRegistry([GITLAB]),
1111
1160
  });
1112
1161
  await runCommand([
1113
1162
  'services',
@@ -1123,7 +1172,7 @@ describe('CLI commands with dependency injection', () => {
1123
1172
  });
1124
1173
  it('should canonicalize service name to lowercase', async () => {
1125
1174
  const deps = createMockDependencies({
1126
- registry: new Registry([GITLAB]),
1175
+ registry: new ServiceRegistry([GITLAB]),
1127
1176
  });
1128
1177
  await runCommand([
1129
1178
  'services',
@@ -1140,7 +1189,7 @@ describe('CLI commands with dependency injection', () => {
1140
1189
  });
1141
1190
  it('should convert spaces to hyphens in service name', async () => {
1142
1191
  const deps = createMockDependencies({
1143
- registry: new Registry([GITLAB]),
1192
+ registry: new ServiceRegistry([GITLAB]),
1144
1193
  });
1145
1194
  await runCommand(['services', 'register', 'my api', '--base-api-url', 'https://api.example.com/'], deps);
1146
1195
  expect(exitCode).toBeNull();
@@ -1149,7 +1198,7 @@ describe('CLI commands with dependency injection', () => {
1149
1198
  });
1150
1199
  it('should reject service name with invalid characters', async () => {
1151
1200
  const deps = createMockDependencies({
1152
- registry: new Registry([GITLAB]),
1201
+ registry: new ServiceRegistry([GITLAB]),
1153
1202
  });
1154
1203
  await runCommand(['services', 'register', 'my@service!', '--base-api-url', 'https://api.example.com/'], deps);
1155
1204
  expect(exitCode).toBe(1);
@@ -1159,7 +1208,7 @@ describe('CLI commands with dependency injection', () => {
1159
1208
  const storePath = join(tempDir, 'credentials.json');
1160
1209
  await writeSecureFile(storePath, '{}');
1161
1210
  const deps = createMockDependencies({
1162
- registry: new Registry([GITLAB]),
1211
+ registry: new ServiceRegistry([GITLAB]),
1163
1212
  });
1164
1213
  await runCommand([
1165
1214
  'services',
@@ -1178,7 +1227,7 @@ describe('CLI commands with dependency injection', () => {
1178
1227
  });
1179
1228
  it('should persist and restore loginUrl', async () => {
1180
1229
  const deps = createMockDependencies({
1181
- registry: new Registry([GITHUB]),
1230
+ registry: new ServiceRegistry([GITHUB]),
1182
1231
  });
1183
1232
  await runCommand([
1184
1233
  'services',
@@ -1196,7 +1245,7 @@ describe('CLI commands with dependency injection', () => {
1196
1245
  });
1197
1246
  it('should reject --login-url without --service-family', async () => {
1198
1247
  const deps = createMockDependencies({
1199
- registry: new Registry([]),
1248
+ registry: new ServiceRegistry([]),
1200
1249
  });
1201
1250
  await runCommand([
1202
1251
  'services',
@@ -1212,7 +1261,7 @@ describe('CLI commands with dependency injection', () => {
1212
1261
  });
1213
1262
  it('should reject --login-url when service family does not support browser login', async () => {
1214
1263
  const deps = createMockDependencies({
1215
- registry: new Registry([GITLAB]),
1264
+ registry: new ServiceRegistry([GITLAB]),
1216
1265
  });
1217
1266
  await runCommand([
1218
1267
  'services',
@@ -1230,7 +1279,7 @@ describe('CLI commands with dependency injection', () => {
1230
1279
  });
1231
1280
  it('should require --login-url when service family supports browser login', async () => {
1232
1281
  const deps = createMockDependencies({
1233
- registry: new Registry([GITHUB]),
1282
+ registry: new ServiceRegistry([GITHUB]),
1234
1283
  });
1235
1284
  await runCommand([
1236
1285
  'services',
@@ -1246,7 +1295,7 @@ describe('CLI commands with dependency injection', () => {
1246
1295
  });
1247
1296
  it('should make registered service usable with auth set', async () => {
1248
1297
  const deps = createMockDependencies({
1249
- registry: new Registry([GITLAB]),
1298
+ registry: new ServiceRegistry([GITLAB]),
1250
1299
  });
1251
1300
  // Register the service
1252
1301
  await runCommand([
@@ -1269,7 +1318,7 @@ describe('CLI commands with dependency injection', () => {
1269
1318
  });
1270
1319
  it('should register a service without --service-family', async () => {
1271
1320
  const deps = createMockDependencies({
1272
- registry: new Registry([GITLAB]),
1321
+ registry: new ServiceRegistry([GITLAB]),
1273
1322
  });
1274
1323
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1275
1324
  expect(exitCode).toBeNull();
@@ -1281,7 +1330,7 @@ describe('CLI commands with dependency injection', () => {
1281
1330
  });
1282
1331
  it('should persist registration without service family to config.json', async () => {
1283
1332
  const deps = createMockDependencies({
1284
- registry: new Registry([GITLAB]),
1333
+ registry: new ServiceRegistry([GITLAB]),
1285
1334
  });
1286
1335
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1287
1336
  const configPath = deps.config.configPath;
@@ -1294,7 +1343,7 @@ describe('CLI commands with dependency injection', () => {
1294
1343
  const storePath = join(tempDir, 'credentials.json');
1295
1344
  await writeSecureFile(storePath, '{}');
1296
1345
  const deps = createMockDependencies({
1297
- registry: new Registry([GITLAB]),
1346
+ registry: new ServiceRegistry([GITLAB]),
1298
1347
  });
1299
1348
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1300
1349
  logs = [];
@@ -1305,7 +1354,7 @@ describe('CLI commands with dependency injection', () => {
1305
1354
  });
1306
1355
  it('should make service without family usable with auth set and curl', async () => {
1307
1356
  const deps = createMockDependencies({
1308
- registry: new Registry([GITLAB]),
1357
+ registry: new ServiceRegistry([GITLAB]),
1309
1358
  });
1310
1359
  // Register the service without family
1311
1360
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
@@ -1327,7 +1376,7 @@ describe('CLI commands with dependency injection', () => {
1327
1376
  });
1328
1377
  it('should reject browser login for service without family', async () => {
1329
1378
  const deps = createMockDependencies({
1330
- registry: new Registry([GITLAB]),
1379
+ registry: new ServiceRegistry([GITLAB]),
1331
1380
  });
1332
1381
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1333
1382
  logs = [];
@@ -1339,7 +1388,7 @@ describe('CLI commands with dependency injection', () => {
1339
1388
  });
1340
1389
  it('should reject set-nocurl for service without family', async () => {
1341
1390
  const deps = createMockDependencies({
1342
- registry: new Registry([GITLAB]),
1391
+ registry: new ServiceRegistry([GITLAB]),
1343
1392
  });
1344
1393
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1345
1394
  logs = [];
@@ -1351,7 +1400,7 @@ describe('CLI commands with dependency injection', () => {
1351
1400
  });
1352
1401
  it('should make registered service usable with curl', async () => {
1353
1402
  const deps = createMockDependencies({
1354
- registry: new Registry([GITLAB]),
1403
+ registry: new ServiceRegistry([GITLAB]),
1355
1404
  });
1356
1405
  // Register the service
1357
1406
  await runCommand([
@@ -1383,7 +1432,7 @@ describe('CLI commands with dependency injection', () => {
1383
1432
  describe('services deregister command', () => {
1384
1433
  it('should deregister a registered service', async () => {
1385
1434
  const deps = createMockDependencies({
1386
- registry: new Registry([GITLAB]),
1435
+ registry: new ServiceRegistry([GITLAB]),
1387
1436
  });
1388
1437
  // Register a service first
1389
1438
  await runCommand([
@@ -1403,7 +1452,7 @@ describe('CLI commands with dependency injection', () => {
1403
1452
  });
1404
1453
  it('should remove service from config.json', async () => {
1405
1454
  const deps = createMockDependencies({
1406
- registry: new Registry([GITLAB]),
1455
+ registry: new ServiceRegistry([GITLAB]),
1407
1456
  });
1408
1457
  await runCommand([
1409
1458
  'services',
@@ -1434,7 +1483,7 @@ describe('CLI commands with dependency injection', () => {
1434
1483
  });
1435
1484
  it('should reject deregistering when credentials still exist', async () => {
1436
1485
  const deps = createMockDependencies({
1437
- registry: new Registry([GITLAB]),
1486
+ registry: new ServiceRegistry([GITLAB]),
1438
1487
  });
1439
1488
  // Register a service
1440
1489
  await runCommand([
@@ -1467,7 +1516,7 @@ describe('CLI commands with dependency injection', () => {
1467
1516
  });
1468
1517
  it('should allow deregistering after credentials are cleared', async () => {
1469
1518
  const deps = createMockDependencies({
1470
- registry: new Registry([GITLAB]),
1519
+ registry: new ServiceRegistry([GITLAB]),
1471
1520
  });
1472
1521
  // Register
1473
1522
  await runCommand([
@@ -1500,6 +1549,143 @@ describe('CLI commands with dependency injection', () => {
1500
1549
  expect(logs).toContain("Service 'my-gitlab' deregistered.");
1501
1550
  });
1502
1551
  });
1552
+ describe('gateway mode (LATCHKEY_GATEWAY)', () => {
1553
+ const GATEWAY_URL = 'http://localhost:9000';
1554
+ const originalFetch = globalThis.fetch;
1555
+ afterEach(() => {
1556
+ globalThis.fetch = originalFetch;
1557
+ });
1558
+ function makeFetchMock(response) {
1559
+ const fetchMock = vi.fn().mockResolvedValue(response);
1560
+ globalThis.fetch = fetchMock;
1561
+ return fetchMock;
1562
+ }
1563
+ it('forwards `services list` to the gateway /latchkey endpoint', async () => {
1564
+ const fetchMock = makeFetchMock(new Response(JSON.stringify({ result: ['slack', 'github'] }), { status: 200 }));
1565
+ const deps = createMockDependencies({
1566
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1567
+ });
1568
+ await runCommand(['services', 'list', '--builtin'], deps);
1569
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1570
+ const [url, init] = fetchMock.mock.calls[0];
1571
+ expect(url).toBe(`${GATEWAY_URL}/latchkey`);
1572
+ expect(init.method).toBe('POST');
1573
+ expect(JSON.parse(init.body)).toEqual({
1574
+ command: 'services list',
1575
+ params: { builtin: true },
1576
+ });
1577
+ expect(logs).toHaveLength(1);
1578
+ expect(JSON.parse(logs[0] ?? '')).toEqual(['slack', 'github']);
1579
+ });
1580
+ it('forwards `services info` to the gateway /latchkey endpoint', async () => {
1581
+ const fetchMock = makeFetchMock(new Response(JSON.stringify({ result: { type: 'built-in' } }), { status: 200 }));
1582
+ const deps = createMockDependencies({
1583
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1584
+ });
1585
+ await runCommand(['services', 'info', 'slack'], deps);
1586
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1587
+ const [, init] = fetchMock.mock.calls[0];
1588
+ expect(JSON.parse(init.body)).toEqual({
1589
+ command: 'services info',
1590
+ params: { serviceName: 'slack' },
1591
+ });
1592
+ });
1593
+ it('forwards `auth list` to the gateway /latchkey endpoint', async () => {
1594
+ const fetchMock = makeFetchMock(new Response(JSON.stringify({ result: { slack: { credentialType: 'slack' } } }), {
1595
+ status: 200,
1596
+ }));
1597
+ const deps = createMockDependencies({
1598
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1599
+ });
1600
+ await runCommand(['auth', 'list'], deps);
1601
+ const [, init] = fetchMock.mock.calls[0];
1602
+ expect(JSON.parse(init.body)).toEqual({ command: 'auth list' });
1603
+ expect(JSON.parse(logs[0] ?? '')).toEqual({
1604
+ slack: { credentialType: 'slack' },
1605
+ });
1606
+ });
1607
+ it('forwards `auth browser` to the gateway /latchkey endpoint', async () => {
1608
+ const fetchMock = makeFetchMock(new Response(JSON.stringify({ result: null }), { status: 200 }));
1609
+ const deps = createMockDependencies({
1610
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1611
+ });
1612
+ await runCommand(['auth', 'browser', 'slack'], deps);
1613
+ const [, init] = fetchMock.mock.calls[0];
1614
+ expect(JSON.parse(init.body)).toEqual({
1615
+ command: 'auth browser',
1616
+ params: { serviceName: 'slack' },
1617
+ });
1618
+ expect(logs).toContain('Done');
1619
+ });
1620
+ it('forwards `auth browser-prepare` and reports `Already prepared.` when the gateway says so', async () => {
1621
+ makeFetchMock(new Response(JSON.stringify({ result: { alreadyPrepared: true } }), { status: 200 }));
1622
+ const deps = createMockDependencies({
1623
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1624
+ });
1625
+ await runCommand(['auth', 'browser-prepare', 'slack'], deps);
1626
+ expect(logs).toContain('Already prepared.');
1627
+ });
1628
+ it.each([
1629
+ ['services list', ['services', 'list']],
1630
+ ['services info', ['services', 'info', 'foo']],
1631
+ ['auth list', ['auth', 'list']],
1632
+ ['auth browser', ['auth', 'browser', 'slack']],
1633
+ ['auth browser-prepare', ['auth', 'browser-prepare', 'slack']],
1634
+ ])('reports gateway errors on stderr (not stdout) for `%s`', async (_name, argv) => {
1635
+ makeFetchMock(new Response(JSON.stringify({ error: 'Unknown service: foo.' }), { status: 400 }));
1636
+ const deps = createMockDependencies({
1637
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1638
+ });
1639
+ await runCommand(argv, deps);
1640
+ expect(exitCode).toBe(1);
1641
+ expect(errorLogs.some((message) => message.includes('Unknown service: foo.'))).toBe(true);
1642
+ // The error message must never be printed to stdout — that would
1643
+ // interleave into JSON output consumed by callers.
1644
+ expect(logs.join('\n')).not.toContain('Unknown service: foo.');
1645
+ expect(logs).toEqual([]);
1646
+ });
1647
+ it('rewrites the curl target URL to the gateway /gateway endpoint', async () => {
1648
+ const fetchMock = vi.fn();
1649
+ globalThis.fetch = fetchMock;
1650
+ const deps = createMockDependencies({
1651
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1652
+ });
1653
+ await runCommand(['curl', '-X', 'GET', 'https://slack.com/api/auth.test'], deps);
1654
+ expect(fetchMock).not.toHaveBeenCalled();
1655
+ expect(capturedArgs).toEqual([
1656
+ '-X',
1657
+ 'GET',
1658
+ `${GATEWAY_URL}/gateway/https://slack.com/api/auth.test`,
1659
+ ]);
1660
+ });
1661
+ it('errors when `latchkey curl` has no URL to rewrite', async () => {
1662
+ const deps = createMockDependencies({
1663
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1664
+ });
1665
+ await runCommand(['curl', '-X', 'GET'], deps);
1666
+ expect(exitCode).toBe(1);
1667
+ expect(capturedArgs).toEqual([]);
1668
+ });
1669
+ it.each([
1670
+ [
1671
+ 'services register',
1672
+ ['services', 'register', 'my-gitlab', '--base-api-url', 'https://gitlab.example.com'],
1673
+ ],
1674
+ ['services deregister', ['services', 'deregister', 'my-gitlab']],
1675
+ ['auth clear', ['auth', 'clear', 'slack']],
1676
+ ['auth set', ['auth', 'set', 'slack', '-H', 'Authorization: Bearer xoxb-test']],
1677
+ ['auth set-nocurl', ['auth', 'set-nocurl', 'slack', 'x']],
1678
+ ['gateway', ['gateway']],
1679
+ ['ensure-browser', ['ensure-browser']],
1680
+ ])('refuses to run `%s` in gateway mode', async (_name, argv) => {
1681
+ const deps = createMockDependencies({
1682
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
1683
+ });
1684
+ await runCommand(argv, deps);
1685
+ expect(exitCode).toBe(1);
1686
+ expect(errorLogs.some((message) => message.includes('LATCHKEY_GATEWAY'))).toBe(true);
1687
+ });
1688
+ });
1503
1689
  });
1504
1690
  describe('registeredServiceStore', () => {
1505
1691
  let tempDir;
@@ -1544,8 +1730,8 @@ describe('registeredServiceStore', () => {
1544
1730
  baseApiUrl: 'https://gitlab.mycompany.com/api/',
1545
1731
  serviceFamily: 'gitlab',
1546
1732
  });
1547
- const registry = new Registry([GITLAB]);
1548
- loadRegisteredServicesIntoRegistry(configPath, registry);
1733
+ const registry = new ServiceRegistry([GITLAB]);
1734
+ loadRegisteredServicesIntoServiceRegistry(configPath, registry);
1549
1735
  const service = registry.getByName('my-gitlab');
1550
1736
  expect(service).not.toBeNull();
1551
1737
  expect(service.baseApiUrls).toEqual(['https://gitlab.mycompany.com/api/']);
@@ -1557,8 +1743,8 @@ describe('registeredServiceStore', () => {
1557
1743
  serviceFamily: 'gitlab',
1558
1744
  loginUrl: 'https://gitlab.mycompany.com/users/sign_in',
1559
1745
  });
1560
- const registry = new Registry([GITLAB]);
1561
- loadRegisteredServicesIntoRegistry(configPath, registry);
1746
+ const registry = new ServiceRegistry([GITLAB]);
1747
+ loadRegisteredServicesIntoServiceRegistry(configPath, registry);
1562
1748
  const service = registry.getByName('my-gitlab');
1563
1749
  expect(service).not.toBeNull();
1564
1750
  expect(service.loginUrl).toBe('https://gitlab.mycompany.com/users/sign_in');
@@ -1568,8 +1754,8 @@ describe('registeredServiceStore', () => {
1568
1754
  saveRegisteredService(configPath, 'my-api', {
1569
1755
  baseApiUrl: 'https://api.example.com/',
1570
1756
  });
1571
- const registry = new Registry([GITLAB]);
1572
- loadRegisteredServicesIntoRegistry(configPath, registry);
1757
+ const registry = new ServiceRegistry([GITLAB]);
1758
+ loadRegisteredServicesIntoServiceRegistry(configPath, registry);
1573
1759
  const service = registry.getByName('my-api');
1574
1760
  expect(service).not.toBeNull();
1575
1761
  expect(service.baseApiUrls).toEqual(['https://api.example.com/']);
@@ -1582,8 +1768,8 @@ describe('registeredServiceStore', () => {
1582
1768
  baseApiUrl: 'https://unknown.example.com/api/',
1583
1769
  serviceFamily: 'nonexistent',
1584
1770
  });
1585
- const registry = new Registry([GITLAB]);
1586
- loadRegisteredServicesIntoRegistry(configPath, registry);
1771
+ const registry = new ServiceRegistry([GITLAB]);
1772
+ loadRegisteredServicesIntoServiceRegistry(configPath, registry);
1587
1773
  expect(registry.getByName('my-unknown')).toBeNull();
1588
1774
  });
1589
1775
  it('should delete a registered service from config', () => {