latchkey 2.7.2 → 2.8.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 (107) hide show
  1. package/README.md +49 -5
  2. package/dist/scripts/cryptFile.js +2 -2
  3. package/dist/scripts/cryptFile.js.map +1 -1
  4. package/dist/scripts/recordBrowserSession.js +3 -2
  5. package/dist/scripts/recordBrowserSession.js.map +1 -1
  6. package/dist/src/cli.js +5 -4
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/cliCommands.d.ts.map +1 -1
  9. package/dist/src/cliCommands.js +44 -6
  10. package/dist/src/cliCommands.js.map +1 -1
  11. package/dist/src/config.d.ts +29 -0
  12. package/dist/src/config.d.ts.map +1 -1
  13. package/dist/src/config.js +45 -0
  14. package/dist/src/config.js.map +1 -1
  15. package/dist/src/encryptedStorage.d.ts +9 -25
  16. package/dist/src/encryptedStorage.d.ts.map +1 -1
  17. package/dist/src/encryptedStorage.js +9 -52
  18. package/dist/src/encryptedStorage.js.map +1 -1
  19. package/dist/src/encryption.d.ts +45 -0
  20. package/dist/src/encryption.d.ts.map +1 -1
  21. package/dist/src/encryption.js +69 -0
  22. package/dist/src/encryption.js.map +1 -1
  23. package/dist/src/gateway/client.d.ts +12 -2
  24. package/dist/src/gateway/client.d.ts.map +1 -1
  25. package/dist/src/gateway/client.js +31 -4
  26. package/dist/src/gateway/client.js.map +1 -1
  27. package/dist/src/gateway/gatewayEndpoint.d.ts +11 -0
  28. package/dist/src/gateway/gatewayEndpoint.d.ts.map +1 -1
  29. package/dist/src/gateway/gatewayEndpoint.js +40 -4
  30. package/dist/src/gateway/gatewayEndpoint.js.map +1 -1
  31. package/dist/src/gateway/password.d.ts +16 -0
  32. package/dist/src/gateway/password.d.ts.map +1 -0
  33. package/dist/src/gateway/password.js +24 -0
  34. package/dist/src/gateway/password.js.map +1 -0
  35. package/dist/src/gateway/permissionPointer.d.ts +56 -0
  36. package/dist/src/gateway/permissionPointer.d.ts.map +1 -0
  37. package/dist/src/gateway/permissionPointer.js +171 -0
  38. package/dist/src/gateway/permissionPointer.js.map +1 -0
  39. package/dist/src/gateway/permissionsOverride.d.ts +56 -0
  40. package/dist/src/gateway/permissionsOverride.d.ts.map +1 -0
  41. package/dist/src/gateway/permissionsOverride.js +157 -0
  42. package/dist/src/gateway/permissionsOverride.js.map +1 -0
  43. package/dist/src/gateway/server.d.ts.map +1 -1
  44. package/dist/src/gateway/server.js +34 -1
  45. package/dist/src/gateway/server.js.map +1 -1
  46. package/dist/src/index.d.ts +2 -2
  47. package/dist/src/index.d.ts.map +1 -1
  48. package/dist/src/index.js +2 -2
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/oauthUtils.d.ts +11 -2
  51. package/dist/src/oauthUtils.d.ts.map +1 -1
  52. package/dist/src/oauthUtils.js +25 -4
  53. package/dist/src/oauthUtils.js.map +1 -1
  54. package/dist/src/serviceRegistry.d.ts.map +1 -1
  55. package/dist/src/serviceRegistry.js +2 -1
  56. package/dist/src/serviceRegistry.js.map +1 -1
  57. package/dist/src/services/index.d.ts +1 -0
  58. package/dist/src/services/index.d.ts.map +1 -1
  59. package/dist/src/services/index.js +1 -0
  60. package/dist/src/services/index.js.map +1 -1
  61. package/dist/src/services/notion-mcp.d.ts +29 -0
  62. package/dist/src/services/notion-mcp.d.ts.map +1 -0
  63. package/dist/src/services/notion-mcp.js +156 -0
  64. package/dist/src/services/notion-mcp.js.map +1 -0
  65. package/dist/src/services/notion.d.ts +1 -1
  66. package/dist/src/services/notion.d.ts.map +1 -1
  67. package/dist/src/services/notion.js +5 -4
  68. package/dist/src/services/notion.js.map +1 -1
  69. package/dist/src/version.d.ts +1 -1
  70. package/dist/src/version.js +1 -1
  71. package/dist/tests/apiCredentialStore.test.js +2 -2
  72. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  73. package/dist/tests/cli.test.js +95 -53
  74. package/dist/tests/cli.test.js.map +1 -1
  75. package/dist/tests/config.test.js +37 -0
  76. package/dist/tests/config.test.js.map +1 -1
  77. package/dist/tests/encryptedStorage.test.js +19 -39
  78. package/dist/tests/encryptedStorage.test.js.map +1 -1
  79. package/dist/tests/encryptedStorageKeyGeneration.test.js +2 -1
  80. package/dist/tests/encryptedStorageKeyGeneration.test.js.map +1 -1
  81. package/dist/tests/gateway.test.js +170 -7
  82. package/dist/tests/gateway.test.js.map +1 -1
  83. package/dist/tests/gatewayClient.test.js +74 -0
  84. package/dist/tests/gatewayClient.test.js.map +1 -1
  85. package/dist/tests/latchkeyEndpoint.test.js +7 -6
  86. package/dist/tests/latchkeyEndpoint.test.js.map +1 -1
  87. package/dist/tests/migrations.test.js +2 -2
  88. package/dist/tests/migrations.test.js.map +1 -1
  89. package/dist/tests/oauthUtils.test.d.ts +2 -0
  90. package/dist/tests/oauthUtils.test.d.ts.map +1 -0
  91. package/dist/tests/oauthUtils.test.js +63 -0
  92. package/dist/tests/oauthUtils.test.js.map +1 -0
  93. package/dist/tests/permissionPointer.test.d.ts +2 -0
  94. package/dist/tests/permissionPointer.test.d.ts.map +1 -0
  95. package/dist/tests/permissionPointer.test.js +152 -0
  96. package/dist/tests/permissionPointer.test.js.map +1 -0
  97. package/dist/tests/permissionsOverride.test.d.ts +2 -0
  98. package/dist/tests/permissionsOverride.test.d.ts.map +1 -0
  99. package/dist/tests/permissionsOverride.test.js +136 -0
  100. package/dist/tests/permissionsOverride.test.js.map +1 -0
  101. package/dist/tests/resolveEncryptionKey.test.d.ts +2 -0
  102. package/dist/tests/resolveEncryptionKey.test.d.ts.map +1 -0
  103. package/dist/tests/resolveEncryptionKey.test.js +26 -0
  104. package/dist/tests/resolveEncryptionKey.test.js.map +1 -0
  105. package/dist/tests/sharedOperations.test.js +34 -50
  106. package/dist/tests/sharedOperations.test.js.map +1 -1
  107. package/package.json +2 -2
