@bevel-software/platform-core-backend 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/agent-guide/access-control.md +146 -300
  2. package/agent-guide/conventions.md +23 -25
  3. package/agent-guide/directory-structure.md +72 -120
  4. package/agent-guide/drafts-and-change-requests.md +92 -0
  5. package/agent-guide/for-git-users.md +47 -0
  6. package/agent-guide/glossary.md +36 -0
  7. package/agent-guide/how-agents-find-things.md +49 -0
  8. package/agent-guide/html-views.md +16 -11
  9. package/agent-guide/new-knowledge-base.md +9 -8
  10. package/agent-guide/skills.md +67 -42
  11. package/agent-guide/start-here.md +65 -0
  12. package/agent-guide/tool-manuals.md +132 -154
  13. package/agent-guide/tools.md +67 -0
  14. package/agent-guide/where-a-new-file-goes.md +45 -28
  15. package/dist/core/create-core-server.js +1 -1
  16. package/dist/core/create-core-server.js.map +1 -1
  17. package/dist/core/create-core-services.d.ts +6 -0
  18. package/dist/core/create-core-services.d.ts.map +1 -1
  19. package/dist/core/create-core-services.js +7 -4
  20. package/dist/core/create-core-services.js.map +1 -1
  21. package/dist/modules/access/access-control.interface.d.ts +22 -0
  22. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.d.ts +3 -1
  24. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  25. package/dist/modules/access/access-control.service.js +75 -0
  26. package/dist/modules/access/access-control.service.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts.map +1 -1
  28. package/dist/modules/access/access.routes.js +23 -4
  29. package/dist/modules/access/access.routes.js.map +1 -1
  30. package/dist/modules/access/index.d.ts +1 -1
  31. package/dist/modules/access/index.d.ts.map +1 -1
  32. package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
  33. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  34. package/dist/modules/agent-guide/agent-guide.js +103 -13
  35. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  36. package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  38. package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
  39. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  40. package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
  41. package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
  42. package/dist/modules/agent-guide/guide-budget.js +67 -0
  43. package/dist/modules/agent-guide/guide-budget.js.map +1 -0
  44. package/dist/modules/agent-guide/index.d.ts +2 -1
  45. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  46. package/dist/modules/agent-guide/index.js +2 -1
  47. package/dist/modules/agent-guide/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  49. package/dist/modules/agent-instructions/compose.js +4 -0
  50. package/dist/modules/agent-instructions/compose.js.map +1 -1
  51. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  52. package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
  53. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  54. package/dist/modules/code-mode/code-mode.tool.js +2 -2
  55. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  56. package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
  57. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  58. package/dist/modules/connection-probe/connection-probe.service.js +1 -38
  59. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  60. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  61. package/dist/modules/mcp/mcp.service.js +6 -35
  62. package/dist/modules/mcp/mcp.service.js.map +1 -1
  63. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
  64. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  65. package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
  66. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  67. package/dist/modules/secrets-vault/index.d.ts +1 -1
  68. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  69. package/dist/modules/secrets-vault/index.js +1 -1
  70. package/dist/modules/secrets-vault/index.js.map +1 -1
  71. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
  72. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  73. package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
  74. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  75. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  76. package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
  77. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  78. package/dist/modules/settings/oidc-check.d.ts.map +1 -1
  79. package/dist/modules/settings/oidc-check.js +5 -17
  80. package/dist/modules/settings/oidc-check.js.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +1 -1
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-helpers/tool-def.js +1 -1
  84. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  85. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
  86. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  87. package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
  88. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
  92. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  93. package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
  94. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  95. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
  96. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
  98. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  99. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  100. package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
  101. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  102. package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
  103. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
  104. package/dist/modules/workspace/agent-access.gate.js +1 -1
  105. package/dist/modules/workspace/agent-access.gate.js.map +1 -1
  106. package/dist/modules/workspace/workspace.tools.d.ts +3 -2
  107. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.tools.js +71 -21
  109. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  110. package/dist/shared/oauth-token-error.d.ts +23 -0
  111. package/dist/shared/oauth-token-error.d.ts.map +1 -0
  112. package/dist/shared/oauth-token-error.js +47 -0
  113. package/dist/shared/oauth-token-error.js.map +1 -0
  114. package/dist/shared/read-capped.d.ts +19 -0
  115. package/dist/shared/read-capped.d.ts.map +1 -0
  116. package/dist/shared/read-capped.js +45 -0
  117. package/dist/shared/read-capped.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/__tests__/hexis-catalog.ts +144 -0
  120. package/src/__tests__/real-disk.ts +21 -0
  121. package/src/__tests__/tokens.ts +19 -0
  122. package/src/__tests__/tool-manuals.ts +32 -0
  123. package/src/__tests__/tool-wiring.ts +25 -0
  124. package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
  125. package/src/core/create-core-server.ts +1 -1
  126. package/src/core/create-core-services.ts +13 -3
  127. package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
  128. package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
  129. package/src/modules/access/access-control.interface.ts +26 -0
  130. package/src/modules/access/access-control.service.ts +92 -0
  131. package/src/modules/access/access.routes.ts +23 -4
  132. package/src/modules/access/index.ts +1 -1
  133. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
  134. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
  135. package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
  136. package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
  137. package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
  138. package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
  139. package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
  140. package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
  141. package/src/modules/agent-guide/agent-guide.ts +120 -13
  142. package/src/modules/agent-guide/guide-budget.ts +73 -0
  143. package/src/modules/agent-guide/index.ts +12 -0
  144. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
  145. package/src/modules/agent-instructions/compose.ts +4 -0
  146. package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
  147. package/src/modules/code-mode/code-mode.tool.ts +2 -2
  148. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
  149. package/src/modules/connection-probe/connection-probe.service.ts +1 -34
  150. package/src/modules/mcp/mcp.service.ts +5 -35
  151. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
  152. package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
  153. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
  154. package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
  155. package/src/modules/secrets-vault/index.ts +1 -1
  156. package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
  157. package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
  158. package/src/modules/settings/oidc-check.ts +5 -18
  159. package/src/modules/skills/skills.tools.ts +1 -1
  160. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
  161. package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
  162. package/src/modules/tool-helpers/tool-def.ts +1 -1
  163. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
  164. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
  165. package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
  166. package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
  167. package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
  168. package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
  169. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
  170. package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
  171. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
  172. package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
  173. package/src/modules/workspace/agent-access.gate.ts +1 -1
  174. package/src/modules/workspace/workspace.tools.ts +74 -21
  175. package/src/shared/__tests__/read-capped.test.ts +68 -0
  176. package/src/shared/oauth-token-error.ts +46 -0
  177. package/src/shared/read-capped.ts +40 -0
  178. package/agent-guide/finding-things.md +0 -7
  179. package/agent-guide/introduction.md +0 -27
