@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.
Files changed (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. 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 (echoed on the token
38
- * response). The durable record of what this token can do, so a later check can
39
- * tell whether it still covers a tool whose required scopes have grown. Absent
40
- * on tokens minted before this was captured (treated as "covers nothing").
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
- let pendingVerifier: string | undefined;
286
- if (meta.pkce) {
287
- pendingVerifier = randomBytes(32).toString('base64url');
288
- const blob = this.readBlob(row.valueEncrypted);
289
- const next: OAuthBlob = { ...blob, pendingVerifier };
290
- await this.db
291
- .update(secrets)
292
- .set({ valueEncrypted: this.crypto().encrypt(JSON.stringify(next)), updatedAt: new Date() })
293
- .where(and(eq(secrets.id, id), eq(secrets.userId, userId)));
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
- // The verifier is one-time — never carried past the exchange it was minted for.
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 = { clientSecret: sharedBlob.clientSecret, tokens: existingTokens, pendingVerifier };
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, which the caller may override from the live tool
453
- // file (`input.scopes`) so an owner adding a permission takes effect without
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
- if (!refreshed.scope) refreshed.scope = tokens.scope;
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
- if (!registrationUrl) {
209
- return {
210
- status: 'unsupported',
211
- reason:
212
- 'server requires sign-in but does not support automatic client registration — declare the oauth provider in the .tool file instead',
213
- };
214
- }
215
-
216
- // 4. Dynamic client registration (RFC 7591), as a PUBLIC client: PKCE
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: found.variable.oauth.authorizationUrl,
393
- tokenUrl: found.variable.oauth.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) {