@bevel-software/platform-core-backend 0.13.6 → 0.14.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 (111) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +2 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +9 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +12 -5
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  12. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  13. package/dist/modules/access-model/kb-read-filter.js +15 -0
  14. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  15. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  16. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  17. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  18. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  19. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  20. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  21. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  22. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  23. package/dist/modules/connection-probe/index.d.ts +3 -0
  24. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  25. package/dist/modules/connection-probe/index.js +3 -0
  26. package/dist/modules/connection-probe/index.js.map +1 -0
  27. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  28. package/dist/modules/diff/diff.routes.js +3 -5
  29. package/dist/modules/diff/diff.routes.js.map +1 -1
  30. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  31. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  32. package/dist/modules/kb-fs/mutex.js +48 -5
  33. package/dist/modules/kb-fs/mutex.js.map +1 -1
  34. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  35. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  36. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  37. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  38. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  39. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  40. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  41. package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
  42. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  43. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  44. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  45. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  46. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  47. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  48. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
  49. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  50. package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
  51. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  52. package/dist/modules/workflow/git/git.service.d.ts +18 -1
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +98 -6
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  57. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  58. package/dist/modules/workflow/workflow.routes.js +98 -9
  59. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  60. package/dist/modules/workflow/workflow.service.d.ts +81 -8
  61. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  62. package/dist/modules/workflow/workflow.service.js +220 -43
  63. package/dist/modules/workflow/workflow.service.js.map +1 -1
  64. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  65. package/dist/modules/workspace/workspace.routes.js +2 -5
  66. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  67. package/dist/modules/workspace/workspace.service.d.ts +5 -1
  68. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  69. package/dist/modules/workspace/workspace.service.js +21 -4
  70. package/dist/modules/workspace/workspace.service.js.map +1 -1
  71. package/dist/shared/token-crypto.d.ts +17 -2
  72. package/dist/shared/token-crypto.d.ts.map +1 -1
  73. package/dist/shared/token-crypto.js +25 -8
  74. package/dist/shared/token-crypto.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/__tests__/core-config.admin.test.ts +21 -0
  77. package/src/core/create-core-server.ts +3 -0
  78. package/src/core/create-core-services.ts +11 -0
  79. package/src/core-config.ts +14 -7
  80. package/src/modules/access-model/kb-read-filter.ts +22 -0
  81. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  82. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  83. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  84. package/src/modules/connection-probe/index.ts +2 -0
  85. package/src/modules/diff/diff.routes.ts +9 -5
  86. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  87. package/src/modules/kb-fs/mutex.ts +50 -5
  88. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  89. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  90. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  91. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  92. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  93. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
  94. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  95. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  96. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
  97. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  98. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  99. package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
  100. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  101. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  102. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  103. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
  104. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  105. package/src/modules/workflow/git/git.service.ts +115 -7
  106. package/src/modules/workflow/workflow.routes.ts +104 -4
  107. package/src/modules/workflow/workflow.service.ts +245 -55
  108. package/src/modules/workspace/workspace.routes.ts +8 -4
  109. package/src/modules/workspace/workspace.service.ts +21 -3
  110. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  111. package/src/shared/token-crypto.ts +28 -10
@@ -0,0 +1,2 @@
1
+ export * from './connection-probe.contract.js';
2
+ export { ConnectionProbeService } from './connection-probe.service.js';
@@ -3,7 +3,7 @@ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared
3
3
  import type { IDiffService } from './diff.interface.js';
4
4
  import type { AuthService } from '../auth/auth.service.js';
5
5
  import type { IAccessControl } from '../access/access-control.interface.js';