@@ -12,6 +12,7 @@ import type { ISecretsVaultService } from '../secrets-vault/index.js';
12
12
  import { bevelSecretsLoaderConfig } from '../secrets-vault/index.js';
13
13
  import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
14
14
  import { assertSafeFetchUrl } from '../../shared/ssrf.js';
15
+ import { readCapped } from '../../shared/read-capped.js';
15
16
  import type { IConnectionProbeService, ProbeStatus, ProbeVerdict } from './connection-probe.contract.js';
16
17
 
17
18
  /** How long a probe may take before we call it inconclusive. */
@@ -753,37 +754,3 @@ async function describeRejection(res: Response, redactor: SecretRedactor): Promi
753
754
  }
754
755
  }
755
756
 
756
- /**
757
- * At most `limit` bytes of a response body, decoded as text.
758
- *
759
- * Each CHUNK is sliced to the remaining budget before being decoded, not just
760
- * counted against it: a provider that answers in one 5MB chunk would otherwise
761
- * put the whole thing through the decoder before the loop noticed it was over,
762
- * which is the bound bypassed by the very party it is meant to bound.
763
- *
764
- * Exported for its test — the property here is how much is held in memory,
765
- * which the caller's 200-character snippet hides completely.
766
- */
767
- export async function readCapped(res: Response, limit: number): Promise<string> {
768
- const reader = res.body?.getReader();
769
- // No stream (a mocked or already-consumed response): fall back to the whole
770
- // body, which for those cases is ours and small.
771
- if (!reader) return (await res.text()).slice(0, limit);
772
- const decoder = new TextDecoder();
773
- let out = '';
774
- let read = 0;
775
- try {
776
- while (read < limit) {
777
- const { done, value } = await reader.read();
778
- if (done) break;
779
- const remaining = limit - read;
780
- const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
781
- read += chunk.byteLength;
782
- out += decoder.decode(chunk, { stream: true });
783
- if (chunk.byteLength < value.byteLength) break;
784
- }
785
- } finally {
786
- await reader.cancel().catch(() => {});
787
- }
788
- return out;
789
- }
@@ -50,7 +50,7 @@ import {
50
50
  type LoadedSkill,
51
51
  } from '@bevel-software/platform-mcp-core';
52
52
  import type { KbLayout } from '@bevel-software/platform-shared';
53
- import { bevelSecretsLoaderConfig, type ForcedRefreshOutcome, type ISecretsVaultService, scopesCovered } from '../secrets-vault/index.js';
53
+ import { bevelSecretsLoaderConfig, type ForcedRefreshOutcome, type ISecretsVaultService, userSecretGap } from '../secrets-vault/index.js';
54
54
  import { EXTERNAL_KB_MANUAL_NAME } from '../tool-manuals/index.js';
