@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
@@ -0,0 +1,218 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { createSecretsVaultRoutes, InvalidSecretError, SecretOAuthError } from '../index.js';
5
+ import type { ToolManualSummary, ToolVariable } from '../../tool-manuals/index.js';
6
+
7
+ /**
8
+ * The tool-scoped sign-in start follows the tool file as it reads NOW
9
+ * (`liveDeclaration`), not the catalog's cached scan and not the copy the
10
+ * owner's client-secret save stored. A client the platform registered itself
11
+ * (oauth-auto) is described by no file and still starts from its stored row.
12
+ *
13
+ * The incident: the GTM plugin's HubSpot connector was replaced, the file got
14
+ * the new client id, the new secret was saved — and every sign-in still asked
15
+ * for consent under the old client id.
16
+ */
17
+
18
+ const USER = 'owner@x.com';
19
+ const ENDPOINTS = {
20
+ authorizationUrl: 'https://app.hubspot.example/oauth/authorize',
21
+ tokenUrl: 'https://api.hubspot.example/oauth/v1/token',
22
+ };
23
+
24
+ const manual = (variables: ToolVariable[], setup?: ToolManualSummary['setup']): ToolManualSummary => ({
25
+ slug: 'hubspot',
26
+ name: 'hubspot',
27
+ path: 'Plugins/GTM/.claude-plugin/plugin.json',
28
+ type: 'mcp',
29
+ variables,
30
+ ...(setup ? { setup } : {}),
31
+ });
32
+ const signIn = (oauth?: ToolVariable['oauth']): ToolVariable => ({
33
+ name: 'HUBSPOT_TOKEN',
34
+ scope: 'user',
35
+ ...(oauth ? { oauth } : {}),
36
+ });
37
+
38
+ let httpServer: HttpServer | undefined;
39
+ afterEach(async () => {
40
+ await new Promise<void>((r) => (httpServer ? httpServer.close(() => r()) : r()));
41
+ httpServer = undefined;
42
+ });
43
+
44
+ /** One server whose catalog says `catalog` and whose file says `live`. */
45
+ async function serve(opts: {
46
+ catalog: ToolManualSummary[];
47
+ live: { manual: ToolManualSummary; variable?: ToolVariable } | null;
48
+ begin?: (input: Record<string, unknown>) => Promise<{ id: string; url: string }>;
49
+ canWrite?: boolean;
50
+ varName?: string;
51
+ /** The live read fails outright (the workspace cannot be opened) instead of answering. */
52
+ liveError?: Error;
53
+ }) {
54
+ const beginToolOAuthByKey = vi.fn(
55
+ opts.begin ?? (async () => ({ id: 'row-1', url: 'https://app.hubspot.example/oauth/authorize?client_id=x' })),
56
+ );
57
+ const putSharedOAuthClientSecret = vi.fn(async () => {});
58
+ const liveDeclaration = vi.fn(async () => {
59
+ if (opts.liveError) throw opts.liveError;
60
+ return opts.live;
61
+ });
62
+ const deps = {
63
+ secretsVault: { beginToolOAuthByKey, putSharedOAuthClientSecret },
64
+ toolManualService: { listAccessible: async () => opts.catalog, liveDeclaration },
65
+ accessControl: { canRead: async () => true, canWrite: async () => opts.canWrite ?? false },
66
+ connectionProbe: {},
67
+ kb: { defaultWorkspaceId: () => 'main' },
68
+ stateSecret: 'state-secret',
69
+ publicBackendUrl: 'http://localhost:3000',
70
+ publicFrontendUrl: 'http://localhost:5173',
71
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0];
72
+ const app = express();
73
+ app.use(express.json());
74
+ app.use((req, _res, next) => {
75
+ req.userId = 'user-1';
76
+ req.userEmail = USER;
77
+ next();
78
+ });
79
+ app.use('/api', createSecretsVaultRoutes(deps));
80
+ httpServer = await new Promise<HttpServer>((resolve) => {
81
+ const s = app.listen(0, () => resolve(s));
82
+ });
83
+ const base = `http://127.0.0.1:${(httpServer.address() as { port: number }).port}/api/secrets/tools/hubspot/vars/${opts.varName ?? 'HUBSPOT_TOKEN'}`;
84
+ return {
85
+ beginToolOAuthByKey,
86
+ putSharedOAuthClientSecret,
87
+ liveDeclaration,
88
+ start: () => fetch(`${base}/oauth/start`, { method: 'POST' }),
89
+ saveSecret: () =>
90
+ fetch(`${base}/oauth/admin`, {
91
+ method: 'PUT',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ clientSecret: 'cs' }),
94
+ }),
95
+ };
96
+ }
97
+
98
+ describe('POST …/oauth/start — a declared sign-in follows the live declaration', () => {
99
+ it('hands the vault the file’s client id, endpoints, PKCE and resource, even when the catalog still says otherwise', async () => {
100
+ const stale = manual([signIn({ clientId: 'client-A', ...ENDPOINTS })], { kind: 'oauth-manual' });
101
+ const fresh = manual([signIn({ clientId: 'client-B', ...ENDPOINTS, scopes: ['crm.read'], resource: 'https://mcp.hubspot.example/' })], {
102
+ kind: 'oauth-manual',
103
+ });
104
+ const s = await serve({ catalog: [stale], live: { manual: fresh, variable: fresh.variables![0] } });
105
+
106
+ const res = await s.start();
107
+ expect(res.status).toBe(200);
108
+ expect(s.liveDeclaration).toHaveBeenCalledWith(USER, 'hubspot', 'HUBSPOT_TOKEN');
109
+ expect(s.beginToolOAuthByKey).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ key: 'hubspot_HUBSPOT_TOKEN',
112
+ scopes: ['crm.read'],
113
+ provider: {
114
+ ...ENDPOINTS,
115
+ clientId: 'client-B',
116
+ scopes: ['crm.read'],
117
+ authParams: undefined,
118
+ pkce: true,
119
+ resource: 'https://mcp.hubspot.example/',
120
+ },
121
+ }),
122
+ );
123
+ });
124
+
125
+ it('a declaration opting out of PKCE is handed over as such', async () => {
126
+ const fresh = manual([signIn({ clientId: 'client-B', ...ENDPOINTS, pkce: false })]);
127
+ const s = await serve({ catalog: [fresh], live: { manual: fresh, variable: fresh.variables![0] } });
128
+ expect((await s.start()).status).toBe(200);
129
+ expect(s.beginToolOAuthByKey.mock.calls[0][0]).toMatchObject({ provider: { pkce: false } });
130
+ });
131
+
132
+ it('refuses a declaration whose endpoints could not be discovered with the discovery reason — the save’s own wording', async () => {
133
+ const reason = 'no authorization-server metadata at https://mcp.hubspot.example';
134
+ const stuck = manual([signIn({ clientId: 'client-B' })], { kind: 'oauth-manual', reason });
135
+ const s = await serve({ catalog: [stuck], live: { manual: stuck, variable: stuck.variables![0] }, canWrite: true });
136
+
137
+ const res = await s.start();
138
+ expect(res.status).toBe(422);
139
+ const { error } = (await res.json()) as { error: string };
140
+ expect(error).toBe(`The sign-in endpoints for this server aren't known yet: ${reason}.`);
141
+ expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
142
+
143
+ const saved = await s.saveSecret();
144
+ expect(saved.status).toBe(422);
145
+ expect(((await saved.json()) as { error: string }).error).toBe(error);
146
+ });
147
+
148
+ it('refuses with "owner hasn’t finished" when the oauth block was removed from the file, never using the stored row', async () => {
149
+ const stale = manual([signIn({ clientId: 'client-A', ...ENDPOINTS })], { kind: 'oauth-manual' });
150
+ const fresh = manual([signIn()]);
151
+ const s = await serve({ catalog: [stale], live: { manual: fresh, variable: fresh.variables![0] } });
152
+
153
+ const res = await s.start();
154
+ expect(res.status).toBe(409);
155
+ expect(((await res.json()) as { error: string }).error).toBe("This tool's owner hasn't finished setting this up");
156
+ expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('passes on the vault’s refusals: a missing secret, and an endpoint that fails validation', async () => {
160
+ const fresh = manual([signIn({ clientId: 'client-B', ...ENDPOINTS, tokenUrl: 'http://127.0.0.1/token' })]);
161
+ const refusing = await serve({
162
+ catalog: [fresh],
163
+ live: { manual: fresh, variable: fresh.variables![0] },
164
+ begin: async () => {
165
+ throw new InvalidSecretError('tokenUrl must use https');
166
+ },
167
+ });
168
+ const invalid = await refusing.start();
169
+ expect(invalid.status).toBe(422);
170
+ expect(((await invalid.json()) as { error: string }).error).toBe('tokenUrl must use https');
171
+ await new Promise<void>((r) => httpServer!.close(() => r()));
172
+
173
+ const unset = await serve({
174
+ catalog: [fresh],
175
+ live: { manual: fresh, variable: fresh.variables![0] },
176
+ begin: async () => {
177
+ throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
178
+ },
179
+ });
180
+ const res = await unset.start();
181
+ expect(res.status).toBe(409);
182
+ expect(((await res.json()) as { error: string }).error).toBe("This tool's owner hasn't finished setting this up");
183
+ });
184
+
185
+ it('a platform-registered sign-in (oauth-auto) starts from its stored row: no provider is handed over', async () => {
186
+ const auto = manual(
187
+ [{ name: 'MCP_OAUTH', scope: 'user', oauth: { clientId: 'dcr-1', ...ENDPOINTS } }],
188
+ { kind: 'oauth-auto' },
189
+ );
190
+ // The file declares no such variable: the catalog's discovery synthesised it.
191
+ const s = await serve({ catalog: [auto], live: { manual: manual([]) }, varName: 'MCP_OAUTH' });
192
+ expect((await s.start()).status).toBe(200);
193
+ const input = s.beginToolOAuthByKey.mock.calls[0][0];
194
+ expect(input).toMatchObject({ key: 'hubspot_MCP_OAUTH' });
195
+ expect(input.provider).toBeUndefined();
196
+ });
197
+
198
+ it('answers 404 when neither the file nor the catalog knows the tool', async () => {
199
+ const s = await serve({ catalog: [], live: null });
200
+ expect((await s.start()).status).toBe(404);
201
+ expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it('answers 500 when the tool files cannot be read, never a misconfiguration verdict from the catalog', async () => {
205
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
206
+ // The catalog still holds a declared sign-in; a live read that fails must
207
+ // not fall back to it and answer "owner hasn't finished" (409) or "not
208
+ // found" (404) for what is a server fault.
209
+ const stale = manual([signIn({ clientId: 'client-A', ...ENDPOINTS })], { kind: 'oauth-manual' });
210
+ const s = await serve({ catalog: [stale], live: null, liveError: new Error('workspace unavailable') });
211
+
212
+ const res = await s.start();
213
+ expect(res.status).toBe(500);
214
+ expect(((await res.json()) as { error: string }).error).toBe('Internal error');
215
+ expect(s.beginToolOAuthByKey).not.toHaveBeenCalled();
216
+ error.mockRestore();
217
+ });
218
+ });
@@ -2,11 +2,11 @@ import type { Server as HttpServer } from 'node:http';
2
2
  import { createHmac } from 'node:crypto';
3
3
  import express from 'express';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import {
6
- createSecretsVaultRoutes,
7
- createSecretsVaultPublicRoutes,
8
- isSafeReturnPath,
9
- } from '../secrets-vault.routes.js';
5
+ import { createSecretsVaultRoutes, createSecretsVaultPublicRoutes, SecretOAuthError } from '../index.js';
6
+ // The one deliberate white-box reach: the open-redirect predicate is pinned as
7
+ // a unit table as well as end-to-end (see below), and it is on no entry because
8
+ // nothing outside the routes should decide what a safe return path is.
9
+ import { isSafeReturnPath } from '../secrets-vault.routes.js';
10
10
 
11
11
  /**
12
12
  * OAUTH `returnTo`: a sign-in started from a page other than /connect must land
@@ -54,6 +54,11 @@ const MANUALS = [
54
54
 
55
55
  const toolManualService = {
56
56
  listAccessible: async () => MANUALS,
57
+ // The sign-in start reads the declaration from the file; here it reads as the catalog does.
58
+ liveDeclaration: async (_who: string, slug: string, varName: string) => {
59
+ const manual = MANUALS.find((m) => m.slug === slug);
60
+ return manual ? { manual, variable: manual.variables.find((v) => v.name === varName) } : null;
61
+ },
57
62
  } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
58
63
 
59
64
  const accessControl = {
@@ -254,7 +259,35 @@ describe('GET /api/secrets/oauth/callback — where the browser lands', () => {
254
259
  const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
255
260
  const location = await callback(base, state);
256
261
  expect(location.startsWith(`${FRONTEND}${RETURN_TO}#error=`)).toBe(true);
257
- // The user-facing message is generic — provider detail stays in the log.
262
+ // A failure that is not the provider's word stays generic: only the
263
+ // vault's own error carries a reason that was scrubbed for the page.
264
+ expect(decodeURIComponent(location.split('#error=')[1])).toBe(
265
+ 'Authorization failed. Check the provider configuration and try again.',
266
+ );
267
+ });
268
+
269
+ it("carries the provider's error code and Hexis's sentence to the page when the exchange was refused", async () => {
270
+ vi.spyOn(console, 'error').mockImplementation(() => {});
271
+ const refusal =
272
+ '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.';
273
+ completeOAuth.mockImplementation(async () => {
274
+ throw new SecretOAuthError(refusal, 400);
275
+ });
276
+ const base = await baseUrl();
277
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
278
+ const location = await callback(base, state);
279
+ expect(decodeURIComponent(location.split('#error=')[1])).toBe(`Authorization failed. ${refusal}`);
280
+ });
281
+
282
+ it("keeps the generic message for the vault's own TRANSPORT errors, which quote what fetch said", async () => {
283
+ vi.spyOn(console, 'error').mockImplementation(() => {});
284
+ completeOAuth.mockImplementation(async () => {
285
+ // No provider status: not a provider's word, and never scrubbed.
286
+ throw new SecretOAuthError('Token endpoint request failed: fetch failed https://user:pw@provider.example/token');
287
+ });
288
+ const base = await baseUrl();
289
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
290
+ const location = await callback(base, state);
258
291
  expect(decodeURIComponent(location.split('#error=')[1])).toBe(
259
292
  'Authorization failed. Check the provider configuration and try again.',
260
293
  );
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
  import { logger } from '../../shared/logging.js';
3
4
 
4
5
  const log = logger('vault');
@@ -7,6 +8,8 @@ import type { Database } from '../database/index.js';
7
8
  import { secrets } from '../database/index.js';
8
9
  import { TokenCrypto } from '../../shared/token-crypto.js';
9
10
  import { assertSafeFetchUrl } from '../../shared/ssrf.js';
11
+ import { readCapped } from '../../shared/read-capped.js';
12
+ import { standardTokenErrorCode } from '../../shared/oauth-token-error.js';
10
13
  import {
11
14
  type ISecretsVaultService,
12
15
  type SecretSummary,
@@ -16,6 +19,7 @@ import {
16
19
  type PutSharedStaticSecretInput,
17
20
  type CreateOAuthSecretInput,
18
21
  type OAuthProviderConfig,
22
+ type DeclaredOAuthProvider,
19
23
  type VariableScopeResolver,
20
24
  type NamespaceSecretCount,
21
25
  isKeyInNamespace,
@@ -32,6 +36,79 @@ const MAX_VALUE_LEN = 100_000;
32
36
  const REFRESH_SKEW_MS = 60_000;
33
37
  /** Upper bound on a token-endpoint round-trip, so a hung provider can't block callers. */
34
38
  const TOKEN_REQUEST_TIMEOUT_MS = 10_000;
39
+ /** How much of a refusing token endpoint's body is read for its error code: an OAuth error object, never a whole error page. */
40
+ const TOKEN_ERROR_BODY_MAX_BYTES = 2048;
41
+ // Hexis's own sentences for a refused token request: what to change, in our
42
+ // words, never the provider's.
43
+ const HINT_CLIENT =
44
+ '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.';
45
+ const HINT_GRANT =
46
+ '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.';
47
+ const HINT_SCOPE =
48
+ "The provider refused the scopes this sign-in asks for. The tool owner should check the declared scopes against the provider's app.";
49
+ const HINT_UNAUTHORIZED =
50
+ "The provider does not allow this client to use this grant. The tool owner should check the provider's app settings.";
51
+ const HINT_REQUEST =
52
+ 'The provider found the token request malformed. The tool owner should check the declared endpoints and parameters.';
53
+ const HINT_PROVIDER = 'The provider could not serve the token request. Try again later.';
54
+
55
+ /**
56
+ * Hexis's sentence for each error code a refusing token endpoint may answer:
57
+ * the standard ones (`shared/oauth-token-error.ts` is the one list of those,
58
+ * read here and by the OIDC configuration check) in `error`, and the ones
59
+ * HubSpot's token endpoint answers in its `status` field. Only a code from
60
+ * those lists is ever taken, exactly, so no other string a provider puts in a
61
+ * field — a secret, a secret with a suffix, free text — can reach a message.
62
+ * Nothing else of the body is ever taken: no description, no message, no first
63
+ * line. That is why a message built here needs no scrubbing, cutting or
64
+ * decoding, and why every spelling a secret could reach a log in (escaped,
65
+ * encoded, cut) stays inexpressible. A standard code without a sentence here
66
+ * (`access_denied`) is named and left unexplained.
67
+ */
68
+ const TOKEN_ERROR_HINTS: ReadonlyMap<string, string> = new Map([
69
+ ['invalid_request', HINT_REQUEST],
70
+ ['invalid_client', HINT_CLIENT],
71
+ ['invalid_grant', HINT_GRANT],
72
+ ['unauthorized_client', HINT_UNAUTHORIZED],
73
+ ['unsupported_grant_type', HINT_UNAUTHORIZED],
74
+ ['invalid_scope', HINT_SCOPE],
75
+ ['server_error', HINT_PROVIDER],
76
+ ['temporarily_unavailable', HINT_PROVIDER],
77
+ ['BAD_CLIENT_ID', HINT_CLIENT],
78
+ ['BAD_CLIENT_SECRET', HINT_CLIENT],
79
+ ['BAD_AUTH_CODE', HINT_GRANT],
80
+ ['BAD_REDIRECT_URI', HINT_GRANT],
81
+ ]);
82
+
83
+ /** The codes HubSpot's token endpoint answers in `status` instead of a standard `error`. */
84
+ const HUBSPOT_STATUS_CODES: ReadonlySet<string> = new Set(['BAD_CLIENT_ID', 'BAD_CLIENT_SECRET', 'BAD_AUTH_CODE', 'BAD_REDIRECT_URI']);
85
+
86
+ /**
87
+ * The known error code a refusing token endpoint answered, or null: a standard
88
+ * `error` (RFC 6749 §5.2), else HubSpot's own `status`, when it is exactly one
89
+ * of the codes known. Without even the code, a bare "HTTP 400" cannot tell a
90
+ * client the provider does not accept from a refused grant.
91
+ */
92
+ export function tokenEndpointErrorCode(bodyText: string): string | null {
93
+ const standard = standardTokenErrorCode(bodyText);
94
+ if (standard !== null) return standard;
95
+ let json: unknown;
96
+ try {
97
+ json = JSON.parse(bodyText);
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
102
+ const { status } = json as Record<string, unknown>;
103
+ return typeof status === 'string' && HUBSPOT_STATUS_CODES.has(status) ? status : null;
104
+ }
105
+
106
+ /** The refusal in one message: the status, the known code when there is one, and Hexis's sentence for it. */
107
+ export function describeTokenEndpointRefusal(status: number, code: string | null): string {
108
+ const base = `Token endpoint returned HTTP ${status}`;
109
+ const hint = code === null ? undefined : TOKEN_ERROR_HINTS.get(code);
110
+ return hint ? `${base} (${code}). ${hint}` : base;
111
+ }
35
112
 
36
113
  /**
37
114
  * How a row is stored, or `null` when there is no row — the column is a plain
@@ -487,8 +564,9 @@ export class DbSecretsVaultService implements ISecretsVaultService {
487
564
  throw new InvalidSecretError('clientSecret is required');
488
565
  }
489
566
  // Validate the provider config the same way createOAuth does (SSRF-safe https
490
- // URLs). Store the client secret AND the provider meta together on the shared
491
- // row so a later `.tool` edit can't redirect the secret to another endpoint.
567
+ // URLs) and store it beside the secret as a record of what was saved — what
568
+ // `statusFor` and the setup surfaces read. A declared sign-in does not run
569
+ // from it: `beginToolOAuthByKey` is handed the declaration as it reads now.
492
570
  const provider = this.requireProvider({ ...input.provider, clientSecret: input.clientSecret });
493
571
  const blob: OAuthBlob = { clientSecret: provider.clientSecret };
494
572
  const valueEncrypted = this.crypto().encrypt(JSON.stringify(blob));
@@ -510,11 +588,15 @@ export class DbSecretsVaultService implements ISecretsVaultService {
510
588
  redirectUri: string;
511
589
  state: string;
512
590
  scopes?: string[];
591
+ provider?: DeclaredOAuthProvider;
513
592
  }): Promise<{ id: string; url: string }> {
514
593
  const userId = this.requireUserId(input.userId);
515
594
  const key = this.requireKey(input.key);
516
- // The shared row holds the owner-set client secret + the provider meta it was
517
- // set for. Both are read from HERE, never from the live `.tool`.
595
+ // The shared row holds the owner-set client secret. For a client the
596
+ // platform registered itself it also holds the provider configuration, the
597
+ // only description of that client there is; for a declared sign-in the
598
+ // configuration it holds is a record of the save, and `input.provider` (the
599
+ // tool file as it reads now) is what the sign-in follows.
518
600
  const [shared] = await this.db
519
601
  .select()
520
602
  .from(secrets)
@@ -524,11 +606,43 @@ export class DbSecretsVaultService implements ISecretsVaultService {
524
606
  throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
525
607
  }
526
608
  const sharedBlob = this.readBlob(shared.valueEncrypted);
527
- const meta = this.readMeta(shared.oauthMeta);
528
- // A missing secret is "not set up yet" ONLY for confidential clients — a
529
- // dynamically-registered PUBLIC client (PKCE-only) never has one.
530
- if (!sharedBlob.clientSecret && !meta.publicClient) {
531
- throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
609
+ let meta: OAuthProviderConfig;
610
+ let oauthMeta: typeof shared.oauthMeta;
611
+ if (input.provider) {
612
+ // A declared client is confidential: without the owner's secret there is
613
+ // nothing to sign in with, whatever the stored row says of itself.
614
+ if (!sharedBlob.clientSecret) {
615
+ throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
616
+ }
617
+ // The save's own validation (https, no private or local address), BEFORE
618
+ // the caller's row is written or a URL built: a declaration that fails it
619
+ // sends nothing anywhere.
620
+ meta = this.requireProvider({ ...input.provider, publicClient: false });
621
+ oauthMeta = this.publicProviderMeta(meta);
622
+ // A MOVED PROPERTY, decided on the ticket (OAuth-Sign-In-Follows-The-Live-
623
+ // Declaration, 2026-10-27): the secret used to be bound to the token
624
+ // endpoint pinned at its save, so a later file edit could not send it
625
+ // anywhere else; now it goes where the file says at each start, so a
626
+ // writer of the tool file can move it (to an https, public host — the
627
+ // validation above still holds). Nothing refuses that; this line is the
628
+ // trail an operator reads when a secret went somewhere new.
629
+ const recorded = (shared.oauthMeta ?? {}) as { tokenUrl?: unknown };
630
+ if (typeof recorded.tokenUrl === 'string' && recorded.tokenUrl !== meta.tokenUrl) {
631
+ // Origins only: a path or query is the file's to say, not the log's.
632
+ const origin = (u: string) => (URL.canParse(u) ? new URL(u).origin : '(unparseable)');
633
+ log.warn(`sign-in on ${key}: the declared token endpoint differs from the one recorded when the client secret was saved`, {
634
+ savedOrigin: origin(recorded.tokenUrl),
635
+ declaredOrigin: origin(meta.tokenUrl),
636
+ });
637
+ }
638
+ } else {
639
+ meta = this.readMeta(shared.oauthMeta);
640
+ oauthMeta = shared.oauthMeta;
641
+ // A missing secret is "not set up yet" ONLY for confidential clients — a
642
+ // dynamically-registered PUBLIC client (PKCE-only) never has one.
643
+ if (!sharedBlob.clientSecret && !meta.publicClient) {
644
+ throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
645
+ }
532
646
  }
533
647
 
534
648
  // Carry forward any tokens the caller already holds so re-entering the flow
@@ -543,12 +657,12 @@ export class DbSecretsVaultService implements ISecretsVaultService {
543
657
  // encKey must degrade to "start fresh", not crash the recovery flow — and (b)
544
658
  // they were minted under the SAME provider config we're about to (re)write, or
545
659
  // a refresh_token for a stale client id/secret would silently fail once carried
546
- // into this row after an owner rotates the shared secret.
660
+ // into this row after an owner rotates the shared secret or edits the
661
+ // declaration. Compared as values, not as serialised text: the stored side
662
+ // has been through jsonb, which does not keep key order, and a declared
663
+ // sign-in's side is built here.
547
664
  let existingTokens: OAuthTokenSet | undefined;
548
- if (
549
- existingUserRow?.kind === 'oauth' &&
550
- JSON.stringify(existingUserRow.oauthMeta) === JSON.stringify(shared.oauthMeta)
551
- ) {
665
+ if (existingUserRow?.kind === 'oauth' && isDeepStrictEqual(existingUserRow.oauthMeta, oauthMeta)) {
552
666
  try {
553
667
  existingTokens = this.readBlob(existingUserRow.valueEncrypted).tokens;
554
668
  } catch {
@@ -567,9 +681,13 @@ export class DbSecretsVaultService implements ISecretsVaultService {
567
681
  const requestedScopes = input.scopes && input.scopes.length ? input.scopes : meta.scopes;
568
682
  const pendingScopes = requestedScopes && requestedScopes.length ? requestedScopes.join(' ') : undefined;
569
683
 
570
- // Provision (or reset) the caller's own oauth row for this key from the shared
571
- // provider meta + secret, preserving any existing tokens. Keyed `<manual>_<VAR>`
572
- // so `resolve` (scope 'user') returns the token once sign-in completes.
684
+ // Provision (or reset) the caller's own oauth row for this key from the provider
685
+ // configuration this start uses + the shared secret, preserving any existing
686
+ // tokens. The row records that configuration because `completeOAuth` and every
687
+ // later refresh of this grant read it from HERE: the exchange goes to the token
688
+ // endpoint, under the client id, that the consent was given for. Keyed
689
+ // `<manual>_<VAR>` so `resolve` (scope 'user') returns the token once sign-in
690
+ // completes.
573
691
  const blob: OAuthBlob = {
574
692
  clientSecret: sharedBlob.clientSecret,
575
693
  tokens: existingTokens,
@@ -579,16 +697,15 @@ export class DbSecretsVaultService implements ISecretsVaultService {
579
697
  const valueEncrypted = this.crypto().encrypt(JSON.stringify(blob));
580
698
  const [row] = await this.db
581
699
  .insert(secrets)
582
- .values({ userId, key, kind: 'oauth', label: shared.label, valueEncrypted, oauthMeta: shared.oauthMeta })
700
+ .values({ userId, key, kind: 'oauth', label: shared.label, valueEncrypted, oauthMeta })
583
701
  .onConflictDoUpdate({
584
702
  target: [secrets.userId, secrets.key],
585
- set: { kind: 'oauth', valueEncrypted, oauthMeta: shared.oauthMeta, updatedAt: new Date() },
703
+ set: { kind: 'oauth', valueEncrypted, oauthMeta, updatedAt: new Date() },
586
704
  })
587
705
  .returning();
588
706
 
589
- // Build the consent URL exactly as beginOAuth does, from the stored meta —
590
- // EXCEPT the requested scopes (above). Client id, addresses, and secret
591
- // stay owner-pinned.
707
+ // Build the consent URL exactly as beginOAuth does, from the configuration
708
+ // this start uses, with the requested scopes chosen above.
592
709
  const url = new URL(meta.authorizationUrl);
593
710
  url.searchParams.set('response_type', 'code');
594
711
  url.searchParams.set('client_id', meta.clientId);
@@ -732,7 +849,18 @@ export class DbSecretsVaultService implements ISecretsVaultService {
732
849
  err instanceof SecretOAuthError &&
733
850
  (err.providerStatus === 400 || err.providerStatus === 401)
734
851
  ) {
735
- return this.wipeTokens(row, blob);
852
+ // The only trace of a sign-in that silently turns into "not connected":
853
+ // say which one, the provider's reason, and what the guarded wipe did —
854
+ // a concurrent refresh may have rotated the tokens first, in which case
855
+ // nothing was wiped and the rotated grant is served.
856
+ const result = await this.wipeTokens(row, blob);
857
+ const did = result.wiped
858
+ ? 'sign-in wiped'
859
+ : result.outcome === 'refreshed'
860
+ ? 'the row had been rotated by a concurrent refresh meanwhile; the rotated sign-in is kept'
861
+ : 'the row had been removed or replaced meanwhile; nothing wiped';
862
+ log.warn(`refresh refused for "${row.key}", ${did}: ${err.message}`);
863
+ return result;
736
864
  }
737
865
  return { outcome: 'transient' };
738
866
  }
@@ -794,7 +922,10 @@ export class DbSecretsVaultService implements ISecretsVaultService {
794
922
  private async wipeTokens(
795
923
  row: typeof secrets.$inferSelect,
796
924
  blob: OAuthBlob,
797
- ): Promise<{ outcome: 'refreshed'; accessToken: string } | { outcome: 'rejected'; accessToken: null }> {
925
+ ): Promise<
926
+ | { outcome: 'refreshed'; accessToken: string; wiped: false }
927
+ | { outcome: 'rejected'; accessToken: null; wiped: boolean }
928
+ > {
798
929
  const next: OAuthBlob = { clientSecret: blob.clientSecret };
799
930
  // Guarded on the ciphertext we read, so a concurrent refresh that
800
931
  // already persisted ROTATED tokens can't be wiped by our stale
@@ -807,13 +938,17 @@ export class DbSecretsVaultService implements ISecretsVaultService {
807
938
  .where(and(eq(secrets.id, row.id), eq(secrets.valueEncrypted, row.valueEncrypted)))
808
939
  .returning({ id: secrets.id });
809
940
  if (Array.isArray(wiped) && wiped.length === 0) {
941
+ // `wiped: false` either way: this write matched nothing, and the caller
942
+ // reports what it did, not what it set out to do.
810
943
  const fresh = await this.currentAccessToken(row.id);
811
- return fresh ? { outcome: 'refreshed', accessToken: fresh } : { outcome: 'rejected', accessToken: null };
944
+ return fresh
945
+ ? { outcome: 'refreshed', accessToken: fresh, wiped: false }
946
+ : { outcome: 'rejected', accessToken: null, wiped: false };
812
947
  }
813
948
  // The sign-in just became not-connected — let credential-validity caches
814
949
  // (pooled downstream connections dialed with the dead token) drop it now.
815
950
  this.notifyMutation(row.userId);
816
- return { outcome: 'rejected', accessToken: null };
951
+ return { outcome: 'rejected', accessToken: null, wiped: true };
817
952
  }
818
953
 
819
954
  async putSharedOAuthProvider(input: {
@@ -887,7 +1022,15 @@ export class DbSecretsVaultService implements ISecretsVaultService {
887
1022
  throw new SecretOAuthError(msg);
888
1023
  }
889
1024
  if (!res.ok) {
890
- throw new SecretOAuthError(`Token endpoint returned HTTP ${res.status}`, res.status);
1025
+ // The provider's error CODE rides in the message: the callback route and
1026
+ // the refresh path log that message, and it is the one line that says
1027
+ // WHY a sign-in failed (a client the provider does not accept, a refused
1028
+ // authorization code). Best effort: the STATUS is the contract (the
1029
+ // refresh path reads 400/401 off it to retire a dead grant), and a body
1030
+ // that fails to read must not turn a definitive refusal into an
1031
+ // unclassified error.
1032
+ const code = tokenEndpointErrorCode(await readCapped(res, TOKEN_ERROR_BODY_MAX_BYTES).catch(() => ''));
1033
+ throw new SecretOAuthError(describeTokenEndpointRefusal(res.status, code), res.status);
891
1034
  }
892
1035
  const json = (await res.json()) as Record<string, unknown>;
893
1036
  const accessToken = typeof json.access_token === 'string' ? json.access_token : '';
@@ -27,4 +27,4 @@ export {
27
27
  DEFAULT_SECRETS_SCOPE,
28
28
  } from './secrets-variable-loader.js';
29
29
  // Reached by other modules; exported here so they stop importing the files.
30
- export { scopesCovered } from './secrets-vault.contract.js';
30
+ export { scopesCovered, userSecretGap, type UserSecretGap } from './secrets-vault.contract.js';