@bevel-software/platform-core-backend 0.12.0 → 0.13.1
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/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.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 +45 -12
- 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 +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -34,10 +34,13 @@ interface OAuthTokenSet {
|
|
|
34
34
|
expires_at?: number;
|
|
35
35
|
token_type?: string;
|
|
36
36
|
/**
|
|
37
|
-
* Space-delimited scopes the provider actually granted
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Space-delimited scopes the provider actually granted. The durable record of
|
|
38
|
+
* what this token can do, so a later check can tell whether it still covers a
|
|
39
|
+
* tool whose required scopes have grown. Taken from the token response's
|
|
40
|
+
* `scope` when echoed; when the provider stays silent, RFC 6749 §5.1 says the
|
|
41
|
+
* grant is identical to the request, so the scopes asked for at `begin*` time
|
|
42
|
+
* are recorded instead. Absent only on tokens minted before this was captured
|
|
43
|
+
* (treated as "covers nothing").
|
|
41
44
|
*/
|
|
42
45
|
scope?: string;
|
|
43
46
|
}
|
|
@@ -51,6 +54,13 @@ interface OAuthBlob {
|
|
|
51
54
|
* it's a one-time secret binding the pending consent to this server.
|
|
52
55
|
*/
|
|
53
56
|
pendingVerifier?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The space-delimited `scope` the pending consent asked for — what the
|
|
59
|
+
* token is deemed granted when the provider's token response echoes none
|
|
60
|
+
* (RFC 6749 §5.1: `scope` is optional when identical to the request). One-time
|
|
61
|
+
* like the verifier: consumed by the exchange it was minted for.
|
|
62
|
+
*/
|
|
63
|
+
pendingScopes?: string;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -281,16 +291,39 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
281
291
|
// PKCE (S256): same as the tool path — mint the verifier, stash it on the
|
|
282
292
|
// row (so `completeOAuth` echoes it at the token exchange), and put only
|
|
283
293
|
// the derived challenge in the consent URL. A provider registered pkce
|
|
284
|
-
// would otherwise fail the exchange from this standalone flow.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
+
// would otherwise fail the exchange from this standalone flow. The
|
|
295
|
+
// requested scopes ride along for the same exchange (see `pendingScopes`).
|
|
296
|
+
const pendingVerifier = meta.pkce ? randomBytes(32).toString('base64url') : undefined;
|
|
297
|
+
const pendingScopes = meta.scopes && meta.scopes.length ? meta.scopes.join(' ') : undefined;
|
|
298
|
+
if (pendingVerifier || pendingScopes) {
|
|
299
|
+
// Read-modify-write on the blob, guarded on the ciphertext we read: a
|
|
300
|
+
// refresh running concurrently (the row is a live credential) may have
|
|
301
|
+
// persisted ROTATED tokens in between, and writing our copy over them
|
|
302
|
+
// would leave the row holding a dead refresh token if this consent is
|
|
303
|
+
// then abandoned. On a miss (0 rows), re-read and merge onto the fresh
|
|
304
|
+
// blob; bounded so a row that keeps changing fails loudly, not forever.
|
|
305
|
+
let current = row;
|
|
306
|
+
for (let attempt = 0; ; attempt++) {
|
|
307
|
+
const blob = this.readBlob(current.valueEncrypted);
|
|
308
|
+
const next: OAuthBlob = { ...blob, pendingVerifier, pendingScopes };
|
|
309
|
+
const written = await this.db
|
|
310
|
+
.update(secrets)
|
|
311
|
+
.set({ valueEncrypted: this.crypto().encrypt(JSON.stringify(next)), updatedAt: new Date() })
|
|
312
|
+
.where(
|
|
313
|
+
and(eq(secrets.id, id), eq(secrets.userId, userId), eq(secrets.valueEncrypted, current.valueEncrypted)),
|
|
314
|
+
)
|
|
315
|
+
.returning({ id: secrets.id });
|
|
316
|
+
if (!Array.isArray(written) || written.length > 0) break;
|
|
317
|
+
if (attempt >= 2) throw new SecretOAuthError('The sign-in changed while starting — try again');
|
|
318
|
+
current = await this.requireRow(userId, id);
|
|
319
|
+
// Only a token rotation is merge-able. A row that is no longer this
|
|
320
|
+
// OAuth secret — re-registered against another provider, or replaced by
|
|
321
|
+
// a static value — would have the consent URL built above pointing at
|
|
322
|
+
// one provider and the pending fields stashed for another.
|
|
323
|
+
if (current.kind !== 'oauth' || JSON.stringify(current.oauthMeta) !== JSON.stringify(row.oauthMeta)) {
|
|
324
|
+
throw new SecretOAuthError('The sign-in changed while starting — try again');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
294
327
|
}
|
|
295
328
|
|
|
296
329
|
const url = new URL(meta.authorizationUrl);
|
|
@@ -340,7 +373,14 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
340
373
|
if (meta.resource) body.set('resource', meta.resource);
|
|
341
374
|
|
|
342
375
|
const tokens = await this.tokenRequest(meta.tokenUrl, body);
|
|
343
|
-
//
|
|
376
|
+
// A silent token response granted what was asked (RFC 6749 §5.1) — record
|
|
377
|
+
// the request as the grant, or every declared scope would read as missing
|
|
378
|
+
// and the sign-in would be flagged "again" forever. An echoed `scope` is
|
|
379
|
+
// the provider's own word and wins — narrower grants included, and so does
|
|
380
|
+
// an explicit empty one: only an ABSENT field means "as requested".
|
|
381
|
+
if (tokens.scope === undefined && blob.pendingScopes) tokens.scope = blob.pendingScopes;
|
|
382
|
+
// The verifier and the requested scopes are one-time — never carried past
|
|
383
|
+
// the exchange they were minted for.
|
|
344
384
|
const next: OAuthBlob = { clientSecret: blob.clientSecret, tokens };
|
|
345
385
|
await this.db
|
|
346
386
|
.update(secrets)
|
|
@@ -433,11 +473,22 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
433
473
|
// only the derived challenge in the consent URL. `completeOAuth` echoes the
|
|
434
474
|
// verifier at the token exchange and drops it.
|
|
435
475
|
const pendingVerifier = meta.pkce ? randomBytes(32).toString('base64url') : undefined;
|
|
476
|
+
// The scopes this consent asks for: the caller may override from the live
|
|
477
|
+
// tool file (`input.scopes`) so an owner adding a permission takes effect
|
|
478
|
+
// without re-setting the secret. Remembered on the row so a token response
|
|
479
|
+
// that echoes no `scope` is read as granting exactly this (RFC 6749 §5.1).
|
|
480
|
+
const requestedScopes = input.scopes && input.scopes.length ? input.scopes : meta.scopes;
|
|
481
|
+
const pendingScopes = requestedScopes && requestedScopes.length ? requestedScopes.join(' ') : undefined;
|
|
436
482
|
|
|
437
483
|
// Provision (or reset) the caller's own oauth row for this key from the shared
|
|
438
484
|
// provider meta + secret, preserving any existing tokens. Keyed `<manual>_<VAR>`
|
|
439
485
|
// so `resolve` (scope 'user') returns the token once sign-in completes.
|
|
440
|
-
const blob: OAuthBlob = {
|
|
486
|
+
const blob: OAuthBlob = {
|
|
487
|
+
clientSecret: sharedBlob.clientSecret,
|
|
488
|
+
tokens: existingTokens,
|
|
489
|
+
pendingVerifier,
|
|
490
|
+
pendingScopes,
|
|
491
|
+
};
|
|
441
492
|
const valueEncrypted = this.crypto().encrypt(JSON.stringify(blob));
|
|
442
493
|
const [row] = await this.db
|
|
443
494
|
.insert(secrets)
|
|
@@ -449,10 +500,8 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
449
500
|
.returning();
|
|
450
501
|
|
|
451
502
|
// Build the consent URL exactly as beginOAuth does, from the stored meta —
|
|
452
|
-
// EXCEPT the requested scopes
|
|
453
|
-
//
|
|
454
|
-
// re-setting the secret. Client id, addresses, and secret stay owner-pinned.
|
|
455
|
-
const requestedScopes = input.scopes && input.scopes.length ? input.scopes : meta.scopes;
|
|
503
|
+
// EXCEPT the requested scopes (above). Client id, addresses, and secret
|
|
504
|
+
// stay owner-pinned.
|
|
456
505
|
const url = new URL(meta.authorizationUrl);
|
|
457
506
|
url.searchParams.set('response_type', 'code');
|
|
458
507
|
url.searchParams.set('client_id', meta.clientId);
|
|
@@ -572,8 +621,10 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
572
621
|
// Some providers omit the refresh_token on refresh — keep the old one.
|
|
573
622
|
if (!refreshed.refresh_token) refreshed.refresh_token = tokens.refresh_token;
|
|
574
623
|
// Likewise, a refresh response often omits `scope` — keep the granted scopes
|
|
575
|
-
// recorded at sign-in so coverage checks don't regress to "unknown".
|
|
576
|
-
|
|
624
|
+
// recorded at sign-in so coverage checks don't regress to "unknown". Same
|
|
625
|
+
// rule as the exchange: only an ABSENT field means "unchanged"; an echoed
|
|
626
|
+
// value, empty included, is the provider's word.
|
|
627
|
+
if (refreshed.scope === undefined) refreshed.scope = tokens.scope;
|
|
577
628
|
const next: OAuthBlob = { clientSecret: blob.clientSecret, tokens: refreshed };
|
|
578
629
|
|
|
579
630
|
// Optimistic concurrency: only persist if the stored ciphertext is unchanged,
|
|
@@ -10,6 +10,22 @@ export type McpAuthDiscovery =
|
|
|
10
10
|
/** The server wants auth but the spec chain didn't complete — leave the tool as-is. */
|
|
11
11
|
| { status: 'unsupported'; reason: string };
|
|
12
12
|
|
|
13
|
+
/** Where a server's sign-in lives — the spec chain's answer before any client exists. */
|
|
14
|
+
interface AuthorizationServerInfo {
|
|
15
|
+
authorizationUrl: string;
|
|
16
|
+
tokenUrl: string;
|
|
17
|
+
/** RFC 7591 endpoint, when the AS offers dynamic registration. */
|
|
18
|
+
registrationUrl?: string;
|
|
19
|
+
/** RFC 8707 resource indicator: the PRM's `resource`, else the MCP URL itself. */
|
|
20
|
+
resource: string;
|
|
21
|
+
scopes?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type AuthorizationServerResolution =
|
|
25
|
+
| { status: 'open' }
|
|
26
|
+
| { status: 'found'; as: AuthorizationServerInfo }
|
|
27
|
+
| { status: 'unsupported'; reason: string };
|
|
28
|
+
|
|
13
29
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
14
30
|
/** Re-probe an open/unsupported server after this long (it may grow/lose auth). */
|
|
15
31
|
const NEGATIVE_TTL_MS = 5 * 60_000;
|
|
@@ -37,12 +53,21 @@ const OAUTH_TTL_MS = 60 * 60_000;
|
|
|
37
53
|
* row from the shared one, and the UTCP variable loader injects the fresh
|
|
38
54
|
* access token into the manual's `Authorization` header at call time.
|
|
39
55
|
*
|
|
56
|
+
* Providers without dynamic registration (HubSpot, Google) stop at step 3.
|
|
57
|
+
* For those, `providerForDeclaredClient` runs steps 1–3 for a client id the
|
|
58
|
+
* OWNER registered by hand and declared on the manual — same endpoints, same
|
|
59
|
+
* PKCE, same resource indicator, nothing persisted (the owner's client-secret
|
|
60
|
+
* save pins the completed provider).
|
|
61
|
+
*
|
|
40
62
|
* Every fetch is SSRF-guarded (https-only, no redirects, bounded) — these URLs
|
|
41
63
|
* originate from user-authored `.tool` files and remote servers' own metadata.
|
|
42
64
|
*/
|
|
43
65
|
export class McpOAuthDiscoveryService {
|
|
44
66
|
private readonly cache = new Map<string, { result: McpAuthDiscovery; expiresAt: number }>();
|
|
45
67
|
private readonly inflight = new Map<string, Promise<McpAuthDiscovery>>();
|
|
68
|
+
/** The metadata walk alone, per MCP URL — shared by every declared client on that server. */
|
|
69
|
+
private readonly asCache = new Map<string, { result: AuthorizationServerResolution; expiresAt: number }>();
|
|
70
|
+
private readonly asInflight = new Map<string, Promise<AuthorizationServerResolution>>();
|
|
46
71
|
|
|
47
72
|
constructor(
|
|
48
73
|
private readonly deps: {
|
|
@@ -75,6 +100,30 @@ export class McpOAuthDiscoveryService {
|
|
|
75
100
|
return pending;
|
|
76
101
|
}
|
|
77
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The provider for a client the OWNER registered: the server's discovered
|
|
105
|
+
* endpoints + resource indicator around the declared `clientId`, PKCE on (the
|
|
106
|
+
* MCP spec requires it; a provider without it ignores the parameters). No
|
|
107
|
+
* registration call, no vault write — the metadata is cached per URL so a
|
|
108
|
+
* re-scan or a second declared client on the same server costs no network.
|
|
109
|
+
*/
|
|
110
|
+
async providerForDeclaredClient(manualName: string, mcpUrl: string, clientId: string): Promise<McpAuthDiscovery> {
|
|
111
|
+
const resolved = await this.resolveAuthorizationServer(manualName, mcpUrl);
|
|
112
|
+
if (resolved.status === 'open') {
|
|
113
|
+
return {
|
|
114
|
+
status: 'unsupported',
|
|
115
|
+
reason:
|
|
116
|
+
'the server did not ask for a sign-in and publishes no OAuth metadata — declare `authorizationUrl` and `tokenUrl` on the sign-in variable if it needs one',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (resolved.status === 'unsupported') return resolved;
|
|
120
|
+
const { authorizationUrl, tokenUrl, resource, scopes } = resolved.as;
|
|
121
|
+
return {
|
|
122
|
+
status: 'oauth',
|
|
123
|
+
provider: { authorizationUrl, tokenUrl, clientId, scopes, pkce: true, resource },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
78
127
|
private remember(key: string, result: McpAuthDiscovery): McpAuthDiscovery {
|
|
79
128
|
const ttl = result.status === 'oauth' ? OAUTH_TTL_MS : NEGATIVE_TTL_MS;
|
|
80
129
|
this.cache.set(key, { result, expiresAt: this.now() + ttl });
|
|
@@ -114,6 +163,89 @@ export class McpOAuthDiscoveryService {
|
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
private async discoverFresh(manualName: string, key: string, mcpUrl: string): Promise<McpAuthDiscovery> {
|
|
166
|
+
const resolved = await this.resolveAuthorizationServer(manualName, mcpUrl);
|
|
167
|
+
if (resolved.status !== 'found') return resolved;
|
|
168
|
+
const { authorizationUrl, tokenUrl, registrationUrl, resource, scopes } = resolved.as;
|
|
169
|
+
if (!registrationUrl) {
|
|
170
|
+
return {
|
|
171
|
+
status: 'unsupported',
|
|
172
|
+
reason:
|
|
173
|
+
'the server requires sign-in but does not allow automatic client registration — register an OAuth app ' +
|
|
174
|
+
`with the provider (redirect URI: ${this.deps.redirectUri}), declare its client id on a user-scoped ` +
|
|
175
|
+
'sign-in variable (the server editor on the tool\'s page, or the plugin.json extensions entry), then set its client secret',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. Dynamic client registration (RFC 7591), as a PUBLIC client: PKCE
|
|
180
|
+
// carries the proof, no secret to store or leak.
|
|
181
|
+
assertSafeFetchUrl(registrationUrl, { requireHttps: true, label: 'registration_endpoint' });
|
|
182
|
+
const regRes = await this.fetchRaw(registrationUrl, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
client_name: 'Bevel Knowledge Base',
|
|
187
|
+
redirect_uris: [this.deps.redirectUri],
|
|
188
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
189
|
+
response_types: ['code'],
|
|
190
|
+
token_endpoint_auth_method: 'none',
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
if (!regRes.ok) {
|
|
194
|
+
return { status: 'unsupported', reason: `client registration failed: HTTP ${regRes.status}` };
|
|
195
|
+
}
|
|
196
|
+
const reg = (await regRes.json().catch(() => null)) as Record<string, unknown> | null;
|
|
197
|
+
const clientId = typeof reg?.client_id === 'string' ? reg.client_id : '';
|
|
198
|
+
if (!clientId) {
|
|
199
|
+
return { status: 'unsupported', reason: 'client registration returned no client_id' };
|
|
200
|
+
}
|
|
201
|
+
const clientSecret = typeof reg?.client_secret === 'string' && reg.client_secret ? reg.client_secret : undefined;
|
|
202
|
+
|
|
203
|
+
const provider: OAuthProviderConfig = {
|
|
204
|
+
authorizationUrl,
|
|
205
|
+
tokenUrl,
|
|
206
|
+
clientId,
|
|
207
|
+
clientSecret,
|
|
208
|
+
scopes,
|
|
209
|
+
pkce: true,
|
|
210
|
+
publicClient: !clientSecret,
|
|
211
|
+
resource,
|
|
212
|
+
};
|
|
213
|
+
await this.deps.secretsVault.putSharedOAuthProvider({
|
|
214
|
+
key,
|
|
215
|
+
label: `${manualName} sign-in`,
|
|
216
|
+
provider,
|
|
217
|
+
});
|
|
218
|
+
// Return the stored (public) shape — no secret rides in cache entries.
|
|
219
|
+
const publicProvider = { ...provider };
|
|
220
|
+
delete publicProvider.clientSecret;
|
|
221
|
+
return { status: 'oauth', provider: publicProvider };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Steps 1–3, memoised per MCP URL (positive and negative alike, short TTL). */
|
|
225
|
+
private async resolveAuthorizationServer(manualName: string, mcpUrl: string): Promise<AuthorizationServerResolution> {
|
|
226
|
+
const cached = this.asCache.get(mcpUrl);
|
|
227
|
+
if (cached && cached.expiresAt > this.now()) return cached.result;
|
|
228
|
+
let pending = this.asInflight.get(mcpUrl);
|
|
229
|
+
if (!pending) {
|
|
230
|
+
pending = this.resolveAuthorizationServerFresh(mcpUrl)
|
|
231
|
+
.catch((err: unknown): AuthorizationServerResolution => ({
|
|
232
|
+
status: 'unsupported',
|
|
233
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
234
|
+
}))
|
|
235
|
+
.then((result) => {
|
|
236
|
+
if (result.status === 'unsupported') {
|
|
237
|
+
console.warn(`[mcp-oauth-discovery] "${manualName}" (${mcpUrl}): ${result.reason}`);
|
|
238
|
+
}
|
|
239
|
+
this.asCache.set(mcpUrl, { result, expiresAt: this.now() + NEGATIVE_TTL_MS });
|
|
240
|
+
return result;
|
|
241
|
+
})
|
|
242
|
+
.finally(() => this.asInflight.delete(mcpUrl));
|
|
243
|
+
this.asInflight.set(mcpUrl, pending);
|
|
244
|
+
}
|
|
245
|
+
return pending;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async resolveAuthorizationServerFresh(mcpUrl: string): Promise<AuthorizationServerResolution> {
|
|
117
249
|
// 1. Probe: an unauthenticated initialize. A 401/403, or ANY response
|
|
118
250
|
// carrying a `WWW-Authenticate` challenge, means the server wants OAuth;
|
|
119
251
|
// a redirect to a login page (fetch throws under `redirect:'error'`)
|
|
@@ -205,57 +337,16 @@ export class McpOAuthDiscoveryService {
|
|
|
205
337
|
if (!authorizationUrl || !tokenUrl) {
|
|
206
338
|
return { status: 'unsupported', reason: 'authorization-server metadata is missing endpoints' };
|
|
207
339
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// carries the proof, no secret to store or leak.
|
|
218
|
-
assertSafeFetchUrl(registrationUrl, { requireHttps: true, label: 'registration_endpoint' });
|
|
219
|
-
const regRes = await this.fetchRaw(registrationUrl, {
|
|
220
|
-
method: 'POST',
|
|
221
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
222
|
-
body: JSON.stringify({
|
|
223
|
-
client_name: 'Bevel Knowledge Base',
|
|
224
|
-
redirect_uris: [this.deps.redirectUri],
|
|
225
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
226
|
-
response_types: ['code'],
|
|
227
|
-
token_endpoint_auth_method: 'none',
|
|
228
|
-
}),
|
|
229
|
-
});
|
|
230
|
-
if (!regRes.ok) {
|
|
231
|
-
return { status: 'unsupported', reason: `client registration failed: HTTP ${regRes.status}` };
|
|
232
|
-
}
|
|
233
|
-
const reg = (await regRes.json().catch(() => null)) as Record<string, unknown> | null;
|
|
234
|
-
const clientId = typeof reg?.client_id === 'string' ? reg.client_id : '';
|
|
235
|
-
if (!clientId) {
|
|
236
|
-
return { status: 'unsupported', reason: 'client registration returned no client_id' };
|
|
237
|
-
}
|
|
238
|
-
const clientSecret = typeof reg?.client_secret === 'string' && reg.client_secret ? reg.client_secret : undefined;
|
|
239
|
-
|
|
240
|
-
const provider: OAuthProviderConfig = {
|
|
241
|
-
authorizationUrl,
|
|
242
|
-
tokenUrl,
|
|
243
|
-
clientId,
|
|
244
|
-
clientSecret,
|
|
245
|
-
scopes,
|
|
246
|
-
pkce: true,
|
|
247
|
-
publicClient: !clientSecret,
|
|
248
|
-
resource,
|
|
340
|
+
return {
|
|
341
|
+
status: 'found',
|
|
342
|
+
as: {
|
|
343
|
+
authorizationUrl,
|
|
344
|
+
tokenUrl,
|
|
345
|
+
...(registrationUrl ? { registrationUrl } : {}),
|
|
346
|
+
resource,
|
|
347
|
+
...(scopes ? { scopes } : {}),
|
|
348
|
+
},
|
|
249
349
|
};
|
|
250
|
-
await this.deps.secretsVault.putSharedOAuthProvider({
|
|
251
|
-
key,
|
|
252
|
-
label: `${manualName} sign-in`,
|
|
253
|
-
provider,
|
|
254
|
-
});
|
|
255
|
-
// Return the stored (public) shape — no secret rides in cache entries.
|
|
256
|
-
const publicProvider = { ...provider };
|
|
257
|
-
delete publicProvider.clientSecret;
|
|
258
|
-
return { status: 'oauth', provider: publicProvider };
|
|
259
350
|
}
|
|
260
351
|
|
|
261
352
|
private async fetchRaw(url: string, init: RequestInit): Promise<Response> {
|
|
@@ -384,19 +384,37 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
384
384
|
if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
|
|
385
385
|
return void res.status(403).json({ error: 'You need write access to this tool to set its client secret.' });
|
|
386
386
|
}
|
|
387
|
+
const { authorizationUrl, tokenUrl } = found.variable.oauth;
|
|
388
|
+
// A declaration that names only its client id is completed from the
|
|
389
|
+
// server's OAuth metadata at scan time. When that didn't happen, pinning
|
|
390
|
+
// the secret to a provider with no endpoints would leave every sign-in
|
|
391
|
+
// failing later with a far less specific error than the catalog's own.
|
|
392
|
+
if (!authorizationUrl || !tokenUrl) {
|
|
393
|
+
const why = found.manual.setup?.reason;
|
|
394
|
+
return void res.status(422).json({
|
|
395
|
+
error: why
|
|
396
|
+
? `The sign-in endpoints for this server aren't known yet: ${why}.`
|
|
397
|
+
: "The sign-in endpoints for this server aren't known yet — declare `authorizationUrl` and `tokenUrl` on the sign-in variable.",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
387
400
|
const clientSecret = (req.body ?? {}).clientSecret;
|
|
388
401
|
await secretsVault.putSharedOAuthClientSecret({
|
|
389
402
|
key: varKey(found.manual.name, found.variable.name),
|
|
390
403
|
clientSecret,
|
|
391
404
|
provider: {
|
|
392
|
-
authorizationUrl
|
|
393
|
-
tokenUrl
|
|
405
|
+
authorizationUrl,
|
|
406
|
+
tokenUrl,
|
|
394
407
|
clientId: found.variable.oauth.clientId,
|
|
395
408
|
scopes: found.variable.oauth.scopes,
|
|
396
409
|
// Static authorize params (e.g. Google's `access_type=offline`) so the
|
|
397
410
|
// provider returns a refresh token — stored with the secret so a later
|
|
398
411
|
// `.tool` edit can't redirect the flow.
|
|
399
412
|
authParams: found.variable.oauth.authParams,
|
|
413
|
+
// PKCE unless the declaration opted out; the resource indicator when
|
|
414
|
+
// the server (or the declaration) names one. Both pinned here for the
|
|
415
|
+
// same reason as the rest: the flow runs from the stored row.
|
|
416
|
+
pkce: found.variable.oauth.pkce !== false,
|
|
417
|
+
resource: found.variable.oauth.resource,
|
|
400
418
|
},
|
|
401
419
|
});
|
|
402
420
|
res.status(201).json({ ok: true });
|
|
@@ -69,6 +69,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
69
69
|
workspaceId,
|
|
70
70
|
branch: branchForWorkspaceId(workspaceId),
|
|
71
71
|
user,
|
|
72
|
+
kbDirName: deps.kbDirName,
|
|
72
73
|
validateWrite: validateRolesWrite,
|
|
73
74
|
creatorAccess: deps.creatorAccess,
|
|
74
75
|
},
|
|
@@ -188,6 +188,44 @@ describe('descriptorsFromMcpJson', () => {
|
|
|
188
188
|
expect(out[0]?.variables?.[0]?.oauth?.clientId).toBe('c-1');
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
it('accepts a client-id-only sign-in (endpoints discovered later), refuses half a pair, carries pkce/resource', () => {
|
|
192
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
|
+
const declare = (oauth: unknown) =>
|
|
194
|
+
descriptorsFromMcpJson(
|
|
195
|
+
'GTM',
|
|
196
|
+
JSON.stringify({ mcpServers: { vendor: { type: 'streamable-http', url: 'https://v.example/mcp' } } }),
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
name: 'gtm',
|
|
199
|
+
extensions: {
|
|
200
|
+
'software.bevel.hexis': { mcpServers: { vendor: { variables: [{ name: 'T', scope: 'user', oauth }] } } },
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
)[0]?.variables?.[0]?.oauth;
|
|
204
|
+
// An MCP server publishes its endpoints — the client id alone is a complete declaration here.
|
|
205
|
+
expect(declare({ clientId: 'c' })).toEqual({ clientId: 'c' });
|
|
206
|
+
// …but half a pair is a broken declaration, not a discoverable one.
|
|
207
|
+
expect(declare({ clientId: 'c', authorizationUrl: 'https://v.example/auth' })).toBeUndefined();
|
|
208
|
+
expect(declare({ clientId: 'c', tokenUrl: 'https://v.example/token' })).toBeUndefined();
|
|
209
|
+
// An emptied editor field reads as absent, not as a malformed URL.
|
|
210
|
+
expect(declare({ clientId: 'c', authorizationUrl: '', tokenUrl: ' ' })).toEqual({ clientId: 'c' });
|
|
211
|
+
// PKCE is on by default: only the opt-out is stored, and it must be a boolean.
|
|
212
|
+
expect(declare({ clientId: 'c', pkce: true })).toEqual({ clientId: 'c' });
|
|
213
|
+
expect(declare({ clientId: 'c', pkce: false })).toEqual({ clientId: 'c', pkce: false });
|
|
214
|
+
expect(declare({ clientId: 'c', pkce: 'no' })).toBeUndefined();
|
|
215
|
+
// The resource indicator names the remote server — same https/SSRF bar as the endpoints.
|
|
216
|
+
expect(declare({ clientId: 'c', resource: 'https://v.example/mcp' })).toEqual({
|
|
217
|
+
clientId: 'c',
|
|
218
|
+
resource: 'https://v.example/mcp',
|
|
219
|
+
});
|
|
220
|
+
expect(declare({ clientId: 'c', resource: 'http://v.example/mcp' })).toBeUndefined();
|
|
221
|
+
expect(declare({ clientId: 'c', resource: 'https://169.254.169.254/mcp' })).toBeUndefined();
|
|
222
|
+
// A secret in a portable file never loads — same keys the `.tool` parser
|
|
223
|
+
// refuses, and the whole server is dropped rather than the key ignored.
|
|
224
|
+
expect(declare({ clientId: 'c', clientSecret: 'shh' })).toBeUndefined();
|
|
225
|
+
expect(declare({ clientId: 'c', client_secret: 'shh' })).toBeUndefined();
|
|
226
|
+
expect(declare({ clientId: 'c', secret: 'shh' })).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
191
229
|
it('yields nothing for an unparsable file, quietly for an absent extensions block', () => {
|
|
192
230
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
193
231
|
expect(descriptorsFromMcpJson('GTM', '{ not json', null)).toEqual([]);
|
|
@@ -290,6 +290,8 @@ describe('putServer', () => {
|
|
|
290
290
|
oauth: { authorizationUrl: 'https://v.example/auth', tokenUrl: 'https://v.example/token', clientId: ' ' },
|
|
291
291
|
},
|
|
292
292
|
],
|
|
293
|
+
[{ name: 'K', scope: 'user', oauth: { clientId: 'c', clientSecret: 'shh' } }], // a secret never lands in a file
|
|
294
|
+
[{ name: 'K', scope: 'user', oauth: { clientId: 'c', authorizationUrl: 'https://v.example/auth' } }], // half a pair
|
|
293
295
|
];
|
|
294
296
|
const before = await readJson('Plugins/GTM/plugin.json');
|
|
295
297
|
for (const variables of bad) {
|