55
55
  import type { IToolManualService } from '../tool-manuals/index.js';
56
56
  import type { SpillStore } from '../workspace/index.js';
@@ -1690,41 +1690,11 @@ export class McpService {
1690
1690
  const statusMap = new Map(status.map((s) => [s.key, s]));
1691
1691
  const missing: { name: string; label?: string | null; brokenSignIn: boolean }[] = [];
1692
1692
  for (const v of userVars) {
1693
- const st = statusMap.get(v.key);
1693
+ // The one readiness rule `list_tool_setup` reports from (`userSecretGap`).
1694
1694
  // A sign-in the caller already HAS a row for but that isn't (or is no
1695
- // longer) usable: dead/wiped grant, abandoned consent, missing scopes.
1696
- const brokenSignIn = Boolean(v.oauth && st?.userConfigured);
1697
- if (!st?.userConfigured) {
1698
- missing.push({ name: v.name, label: v.label, brokenSignIn }); // no row at all → needs a value / sign-in
1699
- continue;
1700
- }
1701
- // A row of the OTHER kind is not this credential: a `.tool` edited from
1702
- // an OAuth sign-in to a plain key (or back) leaves the old row behind,
1703
- // and serving a token where a key is declared — or a key where a
1704
- // sign-in is — fails opaquely at the provider. Not a broken sign-in
1705
- // either: nothing to reset, the user has to set the credential up as
1706
- // the manual now declares it.
1707
- const declaredKind = v.oauth ? 'oauth' : 'static';
1708
- if (st.userKind && st.userKind !== declaredKind) {
1709
- missing.push({ name: v.name, label: v.label, brokenSignIn: false });
1710
- continue;
1711
- }
1712
- // An OAuth-backed var whose row exists but has no token yet is NOT ready —
1713
- // the user has registered but not completed sign-in. Fail closed: anything
1714
- // other than a confirmed `true` (including a non-oauth row → undefined)
1715
- // counts as not-yet-authorized.
1716
- if (v.oauth && st.userAuthorized !== true) {
1717
- missing.push({ name: v.name, label: v.label, brokenSignIn });
1718
- continue;
1719
- }
1720
- // An OAuth-backed var the user signed in for, but whose token was granted
1721
- // fewer scopes than the tool now declares, is ALSO not ready — the call would
1722
- // otherwise fail opaquely at the provider. Compare the live required scopes
1723
- // against the token's recorded granted scopes; an under-scoped (or unknown)
1724
- // token needs re-authorization.
1725
- if (v.oauth && !scopesCovered(v.oauthScopes, st.grantedScopes)) {
1726
- missing.push({ name: v.name, label: v.label, brokenSignIn });
1727
- }
1695
+ // longer) usable is a broken sign-in: abandoned consent, missing scopes.
1696
+ const gap = userSecretGap(v, statusMap.get(v.key));
1697
+ if (gap) missing.push({ name: v.name, label: v.label, brokenSignIn: gap === 'unauthorized' || gap === 'under-scoped' });
1728
1698
  }
1729
1699
  return missing;
1730
1700
  }
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, it, vi, afterEach } from 'vitest';
2
2
  import { createHash } from 'node:crypto';
3
- import { DbSecretsVaultService } from '../db-secrets-vault.service.js';
4
- import { SecretOAuthError } from '../secrets-vault.contract.js';
3
+ import { DbSecretsVaultService, InvalidSecretError, SecretOAuthError } from '../index.js';
5
4
  import { TokenCrypto } from '../../../shared/token-crypto.js';
6
- import type { Database } from '../../database/connection.js';
5
+ import type { Database } from '../../database/index.js';
6
+ import { setLogger } from '../../../shared/logging.js';
7
+ import type { ILogger, LogFields } from '../../../shared/logger.contract.js';
7
8
 
8
9
  const ENC_KEY = Buffer.alloc(32, 7).toString('base64');
9
10
  const crypto = new TokenCrypto(ENC_KEY);
@@ -595,3 +596,429 @@ describe('DbSecretsVaultService — forceRefresh (a downstream rejected the toke
595
596
  expect(fetchMock).not.toHaveBeenCalled();
596
597
  });
597
598
  });
