@bevel-software/platform-core-backend 0.11.2 → 0.12.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 (166) hide show
  1. package/THIRD-PARTY-NOTICES.md +1165 -427
  2. package/dist/core/create-core-server.js +1 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +2 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +5 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core-config.d.ts +7 -0
  9. package/dist/core-config.d.ts.map +1 -1
  10. package/dist/core-config.js +9 -0
  11. package/dist/core-config.js.map +1 -1
  12. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  13. package/dist/modules/code-mode/code-mode.tool.js +7 -1
  14. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  15. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  16. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  17. package/dist/modules/kb-fs/clone-config.js +94 -2
  18. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  19. package/dist/modules/workflow/workflow.service.d.ts +38 -0
  20. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  21. package/dist/modules/workflow/workflow.service.js +112 -6
  22. package/dist/modules/workflow/workflow.service.js.map +1 -1
  23. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts +71 -0
  24. package/dist/modules/workspace/file-readers/doc-extract.service.d.ts.map +1 -0
  25. package/dist/modules/workspace/file-readers/doc-extract.service.js +90 -0
  26. package/dist/modules/workspace/file-readers/doc-extract.service.js.map +1 -0
  27. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts +55 -0
  28. package/dist/modules/workspace/file-readers/doc-extract.types.d.ts.map +1 -0
  29. package/dist/modules/workspace/file-readers/doc-extract.types.js +34 -0
  30. package/dist/modules/workspace/file-readers/doc-extract.types.js.map +1 -0
  31. package/dist/modules/workspace/file-readers/document-reader.d.ts +32 -0
  32. package/dist/modules/workspace/file-readers/document-reader.d.ts.map +1 -0
  33. package/dist/modules/workspace/file-readers/document-reader.js +59 -0
  34. package/dist/modules/workspace/file-readers/document-reader.js.map +1 -0
  35. package/dist/modules/workspace/file-readers/email-reader.d.ts +15 -0
  36. package/dist/modules/workspace/file-readers/email-reader.d.ts.map +1 -0
  37. package/dist/modules/workspace/file-readers/email-reader.js +19 -0
  38. package/dist/modules/workspace/file-readers/email-reader.js.map +1 -0
  39. package/dist/modules/workspace/file-readers/email-text.d.ts +51 -0
  40. package/dist/modules/workspace/file-readers/email-text.d.ts.map +1 -0
  41. package/dist/modules/workspace/file-readers/email-text.js +151 -0
  42. package/dist/modules/workspace/file-readers/email-text.js.map +1 -0
  43. package/dist/modules/workspace/file-readers/extract-docx.d.ts +13 -0
  44. package/dist/modules/workspace/file-readers/extract-docx.d.ts.map +1 -0
  45. package/dist/modules/workspace/file-readers/extract-docx.js +67 -0
  46. package/dist/modules/workspace/file-readers/extract-docx.js.map +1 -0
  47. package/dist/modules/workspace/file-readers/extract-eml.d.ts +18 -0
  48. package/dist/modules/workspace/file-readers/extract-eml.d.ts.map +1 -0
  49. package/dist/modules/workspace/file-readers/extract-eml.js +87 -0
  50. package/dist/modules/workspace/file-readers/extract-eml.js.map +1 -0
  51. package/dist/modules/workspace/file-readers/extract-msg.d.ts +17 -0
  52. package/dist/modules/workspace/file-readers/extract-msg.d.ts.map +1 -0
  53. package/dist/modules/workspace/file-readers/extract-msg.js +121 -0
  54. package/dist/modules/workspace/file-readers/extract-msg.js.map +1 -0
  55. package/dist/modules/workspace/file-readers/extract-odp.d.ts +13 -0
  56. package/dist/modules/workspace/file-readers/extract-odp.d.ts.map +1 -0
  57. package/dist/modules/workspace/file-readers/extract-odp.js +60 -0
  58. package/dist/modules/workspace/file-readers/extract-odp.js.map +1 -0
  59. package/dist/modules/workspace/file-readers/extract-ods.d.ts +10 -0
  60. package/dist/modules/workspace/file-readers/extract-ods.d.ts.map +1 -0
  61. package/dist/modules/workspace/file-readers/extract-ods.js +173 -0
  62. package/dist/modules/workspace/file-readers/extract-ods.js.map +1 -0
  63. package/dist/modules/workspace/file-readers/extract-odt.d.ts +17 -0
  64. package/dist/modules/workspace/file-readers/extract-odt.d.ts.map +1 -0
  65. package/dist/modules/workspace/file-readers/extract-odt.js +45 -0
  66. package/dist/modules/workspace/file-readers/extract-odt.js.map +1 -0
  67. package/dist/modules/workspace/file-readers/extract-pdf.d.ts +3 -0
  68. package/dist/modules/workspace/file-readers/extract-pdf.d.ts.map +1 -0
  69. package/dist/modules/workspace/file-readers/extract-pdf.js +176 -0
  70. package/dist/modules/workspace/file-readers/extract-pdf.js.map +1 -0
  71. package/dist/modules/workspace/file-readers/extract-pptx.d.ts +37 -0
  72. package/dist/modules/workspace/file-readers/extract-pptx.d.ts.map +1 -0
  73. package/dist/modules/workspace/file-readers/extract-pptx.js +288 -0
  74. package/dist/modules/workspace/file-readers/extract-pptx.js.map +1 -0
  75. package/dist/modules/workspace/file-readers/extract-xlsx.d.ts +10 -0
  76. package/dist/modules/workspace/file-readers/extract-xlsx.d.ts.map +1 -0
  77. package/dist/modules/workspace/file-readers/extract-xlsx.js +98 -0
  78. package/dist/modules/workspace/file-readers/extract-xlsx.js.map +1 -0
  79. package/dist/modules/workspace/file-readers/extraction-cache.d.ts +61 -0
  80. package/dist/modules/workspace/file-readers/extraction-cache.d.ts.map +1 -0
  81. package/dist/modules/workspace/file-readers/extraction-cache.js +135 -0
  82. package/dist/modules/workspace/file-readers/extraction-cache.js.map +1 -0
  83. package/dist/modules/workspace/file-readers/file-reader.d.ts +76 -0
  84. package/dist/modules/workspace/file-readers/file-reader.d.ts.map +1 -0
  85. package/dist/modules/workspace/file-readers/file-reader.js +55 -0
  86. package/dist/modules/workspace/file-readers/file-reader.js.map +1 -0
  87. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts +13 -0
  88. package/dist/modules/workspace/file-readers/file-reader.registry.d.ts.map +1 -0
  89. package/dist/modules/workspace/file-readers/file-reader.registry.js +41 -0
  90. package/dist/modules/workspace/file-readers/file-reader.registry.js.map +1 -0
  91. package/dist/modules/workspace/file-readers/image-read.d.ts +35 -0
  92. package/dist/modules/workspace/file-readers/image-read.d.ts.map +1 -0
  93. package/dist/modules/workspace/file-readers/image-read.js +108 -0
  94. package/dist/modules/workspace/file-readers/image-read.js.map +1 -0
  95. package/dist/modules/workspace/file-readers/image-reader.d.ts +19 -0
  96. package/dist/modules/workspace/file-readers/image-reader.d.ts.map +1 -0
  97. package/dist/modules/workspace/file-readers/image-reader.js +30 -0
  98. package/dist/modules/workspace/file-readers/image-reader.js.map +1 -0
  99. package/dist/modules/workspace/file-readers/odf-text.d.ts +26 -0
  100. package/dist/modules/workspace/file-readers/odf-text.d.ts.map +1 -0
  101. package/dist/modules/workspace/file-readers/odf-text.js +116 -0
  102. package/dist/modules/workspace/file-readers/odf-text.js.map +1 -0
  103. package/dist/modules/workspace/file-readers/ooxml-text.d.ts +172 -0
  104. package/dist/modules/workspace/file-readers/ooxml-text.d.ts.map +1 -0
  105. package/dist/modules/workspace/file-readers/ooxml-text.js +439 -0
  106. package/dist/modules/workspace/file-readers/ooxml-text.js.map +1 -0
  107. package/dist/modules/workspace/file-readers/text-reader.d.ts +47 -0
  108. package/dist/modules/workspace/file-readers/text-reader.d.ts.map +1 -0
  109. package/dist/modules/workspace/file-readers/text-reader.js +117 -0
  110. package/dist/modules/workspace/file-readers/text-reader.js.map +1 -0
  111. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  112. package/dist/modules/workspace/startup/kb-git.js +21 -4
  113. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  114. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  115. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  116. package/dist/modules/workspace/workspace.service.js +121 -23
  117. package/dist/modules/workspace/workspace.service.js.map +1 -1
  118. package/dist/modules/workspace/workspace.tools.d.ts +2 -1
  119. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  120. package/dist/modules/workspace/workspace.tools.js +158 -15
  121. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  122. package/package.json +11 -6
  123. package/src/core/create-core-server.ts +1 -1
  124. package/src/core/create-core-services.ts +6 -0
  125. package/src/core-config.ts +9 -0
  126. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +30 -0
  127. package/src/modules/code-mode/code-mode.tool.ts +7 -1
  128. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  129. package/src/modules/kb-fs/clone-config.ts +97 -2
  130. package/src/modules/secrets-vault/secrets-vault.routes.ts +582 -582
  131. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +2 -1
  132. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  133. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  134. package/src/modules/workflow/workflow.service.ts +118 -6
  135. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  136. package/src/modules/workspace/__tests__/workspace.tools.test.ts +500 -2
  137. package/src/modules/workspace/file-readers/__tests__/doc-extract.test.ts +1658 -0
  138. package/src/modules/workspace/file-readers/__tests__/email-extract.test.ts +485 -0
  139. package/src/modules/workspace/file-readers/__tests__/file-reader.registry.test.ts +97 -0
  140. package/src/modules/workspace/file-readers/__tests__/image-read.test.ts +100 -0
  141. package/src/modules/workspace/file-readers/doc-extract.service.ts +104 -0
  142. package/src/modules/workspace/file-readers/doc-extract.types.ts +63 -0
  143. package/src/modules/workspace/file-readers/document-reader.ts +64 -0
  144. package/src/modules/workspace/file-readers/email-reader.ts +21 -0
  145. package/src/modules/workspace/file-readers/email-text.ts +193 -0
  146. package/src/modules/workspace/file-readers/extract-docx.ts +67 -0
  147. package/src/modules/workspace/file-readers/extract-eml.ts +92 -0
  148. package/src/modules/workspace/file-readers/extract-msg.ts +134 -0
  149. package/src/modules/workspace/file-readers/extract-odp.ts +63 -0
  150. package/src/modules/workspace/file-readers/extract-ods.ts +182 -0
  151. package/src/modules/workspace/file-readers/extract-odt.ts +48 -0
  152. package/src/modules/workspace/file-readers/extract-pdf.ts +178 -0
  153. package/src/modules/workspace/file-readers/extract-pptx.ts +302 -0
  154. package/src/modules/workspace/file-readers/extract-xlsx.ts +96 -0
  155. package/src/modules/workspace/file-readers/extraction-cache.ts +142 -0
  156. package/src/modules/workspace/file-readers/file-reader.registry.ts +45 -0
  157. package/src/modules/workspace/file-readers/file-reader.ts +104 -0
  158. package/src/modules/workspace/file-readers/image-read.ts +122 -0
  159. package/src/modules/workspace/file-readers/image-reader.ts +39 -0
  160. package/src/modules/workspace/file-readers/odf-text.ts +123 -0
  161. package/src/modules/workspace/file-readers/ooxml-text.ts +477 -0
  162. package/src/modules/workspace/file-readers/text-reader.ts +131 -0
  163. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  164. package/src/modules/workspace/startup/kb-git.ts +20 -7
  165. package/src/modules/workspace/workspace.service.ts +132 -25
  166. package/src/modules/workspace/workspace.tools.ts +174 -12