@@ -16,17 +16,18 @@ import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/cor
16
16
  import { RegisteredService } from '../src/services/core/registered.js';
17
17
  import { GITLAB } from '../src/services/gitlab.js';
18
18
  import { GITHUB } from '../src/services/github.js';
19
+ import { derivePermissionsOverrideSigningKey, verifyPermissionsOverrideJwt, } from '../src/gateway/permissionsOverride.js';
19
20
  import { TELEGRAM } from '../src/services/telegram.js';
20
21
  import { deleteRegisteredService, loadRegisteredServices, saveRegisteredService, } from '../src/configDataStore.js';
21
22
  import { loadRegisteredServicesIntoServiceRegistry } from '../src/serviceRegistry.js';
22
23
  // Use a fixed test key for deterministic test behavior (32 bytes = 256 bits, base64 encoded)
23
24
  const TEST_ENCRYPTION_KEY = 'dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=';
24
- async function writeSecureFile(path, content) {
25
- const storage = await EncryptedStorage.create({ encryptionKeyOverride: TEST_ENCRYPTION_KEY });
25
+ function writeSecureFile(path, content) {
26
+ const storage = new EncryptedStorage(TEST_ENCRYPTION_KEY);
26
27
  storage.writeFile(path, content);
27
28
  }
28
- async function readSecureFile(path) {
29
- const storage = await EncryptedStorage.create({ encryptionKeyOverride: TEST_ENCRYPTION_KEY });
29
+ function readSecureFile(path) {
30
+ const storage = new EncryptedStorage(TEST_ENCRYPTION_KEY);
30
31
  return storage.readFile(path);
31
32
  }
32
33
  function getCliPath() {
@@ -301,6 +302,9 @@ describe('CLI commands with dependency injection', () => {
301
302
  gatewayUrl: overrides.gatewayUrl ?? null,
302
303
  gatewayListenHost: overrides.gatewayListenHost ?? 'localhost',
303
304
  gatewayListenPort: overrides.gatewayListenPort ?? 1989,
305
+ gatewayPassword: overrides.gatewayPassword ?? null,
306
+ gatewayListenPassword: overrides.gatewayListenPassword ?? null,
307
+ gatewayPermissionsOverride: overrides.gatewayPermissionsOverride ?? null,
304
308
  checkSensitiveFilePermissions: () => undefined,
305
309
  checkSystemPrerequisites: () => undefined,
306
310
  };
@@ -403,7 +407,7 @@ describe('CLI commands with dependency injection', () => {
403
407
  });
404
408
  it('should include services with stored credentials when using --viable', async () => {
405
409
  const storePath = join(tempDir, 'credentials.json');
406
- await writeSecureFile(storePath, JSON.stringify({
410
+ writeSecureFile(storePath, JSON.stringify({
407
411
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
408
412
  }));
409
413
  const deps = createMockDependencies();
@@ -414,7 +418,7 @@ describe('CLI commands with dependency injection', () => {
414
418
  });
415
419
  it('should include services with browser auth when using --viable', async () => {
416
420
  const storePath = join(tempDir, 'credentials.json');
417
- await writeSecureFile(storePath, '{}');
421
+ writeSecureFile(storePath, '{}');
418
422
  // The default mock slack service has getSession defined, so it supports browser auth
419
423
  // Ensure a graphical environment is available so browser auth is considered viable
420
424
  const originalDisplay = process.env.DISPLAY;
@@ -437,7 +441,7 @@ describe('CLI commands with dependency injection', () => {
437
441
  });
438
442
  it('should exclude services without credentials or browser auth when using --viable', async () => {
439
443
  const storePath = join(tempDir, 'credentials.json');
440
- await writeSecureFile(storePath, '{}');
444
+ writeSecureFile(storePath, '{}');
441
445
  const noLoginService = {
442
446
  name: 'nologin',
443
447
  displayName: 'No Login Service',
@@ -463,7 +467,7 @@ describe('CLI commands with dependency injection', () => {
463
467
  });
464
468
  it('should exclude browser-capable services when browser is disabled and no credentials with --viable', async () => {
465
469
  const storePath = join(tempDir, 'credentials.json');
466
- await writeSecureFile(storePath, '{}');
470
+ writeSecureFile(storePath, '{}');
467
471
  const deps = createMockDependencies({
468
472
  config: createMockConfig({ browserDisabled: true }),
469
473
  });
@@ -474,7 +478,7 @@ describe('CLI commands with dependency injection', () => {
474
478
  });
475
479
  it('should exclude browser-capable services when no graphical environment and no credentials with --viable', async () => {
476
480
  const storePath = join(tempDir, 'credentials.json');
477
- await writeSecureFile(storePath, '{}');
481
+ writeSecureFile(storePath, '{}');
478
482
  const originalPlatform = process.platform;
479
483
  const originalDisplay = process.env.DISPLAY;
480
484
  const originalWayland = process.env.WAYLAND_DISPLAY;
@@ -506,7 +510,7 @@ describe('CLI commands with dependency injection', () => {
506
510
  });
507
511
  it('should include services with credentials even when no graphical environment with --viable', async () => {
508
512
  const storePath = join(tempDir, 'credentials.json');
509
- await writeSecureFile(storePath, JSON.stringify({
513
+ writeSecureFile(storePath, JSON.stringify({
510
514
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
511
515
  }));
512
516
  const originalPlatform = process.platform;
@@ -540,7 +544,7 @@ describe('CLI commands with dependency injection', () => {
540
544
  });
541
545
  it('should include services with credentials even when browser is disabled with --viable', async () => {
542
546
  const storePath = join(tempDir, 'credentials.json');
543
- await writeSecureFile(storePath, JSON.stringify({
547
+ writeSecureFile(storePath, JSON.stringify({
544
548
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
545
549
  }));
546
550
  const deps = createMockDependencies({
@@ -553,7 +557,7 @@ describe('CLI commands with dependency injection', () => {
553
557
  });
554
558
  it('should combine --builtin and --viable filters', async () => {
555
559
  const storePath = join(tempDir, 'credentials.json');
556
- await writeSecureFile(storePath, JSON.stringify({
560
+ writeSecureFile(storePath, JSON.stringify({
557
561
  'my-gitlab': {
558
562
  objectType: 'rawCurl',
559
563
  curlArguments: ['-H', 'PRIVATE-TOKEN: token'],
@@ -587,7 +591,7 @@ describe('CLI commands with dependency injection', () => {
587
591
  describe('services info command', () => {
588
592
  it('should show login options, credentials status, and developer notes', async () => {
589
593
  const storePath = join(tempDir, 'credentials.json');
590
- await writeSecureFile(storePath, '{}');
594
+ writeSecureFile(storePath, '{}');
591
595
  const deps = createMockDependencies();
592
596
  await runCommand(['services', 'info', 'slack'], deps);
593
597
  expect(logs).toHaveLength(1);
@@ -601,7 +605,7 @@ describe('CLI commands with dependency injection', () => {
601
605
  });
602
606
  it('should show auth set only for services without browser login', async () => {
603
607
  const storePath = join(tempDir, 'credentials.json');
604
- await writeSecureFile(storePath, '{}');
608
+ writeSecureFile(storePath, '{}');
605
609
  const noLoginService = {
606
610
  name: 'nologin',
607
611
  displayName: 'No Login Service',
@@ -626,7 +630,7 @@ describe('CLI commands with dependency injection', () => {
626
630
  });
627
631
  it('should not list browser in authOptions when LATCHKEY_DISABLE_BROWSER is in effect', async () => {
628
632
  const storePath = join(tempDir, 'credentials.json');
629
- await writeSecureFile(storePath, '{}');
633
+ writeSecureFile(storePath, '{}');
630
634
  const deps = createMockDependencies({
631
635
  config: createMockConfig({ browserDisabled: true }),
632
636
  });
@@ -636,7 +640,7 @@ describe('CLI commands with dependency injection', () => {
636
640
  });
637
641
  it('should show valid credentials status when credentials are valid', async () => {
638
642
  const storePath = join(tempDir, 'credentials.json');
639
- await writeSecureFile(storePath, JSON.stringify({
643
+ writeSecureFile(storePath, JSON.stringify({
640
644
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
641
645
  }));
642
646
  const deps = createMockDependencies();
@@ -652,7 +656,7 @@ describe('CLI commands with dependency injection', () => {
652
656
  });
653
657
  it('should show type as registered for registered services', async () => {
654
658
  const storePath = join(tempDir, 'credentials.json');
655
- await writeSecureFile(storePath, '{}');
659
+ writeSecureFile(storePath, '{}');
656
660
  const registeredService = new RegisteredService('my-gitlab', 'https://gitlab.example.com');
657
661
  const deps = createMockDependencies();
658
662
  deps.registry.addService(registeredService);
@@ -664,12 +668,12 @@ describe('CLI commands with dependency injection', () => {
664
668
  describe('clear command', () => {
665
669
  it('should delete credentials for a service', async () => {
666
670
  const storePath = join(tempDir, 'credentials.json');
667
- await writeSecureFile(storePath, JSON.stringify({
671
+ writeSecureFile(storePath, JSON.stringify({
668
672
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
669
673
  }));
670
674
  const deps = createMockDependencies();
671
675
  await runCommand(['auth', 'clear', 'slack'], deps);
672
- const storedData = JSON.parse((await readSecureFile(storePath)) ?? '{}');
676
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
673
677
  expect(storedData.slack).toBeUndefined();
674
678
  });
675
679
  it('should return error for unknown service', async () => {
@@ -680,13 +684,13 @@ describe('CLI commands with dependency injection', () => {
680
684
  });
681
685
  it('should preserve other services when clearing one', async () => {
682
686
  const storePath = join(tempDir, 'credentials.json');
683
- await writeSecureFile(storePath, JSON.stringify({
687
+ writeSecureFile(storePath, JSON.stringify({
684
688
  slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
685
689
  discord: { objectType: 'authorizationBare', token: 'discord-token' },
686
690
  }));
687
691
  const deps = createMockDependencies();
688
692
  await runCommand(['auth', 'clear', 'slack'], deps);
689
- const storedData = JSON.parse((await readSecureFile(storePath)) ?? '{}');
693
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
690
694
  expect(storedData.slack).toBeUndefined();
691
695
  expect(storedData.discord).toBeDefined();
692
696
  expect(storedData.discord?.token).toBe('discord-token');
@@ -694,8 +698,8 @@ describe('CLI commands with dependency injection', () => {
694
698
  it('should delete both store and browser state with -y flag', async () => {
695
699
  const storePath = join(tempDir, 'credentials.json');
696
700
  const browserStatePath = join(tempDir, 'browser_state.json');
697
- await writeSecureFile(storePath, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
698
- await writeSecureFile(browserStatePath, '{}');
701
+ writeSecureFile(storePath, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
702
+ writeSecureFile(browserStatePath, '{}');
699
703
  const deps = createMockDependencies();
700
704
  await runCommand(['auth', 'clear', '-y'], deps);
701
705
  expect(existsSync(storePath)).toBe(false);
@@ -705,7 +709,7 @@ describe('CLI commands with dependency injection', () => {
705
709
  describe('auth list command', () => {
706
710
  it('should list stored credentials with their status', async () => {
707
711
  const storePath = join(tempDir, 'credentials.json');
708
- await writeSecureFile(storePath, JSON.stringify({
712
+ writeSecureFile(storePath, JSON.stringify({
709
713
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
710
714
  }));
711
715
  const deps = createMockDependencies();
@@ -719,7 +723,7 @@ describe('CLI commands with dependency injection', () => {
719
723
  });
720
724
  it('should output empty object when no credentials are stored', async () => {
721
725
  const storePath = join(tempDir, 'credentials.json');
722
- await writeSecureFile(storePath, '{}');
726
+ writeSecureFile(storePath, '{}');
723
727
  const deps = createMockDependencies();
724
728
  await runCommand(['auth', 'list'], deps);
725
729
  expect(logs).toHaveLength(1);
@@ -728,7 +732,7 @@ describe('CLI commands with dependency injection', () => {
728
732
  });
729
733
  it('should treat unknown services as valid', async () => {
730
734
  const storePath = join(tempDir, 'credentials.json');
731
- await writeSecureFile(storePath, JSON.stringify({
735
+ writeSecureFile(storePath, JSON.stringify({
732
736
  unknown: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: secret'] },
733
737
  }));
734
738
  const deps = createMockDependencies();
@@ -744,11 +748,11 @@ describe('CLI commands with dependency injection', () => {
744
748
  describe('auth set command', () => {
745
749
  it('should store raw curl credentials', async () => {
746
750
  const storePath = join(tempDir, 'credentials.json');
747
- await writeSecureFile(storePath, '{}');
751
+ writeSecureFile(storePath, '{}');
748
752
  const deps = createMockDependencies();
749
753
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: secret', '-H', 'X-Other: value'], deps);
750
754
  expect(logs).toContain('Credentials stored.');
751
- const storedData = JSON.parse((await readSecureFile(storePath)) ?? '{}');
755
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
752
756
  expect(storedData.slack).toEqual({
753
757
  objectType: 'rawCurl',
754
758
  curlArguments: ['-H', 'X-Token: secret', '-H', 'X-Other: value'],
@@ -771,13 +775,13 @@ describe('CLI commands with dependency injection', () => {
771
775
  });
772
776
  it('should overwrite existing credentials', async () => {
773
777
  const storePath = join(tempDir, 'credentials.json');
774
- await writeSecureFile(storePath, JSON.stringify({
778
+ writeSecureFile(storePath, JSON.stringify({
775
779
  slack: { objectType: 'slack', token: 'old-token', dCookie: 'old-cookie' },
776
780
  }));
777
781
  const deps = createMockDependencies();
778
782
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: new-secret'], deps);
779
783
  expect(logs).toContain('Credentials stored.');
780
- const storedData = JSON.parse((await readSecureFile(storePath)) ?? '{}');
784
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
781
785
  expect(storedData.slack).toEqual({
782
786
  objectType: 'rawCurl',
783
787
  curlArguments: ['-H', 'X-Token: new-secret'],
@@ -787,13 +791,13 @@ describe('CLI commands with dependency injection', () => {
787
791
  describe('auth set-nocurl command', () => {
788
792
  it('should store telegram bot credentials', async () => {
789
793
  const storePath = join(tempDir, 'credentials.json');
790
- await writeSecureFile(storePath, '{}');
794
+ writeSecureFile(storePath, '{}');
791
795
  const deps = createMockDependencies({
792
796
  registry: new ServiceRegistry([TELEGRAM]),
793
797
  });
794
798
  await runCommand(['auth', 'set-nocurl', 'telegram', '123456:ABC-DEF'], deps);
795
799
  expect(logs).toContain('Credentials stored.');
796
- const storedData = JSON.parse((await readSecureFile(storePath)) ?? '{}');
800
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
797
801
  expect(storedData.telegram).toEqual({
798
802
  objectType: 'telegramBot',
799
803
  token: '123456:ABC-DEF',
@@ -827,7 +831,7 @@ describe('CLI commands with dependency injection', () => {
827
831
  describe('curl command', () => {
828
832
  it('should pass arguments to subprocess', async () => {
829
833
  const storePath = join(tempDir, 'credentials.json');
830
- await writeSecureFile(storePath, JSON.stringify({
834
+ writeSecureFile(storePath, JSON.stringify({
831
835
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
832
836
  }));
833
837
  const deps = createMockDependencies();
@@ -843,7 +847,7 @@ describe('CLI commands with dependency injection', () => {
843
847
  });
844
848
  it('should pass raw curl credentials to subprocess', async () => {
845
849
  const storePath = join(tempDir, 'credentials.json');
846
- await writeSecureFile(storePath, JSON.stringify({
850
+ writeSecureFile(storePath, JSON.stringify({
847
851
  slack: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Custom: header'] },
848
852
  }));
849
853
  const deps = createMockDependencies();
@@ -853,7 +857,7 @@ describe('CLI commands with dependency injection', () => {
853
857
  });
854
858
  it('should pass multiple arguments correctly', async () => {
855
859
  const storePath = join(tempDir, 'credentials.json');
856
- await writeSecureFile(storePath, JSON.stringify({
860
+ writeSecureFile(storePath, JSON.stringify({
857
861
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
858
862
  }));
859
863
  const deps = createMockDependencies();
@@ -875,7 +879,7 @@ describe('CLI commands with dependency injection', () => {
875
879
  });
876
880
  it('should return subprocess exit code', async () => {
877
881
  const storePath = join(tempDir, 'credentials.json');
878
- await writeSecureFile(storePath, JSON.stringify({
882
+ writeSecureFile(storePath, JSON.stringify({
879
883
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
880
884
  }));
881
885
  const deps = createMockDependencies({
@@ -905,7 +909,7 @@ describe('CLI commands with dependency injection', () => {
905
909
  });
906
910
  it('should pass through missing credentials when passthroughUnknown is enabled', async () => {
907
911
  const storePath = join(tempDir, 'credentials.json');
908
- await writeSecureFile(storePath, '{}');
912
+ writeSecureFile(storePath, '{}');
909
913
  const deps = createMockDependencies({
910
914
  config: createMockConfig({ passthroughUnknown: true }),
911
915
  });
@@ -916,7 +920,7 @@ describe('CLI commands with dependency injection', () => {
916
920
  });
917
921
  it('should still inject credentials for known services when passthroughUnknown is enabled', async () => {
918
922
  const storePath = join(tempDir, 'credentials.json');
919
- await writeSecureFile(storePath, JSON.stringify({
923
+ writeSecureFile(storePath, JSON.stringify({
920
924
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
921
925
  }));
922
926
  const deps = createMockDependencies({
@@ -928,7 +932,7 @@ describe('CLI commands with dependency injection', () => {
928
932
  });
929
933
  it('should read credentials from store and not call login', async () => {
930
934
  const storePath = join(tempDir, 'credentials.json');
931
- await writeSecureFile(storePath, JSON.stringify({
935
+ writeSecureFile(storePath, JSON.stringify({
932
936
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
933
937
  }));
934
938
  const mockLogin = vi.fn();
@@ -957,14 +961,14 @@ describe('CLI commands with dependency injection', () => {
957
961
  });
958
962
  it('should return error when no credentials in store', async () => {
959
963
  const storePath = join(tempDir, 'credentials.json');
960
- await writeSecureFile(storePath, '{}');
964
+ writeSecureFile(storePath, '{}');
961
965
  const deps = createMockDependencies();
962
966
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
963
967
  expect(exitCode).toBe(1);
964
968
  });
965
969
  it('should inject telegram bot token into URL path', async () => {
966
970
  const storePath = join(tempDir, 'credentials.json');
967
- await writeSecureFile(storePath, JSON.stringify({
971
+ writeSecureFile(storePath, JSON.stringify({
968
972
  telegram: { objectType: 'telegramBot', token: '123456:ABC-DEF' },
969
973
  }));
970
974
  const deps = createMockDependencies({
@@ -976,7 +980,7 @@ describe('CLI commands with dependency injection', () => {
976
980
  });
977
981
  it('should work when service does not have getSession but credentials exist', async () => {
978
982
  const storePath = join(tempDir, 'credentials.json');
979
- await writeSecureFile(storePath, JSON.stringify({
983
+ writeSecureFile(storePath, JSON.stringify({
980
984
  nologin: { objectType: 'rawCurl', curlArguments: ['-H', 'X-API-Key: secret'] },
981
985
  }));
982
986
  const noLoginService = {
@@ -1005,7 +1009,7 @@ describe('CLI commands with dependency injection', () => {
1005
1009
  });
1006
1010
  it('should reject request when permission check denies it', async () => {
1007
1011
  const storePath = join(tempDir, 'credentials.json');
1008
- await writeSecureFile(storePath, JSON.stringify({
1012
+ writeSecureFile(storePath, JSON.stringify({
1009
1013
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1010
1014
  }));
1011
1015
  const deps = createMockDependencies({
@@ -1018,7 +1022,7 @@ describe('CLI commands with dependency injection', () => {
1018
1022
  });
1019
1023
  it('should allow request when permission check approves it', async () => {
1020
1024
  const storePath = join(tempDir, 'credentials.json');
1021
- await writeSecureFile(storePath, JSON.stringify({
1025
+ writeSecureFile(storePath, JSON.stringify({
1022
1026
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1023
1027
  }));
1024
1028
  const deps = createMockDependencies({
@@ -1030,7 +1034,7 @@ describe('CLI commands with dependency injection', () => {
1030
1034
  });
1031
1035
  it('should exit with error when permission check fails', async () => {
1032
1036
  const storePath = join(tempDir, 'credentials.json');
1033
- await writeSecureFile(storePath, JSON.stringify({
1037
+ writeSecureFile(storePath, JSON.stringify({
1034
1038
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1035
1039
  }));
1036
1040
  const { PermissionCheckError } = await import('../src/permissions.js');
@@ -1070,7 +1074,7 @@ describe('CLI commands with dependency injection', () => {
1070
1074
  });
1071
1075
  it('should return error when no graphical environment is available', async () => {
1072
1076
  const storePath = join(tempDir, 'credentials.json');
1073
- await writeSecureFile(storePath, '{}');
1077
+ writeSecureFile(storePath, '{}');
1074
1078
  const originalPlatform = process.platform;
1075
1079
  const originalDisplay = process.env.DISPLAY;
1076
1080
  const originalWayland = process.env.WAYLAND_DISPLAY;
@@ -1235,7 +1239,7 @@ describe('CLI commands with dependency injection', () => {
1235
1239
  });
1236
1240
  it('should not expose browser auth without --login-url', async () => {
1237
1241
  const storePath = join(tempDir, 'credentials.json');
1238
- await writeSecureFile(storePath, '{}');
1242
+ writeSecureFile(storePath, '{}');
1239
1243
  const deps = createMockDependencies({
1240
1244
  registry: new ServiceRegistry([GITLAB]),
1241
1245
  });
@@ -1338,7 +1342,7 @@ describe('CLI commands with dependency injection', () => {
1338
1342
  ], deps);
1339
1343
  // Now store credentials for it
1340
1344
  const storePath = join(tempDir, 'credentials.json');
1341
- await writeSecureFile(storePath, '{}');
1345
+ writeSecureFile(storePath, '{}');
1342
1346
  logs = [];
1343
1347
  exitCode = null;
1344
1348
  await runCommand(['auth', 'set', 'my-gitlab', '-H', 'PRIVATE-TOKEN: my-secret-token'], deps);
@@ -1370,7 +1374,7 @@ describe('CLI commands with dependency injection', () => {
1370
1374
  });
1371
1375
  it('should not expose browser auth for service without family', async () => {
1372
1376
  const storePath = join(tempDir, 'credentials.json');
1373
- await writeSecureFile(storePath, '{}');
1377
+ writeSecureFile(storePath, '{}');
1374
1378
  const deps = createMockDependencies({
1375
1379
  registry: new ServiceRegistry([GITLAB]),
1376
1380
  });
@@ -1389,7 +1393,7 @@ describe('CLI commands with dependency injection', () => {
1389
1393
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1390
1394
  // Store credentials
1391
1395
  const storePath = join(tempDir, 'credentials.json');
1392
- await writeSecureFile(storePath, JSON.stringify({
1396
+ writeSecureFile(storePath, JSON.stringify({
1393
1397
  'my-api': {
1394
1398
  objectType: 'rawCurl',
1395
1399
  curlArguments: ['-H', 'Authorization: Bearer my-token'],
@@ -1443,7 +1447,7 @@ describe('CLI commands with dependency injection', () => {
1443
1447
  ], deps);
1444
1448
  // Store credentials
1445
1449
  const storePath = join(tempDir, 'credentials.json');
1446
- await writeSecureFile(storePath, JSON.stringify({
1450
+ writeSecureFile(storePath, JSON.stringify({
1447
1451
  'my-gitlab': {
1448
1452
  objectType: 'rawCurl',
1449
1453
  curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
@@ -1526,7 +1530,7 @@ describe('CLI commands with dependency injection', () => {
1526
1530
  ], deps);
1527
1531
  // Store credentials for it
1528
1532
  const storePath = join(tempDir, 'credentials.json');
1529
- await writeSecureFile(storePath, JSON.stringify({
1533
+ writeSecureFile(storePath, JSON.stringify({
1530
1534
  'my-gitlab': {
1531
1535
  objectType: 'rawCurl',
1532
1536
  curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
@@ -1559,7 +1563,7 @@ describe('CLI commands with dependency injection', () => {
1559
1563
  ], deps);
1560
1564
  // Store and then clear credentials
1561
1565
  const storePath = join(tempDir, 'credentials.json');
1562
- await writeSecureFile(storePath, JSON.stringify({
1566
+ writeSecureFile(storePath, JSON.stringify({
1563
1567
  'my-gitlab': {
1564
1568
  objectType: 'rawCurl',
1565
1569
  curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
@@ -1578,6 +1582,44 @@ describe('CLI commands with dependency injection', () => {
1578
1582
  expect(logs).toContain("Service 'my-gitlab' deregistered.");
1579
1583
  });
1580
1584
  });
1585
+ describe('gateway create-jwt command', () => {
1586
+ it('prints a JWT for an existing absolute path', async () => {
1587
+ const permissionsPath = join(tempDir, 'permissions.json');
1588
+ writeFileSync(permissionsPath, '{}');
1589
+ const deps = createMockDependencies();
1590
+ await runCommand(['gateway', 'create-jwt', permissionsPath], deps);
1591
+ expect(exitCode).toBeNull();
1592
+ expect(logs).toHaveLength(1);
1593
+ const jwt = logs[0];
1594
+ expect(jwt.split('.')).toHaveLength(3);
1595
+ const signingKey = derivePermissionsOverrideSigningKey(TEST_ENCRYPTION_KEY);
1596
+ const payload = verifyPermissionsOverrideJwt(jwt, signingKey);
1597
+ expect(payload.permissionsConfig).toBe(permissionsPath);
1598
+ });
1599
+ it('refuses to issue a JWT when the path does not exist', async () => {
1600
+ const missingPath = join(tempDir, 'missing.json');
1601
+ const deps = createMockDependencies();
1602
+ await runCommand(['gateway', 'create-jwt', missingPath], deps);
1603
+ expect(exitCode).toBe(1);
1604
+ expect(errorLogs.some((message) => message.includes('does not exist'))).toBe(true);
1605
+ });
1606
+ it('issues a JWT without checking existence when --no-validate is given', async () => {
1607
+ const missingPath = join(tempDir, 'missing.json');
1608
+ const deps = createMockDependencies();
1609
+ await runCommand(['gateway', 'create-jwt', missingPath, '--no-validate'], deps);
1610
+ expect(exitCode).toBeNull();
1611
+ expect(logs).toHaveLength(1);
1612
+ const signingKey = derivePermissionsOverrideSigningKey(TEST_ENCRYPTION_KEY);
1613
+ const payload = verifyPermissionsOverrideJwt(logs[0], signingKey);
1614
+ expect(payload.permissionsConfig).toBe(missingPath);
1615
+ });
1616
+ it('rejects a non-absolute path', async () => {
1617
+ const deps = createMockDependencies();
1618
+ await runCommand(['gateway', 'create-jwt', 'relative.json', '--no-validate'], deps);
1619
+ expect(exitCode).toBe(1);
1620
+ expect(errorLogs.some((message) => message.includes('absolute'))).toBe(true);
1621
+ });
1622
+ });
1581
1623
  describe('gateway mode (LATCHKEY_GATEWAY)', () => {
1582
1624
  const GATEWAY_URL = 'http://localhost:9000';
1583
1625
  const originalFetch = globalThis.fetch;