599
+
600
+ describe("DbSecretsVaultService — a refused token request names the provider's error code, never its text", () => {
601
+ const confidentialRow = (blob: Record<string, unknown>) =>
602
+ sharedRow({
603
+ id: 'user-row-1',
604
+ userId: 'user-1',
605
+ oauthMeta: { ...PUBLIC_META, publicClient: false },
606
+ valueEncrypted: crypto.encrypt(JSON.stringify(blob)),
607
+ });
608
+ const SECRET = 'client-secret-1';
609
+ const BLOB = { clientSecret: SECRET, pendingVerifier: 'the-verifier' };
610
+ const INVALID_CLIENT =
611
+ 'Token endpoint returned HTTP 400 (invalid_client). The client id or client secret this sign-in uses is not accepted by the provider. The tool owner should check the declared client id and set the client secret again.';
612
+
613
+ async function exchangeFailure(
614
+ body: string | ReadableStream<Uint8Array>,
615
+ status = 400,
616
+ blob: Record<string, unknown> = BLOB,
617
+ ): Promise<SecretOAuthError> {
618
+ const { db } = makeFakeDb([[confidentialRow(blob)]]);
619
+ const svc = new DbSecretsVaultService(db, ENC_KEY);
620
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status })));
621
+ try {
622
+ await svc.completeOAuth('user-1', 'user-row-1', 'the-code', 'https://bevel.example.com/cb');
623
+ } catch (err) {
624
+ expect(err).toBeInstanceOf(SecretOAuthError);
625
+ return err as SecretOAuthError;
626
+ }
627
+ throw new Error('the exchange was expected to fail');
628
+ }
629
+
630
+ it('names an OAuth error code and says what to do about it, with the status', async () => {
631
+ const client = await exchangeFailure(
632
+ JSON.stringify({ error: 'invalid_client', error_description: 'Client authentication failed' }),
633
+ );
634
+ expect(client.message).toBe(INVALID_CLIENT);
635
+ expect(client.providerStatus).toBe(400);
636
+ const grant = await exchangeFailure(JSON.stringify({ error: 'invalid_grant' }));
637
+ expect(grant.message).toBe(
638
+ 'Token endpoint returned HTTP 400 (invalid_grant). The provider refused the grant: for a sign-in, a redirect URL mismatch or an expired or reused authorization code; for a refresh, a refresh token that has expired or been revoked. Sign in again.',
639
+ );
640
+ const scope = await exchangeFailure(JSON.stringify({ error: 'invalid_scope' }));
641
+ expect(scope.message).toBe(
642
+ "Token endpoint returned HTTP 400 (invalid_scope). The provider refused the scopes this sign-in asks for. The tool owner should check the declared scopes against the provider's app.",
643
+ );
644
+ const unauthorized = await exchangeFailure(JSON.stringify({ error: 'unauthorized_client' }));
645
+ expect(unauthorized.message).toBe(
646
+ "Token endpoint returned HTTP 400 (unauthorized_client). The provider does not allow this client to use this grant. The tool owner should check the provider's app settings.",
647
+ );
648
+ });
649
+
650
+ it("names a provider's own status code (HubSpot's shape) with the sentence of its OAuth equivalent", async () => {
651
+ const err = await exchangeFailure(
652
+ JSON.stringify({ status: 'BAD_CLIENT_SECRET', message: 'client secret does not match', correlationId: 'c1' }),
653
+ );
654
+ expect(err.message).toBe(INVALID_CLIENT.replace('(invalid_client)', '(BAD_CLIENT_SECRET)'));
655
+ });
656
+
657
+ it('takes nothing of the body but the code: descriptions, unknown shapes, plain text and cut bodies leave no trace', async () => {
658
+ const withDescription = JSON.stringify({ error: 'invalid_client', error_description: `secret ${SECRET} rejected` });
659
+ expect((await exchangeFailure(withDescription)).message).toBe(INVALID_CLIENT);
660
+ for (const body of [
661
+ JSON.stringify({ detail: `${SECRET} rejected` }),
662
+ `<html>${SECRET} rejected</html>`,
663
+ `${SECRET} rejected\nmore`,
664
+ JSON.stringify({ error: 'invalid_client', error_description: SECRET }).slice(0, 30),
665
+ JSON.stringify(`${SECRET} rejected`),
666
+ JSON.stringify([`${SECRET} rejected`]),
667
+ '{}',
668
+ '',
669
+ ]) {
670
+ expect((await exchangeFailure(body)).message).toBe('Token endpoint returned HTTP 400');
671
+ }
672
+ });
673
+
674
+ it('takes a code only when it is exactly one Hexis knows: anything else in that field is not a code', async () => {
675
+ // A secret, a secret with a suffix, a near-miss of a known code, a
676
+ // prototype key, free text: none is on the list, none reaches a message.
677
+ for (const code of [
678
+ 'wordy_secret',
679
+ 'wordy_secret_rejected',
680
+ 'Invalid_Client',
681
+ 'invalid_client ',
682
+ 'invalid_token',
683
+ 'constructor',
684
+ 'bad code',
685
+ '',
686
+ ]) {
687
+ const err = await exchangeFailure(JSON.stringify({ error: code }), 400, {
688
+ clientSecret: 'wordy_secret',
689
+ pendingVerifier: 'the-verifier',
690
+ });
691
+ expect(err.message).toBe('Token endpoint returned HTTP 400');
692
+ }
693
+ });
694
+
695
+ it('keeps the status when the refusal body cannot be read, so a refused refresh still retires the grant', async () => {
696
+ const failing = () =>
697
+ new ReadableStream<Uint8Array>({
698
+ pull() {
699
+ throw new Error('connection reset');
700
+ },
701
+ });
702
+ const err = await exchangeFailure(failing(), 401);
703
+ expect(err.message).toBe('Token endpoint returned HTTP 401');
704
+ expect(err.providerStatus).toBe(401);
705
+
706
+ const { db, captured } = makeFakeDb([
707
+ [confidentialRow({ clientSecret: SECRET, tokens: { access_token: 'at', refresh_token: 'rt', expires_at: 1 } })],
708
+ undefined,
709
+ ]);
710
+ const svc = new DbSecretsVaultService(db, ENC_KEY);
711
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(failing(), { status: 400 })));
712
+ await expect(svc.forceRefresh('user-1', KEY)).resolves.toBe('rejected');
713
+ expect(JSON.parse(crypto.decrypt(captured.set[0].valueEncrypted))).toEqual({ clientSecret: SECRET });
714
+ });
715
+
716
+ it('reads a refusal body only up to its cap and cancels the rest of the stream', async () => {
717
+ // Without the cap this never returns: the stream has no end.
718
+ let pulls = 0;
719
+ let cancelled = false;
720
+ const endless = new ReadableStream<Uint8Array>({
721
+ pull(controller) {
722
+ pulls += 1;
723
+ controller.enqueue(new TextEncoder().encode('z'.repeat(1024)));
724
+ },
725
+ cancel() {
726
+ cancelled = true;
727
+ },
728
+ });
729
+ const err = await exchangeFailure(endless, 502);
730
+ expect(err.message).toBe('Token endpoint returned HTTP 502');
731
+ expect(pulls).toBeLessThanOrEqual(3);
732
+ expect(cancelled).toBe(true);
733
+ });
734
+
735
+ it('a refused refresh is logged with the key, the outcome and the code as the sign-in is wiped', async () => {
736
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
737
+ const row = confidentialRow({
738
+ clientSecret: SECRET,
739
+ tokens: { access_token: 'at-old', refresh_token: 'rt-old', expires_at: 1 },
740
+ });
741
+ const { db } = makeFakeDb([[row], undefined]);
742
+ const svc = new DbSecretsVaultService(db, ENC_KEY);
743
+ vi.stubGlobal(
744
+ 'fetch',
745
+ vi.fn(
746
+ async () =>
747
+ new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'rt-old was revoked' }), {
748
+ status: 400,
749
+ }),
750
+ ),
751
+ );
752
+
753
+ await expect(svc.forceRefresh('user-1', KEY)).resolves.toBe('rejected');
754
+
755
+ const line = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes('refresh refused'));
756
+ expect(line).toContain(`refresh refused for "${KEY}", sign-in wiped: Token endpoint returned HTTP 400 (invalid_grant).`);
757
+ expect(line).not.toContain('rt-old');
758
+ warn.mockRestore();
759
+ });
760
+
761
+ it('a refused refresh that lost to a concurrent rotation logs that the rotated sign-in was kept, not wiped', async () => {
762
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
763
+ const row = confidentialRow({
764
+ clientSecret: SECRET,
765
+ tokens: { access_token: 'at-old', refresh_token: 'rt-old', expires_at: 1 },
766
+ });
767
+ const rotated = crypto.encrypt(
768
+ JSON.stringify({ clientSecret: SECRET, tokens: { access_token: 'rotated-at', refresh_token: 'rt-2' } }),
769
+ );
770
+ // The row moved on between our read and our guarded wipe: the wipe matches nothing.
771
+ const { db, updates } = whereAwareDb({ ...row, valueEncrypted: rotated }, row.valueEncrypted);
772
+ const svc = new DbSecretsVaultService(db, ENC_KEY);
773
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })));
774
+
775
+ await expect(svc.forceRefresh('user-1', KEY)).resolves.toBe('refreshed');
776
+ expect(updates).toEqual([]);
777
+
778
+ const line = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes('refresh refused'));
779
+ expect(line).toContain(`refresh refused for "${KEY}", the row had been rotated by a concurrent refresh meanwhile`);
780
+ expect(line).toContain('the rotated sign-in is kept: Token endpoint returned HTTP 400 (invalid_grant)');
781
+ expect(line).not.toContain('sign-in wiped');
782
+ warn.mockRestore();
783
+ });
784
+
785
+ it('a refused refresh whose wipe matched nothing because the row was replaced logs that nothing was wiped', async () => {
786
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
787
+ const row = confidentialRow({
788
+ clientSecret: SECRET,
789
+ tokens: { access_token: 'at-old', refresh_token: 'rt-old', expires_at: 1 },
790
+ });
791
+ // Read as the OAuth row it was; by the time the wipe writes, an edit has
792
+ // made it a static value, so the guarded wipe matches nothing and no grant
793
+ // is there to serve: `rejected`, yet nothing was wiped.
794
+ const { db, updates } = whereAwareDb(
795
+ { ...row, kind: 'static', valueEncrypted: crypto.encrypt(JSON.stringify({ value: 'x' })) },
796
+ { valueEncrypted: row.valueEncrypted, kind: 'oauth' },
797
+ );
798
+ const svc = new DbSecretsVaultService(db, ENC_KEY);
799
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })));
800
+
801
+ await expect(svc.forceRefresh('user-1', KEY)).resolves.toBe('rejected');
802
+ expect(updates).toEqual([]);
803
+
804
+ const line = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes('refresh refused'));
805
+ expect(line).toContain(`refresh refused for "${KEY}", the row had been removed or replaced meanwhile; nothing wiped:`);
806
+ expect(line).not.toContain('sign-in wiped');
807
+ warn.mockRestore();
808
+ });
809
+ });
810
+
811
+ describe('DbSecretsVaultService — a declared sign-in follows the live declaration', () => {
812
+ // What the owner's client-secret save stored: client id A and its endpoints.
813
+ const SAVED_META = {
814
+ authorizationUrl: 'https://old.example.com/authorize',
815
+ tokenUrl: 'https://old.example.com/token',
816
+ clientId: 'client-A',
817
+ scopes: ['crm.read'],
818
+ authParams: {},
819
+ pkce: true,
820
+ resource: 'https://mcp.example.com/',
821
+ };
822
+ // What the tool file reads now: client id B.
823
+ const LIVE = {
824
+ authorizationUrl: 'https://app.example.com/oauth/authorize',
825
+ tokenUrl: 'https://api.example.com/oauth/v1/token',
826
+ clientId: 'client-B',
827
+ scopes: ['crm.read', 'crm.write'],
828
+ authParams: { optional_scope: 'tickets' },
829
+ pkce: true,
830
+ resource: 'https://mcp.example.com/',
831
+ };
832
+ const confidentialShared = () =>
833
+ sharedRow({ oauthMeta: SAVED_META, valueEncrypted: crypto.encrypt(JSON.stringify({ clientSecret: 'cs-owner' })) });
834
+
835
+ const begin = (svc: DbSecretsVaultService, provider: Record<string, unknown> = LIVE) =>
836
+ svc.beginToolOAuthByKey({
837
+ userId: 'user-1',
838
+ key: KEY,
839
+ redirectUri: 'https://bevel.example.com/api/secrets/oauth/callback',
840
+ state: 's',
841
+ provider: provider as never,
842
+ });
843
+
844
+ it('builds the consent URL from the live declaration, not the stored copy, and records it on the caller row', async () => {
845
+ const { db, captured } = makeFakeDb([[confidentialShared()], [], [{ id: 'user-row-1' }]]);
846
+ const { url } = await begin(new DbSecretsVaultService(db, ENC_KEY));
847
+
848
+ const consent = new URL(url);
849
+ expect(consent.origin + consent.pathname).toBe(LIVE.authorizationUrl);
850
+ expect(consent.searchParams.get('client_id')).toBe('client-B');
851
+ expect(consent.searchParams.get('resource')).toBe(LIVE.resource);
852
+ expect(consent.searchParams.get('scope')).toBe('crm.read crm.write');
853
+ expect(consent.searchParams.get('optional_scope')).toBe('tickets');
854
+ const blob = JSON.parse(crypto.decrypt(captured.values[0].valueEncrypted));
855
+ expect(consent.searchParams.get('code_challenge')).toBe(
856
+ createHash('sha256').update(blob.pendingVerifier).digest('base64url'),
857
+ );
858
+ // The secret is the owner's, unchanged; the configuration is the file's.
859
+ expect(blob.clientSecret).toBe('cs-owner');
860
+ expect(captured.values[0].oauthMeta).toMatchObject({ clientId: 'client-B', tokenUrl: LIVE.tokenUrl });
861
+ expect(captured.values[0].oauthMeta.publicClient).toBeUndefined();
862
+ });
863
+
864
+ it('omits PKCE when the declaration opts out', async () => {
865
+ const { db, captured } = makeFakeDb([[confidentialShared()], [], [{ id: 'user-row-1' }]]);
866
+ const { url } = await begin(new DbSecretsVaultService(db, ENC_KEY), { ...LIVE, pkce: false });
867
+ expect(new URL(url).searchParams.get('code_challenge')).toBeNull();
868
+ expect(JSON.parse(crypto.decrypt(captured.values[0].valueEncrypted)).pendingVerifier).toBeUndefined();
869
+ });
870
+
871
+ it('the exchange that completes it posts to the live token endpoint with client id B and the stored secret', async () => {
872
+ const start = makeFakeDb([[confidentialShared()], [], [{ id: 'user-row-1' }]]);
873
+ await begin(new DbSecretsVaultService(start.db, ENC_KEY));
874
+ // The callback reads the caller row exactly as the start wrote it.
875
+ const written = start.captured.values[0];
876
+ const userRow = sharedRow({ id: 'user-row-1', userId: 'user-1', ...written });
877
+ const { db } = makeFakeDb([[userRow], undefined]);
878
+ const fetchMock = vi.fn<(url: unknown, init?: RequestInit) => Promise<Response>>(
879
+ async () =>
880
+ new Response(JSON.stringify({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }), { status: 200 }),
881
+ );
882
+ vi.stubGlobal('fetch', fetchMock);
883
+
884
+ await new DbSecretsVaultService(db, ENC_KEY).completeOAuth('user-1', 'user-row-1', 'code', 'https://bevel.example.com/cb');
885
+
886
+ expect(String(fetchMock.mock.calls[0][0])).toBe(LIVE.tokenUrl);
887
+ const body = new URLSearchParams(String(fetchMock.mock.calls[0][1]?.body));
888
+ expect(body.get('client_id')).toBe('client-B');
889
+ expect(body.get('client_secret')).toBe('cs-owner');
890
+ expect(body.get('resource')).toBe(LIVE.resource);
891
+ });
892
+
893
+ it('a later refresh of that grant uses the configuration the consent was given for', async () => {
894
+ const userRow = sharedRow({
895
+ id: 'user-row-1',
896
+ userId: 'user-1',
897
+ oauthMeta: { ...LIVE },
898
+ valueEncrypted: crypto.encrypt(
899
+ JSON.stringify({ clientSecret: 'cs-owner', tokens: { access_token: 'old', refresh_token: 'rt', expires_at: 1_000 } }),
900
+ ),
901
+ });
902
+ const { db } = makeFakeDb([[userRow], undefined]);
903
+ const fetchMock = vi.fn<(url: unknown, init?: RequestInit) => Promise<Response>>(
904
+ async () =>
905
+ new Response(JSON.stringify({ access_token: 'fresh', expires_in: 3600 }), { status: 200 }),
906
+ );
907
+ vi.stubGlobal('fetch', fetchMock);
908
+
909
+ await expect(
910
+ new DbSecretsVaultService(db, ENC_KEY, undefined, async () => 'user' as const).resolve('user-1', KEY),
911
+ ).resolves.toBe('fresh');
912
+ expect(String(fetchMock.mock.calls[0][0])).toBe(LIVE.tokenUrl);
913
+ expect(new URLSearchParams(String(fetchMock.mock.calls[0][1]?.body)).get('client_id')).toBe('client-B');
914
+ });
915
+
916
+ it.each([
917
+ ['an http:// token endpoint', { tokenUrl: 'http://api.example.com/token' }, 'tokenUrl must use https'],
918
+ ['a loopback token endpoint', { tokenUrl: 'https://127.0.0.1/token' }, 'tokenUrl host is not allowed'],
919
+ ['a private token endpoint', { tokenUrl: 'https://10.0.0.5/token' }, 'tokenUrl host is not allowed'],
920
+ ['a link-local authorize endpoint', { authorizationUrl: 'https://169.254.169.254/authorize' }, 'authorizationUrl host is not allowed'],
921
+ ])('refuses %s with the save’s validation message, writing and sending nothing', async (_name, over, message) => {
922
+ const { db } = makeFakeDb([[confidentialShared()]]);
923
+ const fetchMock = vi.fn();
924
+ vi.stubGlobal('fetch', fetchMock);
925
+ const err = await begin(new DbSecretsVaultService(db, ENC_KEY), { ...LIVE, ...over }).catch((e: unknown) => e);
926
+ expect(err).toBeInstanceOf(InvalidSecretError);
927
+ expect((err as Error).message).toBe(message);
928
+ expect(db.insert).not.toHaveBeenCalled();
929
+ expect(fetchMock).not.toHaveBeenCalled();
930
+ });
931
+
932
+ it("refuses with \"owner hasn't finished\" when the shared row holds no secret, whatever it says of itself", async () => {
933
+ // A row the platform once registered as a public client, under a file that
934
+ // now declares its own client: the declared client is confidential.
935
+ const { db } = makeFakeDb([[sharedRow()]]);
936
+ await expect(begin(new DbSecretsVaultService(db, ENC_KEY))).rejects.toThrow("hasn't finished setting this up");
937
+ expect(db.insert).not.toHaveBeenCalled();
938
+ });
939
+
940
+ it('keeps the tokens a re-entered sign-in already holds when the declaration is unchanged, key order notwithstanding', async () => {
941
+ // jsonb does not keep key order: the stored copy reads back reordered.
942
+ const reordered = Object.fromEntries(Object.entries({ ...LIVE }).reverse());
943
+ const existing = sharedRow({
944
+ id: 'user-row-1',
945
+ userId: 'user-1',
946
+ oauthMeta: reordered,
947
+ valueEncrypted: crypto.encrypt(JSON.stringify({ clientSecret: 'cs-owner', tokens: { access_token: 'held' } })),
948
+ });
949
+ const { db, captured } = makeFakeDb([[confidentialShared()], [existing], [{ id: 'user-row-1' }]]);
950
+ await begin(new DbSecretsVaultService(db, ENC_KEY));
951
+ expect(JSON.parse(crypto.decrypt(captured.values[0].valueEncrypted)).tokens).toEqual({ access_token: 'held' });
952
+ });
953
+
954
+ it('does not carry tokens minted under client A into a sign-in under client B', async () => {
955
+ const existing = sharedRow({
956
+ id: 'user-row-1',
957
+ userId: 'user-1',
958
+ oauthMeta: SAVED_META,
959
+ valueEncrypted: crypto.encrypt(JSON.stringify({ clientSecret: 'cs-owner', tokens: { access_token: 'under-A' } })),
960
+ });
961
+ const { db, captured } = makeFakeDb([[confidentialShared()], [existing], [{ id: 'user-row-1' }]]);
962
+ await begin(new DbSecretsVaultService(db, ENC_KEY));
963
+ expect(JSON.parse(crypto.decrypt(captured.values[0].valueEncrypted)).tokens).toBeUndefined();
964
+ });
965
+
966
+ it('a platform-registered client (no provider handed over) still starts from its stored row', async () => {
967
+ const { db, captured } = makeFakeDb([[sharedRow()], [], [{ id: 'user-row-1' }]]);
968
+ const { url } = await new DbSecretsVaultService(db, ENC_KEY).beginToolOAuthByKey({
969
+ userId: 'user-1',
970
+ key: KEY,
971
+ redirectUri: 'https://bevel.example.com/cb',
972
+ state: 's',
973
+ });
974
+ expect(new URL(url).searchParams.get('client_id')).toBe('dcr-client-1');
975
+ expect(captured.values[0].oauthMeta).toEqual(PUBLIC_META);
976
+ });
977
+
978
+ // The moved property, pinned in both directions: the secret used to be bound to
979
+ // the token endpoint recorded at its save; it now goes where the file says, and
980
+ // the move leaves a line naming both origins (never a path) for an operator.
981
+ describe('where the stored secret goes when the declaration moves the token endpoint', () => {
982
+ const capture = () => {
983
+ const lines: Array<{ message: string; fields?: LogFields }> = [];
984
+ const make = (): ILogger => ({
985
+ debug: () => {},
986
+ info: () => {},
987
+ warn: (message, fields) => void lines.push({ message, fields }),
988
+ error: () => {},
989
+ child: () => make(),
990
+ });
991
+ const previous = setLogger(make());
992
+ return { lines, restore: () => setLogger(previous) };
993
+ };
994
+
995
+ it('follows the file to the new endpoint, and says so with both origins', async () => {
996
+ const log = capture();
997
+ try {
998
+ const { db, captured } = makeFakeDb([[confidentialShared()], [], [{ id: 'user-row-1' }]]);
999
+ await begin(new DbSecretsVaultService(db, ENC_KEY));
1000
+ expect(captured.values[0].oauthMeta.tokenUrl).toBe(LIVE.tokenUrl);
1001
+ expect(JSON.parse(crypto.decrypt(captured.values[0].valueEncrypted)).clientSecret).toBe('cs-owner');
1002
+ expect(log.lines).toEqual([
1003
+ {
1004
+ message: `sign-in on ${KEY}: the declared token endpoint differs from the one recorded when the client secret was saved`,
1005
+ fields: { savedOrigin: 'https://old.example.com', declaredOrigin: 'https://api.example.com' },
1006
+ },
1007
+ ]);
1008
+ } finally {
1009
+ log.restore();
1010
+ }
1011
+ });
1012
+
1013
+ it('says nothing when the declared token endpoint is the one recorded at the save', async () => {
1014
+ const log = capture();
1015
+ try {
1016
+ const { db } = makeFakeDb([[confidentialShared()], [], [{ id: 'user-row-1' }]]);
1017
+ await begin(new DbSecretsVaultService(db, ENC_KEY), { ...LIVE, tokenUrl: SAVED_META.tokenUrl });
1018
+ expect(log.lines).toEqual([]);
1019
+ } finally {
1020
+ log.restore();
1021
+ }
1022
+ });
1023
+ });
1024
+ });