@@ -1,582 +1,582 @@
1
- import express from 'express';
2
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
3
- import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
4
- import {
5
- InvalidSecretError,
6
- SecretNotFoundError,
7
- SecretOAuthError,
8
- scopesCovered,
9
- missingScopes,
10
- type ISecretsVaultService,
11
- } from './secrets-vault.contract.js';
12
- import type { IToolManualService, ToolManualSummary, ToolVariable } from '../tool-manuals/tool-manuals.contract.js';
13
- import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
14
- import type { IAccessControl } from '../access/access-control.interface.js';
15
- import { workspaceIdForBranch } from '../../shared/workspace-id.js';
16
- import '../auth/auth.middleware.js'; // Express Request augmentation (req.userId / req.userEmail)
17
-
18
- export interface SecretsVaultRoutesDeps {
19
- secretsVault: ISecretsVaultService;
20
- /** Source of `.tool` manuals + their declared variable scopes (default-branch catalog). */
21
- toolManualService: IToolManualService;
22
- /** Gates who may set a tool's ADMIN (shared) secrets — writers of the `.tool` file. */
23
- accessControl: IAccessControl;
24
- /** HMAC secret for signing the OAuth `state` (reuse the connector state secret). */
25
- stateSecret: string;
26
- /** Public base URL of THIS backend — builds the OAuth redirect URI. */
27
- publicBackendUrl: string;
28
- /** Public base URL of the SPA — where the OAuth callback lands the browser. */
29
- publicFrontendUrl: string;
30
- }
31
-
32
- function redirectUriFor(publicBackendUrl: string): string {
33
- return `${publicBackendUrl}/api/secrets/oauth/callback`;
34
- }
35
-
36
- /**
37
- * Is this a SAME-ORIGIN path we're willing to bounce the browser back to after a
38
- * sign-in? The callback builds `${publicFrontendUrl}${dest}#…`, so anything that
39
- * survives this check gets concatenated onto our own origin — which makes every
40
- * rule here an open-redirect rule, not a formatting preference:
41
- * - must start with a single `/` — `//evil.com` is a protocol-relative URL and
42
- * `https://evil.com` an absolute one; both would leave our origin entirely.
43
- * - no `\` — WHATWG folds a backslash to a slash for http(s), so `/\evil.com`
44
- * is `//evil.com` by another spelling.
45
- * - no `#` — the callback appends its own fragment; a caller-supplied one would
46
- * swallow the `#authorized=…`/`#error=…` the landing page reads.
47
- * - no CR/LF — never let a caller-controlled string reach a `Location` header
48
- * with a line break in it (response splitting).
49
- * - ≤512 chars — a signed `state` rides in the provider's authorize URL, and
50
- * some providers cap its length; this keeps the round-trip inside every
51
- * budget without needing to know each provider's limit.
52
- * Exported for the route tests: this predicate IS the security boundary, so it
53
- * is pinned by a unit table rather than only exercised end-to-end.
54
- */
55
- export function isSafeReturnPath(returnTo: unknown): returnTo is string {
56
- return (
57
- typeof returnTo === 'string' &&
58
- returnTo.length <= 512 &&
59
- returnTo.startsWith('/') &&
60
- !returnTo.startsWith('//') &&
61
- !returnTo.includes('\\') &&
62
- !returnTo.includes('#') &&
63
- !/[\r\n]/.test(returnTo)
64
- );
65
- }
66
-
67
- /**
68
- * Authenticated Secrets Vault CRUD + OAuth start. Every route is scoped to
69
- * `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
70
- */
71
- export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
72
- const { secretsVault, toolManualService, accessControl } = deps;
73
- const router = express.Router();
74
- // A FUNCTION, not a constant. Routers are constructed at boot, and on a
75
- // deployment configured through the setup screen the branch model does not
76
- // exist yet at that moment — `DEFAULT_BRANCH` is still ''. The live binding
77
- // updates when setup applies the model, but only reads INSIDE a function
78
- // body see it; a construction-time capture would keep handing an empty
79
- // workspace id to the access resolver until the next restart ("Invalid
80
- // workspace ID" on every /secrets/tools call).
81
- const defaultWs = () => workspaceIdForBranch(DEFAULT_BRANCH);
82
-
83
- // The vault key = the exact key UTCP looks the var up under (doubles underscores
84
- // in the manual namespace), so storage and resolution agree for snake_case ids.
85
- const varKey = (manualName: string, varName: string) => utcpNamespacedKey(manualName, varName);
86
-
87
- /** Find an accessible (readable) manual by slug + its DECLARED variable, or null. */
88
- async function findManualVar(
89
- email: string,
90
- slug: string,
91
- varName: string,
92
- ): Promise<{ manual: ToolManualSummary; variable: ToolVariable } | null> {
93
- const manual = (await toolManualService.listAccessible(email)).find((m) => m.slug === slug);
94
- if (!manual) return null;
95
- const variable = (manual.variables ?? []).find((v) => v.name === varName);
96
- return variable ? { manual, variable } : null;
97
- }
98
-
99
- router.get('/secrets', async (req, res) => {
100
- const userId = req.userId;
101
- if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
102
- try {
103
- res.json({ secrets: await secretsVault.list(userId) });
104
- } catch (err) {
105
- console.error('[secrets] list failed:', err);
106
- res.status(500).json({ error: 'Internal error' });
107
- }
108
- });
109
-
110
- router.post('/secrets/static', async (req, res) => {
111
- const userId = req.userId;
112
- if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
113
- try {
114
- const body = req.body ?? {};
115
- const secret = await secretsVault.putStatic({
116
- userId,
117
- key: body.key,
118
- value: body.value,
119
- label: body.label,
120
- });
121
- res.status(201).json({ secret });
122
- } catch (err) {
123
- mapError(err, res, 'create static');
124
- }
125
- });
126
-
127
- router.post('/secrets/oauth', async (req, res) => {
128
- const userId = req.userId;
129
- if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
130
- try {
131
- const body = req.body ?? {};
132
- const secret = await secretsVault.createOAuth({
133
- userId,
134
- key: body.key,
135
- label: body.label,
136
- provider: body.provider,
137
- });
138
- res.status(201).json({ secret });
139
- } catch (err) {
140
- mapError(err, res, 'create oauth');
141
- }
142
- });
143
-
144
- router.delete('/secrets/:id', async (req, res) => {
145
- const userId = req.userId;
146
- if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
147
- try {
148
- await secretsVault.remove(userId, req.params.id);
149
- res.status(204).end();
150
- } catch (err) {
151
- mapError(err, res, 'delete');
152
- }
153
- });
154
-
155
- // Return the provider consent URL as JSON (not a 302): the SPA fetches this
156
- // with its bearer token, then navigates the browser to the URL.
157
- router.get('/secrets/:id/oauth/start', async (req, res) => {
158
- const userId = req.userId;
159
- if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
160
- try {
161
- const secret = await secretsVault.getById(userId, req.params.id);
162
- if (!secret) return void res.status(404).json({ error: 'Secret not found' });
163
- if (secret.kind !== 'oauth') return void res.status(400).json({ error: 'Secret has no OAuth flow' });
164
- const state = signState(deps.stateSecret, {
165
- u: userId,
166
- i: req.params.id,
167
- n: randomBytes(8).toString('hex'),
168
- });
169
- const url = await secretsVault.beginOAuth(
170
- userId,
171
- req.params.id,
172
- redirectUriFor(deps.publicBackendUrl),
173
- state,
174
- );
175
- res.json({ url });
176
- } catch (err) {
177
- mapError(err, res, 'oauth start');
178
- }
179
- });
180
-
181
- // ---- per-tool secrets (two-tier: admin/shared + per-user) ------------------
182
-
183
- // The caller's accessible `.tool` manuals, each with its declared variables,
184
- // per-variable config status, and whether the caller may set ADMIN secrets.
185
- // `?path=` narrows to a single tool (used by the `.tool` editor sidebar).
186
- router.get('/secrets/tools', async (req, res) => {
187
- const userId = req.userId;
188
- const email = req.userEmail;
189
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
190
- try {
191
- const pathFilter = typeof req.query.path === 'string' ? req.query.path : null;
192
- let manuals = await toolManualService.listAccessible(email);
193
- if (pathFilter) manuals = manuals.filter((m) => m.path === pathFilter);
194
-
195
- const allKeys = manuals.flatMap((m) => (m.variables ?? []).map((v) => varKey(m.name, v.name)));
196
- const status = await secretsVault.statusFor(userId, allKeys);
197
- const statusByKey = new Map(status.map((s) => [s.key, s]));
198
-
199
- const tools = await Promise.all(
200
- manuals.map(async (m) => ({
201
- slug: m.slug,
202
- name: m.name,
203
- path: m.path,
204
- type: m.type,
205
- setup: m.setup ?? null,
206
- canWrite: await accessControl.canWrite(defaultWs(), email, m.path),
207
- variables: (m.variables ?? []).map((v) => {
208
- const key = varKey(m.name, v.name);
209
- const st = statusByKey.get(key);
210
- const isOAuth = v.oauth != null;
211
- const authorized = isOAuth ? (st?.userAuthorized ?? false) : undefined;
212
- // For an OAuth var: which declared permissions the token doesn't cover,
213
- // so the panel can say WHAT it works for and what it doesn't. Only
214
- // meaningful once authorized (an unauthorized var has no token at all).
215
- const missing = isOAuth && authorized ? missingScopes(v.oauth?.scopes, st?.grantedScopes) : [];
216
- return {
217
- name: v.name,
218
- scope: v.scope,
219
- label: v.label ?? null,
220
- key,
221
- adminConfigured: st?.adminConfigured ?? false,
222
- userConfigured: st?.userConfigured ?? false,
223
- oauth: isOAuth,
224
- authorized,
225
- needsReauth: isOAuth && authorized === true && missing.length > 0,
226
- missingScopes: missing,
227
- };
228
- }),
229
- })),
230
- );
231
- res.json({ tools });
232
- } catch (err) {
233
- console.error('[secrets] list tools failed:', err);
234
- res.status(500).json({ error: 'Internal error' });
235
- }
236
- });
237
-
238
- // The aggregated "connect your tools" view for a single user: every accessible
239
- // tool with ONLY its per-user (`user`-scoped) variables and whether the caller
240
- // has set each, plus the caller's OAuth secrets and their authorized state. This
241
- // is the surface an external-agent user lands on from the needs-authorization
242
- // link — it shows exactly what THEY must provide, never the admin/shared items.
243
- router.get('/connect/pending', async (req, res) => {
244
- const userId = req.userId;
245
- const email = req.userEmail;
246
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
247
- try {
248
- const manuals = await toolManualService.listAccessible(email);
249
- const allKeys = manuals.flatMap((m) =>
250
- (m.variables ?? []).filter((v) => v.scope === 'user').map((v) => varKey(m.name, v.name)),
251
- );
252
- const status = await secretsVault.statusFor(userId, allKeys);
253
- const statusByKey = new Map(status.map((s) => [s.key, s]));
254
-
255
- const tools = manuals
256
- .map((m) => ({
257
- slug: m.slug,
258
- name: m.name,
259
- path: m.path,
260
- type: m.type,
261
- // Plain (non-OAuth) per-user vars render as key inputs under "Keys".
262
- variables: (m.variables ?? [])
263
- .filter((v) => v.scope === 'user' && !v.oauth)
264
- .map((v) => {
265
- const key = varKey(m.name, v.name);
266
- return {
267
- name: v.name,
268
- label: v.label ?? null,
269
- key,
270
- configured: statusByKey.get(key)?.userConfigured ?? false,
271
- };
272
- }),
273
- }))
274
- // Only surface tools that actually have per-user items to configure.
275
- .filter((t) => t.variables.length > 0);
276
-
277
- // OAuth-backed per-user vars render as Authorize buttons under "Sign-ins".
278
- // Keyed by slug+var (the caller's row may not exist yet), with authorized
279
- // state from the oauth-aware status.
280
- const toolOAuth = manuals.flatMap((m) =>
281
- (m.variables ?? [])
282
- .filter((v) => v.scope === 'user' && v.oauth)
283
- .map((v) => {
284
- const st = statusByKey.get(varKey(m.name, v.name));
285
- const authorized = st?.userAuthorized ?? false;
286
- // Signed in, but the token's granted scopes no longer cover what the
287
- // tool declares → surface as needing re-authorization, not as connected.
288
- const needsReauth =
289
- authorized && !scopesCovered(v.oauth?.scopes, st?.grantedScopes);
290
- return {
291
- slug: m.slug,
292
- varName: v.name,
293
- toolName: m.name,
294
- key: varKey(m.name, v.name),
295
- label: v.label ?? null,
296
- authorized,
297
- needsReauth,
298
- };
299
- }),
300
- );
301
-
302
- // Standalone sign-ins = oauth secrets the user registered directly on the
303
- // Secrets page. A TOOL sign-in provisions a per-user row in the same
304
- // table (keyed `<manual>_<VAR>`), so exclude every tool-var key here or
305
- // an authorized tool sign-in would render twice — once as a toolOAuth
306
- // row and again as a "standalone" one.
307
- const toolOAuthKeys = new Set(toolOAuth.map((o) => o.key));
308
- const secrets = await secretsVault.list(userId);
309
- const oauth = secrets
310
- .filter((s) => s.kind === 'oauth' && !toolOAuthKeys.has(s.key))
311
- .map((s) => ({ id: s.id, key: s.key, label: s.label, authorized: s.authorized ?? false }));
312
-
313
- res.json({ tools, oauth, toolOAuth });
314
- } catch (err) {
315
- console.error('[secrets] connect/pending failed:', err);
316
- res.status(500).json({ error: 'Internal error' });
317
- }
318
- });
319
-
320
- // Set/replace a tool's ADMIN (shared) secret — requires WRITE on the `.tool` file.
321
- router.put('/secrets/tools/:slug/vars/:var/admin', async (req, res) => {
322
- const userId = req.userId;
323
- const email = req.userEmail;
324
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
325
- try {
326
- const found = await findManualVar(email, req.params.slug, req.params.var);
327
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
328
- if (found.variable.scope !== 'admin') {
329
- return void res.status(422).json({ error: 'This variable is set by each user, not the tool owner.' });
330
- }
331
- if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
332
- return void res.status(403).json({ error: 'You need write access to this tool to set its shared secrets.' });
333
- }
334
- const body = req.body ?? {};
335
- const secret = await secretsVault.putSharedStatic({
336
- key: varKey(found.manual.name, found.variable.name),
337
- value: body.value,
338
- label: body.label,
339
- });
340
- res.status(201).json({ secret });
341
- } catch (err) {
342
- mapError(err, res, 'set admin var');
343
- }
344
- });
345
-
346
- // Set/replace the CALLER's per-user secret for a user-scope variable.
347
- router.put('/secrets/tools/:slug/vars/:var/user', async (req, res) => {
348
- const userId = req.userId;
349
- const email = req.userEmail;
350
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
351
- try {
352
- const found = await findManualVar(email, req.params.slug, req.params.var);
353
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
354
- if (found.variable.scope !== 'user') {
355
- return void res.status(422).json({ error: 'This variable is set by the tool owner, not per user.' });
356
- }
357
- const body = req.body ?? {};
358
- const secret = await secretsVault.putStatic({
359
- userId,
360
- key: varKey(found.manual.name, found.variable.name),
361
- value: body.value,
362
- label: body.label,
363
- });
364
- res.status(201).json({ secret });
365
- } catch (err) {
366
- mapError(err, res, 'set user var');
367
- }
368
- });
369
-
370
- // Set the confidential client secret for a tool's OAuth-backed variable — the
371
- // ONLY place the secret is provided. Requires WRITE on the `.tool`. The provider
372
- // config comes from the variable's own `oauth` declaration (stored WITH the
373
- // secret so a later `.tool` edit can't redirect it).
374
- router.put('/secrets/tools/:slug/vars/:var/oauth/admin', async (req, res) => {
375
- const userId = req.userId;
376
- const email = req.userEmail;
377
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
378
- try {
379
- const found = await findManualVar(email, req.params.slug, req.params.var);
380
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
381
- if (!found.variable.oauth) {
382
- return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
383
- }
384
- if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
385
- return void res.status(403).json({ error: 'You need write access to this tool to set its client secret.' });
386
- }
387
- const clientSecret = (req.body ?? {}).clientSecret;
388
- await secretsVault.putSharedOAuthClientSecret({
389
- key: varKey(found.manual.name, found.variable.name),
390
- clientSecret,
391
- provider: {
392
- authorizationUrl: found.variable.oauth.authorizationUrl,
393
- tokenUrl: found.variable.oauth.tokenUrl,
394
- clientId: found.variable.oauth.clientId,
395
- scopes: found.variable.oauth.scopes,
396
- // Static authorize params (e.g. Google's `access_type=offline`) so the
397
- // provider returns a refresh token — stored with the secret so a later
398
- // `.tool` edit can't redirect the flow.
399
- authParams: found.variable.oauth.authParams,
400
- },
401
- });
402
- res.status(201).json({ ok: true });
403
- } catch (err) {
404
- mapError(err, res, 'set oauth client secret');
405
- }
406
- });
407
-
408
- // Start sign-in for a tool's OAuth-backed variable. Provisions the caller's row
409
- // from the owner-set secret and returns the provider consent URL; the callback
410
- // bounces back to wherever the flow started (via `r` in the signed state) — an
411
- // optional `{ returnTo }` body when the caller asks for a specific page, else
412
- // /connect, which is where every caller came from before `returnTo` existed.
413
- router.post('/secrets/tools/:slug/vars/:var/oauth/start', async (req, res) => {
414
- const userId = req.userId;
415
- const email = req.userEmail;
416
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
417
- try {
418
- const found = await findManualVar(email, req.params.slug, req.params.var);
419
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
420
- if (!found.variable.oauth) {
421
- return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
422
- }
423
- const key = varKey(found.manual.name, found.variable.name);
424
- // Provision the caller's oauth row (from the owner-set secret) FIRST — its id
425
- // is what the un-authed callback keys on. Pass an empty state placeholder; we
426
- // sign the real state below with the returned id and swap it into the URL, so
427
- // the callback resolves it byte-for-byte like the standalone flow.
428
- const { id, url } = await secretsVault.beginToolOAuthByKey({
429
- userId,
430
- key,
431
- redirectUri: redirectUriFor(deps.publicBackendUrl),
432
- state: '',
433
- // Request the permissions the tool file declares RIGHT NOW (not the frozen
434
- // copy on the owner-set row), so adding a scope to the `.tool` takes effect
435
- // on the next sign-in. The secret/clientId/addresses stay owner-pinned.
436
- scopes: found.variable.oauth.scopes,
437
- });
438
- // The return path is signed INTO the state (HMAC-SHA256) rather than kept
439
- // in a query param or a cookie: the callback arrives un-authenticated from
440
- // the provider, so the state is the only thing it can trust. An absent or
441
- // unsafe `returnTo` falls back to the legacy `'connect'` marker, which
442
- // keeps a body-less start byte-identical to what it produced before.
443
- const returnTo: unknown = (req.body ?? {}).returnTo;
444
- const state = signState(deps.stateSecret, {
445
- u: userId,
446
- i: id,
447
- n: randomBytes(8).toString('hex'),
448
- r: isSafeReturnPath(returnTo) ? returnTo : 'connect',
449
- });
450
- const consentUrl = new URL(url);
451
- consentUrl.searchParams.set('state', state);
452
- res.json({ url: consentUrl.toString() });
453
- } catch (err) {
454
- mapError(err, res, 'tool oauth start');
455
- }
456
- });
457
-
458
- router.delete('/secrets/tools/:slug/vars/:var/admin', async (req, res) => {
459
- const userId = req.userId;
460
- const email = req.userEmail;
461
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
462
- try {
463
- const found = await findManualVar(email, req.params.slug, req.params.var);
464
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
465
- if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
466
- return void res.status(403).json({ error: 'You need write access to this tool to remove its shared secrets.' });
467
- }
468
- await secretsVault.removeShared(varKey(found.manual.name, found.variable.name));
469
- res.status(204).end();
470
- } catch (err) {
471
- mapError(err, res, 'delete admin var');
472
- }
473
- });
474
-
475
- router.delete('/secrets/tools/:slug/vars/:var/user', async (req, res) => {
476
- const userId = req.userId;
477
- const email = req.userEmail;
478
- if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
479
- try {
480
- const found = await findManualVar(email, req.params.slug, req.params.var);
481
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
482
- await secretsVault.removeUserByKey(userId, varKey(found.manual.name, found.variable.name));
483
- res.status(204).end();
484
- } catch (err) {
485
- mapError(err, res, 'delete user var');
486
- }
487
- });
488
-
489
- return router;
490
- }
491
-
492
- /**
493
- * The un-authenticated OAuth callback. Mounted BEFORE the JWT middleware: the
494
- * provider redirects the browser here with no Authorization header, so the
495
- * caller's identity rides in the signed `state`.
496
- */
497
- export function createSecretsVaultPublicRoutes(deps: SecretsVaultRoutesDeps): express.Router {
498
- const { secretsVault } = deps;
499
- const router = express.Router();
500
-
501
- router.get('/secrets/oauth/callback', async (req, res) => {
502
- const code = typeof req.query.code === 'string' ? req.query.code : '';
503
- const stateRaw = typeof req.query.state === 'string' ? req.query.state : '';
504
- // The landing page depends on where the flow started (the `r` field in the
505
- // signed state): an explicit same-origin path returns there, the legacy
506
- // `'connect'` marker to /connect, the standalone Secrets page to /secrets.
507
- // Pre-verification errors can't know `r`, so they fall back to /secrets.
508
- const back = (frag: string, dest = '/secrets') =>
509
- res.redirect(`${deps.publicFrontendUrl}${dest}#${frag}`);
510
- if (!code || !stateRaw) return void back(`error=${encodeURIComponent('Invalid OAuth callback.')}`);
511
-
512
- const state = verifyState(deps.stateSecret, stateRaw);
513
- if (!state) return void back(`error=${encodeURIComponent('OAuth state mismatch.')}`);
514
- // Re-validated HERE even though `r` was validated on the way in and is
515
- // HMAC-signed: this is the line that concatenates a stored string onto our
516
- // public origin and hands it to the browser as a `Location`. Defense in
517
- // depth — the check costs nothing, and it means an open redirect would need
518
- // BOTH the signing key and a validation bug, not either one.
519
- const dest = isSafeReturnPath(state.r) ? state.r : state.r === 'connect' ? '/connect' : '/secrets';
520
-
521
- try {
522
- await secretsVault.completeOAuth(state.u, state.i, code, redirectUriFor(deps.publicBackendUrl));
523
- back(`authorized=${encodeURIComponent(state.i)}`, dest);
524
- } catch (err) {
525
- console.error('[secrets] oauth callback failed:', err instanceof Error ? err.message : String(err));
526
- back(`error=${encodeURIComponent('Authorization failed. Check the provider configuration and try again.')}`, dest);
527
- }
528
- });
529
-
530
- return router;
531
- }
532
-
533
- function mapError(err: unknown, res: express.Response, op: string): void {
534
- if (err instanceof InvalidSecretError) return void res.status(422).json({ error: err.message });
535
- if (err instanceof SecretNotFoundError) return void res.status(404).json({ error: err.message });
536
- if (err instanceof SecretOAuthError) return void res.status(409).json({ error: err.message });
537
- console.error(`[secrets] ${op} failed:`, err);
538
- res.status(500).json({ error: 'Internal error' });
539
- }
540
-
541
- // ---- signed-state helpers (CSRF + identity for the un-authed callback) -------
542
-
543
- interface OAuthState {
544
- u: string; // user id
545
- i: string; // secret id
546
- n: string; // nonce
547
- iat: number; // issued-at (epoch ms)
548
- /**
549
- * Return path hint: a same-origin path (`isSafeReturnPath`) → land there; the
550
- * legacy marker `'connect'` → /connect; anything else (incl. absent) →
551
- * /secrets. Signed, so the callback can trust it — but re-validated anyway.
552
- */
553
- r?: string;
554
- }
555
-
556
- const OAUTH_STATE_MAX_AGE_MS = 10 * 60_000;
557
- const OAUTH_STATE_SKEW_MS = 60_000;
558
-
559
- function signState(secret: string, state: Omit<OAuthState, 'iat'>): string {
560
- const full: OAuthState = { ...state, iat: Date.now() };
561
- const body = Buffer.from(JSON.stringify(full)).toString('base64url');
562
- const sig = createHmac('sha256', secret).update(body).digest('base64url');
563
- return `${body}.${sig}`;
564
- }
565
-
566
- function verifyState(secret: string, token: string): OAuthState | null {
567
- const [body, sig] = token.split('.');
568
- if (!body || !sig) return null;
569
- const expected = createHmac('sha256', secret).update(body).digest('base64url');
570
- const a = Buffer.from(sig);
571
- const b = Buffer.from(expected);
572
- if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
573
- try {
574
- const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState;
575
- if (typeof parsed.iat !== 'number' || !parsed.u || !parsed.i) return null;
576
- const age = Date.now() - parsed.iat;
577
- if (age > OAUTH_STATE_MAX_AGE_MS || age < -OAUTH_STATE_SKEW_MS) return null;
578
- return parsed;
579
- } catch {
580
- return null;
581
- }
582
- }
1
+ import express from 'express';
2
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
4
+ import {
5
+ InvalidSecretError,
6
+ SecretNotFoundError,
7
+ SecretOAuthError,
8
+ scopesCovered,
9
+ missingScopes,
10
+ type ISecretsVaultService,
11
+ } from './secrets-vault.contract.js';
12
+ import type { IToolManualService, ToolManualSummary, ToolVariable } from '../tool-manuals/tool-manuals.contract.js';
13
+ import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
14
+ import type { IAccessControl } from '../access/access-control.interface.js';
15
+ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
16
+ import '../auth/auth.middleware.js'; // Express Request augmentation (req.userId / req.userEmail)
17
+
18
+ export interface SecretsVaultRoutesDeps {
19
+ secretsVault: ISecretsVaultService;
20
+ /** Source of `.tool` manuals + their declared variable scopes (default-branch catalog). */
21
+ toolManualService: IToolManualService;
22
+ /** Gates who may set a tool's ADMIN (shared) secrets — writers of the `.tool` file. */
23
+ accessControl: IAccessControl;
24
+ /** HMAC secret for signing the OAuth `state` (reuse the connector state secret). */
25
+ stateSecret: string;
26
+ /** Public base URL of THIS backend — builds the OAuth redirect URI. */
27
+ publicBackendUrl: string;
28
+ /** Public base URL of the SPA — where the OAuth callback lands the browser. */
29
+ publicFrontendUrl: string;
30
+ }
31
+
32
+ function redirectUriFor(publicBackendUrl: string): string {
33
+ return `${publicBackendUrl}/api/secrets/oauth/callback`;
34
+ }
35
+
36
+ /**
37
+ * Is this a SAME-ORIGIN path we're willing to bounce the browser back to after a
38
+ * sign-in? The callback builds `${publicFrontendUrl}${dest}#…`, so anything that
39
+ * survives this check gets concatenated onto our own origin — which makes every
40
+ * rule here an open-redirect rule, not a formatting preference:
41
+ * - must start with a single `/` — `//evil.com` is a protocol-relative URL and
42
+ * `https://evil.com` an absolute one; both would leave our origin entirely.
43
+ * - no `\` — WHATWG folds a backslash to a slash for http(s), so `/\evil.com`
44
+ * is `//evil.com` by another spelling.
45
+ * - no `#` — the callback appends its own fragment; a caller-supplied one would
46
+ * swallow the `#authorized=…`/`#error=…` the landing page reads.
47
+ * - no CR/LF — never let a caller-controlled string reach a `Location` header
48
+ * with a line break in it (response splitting).
49
+ * - ≤512 chars — a signed `state` rides in the provider's authorize URL, and
50
+ * some providers cap its length; this keeps the round-trip inside every
51
+ * budget without needing to know each provider's limit.
52
+ * Exported for the route tests: this predicate IS the security boundary, so it
53
+ * is pinned by a unit table rather than only exercised end-to-end.
54
+ */
55
+ export function isSafeReturnPath(returnTo: unknown): returnTo is string {
56
+ return (
57
+ typeof returnTo === 'string' &&
58
+ returnTo.length <= 512 &&
59
+ returnTo.startsWith('/') &&
60
+ !returnTo.startsWith('//') &&
61
+ !returnTo.includes('\\') &&
62
+ !returnTo.includes('#') &&
63
+ !/[\r\n]/.test(returnTo)
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Authenticated Secrets Vault CRUD + OAuth start. Every route is scoped to
69
+ * `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
70
+ */
71
+ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
72
+ const { secretsVault, toolManualService, accessControl } = deps;
73
+ const router = express.Router();
74
+ // A FUNCTION, not a constant. Routers are constructed at boot, and on a
75
+ // deployment configured through the setup screen the branch model does not
76
+ // exist yet at that moment — `DEFAULT_BRANCH` is still ''. The live binding
77
+ // updates when setup applies the model, but only reads INSIDE a function
78
+ // body see it; a construction-time capture would keep handing an empty
79
+ // workspace id to the access resolver until the next restart ("Invalid
80
+ // workspace ID" on every /secrets/tools call).
81
+ const defaultWs = () => workspaceIdForBranch(DEFAULT_BRANCH);
82
+
83
+ // The vault key = the exact key UTCP looks the var up under (doubles underscores
84
+ // in the manual namespace), so storage and resolution agree for snake_case ids.
85
+ const varKey = (manualName: string, varName: string) => utcpNamespacedKey(manualName, varName);
86
+
87
+ /** Find an accessible (readable) manual by slug + its DECLARED variable, or null. */
88
+ async function findManualVar(
89
+ email: string,
90
+ slug: string,
91
+ varName: string,
92
+ ): Promise<{ manual: ToolManualSummary; variable: ToolVariable } | null> {
93
+ const manual = (await toolManualService.listAccessible(email)).find((m) => m.slug === slug);
94
+ if (!manual) return null;
95
+ const variable = (manual.variables ?? []).find((v) => v.name === varName);
96
+ return variable ? { manual, variable } : null;
97
+ }
98
+
99
+ router.get('/secrets', async (req, res) => {
100
+ const userId = req.userId;
101
+ if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
102
+ try {
103
+ res.json({ secrets: await secretsVault.list(userId) });
104
+ } catch (err) {
105
+ console.error('[secrets] list failed:', err);
106
+ res.status(500).json({ error: 'Internal error' });
107
+ }
108
+ });
109
+
110
+ router.post('/secrets/static', async (req, res) => {
111
+ const userId = req.userId;
112
+ if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
113
+ try {
114
+ const body = req.body ?? {};
115
+ const secret = await secretsVault.putStatic({
116
+ userId,
117
+ key: body.key,
118
+ value: body.value,
119
+ label: body.label,
120
+ });
121
+ res.status(201).json({ secret });
122
+ } catch (err) {
123
+ mapError(err, res, 'create static');
124
+ }
125
+ });
126
+
127
+ router.post('/secrets/oauth', async (req, res) => {
128
+ const userId = req.userId;
129
+ if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
130
+ try {
131
+ const body = req.body ?? {};
132
+ const secret = await secretsVault.createOAuth({
133
+ userId,
134
+ key: body.key,
135
+ label: body.label,
136
+ provider: body.provider,
137
+ });
138
+ res.status(201).json({ secret });
139
+ } catch (err) {
140
+ mapError(err, res, 'create oauth');
141
+ }
142
+ });
143
+
144
+ router.delete('/secrets/:id', async (req, res) => {
145
+ const userId = req.userId;
146
+ if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
147
+ try {
148
+ await secretsVault.remove(userId, req.params.id);
149
+ res.status(204).end();
150
+ } catch (err) {
151
+ mapError(err, res, 'delete');
152
+ }
153
+ });
154
+
155
+ // Return the provider consent URL as JSON (not a 302): the SPA fetches this
156
+ // with its bearer token, then navigates the browser to the URL.
157
+ router.get('/secrets/:id/oauth/start', async (req, res) => {
158
+ const userId = req.userId;
159
+ if (!userId) return void res.status(401).json({ error: 'Not authenticated' });
160
+ try {
161
+ const secret = await secretsVault.getById(userId, req.params.id);
162
+ if (!secret) return void res.status(404).json({ error: 'Secret not found' });
163
+ if (secret.kind !== 'oauth') return void res.status(400).json({ error: 'Secret has no OAuth flow' });
164
+ const state = signState(deps.stateSecret, {
165
+ u: userId,
166
+ i: req.params.id,
167
+ n: randomBytes(8).toString('hex'),
168
+ });
169
+ const url = await secretsVault.beginOAuth(
170
+ userId,
171
+ req.params.id,
172
+ redirectUriFor(deps.publicBackendUrl),
173
+ state,
174
+ );
175
+ res.json({ url });
176
+ } catch (err) {
177
+ mapError(err, res, 'oauth start');
178
+ }
179
+ });
180
+
181
+ // ---- per-tool secrets (two-tier: admin/shared + per-user) ------------------
182
+
183
+ // The caller's accessible `.tool` manuals, each with its declared variables,
184
+ // per-variable config status, and whether the caller may set ADMIN secrets.
185
+ // `?path=` narrows to a single tool (used by the `.tool` editor sidebar).
186
+ router.get('/secrets/tools', async (req, res) => {
187
+ const userId = req.userId;
188
+ const email = req.userEmail;
189
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
190
+ try {
191
+ const pathFilter = typeof req.query.path === 'string' ? req.query.path : null;
192
+ let manuals = await toolManualService.listAccessible(email);
193
+ if (pathFilter) manuals = manuals.filter((m) => m.path === pathFilter);
194
+
195
+ const allKeys = manuals.flatMap((m) => (m.variables ?? []).map((v) => varKey(m.name, v.name)));
196
+ const status = await secretsVault.statusFor(userId, allKeys);
197
+ const statusByKey = new Map(status.map((s) => [s.key, s]));
198
+
199
+ const tools = await Promise.all(
200
+ manuals.map(async (m) => ({
201
+ slug: m.slug,
202
+ name: m.name,
203
+ path: m.path,
204
+ type: m.type,
205
+ setup: m.setup ?? null,
206
+ canWrite: await accessControl.canWrite(defaultWs(), email, m.path),
207
+ variables: (m.variables ?? []).map((v) => {
208
+ const key = varKey(m.name, v.name);
209
+ const st = statusByKey.get(key);
210
+ const isOAuth = v.oauth != null;
211
+ const authorized = isOAuth ? (st?.userAuthorized ?? false) : undefined;
212
+ // For an OAuth var: which declared permissions the token doesn't cover,
213
+ // so the panel can say WHAT it works for and what it doesn't. Only
214
+ // meaningful once authorized (an unauthorized var has no token at all).
215
+ const missing = isOAuth && authorized ? missingScopes(v.oauth?.scopes, st?.grantedScopes) : [];
216
+ return {
217
+ name: v.name,
218
+ scope: v.scope,
219
+ label: v.label ?? null,
220
+ key,
221
+ adminConfigured: st?.adminConfigured ?? false,
222
+ userConfigured: st?.userConfigured ?? false,
223
+ oauth: isOAuth,
224
+ authorized,
225
+ needsReauth: isOAuth && authorized === true && missing.length > 0,
226
+ missingScopes: missing,
227
+ };
228
+ }),
229
+ })),
230
+ );
231
+ res.json({ tools });
232
+ } catch (err) {
233
+ console.error('[secrets] list tools failed:', err);
234
+ res.status(500).json({ error: 'Internal error' });
235
+ }
236
+ });
237
+
238
+ // The aggregated "connect your tools" view for a single user: every accessible
239
+ // tool with ONLY its per-user (`user`-scoped) variables and whether the caller
240
+ // has set each, plus the caller's OAuth secrets and their authorized state. This
241
+ // is the surface an external-agent user lands on from the needs-authorization
242
+ // link — it shows exactly what THEY must provide, never the admin/shared items.
243
+ router.get('/connect/pending', async (req, res) => {
244
+ const userId = req.userId;
245
+ const email = req.userEmail;
246
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
247
+ try {
248
+ const manuals = await toolManualService.listAccessible(email);
249
+ const allKeys = manuals.flatMap((m) =>
250
+ (m.variables ?? []).filter((v) => v.scope === 'user').map((v) => varKey(m.name, v.name)),
251
+ );
252
+ const status = await secretsVault.statusFor(userId, allKeys);
253
+ const statusByKey = new Map(status.map((s) => [s.key, s]));
254
+
255
+ const tools = manuals
256
+ .map((m) => ({
257
+ slug: m.slug,
258
+ name: m.name,
259
+ path: m.path,
260
+ type: m.type,
261
+ // Plain (non-OAuth) per-user vars render as key inputs under "Keys".
262
+ variables: (m.variables ?? [])
263
+ .filter((v) => v.scope === 'user' && !v.oauth)
264
+ .map((v) => {
265
+ const key = varKey(m.name, v.name);
266
+ return {
267
+ name: v.name,
268
+ label: v.label ?? null,
269
+ key,
270
+ configured: statusByKey.get(key)?.userConfigured ?? false,
271
+ };
272
+ }),
273
+ }))
274
+ // Only surface tools that actually have per-user items to configure.
275
+ .filter((t) => t.variables.length > 0);
276
+
277
+ // OAuth-backed per-user vars render as Authorize buttons under "Sign-ins".
278
+ // Keyed by slug+var (the caller's row may not exist yet), with authorized
279
+ // state from the oauth-aware status.
280
+ const toolOAuth = manuals.flatMap((m) =>
281
+ (m.variables ?? [])
282
+ .filter((v) => v.scope === 'user' && v.oauth)
283
+ .map((v) => {
284
+ const st = statusByKey.get(varKey(m.name, v.name));
285
+ const authorized = st?.userAuthorized ?? false;
286
+ // Signed in, but the token's granted scopes no longer cover what the
287
+ // tool declares → surface as needing re-authorization, not as connected.
288
+ const needsReauth =
289
+ authorized && !scopesCovered(v.oauth?.scopes, st?.grantedScopes);
290
+ return {
291
+ slug: m.slug,
292
+ varName: v.name,
293
+ toolName: m.name,
294
+ key: varKey(m.name, v.name),
295
+ label: v.label ?? null,
296
+ authorized,
297
+ needsReauth,
298
+ };
299
+ }),
300
+ );
301
+
302
+ // Standalone sign-ins = oauth secrets the user registered directly on the
303
+ // Secrets page. A TOOL sign-in provisions a per-user row in the same
304
+ // table (keyed `<manual>_<VAR>`), so exclude every tool-var key here or
305
+ // an authorized tool sign-in would render twice — once as a toolOAuth
306
+ // row and again as a "standalone" one.
307
+ const toolOAuthKeys = new Set(toolOAuth.map((o) => o.key));
308
+ const secrets = await secretsVault.list(userId);
309
+ const oauth = secrets
310
+ .filter((s) => s.kind === 'oauth' && !toolOAuthKeys.has(s.key))
311
+ .map((s) => ({ id: s.id, key: s.key, label: s.label, authorized: s.authorized ?? false }));
312
+
313
+ res.json({ tools, oauth, toolOAuth });
314
+ } catch (err) {
315
+ console.error('[secrets] connect/pending failed:', err);
316
+ res.status(500).json({ error: 'Internal error' });
317
+ }
318
+ });
319
+
320
+ // Set/replace a tool's ADMIN (shared) secret — requires WRITE on the `.tool` file.
321
+ router.put('/secrets/tools/:slug/vars/:var/admin', async (req, res) => {
322
+ const userId = req.userId;
323
+ const email = req.userEmail;
324
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
325
+ try {
326
+ const found = await findManualVar(email, req.params.slug, req.params.var);
327
+ if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
328
+ if (found.variable.scope !== 'admin') {
329
+ return void res.status(422).json({ error: 'This variable is set by each user, not the tool owner.' });
330
+ }
331
+ if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
332
+ return void res.status(403).json({ error: 'You need write access to this tool to set its shared secrets.' });
333
+ }
334
+ const body = req.body ?? {};
335
+ const secret = await secretsVault.putSharedStatic({
336
+ key: varKey(found.manual.name, found.variable.name),
337
+ value: body.value,
338
+ label: body.label,
339
+ });
340
+ res.status(201).json({ secret });
341
+ } catch (err) {
342
+ mapError(err, res, 'set admin var');
343
+ }
344
+ });
345
+
346
+ // Set/replace the CALLER's per-user secret for a user-scope variable.
347
+ router.put('/secrets/tools/:slug/vars/:var/user', async (req, res) => {
348
+ const userId = req.userId;
349
+ const email = req.userEmail;
350
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
351
+ try {
352
+ const found = await findManualVar(email, req.params.slug, req.params.var);
353
+ if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
354
+ if (found.variable.scope !== 'user') {
355
+ return void res.status(422).json({ error: 'This variable is set by the tool owner, not per user.' });
356
+ }
357
+ const body = req.body ?? {};
358
+ const secret = await secretsVault.putStatic({
359
+ userId,
360
+ key: varKey(found.manual.name, found.variable.name),
361
+ value: body.value,
362
+ label: body.label,
363
+ });
364
+ res.status(201).json({ secret });
365
+ } catch (err) {
366
+ mapError(err, res, 'set user var');
367
+ }
368
+ });
369
+
370
+ // Set the confidential client secret for a tool's OAuth-backed variable — the
371
+ // ONLY place the secret is provided. Requires WRITE on the `.tool`. The provider
372
+ // config comes from the variable's own `oauth` declaration (stored WITH the
373
+ // secret so a later `.tool` edit can't redirect it).
374
+ router.put('/secrets/tools/:slug/vars/:var/oauth/admin', async (req, res) => {
375
+ const userId = req.userId;
376
+ const email = req.userEmail;
377
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
378
+ try {
379
+ const found = await findManualVar(email, req.params.slug, req.params.var);
380
+ if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
381
+ if (!found.variable.oauth) {
382
+ return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
383
+ }
384
+ if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
385
+ return void res.status(403).json({ error: 'You need write access to this tool to set its client secret.' });
386
+ }
387
+ const clientSecret = (req.body ?? {}).clientSecret;
388
+ await secretsVault.putSharedOAuthClientSecret({
389
+ key: varKey(found.manual.name, found.variable.name),
390
+ clientSecret,
391
+ provider: {
392
+ authorizationUrl: found.variable.oauth.authorizationUrl,
393
+ tokenUrl: found.variable.oauth.tokenUrl,
394
+ clientId: found.variable.oauth.clientId,
395
+ scopes: found.variable.oauth.scopes,
396
+ // Static authorize params (e.g. Google's `access_type=offline`) so the
397
+ // provider returns a refresh token — stored with the secret so a later
398
+ // `.tool` edit can't redirect the flow.
399
+ authParams: found.variable.oauth.authParams,
400
+ },
401
+ });
402
+ res.status(201).json({ ok: true });
403
+ } catch (err) {
404
+ mapError(err, res, 'set oauth client secret');
405
+ }
406
+ });
407
+
408
+ // Start sign-in for a tool's OAuth-backed variable. Provisions the caller's row
409
+ // from the owner-set secret and returns the provider consent URL; the callback
410
+ // bounces back to wherever the flow started (via `r` in the signed state) — an
411
+ // optional `{ returnTo }` body when the caller asks for a specific page, else
412
+ // /connect, which is where every caller came from before `returnTo` existed.
413
+ router.post('/secrets/tools/:slug/vars/:var/oauth/start', async (req, res) => {
414
+ const userId = req.userId;
415
+ const email = req.userEmail;
416
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
417
+ try {
418
+ const found = await findManualVar(email, req.params.slug, req.params.var);
419
+ if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
420
+ if (!found.variable.oauth) {
421
+ return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
422
+ }
423
+ const key = varKey(found.manual.name, found.variable.name);
424
+ // Provision the caller's oauth row (from the owner-set secret) FIRST — its id
425
+ // is what the un-authed callback keys on. Pass an empty state placeholder; we
426
+ // sign the real state below with the returned id and swap it into the URL, so
427
+ // the callback resolves it byte-for-byte like the standalone flow.
428
+ const { id, url } = await secretsVault.beginToolOAuthByKey({
429
+ userId,
430
+ key,
431
+ redirectUri: redirectUriFor(deps.publicBackendUrl),
432
+ state: '',
433
+ // Request the permissions the tool file declares RIGHT NOW (not the frozen
434
+ // copy on the owner-set row), so adding a scope to the `.tool` takes effect
435
+ // on the next sign-in. The secret/clientId/addresses stay owner-pinned.
436
+ scopes: found.variable.oauth.scopes,
437
+ });
438
+ // The return path is signed INTO the state (HMAC-SHA256) rather than kept
439
+ // in a query param or a cookie: the callback arrives un-authenticated from
440
+ // the provider, so the state is the only thing it can trust. An absent or
441
+ // unsafe `returnTo` falls back to the legacy `'connect'` marker, which
442
+ // keeps a body-less start byte-identical to what it produced before.
443
+ const returnTo: unknown = (req.body ?? {}).returnTo;
444
+ const state = signState(deps.stateSecret, {
445
+ u: userId,
446
+ i: id,
447
+ n: randomBytes(8).toString('hex'),
448
+ r: isSafeReturnPath(returnTo) ? returnTo : 'connect',
449
+ });
450
+ const consentUrl = new URL(url);
451
+ consentUrl.searchParams.set('state', state);
452
+ res.json({ url: consentUrl.toString() });
453
+ } catch (err) {
454
+ mapError(err, res, 'tool oauth start');
455
+ }
456
+ });
457
+
458
+ router.delete('/secrets/tools/:slug/vars/:var/admin', async (req, res) => {
459
+ const userId = req.userId;
460
+ const email = req.userEmail;
461
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
462
+ try {
463
+ const found = await findManualVar(email, req.params.slug, req.params.var);
464
+ if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
465
+ if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
466
+ return void res.status(403).json({ error: 'You need write access to this tool to remove its shared secrets.' });
467
+ }
468
+ await secretsVault.removeShared(varKey(found.manual.name, found.variable.name));
469
+ res.status(204).end();
470
+ } catch (err) {
471
+ mapError(err, res, 'delete admin var');
472
+ }
473
+ });
474
+
475
+ router.delete('/secrets/tools/:slug/vars/:var/user', async (req, res) => {
476
+ const userId = req.userId;
477
+ const email = req.userEmail;
478
+ if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
479
+ try {
480
+ const found = await findManualVar(email, req.params.slug, req.params.var);
481
+ if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
482
+ await secretsVault.removeUserByKey(userId, varKey(found.manual.name, found.variable.name));
483
+ res.status(204).end();
484
+ } catch (err) {
485
+ mapError(err, res, 'delete user var');
486
+ }
487
+ });
488
+
489
+ return router;
490
+ }
491
+
492
+ /**
493
+ * The un-authenticated OAuth callback. Mounted BEFORE the JWT middleware: the
494
+ * provider redirects the browser here with no Authorization header, so the
495
+ * caller's identity rides in the signed `state`.
496
+ */
497
+ export function createSecretsVaultPublicRoutes(deps: SecretsVaultRoutesDeps): express.Router {
498
+ const { secretsVault } = deps;
499
+ const router = express.Router();
500
+
501
+ router.get('/secrets/oauth/callback', async (req, res) => {
502
+ const code = typeof req.query.code === 'string' ? req.query.code : '';
503
+ const stateRaw = typeof req.query.state === 'string' ? req.query.state : '';
504
+ // The landing page depends on where the flow started (the `r` field in the
505
+ // signed state): an explicit same-origin path returns there, the legacy
506
+ // `'connect'` marker to /connect, the standalone Secrets page to /secrets.
507
+ // Pre-verification errors can't know `r`, so they fall back to /secrets.
508
+ const back = (frag: string, dest = '/secrets') =>
509
+ res.redirect(`${deps.publicFrontendUrl}${dest}#${frag}`);
510
+ if (!code || !stateRaw) return void back(`error=${encodeURIComponent('Invalid OAuth callback.')}`);
511
+
512
+ const state = verifyState(deps.stateSecret, stateRaw);
513
+ if (!state) return void back(`error=${encodeURIComponent('OAuth state mismatch.')}`);
514
+ // Re-validated HERE even though `r` was validated on the way in and is
515
+ // HMAC-signed: this is the line that concatenates a stored string onto our
516
+ // public origin and hands it to the browser as a `Location`. Defense in
517
+ // depth — the check costs nothing, and it means an open redirect would need
518
+ // BOTH the signing key and a validation bug, not either one.
519
+ const dest = isSafeReturnPath(state.r) ? state.r : state.r === 'connect' ? '/connect' : '/secrets';
520
+
521
+ try {
522
+ await secretsVault.completeOAuth(state.u, state.i, code, redirectUriFor(deps.publicBackendUrl));
523
+ back(`authorized=${encodeURIComponent(state.i)}`, dest);
524
+ } catch (err) {
525
+ console.error('[secrets] oauth callback failed:', err instanceof Error ? err.message : String(err));
526
+ back(`error=${encodeURIComponent('Authorization failed. Check the provider configuration and try again.')}`, dest);
527
+ }
528
+ });
529
+
530
+ return router;
531
+ }
532
+
533
+ function mapError(err: unknown, res: express.Response, op: string): void {
534
+ if (err instanceof InvalidSecretError) return void res.status(422).json({ error: err.message });
535
+ if (err instanceof SecretNotFoundError) return void res.status(404).json({ error: err.message });
536
+ if (err instanceof SecretOAuthError) return void res.status(409).json({ error: err.message });
537
+ console.error(`[secrets] ${op} failed:`, err);
538
+ res.status(500).json({ error: 'Internal error' });
539
+ }
540
+
541
+ // ---- signed-state helpers (CSRF + identity for the un-authed callback) -------
542
+
543
+ interface OAuthState {
544
+ u: string; // user id
545
+ i: string; // secret id
546
+ n: string; // nonce
547
+ iat: number; // issued-at (epoch ms)
548
+ /**
549
+ * Return path hint: a same-origin path (`isSafeReturnPath`) → land there; the
550
+ * legacy marker `'connect'` → /connect; anything else (incl. absent) →
551
+ * /secrets. Signed, so the callback can trust it — but re-validated anyway.
552
+ */
553
+ r?: string;
554
+ }
555
+
556
+ const OAUTH_STATE_MAX_AGE_MS = 10 * 60_000;
557
+ const OAUTH_STATE_SKEW_MS = 60_000;
558
+
559
+ function signState(secret: string, state: Omit<OAuthState, 'iat'>): string {
560
+ const full: OAuthState = { ...state, iat: Date.now() };
561
+ const body = Buffer.from(JSON.stringify(full)).toString('base64url');
562
+ const sig = createHmac('sha256', secret).update(body).digest('base64url');
563
+ return `${body}.${sig}`;
564
+ }
565
+
566
+ function verifyState(secret: string, token: string): OAuthState | null {
567
+ const [body, sig] = token.split('.');
568
+ if (!body || !sig) return null;
569
+ const expected = createHmac('sha256', secret).update(body).digest('base64url');
570
+ const a = Buffer.from(sig);
571
+ const b = Buffer.from(expected);
572
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
573
+ try {
574
+ const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState;
575
+ if (typeof parsed.iat !== 'number' || !parsed.u || !parsed.i) return null;
576
+ const age = Date.now() - parsed.iat;
577
+ if (age > OAUTH_STATE_MAX_AGE_MS || age < -OAUTH_STATE_SKEW_MS) return null;
578
+ return parsed;
579
+ } catch {
580
+ return null;
581
+ }
582
+ }