@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +3 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
this.tails.
|
|
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
|
|
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
|
|
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 === '
|
|
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;
|