6
- import { toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
6
+ import { canReadWorkspacePath, toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
7
7
  import { WorkflowDomainError, WorkflowValidationError } from '../../shared/domain-errors.js';
8
8
  import { LockingFilesystem } from '../kb-fs/locking-filesystem.js';
9
9
  import { branchForWorkspaceId } from '../../shared/workspace-id.js';
@@ -138,10 +138,14 @@ export function createDiffRoutes(
138
138
  }
139
139
 
140
140
  /** Read gate for a single change path (mirrors GET /review/file). Returns true to proceed. */
141
- async function canReadPath(workspaceId: string, email: string, pathParam: string): Promise<boolean> {
142
- const rel = toKbRelative(pathParam, kbDirName);
143
- // Non-KB paths (toKbRelative → null) carry no read rules and pass.
144
- return rel === null || (await accessControl.canRead(workspaceId, email, rel));
141
+ function canReadPath(workspaceId: string, email: string, pathParam: string): Promise<boolean> {
142
+ return canReadWorkspacePath(
143
+ (w, e, p) => accessControl.canRead(w, e, p),
144
+ workspaceId,
145
+ email,
146
+ kbDirName,
147
+ pathParam,
148
+ );
145
149
  }
146
150
 
147
151
  router.get('/workspace/:id/review', async (req, res) => {
@@ -8,6 +8,113 @@ function deferred<T = void>() {
8
8
  }
9
9
 
10
10
  describe('WorkspaceMutex', () => {
11
+ describe('runAll', () => {
12
+ /**
13
+ * The property nesting cannot provide: `run(a, () => run(b, fn))` releases
14
+ * nothing, but between taking `a` and taking `b` the second key is free,
15
+ * so a single-key task can slip in, run to completion, and be gone before
16
+ * the pair operation's body starts. `runAll` reserves the set in one
17
+ * synchronous step, so it cannot be interleaved that way.
18
+ */
19
+ it('reserves every key before any of them can be taken by another task', async () => {
20
+ const mtx = new WorkspaceMutex();
21
+ const order: string[] = [];
22
+ const release = deferred();
23
+
24
+ // Something already holds 'a', so the pair operation cannot start yet —
25
+ // this is the window a nested acquisition would leave 'b' open in.
26
+ const holder = mtx.run('a', async () => {
27
+ order.push('holder-start');
28
+ await release.promise;
29
+ order.push('holder-end');
30
+ });
31
+ await new Promise((r) => setImmediate(r));
32
+
33
+ const pair = mtx.runAll(['a', 'b'], async () => {
34
+ order.push('pair');
35
+ });
36
+ // Submitted while the pair is still waiting on 'a'. If 'b' were taken
37
+ // only after 'a' were granted, this would run first.
38
+ const single = mtx.run('b', async () => {
39
+ order.push('single-b');
40
+ });
41
+
42
+ release.resolve();
43
+ await Promise.all([holder, pair, single]);
44
+ expect(order).toEqual(['holder-start', 'holder-end', 'pair', 'single-b']);
45
+ });
46
+
47
+ it('lets a task on an unrelated key run in parallel', async () => {
48
+ const mtx = new WorkspaceMutex();
49
+ const order: string[] = [];
50
+ const release = deferred();
51
+
52
+ const holder = mtx.run('a', async () => {
53
+ await release.promise;
54
+ order.push('holder');
55
+ });
56
+ await new Promise((r) => setImmediate(r));
57
+
58
+ const pair = mtx.runAll(['a', 'b'], async () => void order.push('pair'));
59
+ const other = mtx.run('c', async () => void order.push('other'));
60
+
61
+ await other;
62
+ expect(order).toEqual(['other']);
63
+ release.resolve();
64
+ await Promise.all([holder, pair]);
65
+ expect(order).toEqual(['other', 'holder', 'pair']);
66
+ });
67
+
68
+ /**
69
+ * A failing task on ONE key must not release the others. `Promise.all`
70
+ * settles on the first rejection rather than waiting for the rest, so
71
+ * combining the predecessors without catching each one first would start
72
+ * this task while another key was still held.
73
+ */
74
+ it('waits for every predecessor even when one of them fails first', async () => {
75
+ const mtx = new WorkspaceMutex();
76
+ const order: string[] = [];
77
+ const failA = deferred();
78
+ const finishB = deferred();
79
+
80
+ const failing = mtx.run('a', async () => {
81
+ await failA.promise;
82
+ throw new Error('a-failed');
83
+ });
84
+ failing.catch(() => undefined); // handled below; keep the rejection quiet
85
+ const running = mtx.run('b', async () => {
86
+ await finishB.promise;
87
+ order.push('b-end');
88
+ });
89
+
90
+ // Queued while BOTH predecessors are still pending.
91
+ const pair = mtx.runAll(['a', 'b'], async () => void order.push('pair'));
92
+
93
+ failA.resolve();
94
+ await expect(failing).rejects.toThrow('a-failed');
95
+ await new Promise((r) => setImmediate(r));
96
+ // 'a' has failed, but 'b' still holds its key — the pair must wait.
97
+ expect(order).toEqual([]);
98
+
99
+ finishB.resolve();
100
+ await Promise.all([running, pair]);
101
+ expect(order).toEqual(['b-end', 'pair']);
102
+ });
103
+
104
+ it('releases every key when the body throws', async () => {
105
+ const mtx = new WorkspaceMutex();
106
+ await expect(mtx.runAll(['a', 'b'], async () => { throw new Error('boom'); })).rejects.toThrow('boom');
107
+ // Both keys must be usable again, or one failure wedges them forever.
108
+ await expect(mtx.run('a', async () => 'a-ok')).resolves.toBe('a-ok');
109
+ await expect(mtx.run('b', async () => 'b-ok')).resolves.toBe('b-ok');
110
+ });
111
+
112
+ it('collapses a duplicate key instead of waiting on itself', async () => {
113
+ const mtx = new WorkspaceMutex();
114
+ await expect(mtx.runAll(['a', 'a'], async () => 'done')).resolves.toBe('done');
115
+ });
116
+ });
117
+
11
118
  it('serializes tasks for the same key in submission order', async () => {
12
119
  const mtx = new WorkspaceMutex();
13
120
  const order: string[] = [];
@@ -8,14 +8,59 @@ export class WorkspaceMutex {
8
8
  private readonly tails = new Map<string, Promise<unknown>>();
9
9
 
10
10
  async run<T>(key: string, fn: () => Promise<T>): Promise<T> {
11
- const prev = this.tails.get(key) ?? Promise.resolve();
12
- const next = prev.catch(() => undefined).then(fn);
13
- this.tails.set(key, next);
11
+ return this.runAll([key], fn);
12
+ }
13
+
14
+ /**
15
+ * Reserve SEVERAL keys for one operation, atomically.
16
+ *
17
+ * Nesting `run(a, () => run(b, fn))` does NOT do this: between taking `a`
18
+ * and taking `b` there is a gap in which another operation can take `b`,
19
+ * finish, and release it — so an operation holding both keys can still be
20
+ * interleaved with one holding just the second. For branch lifecycle that
21
+ * gap is the whole bug: a change request being opened `Y -> X` would hold
22
+ * one branch's key while the other was deleted out from under it.
23
+ *
24
+ * This reserves the set in ONE synchronous step. The tails are read and
25
+ * rewritten without an await between, so no other call can observe a
26
+ * half-taken set: a concurrent `run` on any of these keys either already
27
+ * sits in `prev` (it goes first) or chains onto `next` (it goes after).
28
+ * Within one call nothing is ever held while waiting for something else,
29
+ * so callers that do not nest need no ordering discipline and cannot
30
+ * deadlock.
31
+ *
32
+ * NESTING is the one way to reintroduce hold-and-wait, so never nest
33
+ * `run`/`runAll` calls on the same instance. An inner call chains behind
34
+ * everything already queued on its keys, which can include a `runAll` that
35
+ * is itself waiting for the outer call to finish: `run(b, () => run(a, …))`
36
+ * was harmless when every operation took a single key, but against a
37
+ * concurrent `runAll([a, b])` it deadlocks — outer waits inner, inner
38
+ * waits the pair, the pair waits outer. No caller nests today; keep it
39
+ * that way, or hand the full key set to one `runAll` at the top.
40
+ *
41
+ * Duplicate keys collapse; the operation waits on each distinct key once.
42
+ *
43
+ * Each predecessor is caught INDIVIDUALLY, before the `Promise.all`. A bare
44
+ * `Promise.all` settles on the first rejection rather than waiting for the
45
+ * rest, so one key's task failing early would start this one while another
46
+ * key was still held — a failure on any key would punch a hole in the
47
+ * mutual exclusion of every key beside it. Catching first makes the
48
+ * combined promise wait for all of them to settle, however each ends.
49
+ */
50
+ async runAll<T>(keys: string[], fn: () => Promise<T>): Promise<T> {
51
+ const distinct = [...new Set(keys)];
52
+ const prev = Promise.all(
53
+ distinct.map((k) => (this.tails.get(k) ?? Promise.resolve()).catch(() => undefined)),
54
+ );
55
+ const next = prev.then(fn);
56
+ for (const k of distinct) this.tails.set(k, next);
14
57
  try {
15
58
  return await next;
16
59
  } finally {
17
- if (this.tails.get(key) === next) {
18
- this.tails.delete(key);
60
+ for (const k of distinct) {
61
+ if (this.tails.get(k) === next) {
62
+ this.tails.delete(k);
63
+ }
19
64
  }
20
65
  }
21
66
  }
@@ -76,6 +76,10 @@ async function baseUrlWith(auth: { userId?: string; email?: string }): Promise<s
76
76
  secretsVault,
77
77
  toolManualService,
78
78
  accessControl,
79
+ // These tests are about the credential write, not the probe.
80
+ connectionProbe: {
81
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
82
+ },
79
83
  stateSecret: 'test-secret',
80
84
  publicBackendUrl: 'http://localhost:3000',
81
85
  publicFrontendUrl: 'http://localhost:5173',
@@ -174,6 +178,10 @@ describe('GET /api/connect/pending — OAuth scope coverage', () => {
174
178
  secretsVault: vaultWithGranted(grantedScopes),
175
179
  toolManualService: oauthTool,
176
180
  accessControl,
181
+ // These tests are about the credential write, not the probe.
182
+ connectionProbe: {
183
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
184
+ },
177
185
  stateSecret: 'test-secret',
178
186
  publicBackendUrl: 'http://localhost:3000',
179
187
  publicFrontendUrl: 'http://localhost:5173',
@@ -257,6 +265,10 @@ describe('GET /api/connect/pending — tool sign-ins are not double-listed as st
257
265
  secretsVault: vault,
258
266
  toolManualService: oauthTool,
259
267
  accessControl,
268
+ // These tests are about the credential write, not the probe.
269
+ connectionProbe: {
270
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
271
+ },
260
272
  stateSecret: 'test-secret',
261
273
  publicBackendUrl: 'http://localhost:3000',
262
274
  publicFrontendUrl: 'http://localhost:5173',
@@ -31,8 +31,7 @@ const TOOL_PATH = 'Tools/weather.tool';
31
31
  const USER = 'user@x.com';
32
32
  const RETURN_TO = '/skills-and-tools/tools/weather';
33
33
 
34
- const toolManualService = {
35
- listAccessible: async () => [
34
+ const MANUALS = [
36
35
  {
37
36
  slug: 'weather',
38
37
  name: 'weather',
@@ -51,7 +50,10 @@ const toolManualService = {
51
50
  },
52
51
  ],
53
52
  },
54
- ],
53
+ ];
54
+
55
+ const toolManualService = {
56
+ listAccessible: async () => MANUALS,
55
57
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
56
58
 
57
59
  const accessControl = {
@@ -65,10 +67,15 @@ const secretsVault = {
65
67
  completeOAuth,
66
68
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
67
69
 
70
+ const connectionProbe = {
71
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
72
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['connectionProbe'];
73
+
68
74
  const deps = {
69
75
  secretsVault,
70
76
  toolManualService,
71
77
  accessControl,
78
+ connectionProbe,
72
79
  stateSecret: STATE_SECRET,
73
80
  publicBackendUrl: 'http://localhost:3000',
74
81
  publicFrontendUrl: FRONTEND,
@@ -28,6 +28,9 @@ const toolManualService = {
28
28
  setup: { kind: 'oauth-manual' as const, reason: 'no authorization-server metadata at https://weather.example' },
29
29
  variables: [
30
30
  { name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
31
+ // A plain per-user key, so the user-scoped write path (and the
32
+ // per-user invalidation it owes) is exercised alongside the shared one.
33
+ { name: 'MY_KEY', scope: 'user' as const, label: 'Your key' },
31
34
  {
32
35
  name: 'SIGNIN',
33
36
  scope: 'user' as const,
@@ -56,9 +59,11 @@ const toolManualService = {
56
59
  ],
57
60
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
58
61
 
62
+ const putStatic = vi.fn(async () => ({ id: 'u1' }));
59
63
  const putSharedStatic = vi.fn(async () => ({ id: 's1' }));
60
64
  const putSharedOAuthClientSecret = vi.fn(async () => {});
61
65
  const secretsVault = {
66
+ putStatic,
62
67
  putSharedStatic,
63
68
  putSharedOAuthClientSecret,
64
69
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
@@ -69,6 +74,10 @@ const accessControl = {
69
74
  canWrite: async (_ws: string, email: string, path: string) => email === WRITER && path === TOOL_PATH,
70
75
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['accessControl'];
71
76
 
77
+ const connectionProbe = {
78
+ probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
79
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['connectionProbe'];
80
+
72
81
  let httpServer: HttpServer | undefined;
73
82
 
74
83
  async function baseUrlAs(email: string): Promise<string> {
@@ -85,6 +94,7 @@ async function baseUrlAs(email: string): Promise<string> {
85
94
  secretsVault,
86
95
  toolManualService,
87
96
  accessControl,
97
+ connectionProbe,
88
98
  stateSecret: 'test-secret',
89
99
  publicBackendUrl: 'http://localhost:3000',
90
100
  publicFrontendUrl: 'http://localhost:5173',
@@ -102,6 +112,7 @@ afterEach(async () => {
102
112
  httpServer = undefined;
103
113
  putSharedStatic.mockClear();
104
114
  putSharedOAuthClientSecret.mockClear();
115
+ putStatic.mockClear();
105
116
  });
106
117
 
107
118
  describe('tool owner gate — shared config requires WRITE on the `.tool` file', () => {
@@ -116,6 +127,21 @@ describe('tool owner gate — shared config requires WRITE on the `.tool` file',
116
127
  expect(putSharedStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_SHARED_KEY' }));
117
128
  });
118
129
 
130
+ it('a mere READER may still set their OWN value for the same tool', async () => {
131
+ // The gate is on the SHARED value only. One person's own key says nothing
132
+ // about anyone else's, so needing write access to the `.tool` file to type
133
+ // your own credential would lock every reader out of the tools they can see.
134
+ const base = await baseUrlAs(READER);
135
+ const res = await fetch(`${base}/api/secrets/tools/weather/vars/MY_KEY/user`, {
136
+ method: 'PUT',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ value: 'mine-123' }),
139
+ });
140
+ expect(res.status).toBe(201);
141
+ expect(putStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_MY_KEY' }));
142
+ expect(putSharedStatic).not.toHaveBeenCalled();
143
+ });
144
+
119
145
  it('a non-writer is refused (403), even though they can READ the tool', async () => {
120
146
  const base = await baseUrlAs(READER);
121
147
  const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
@@ -97,7 +97,7 @@ export class DbSecretsVaultService implements ISecretsVaultService {
97
97
  if (!this.cryptoInstance) {
98
98
  if (!this.encKey) {
99
99
  throw new InvalidSecretError(
100
- 'Secrets require an encryption key — set CONNECTOR_CONFIG_ENC_KEY (or SHAREPOINT_TOKEN_ENC_KEY).',
100
+ 'Secrets require an encryption key — set SECRETS_ENC_KEY.',
101
101
  );
102
102
  }
103
103
  this.cryptoInstance = new TokenCrypto(this.encKey);
@@ -10,6 +10,7 @@ import {
10
10
  type ISecretsVaultService,
11
11
  } from './secrets-vault.contract.js';
12
12
  import type { IToolManualService, ToolManualSummary, ToolVariable } from '../tool-manuals/tool-manuals.contract.js';
13
+ import type { IConnectionProbeService } from '../connection-probe/connection-probe.contract.js';
13
14
  import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
14
15
  import type { IAccessControl } from '../access/access-control.interface.js';
15
16
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
@@ -21,6 +22,11 @@ export interface SecretsVaultRoutesDeps {
21
22
  toolManualService: IToolManualService;
22
23
  /** Gates who may set a tool's ADMIN (shared) secrets — writers of the `.tool` file. */
23
24
  accessControl: IAccessControl;
25
+ /**
26
+ * Verdicts of the last credential PROBE per tool — what makes the difference
27
+ * between "a key is stored" and "the key works" visible to the UI.
28
+ */
29
+ connectionProbe: IConnectionProbeService;
24
30
  /** HMAC secret for signing the OAuth `state` (reuse the connector state secret). */
25
31
  stateSecret: string;
26
32
  /** Public base URL of THIS backend — builds the OAuth redirect URI. */
@@ -69,7 +75,7 @@ export function isSafeReturnPath(returnTo: unknown): returnTo is string {
69
75
  * `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
70
76
  */
71
77
  export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
72
- const { secretsVault, toolManualService, accessControl } = deps;
78
+ const { secretsVault, toolManualService, accessControl, connectionProbe } = deps;
73
79
  const router = express.Router();
74
80
  // A FUNCTION, not a constant. Routers are constructed at boot, and on a
75
81
  // deployment configured through the setup screen the branch model does not
@@ -361,6 +367,10 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
361
367
  value: body.value,
362
368
  label: body.label,
363
369
  });
370
+ // No probe here, and nothing to invalidate: a verdict is never stored, so
371
+ // saving a key cannot leave a stale one behind. The caller follows this
372
+ // with an explicit check, which keeps the save fast and gives the UI a
373
+ // "Testing…" state to show instead of a frozen dialog.
364
374
  res.status(201).json({ secret });
365
375
  } catch (err) {
366
376
  mapError(err, res, 'set user var');
@@ -504,6 +514,30 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
504
514
  }
505
515
  });
506
516
 
517
+ /**
518
+ * Probe one tool's credential NOW and return the verdict.
519
+ *
520
+ * A POST because it has an effect on the world — it makes a real
521
+ * authenticated call to the provider — and because browsers and proxies are
522
+ * free to cache a GET, which for a freshness check would defeat the point.
523
+ *
524
+ * A probe outcome is never an HTTP error: a rejected credential is a
525
+ * successful check that found a problem, so it comes back 200 with
526
+ * `status: 'failed'`. Only a tool that can't be found or read 404s.
527
+ */
528
+ router.post('/secrets/tools/:slug/check', async (req, res) => {
529
+ const userId = req.userId;
530
+ const email = req.userEmail;
531
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
532
+ try {
533
+ const verdict = await connectionProbe.probe(userId, email, req.params.slug);
534
+ if (!verdict) return void res.status(404).json({ error: 'Tool not found' });
535
+ res.json({ verdict });
536
+ } catch (err) {
537
+ mapError(err, res, 'check tool connection');
538
+ }
539
+ });
540
+
507
541
  return router;
508
542
  }
509
543
 
@@ -37,6 +37,17 @@ describe('descriptorsFromMcpJson', () => {
37
37
  ]);
38
38
  });
39
39
 
40
+ it('refuses an sse server rather than rebuilding it as a transport it is not', () => {
41
+ // The pinned MCP client has no sse transport; emitting `http` for an sse
42
+ // server configures a handshake the server does not speak.
43
+ const out = descriptorsFromMcpJson(
44
+ 'GTM',
45
+ JSON.stringify({ mcpServers: { legacy: { type: 'sse', url: 'https://mcp.legacy.example/sse' } } }),
46
+ null,
47
+ );
48
+ expect(out).toEqual([]);
49
+ });
50
+
40
51
  it('merges extension auth over mcp.json literals and carries variables + description', () => {
41
52
  const out = descriptorsFromMcpJson(
42
53
  'GTM',
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeToolManual } from '../tool-manuals.service.js';
3
+
4
+ /**
5
+ * The `healthCheck:` block is a URL this SERVER will fetch with the caller's
6
+ * credential attached, unattended, on every save and re-check. That makes it a
7
+ * second fetch target on the same footing as the manual's own `url`, so it is
8
+ * policed by the same SSRF rule — and held to one extra rule of its own: it may
9
+ * not mutate.
10
+ */
11
+
12
+ const toolFile = (body: string) => `---
13
+ name: acme
14
+ type: http
15
+ url: https://api.acme.test/utcp
16
+ headers:
17
+ Authorization: Bearer \${API_KEY}
18
+ ${body}---
19
+ notes
20
+ `;
21
+
22
+ const parse = (body: string) => normalizeToolManual('acme', 'Plugins/acme.tool', toolFile(body));
23
+
24
+ describe('`.tool` healthCheck', () => {
25
+ it('is absent when nothing is declared — the tool is simply unverifiable', () => {
26
+ expect(parse('').healthCheck).toBeUndefined();
27
+ });
28
+
29
+ it('inherits the manual\'s headers, so a one-line declaration still authenticates', () => {
30
+ // This is what makes the common case cheap: the credential already lives in
31
+ // `headers`, and re-typing it on the probe would be a chance to get it wrong.
32
+ expect(parse('healthCheck:\n url: https://api.acme.test/me\n').healthCheck).toEqual({
33
+ url: 'https://api.acme.test/me',
34
+ headers: { Authorization: 'Bearer ${API_KEY}' },
35
+ });
36
+ });
37
+
38
+ it('keeps its own headers when it declares them', () => {
39
+ const hc = parse('healthCheck:\n url: https://api.acme.test/me\n headers:\n X-Key: ${API_KEY}\n').healthCheck;
40
+ expect(hc?.headers).toEqual({ 'X-Key': '${API_KEY}' });
41
+ });
42
+
43
+ it("an INLINE manual's probe inherits NO top-level headers — execution never sends them", () => {
44
+ // An inline manual's real calls go through each embedded tool's own call
45
+ // template; nothing ever sends the manual's top-level `headers:`. A probe
46
+ // that inherited them proved a request no call makes — `Connected` about
47
+ // the wrong request. An inline probe declares its headers on the
48
+ // healthCheck itself, or sends none.
49
+ const inline = normalizeToolManual(
50
+ 'acme',
51
+ 'Plugins/acme.tool',
52
+ `---
53
+ name: acme
54
+ type: inline
55
+ headers:
56
+ Authorization: Bearer \${API_KEY}
57
+ healthCheck:
58
+ url: https://api.acme.test/me
59
+ tools: []
60
+ ---
61
+ `,
62
+ );
63
+ expect(inline.healthCheck?.headers).toBeUndefined();
64
+ });
65
+
66
+ it('refuses a method that could mutate', () => {
67
+ // Silently downgrading POST to GET would leave the author believing they had
68
+ // declared something we are not doing.
69
+ expect(() => parse('healthCheck:\n url: https://api.acme.test/me\n method: POST\n')).toThrow(
70
+ /may not mutate/,
71
+ );
72
+ });
73
+
74
+ it('refuses a probe pointed at an internal host', () => {
75
+ expect(() => parse('healthCheck:\n url: http://169.254.169.254/latest/meta-data\n')).toThrow(
76
+ /not allowed/,
77
+ );
78
+ });
79
+
80
+ it('refuses a declaration with no url', () => {
81
+ expect(() => parse('healthCheck:\n method: GET\n')).toThrow(/must have a `url`/);
82
+ });
83
+
84
+ /**
85
+ * A local-only `.tool` is never fetched by this server, so its probe is exempt
86
+ * from the guard for exactly the same reason its `url` is.
87
+ */
88
+ it('exempts a local-only tool, as it does for the manual url', () => {
89
+ const local = normalizeToolManual(
90
+ 'acme',
91
+ 'Plugins/acme.tool',
92
+ `---
93
+ name: acme
94
+ type: http
95
+ remote: false
96
+ url: http://localhost:9000/utcp
97
+ healthCheck:
98
+ url: http://localhost:9000/me
99
+ ---
100
+ `,
101
+ );
102
+ expect(local.healthCheck?.url).toBe('http://localhost:9000/me');
103
+ });
104
+ });
@@ -82,6 +82,62 @@ describe('ToolManualService', () => {
82
82
  expect(list.find((m) => m.name === 'billing')!.type).toBe('http');
83
83
  });
84
84
 
85
+ test('surfaces a variable referenced ONLY by a health check, and keeps the probe off the summary', async () => {
86
+ // Two halves of the same contract. The probe's `${VAR}` has to reach the
87
+ // secrets UI or nobody can ever fill it in and the tool reports
88
+ // `unverifiable` forever — but the probe's HEADERS must not ride the
89
+ // browser-facing summary, since a `.tool` may write a literal token there.
90
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
91
+ await writeFile(
92
+ join(tools, 'probe.tool'),
93
+ JSON.stringify({
94
+ name: 'probe',
95
+ type: 'http',
96
+ url: 'https://api.example.com/utcp',
97
+ healthCheck: { url: 'https://api.example.com/me', headers: { 'X-Key': '${PROBE_ONLY_KEY}' } },
98
+ }),
99
+ );
100
+
101
+ const summary = (await svc().listAccessible('user@x.eu')).find((m) => m.name === 'probe')!;
102
+ expect(summary.variables?.map((v) => v.name)).toContain('PROBE_ONLY_KEY');
103
+ expect(summary).not.toHaveProperty('healthCheck');
104
+
105
+ // The server still reaches it, through the accessor that never serializes.
106
+ const target = await svc().probeTargetFor('user@x.eu', 'probe');
107
+ expect(target?.healthCheck?.headers).toEqual({ 'X-Key': '${PROBE_ONLY_KEY}' });
108
+ });
109
+
110
+ test('builds a call template only for the probe that actually dials one', async () => {
111
+ // The template is dialled by exactly one probe: an `mcp` manual, reachable
112
+ // from this process, that declared no health check. Building it for the
113
+ // others costs a validation pass per probe and warn-logs about a value
114
+ // nothing was going to use.
115
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
116
+ await writeFile(
117
+ join(tools, 'chat.tool'),
118
+ JSON.stringify({ name: 'chat', type: 'mcp', url: 'https://mcp.example.com' }),
119
+ );
120
+ await writeFile(
121
+ join(tools, 'checked.tool'),
122
+ JSON.stringify({
123
+ name: 'checked',
124
+ type: 'mcp',
125
+ url: 'https://mcp.example.com',
126
+ healthCheck: { url: 'https://api.example.com/me' },
127
+ }),
128
+ );
129
+
130
+ const service = svc();
131
+ // Asserted through the template's own type, not `not.toBeNull()`: the
132
+ // optional chain yields `undefined` when the lookup itself fails, and
133
+ // `undefined` is not null — so the weaker form passes on no target at all.
134
+ expect((await service.probeTargetFor('user@x.eu', 'chat'))?.callTemplate?.call_template_type).toBe('mcp');
135
+ // A declared health check wins for every type, so this one never dials it.
136
+ expect((await service.probeTargetFor('user@x.eu', 'checked'))?.callTemplate).toBeNull();
137
+ // An http manual is probed only by what it declares, never by a handshake.
138
+ expect((await service.probeTargetFor('user@x.eu', 'billing'))?.callTemplate).toBeNull();
139
+ });
140
+
85
141
  test('ACL filters out manuals the user cannot read', async () => {
86
142
  const list = await svc(denyBilling).listAccessible('user@x.eu');
87
143
  expect(list.map((m) => m.name)).toEqual(['weather']);
@@ -292,7 +292,19 @@ export function descriptorsFromMcpJson(
292
292
  continue;
293
293
  }
294
294
 
295
- if (raw.type === 'streamable-http' || raw.type === 'sse') {
295
+ if (raw.type === 'sse') {
296
+ // The pinned `@utcp/mcp` speaks `stdio` and streamable `http` — there
297
+ // is no sse transport in its schema, so a template claiming one either
298
+ // fails validation or, worse, dials a handshake the server does not
299
+ // speak. Refusing here names the fix; silently rebuilding as http used
300
+ // to configure exactly that wrong handshake.
301
+ console.warn(
302
+ `[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: the MCP client has no \`sse\` transport — declare the server as \`streamable-http\` if it supports it.`,
303
+ );
304
+ continue;
305
+ }
306
+
307
+ if (raw.type === 'streamable-http') {
296
308
  if (typeof raw.url !== 'string' || raw.url.length === 0) {
297
309
  console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no url.`);
298
310
  continue;