@bevel-software/platform-core-backend 0.13.6 → 0.14.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 (111) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -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 +9 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +12 -5
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  12. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  13. package/dist/modules/access-model/kb-read-filter.js +15 -0
  14. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  15. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  16. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  17. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  18. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  19. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  20. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  21. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  22. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  23. package/dist/modules/connection-probe/index.d.ts +3 -0
  24. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  25. package/dist/modules/connection-probe/index.js +3 -0
  26. package/dist/modules/connection-probe/index.js.map +1 -0
  27. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  28. package/dist/modules/diff/diff.routes.js +3 -5
  29. package/dist/modules/diff/diff.routes.js.map +1 -1
  30. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  31. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  32. package/dist/modules/kb-fs/mutex.js +48 -5
  33. package/dist/modules/kb-fs/mutex.js.map +1 -1
  34. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  35. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  36. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  37. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  38. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  39. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  40. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  41. package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
  42. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  43. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  44. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  45. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  46. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  47. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  48. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
  49. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  50. package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
  51. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  52. package/dist/modules/workflow/git/git.service.d.ts +18 -1
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +98 -6
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  57. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  58. package/dist/modules/workflow/workflow.routes.js +98 -9
  59. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  60. package/dist/modules/workflow/workflow.service.d.ts +81 -8
  61. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  62. package/dist/modules/workflow/workflow.service.js +220 -43
  63. package/dist/modules/workflow/workflow.service.js.map +1 -1
  64. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  65. package/dist/modules/workspace/workspace.routes.js +2 -5
  66. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  67. package/dist/modules/workspace/workspace.service.d.ts +5 -1
  68. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  69. package/dist/modules/workspace/workspace.service.js +21 -4
  70. package/dist/modules/workspace/workspace.service.js.map +1 -1
  71. package/dist/shared/token-crypto.d.ts +17 -2
  72. package/dist/shared/token-crypto.d.ts.map +1 -1
  73. package/dist/shared/token-crypto.js +25 -8
  74. package/dist/shared/token-crypto.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/__tests__/core-config.admin.test.ts +21 -0
  77. package/src/core/create-core-server.ts +3 -0
  78. package/src/core/create-core-services.ts +11 -0
  79. package/src/core-config.ts +14 -7
  80. package/src/modules/access-model/kb-read-filter.ts +22 -0
  81. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  82. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  83. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  84. package/src/modules/connection-probe/index.ts +2 -0
  85. package/src/modules/diff/diff.routes.ts +9 -5
  86. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  87. package/src/modules/kb-fs/mutex.ts +50 -5
  88. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  89. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  90. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  91. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  92. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  93. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
  94. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  95. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  96. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
  97. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  98. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  99. package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
  100. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  101. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  102. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  103. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
  104. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  105. package/src/modules/workflow/git/git.service.ts +115 -7
  106. package/src/modules/workflow/workflow.routes.ts +104 -4
  107. package/src/modules/workflow/workflow.service.ts +245 -55
  108. package/src/modules/workspace/workspace.routes.ts +8 -4
  109. package/src/modules/workspace/workspace.service.ts +21 -3
  110. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  111. package/src/shared/token-crypto.ts +28 -10
@@ -174,10 +174,19 @@ export class McpServerEditService {
174
174
  const servers = mcp.mcpServers as Record<string, unknown>;
175
175
  if (!(name in servers)) throw new McpServerEditError('No such server.', 404);
176
176
 
177
- if (write.transport !== 'streamable-http' && write.transport !== 'sse' && write.transport !== 'stdio') {
178
- // Persisting an unknown transport would save an entry discovery then
179
- // refuses — an unusable server with no error at the moment it was made.
180
- throw new McpServerEditError(`Unknown transport "${String(write.transport)}".`, 422);
177
+ if (write.transport !== 'streamable-http' && write.transport !== 'stdio') {
178
+ // Persisting a transport discovery refuses would save an entry that
179
+ // vanishes from the catalog the moment it is written — an unusable
180
+ // server with no error at the moment it was made. `sse` is refused BY
181
+ // NAME with the fix spelled out, because the pinned MCP client has no
182
+ // sse transport; an EXISTING sse entry stays readable here, so it can
183
+ // be edited to `streamable-http` rather than being stranded.
184
+ throw new McpServerEditError(
185
+ write.transport === 'sse'
186
+ ? 'The MCP client has no `sse` transport — declare the server as `streamable-http` if it supports it.'
187
+ : `Unknown transport "${String(write.transport)}".`,
188
+ 422,
189
+ );
181
190
  }
182
191
  // The EFFECTIVE value of every field, PATCH-over-stored (see
183
192
  // McpServerWrite): `undefined` keeps what the files already say, a
@@ -88,6 +88,57 @@ export interface ToolVariable {
88
88
  oauth?: ToolVariableOAuth;
89
89
  }
90
90
 
91
+ /**
92
+ * An optional cheap, read-only call that proves a credential actually WORKS.
93
+ *
94
+ * A `type: mcp` manual needs none — its handshake is authenticated, so merely
95
+ * connecting already tests the token. `http` and `inline` manuals have no such
96
+ * moment: registering an `http` manual fetches its DESCRIPTION, which providers
97
+ * usually serve publicly, so a wrong key sails through; an `inline` manual's
98
+ * discovery template points at Bevel's own API and never touches the provider
99
+ * at all. Without a declaration there is genuinely nothing to call, and the
100
+ * platform reports the connection as unverified rather than guessing.
101
+ *
102
+ * NOT inferred from the manual's own tools. Picking one automatically would
103
+ * mean calling an arbitrary tool with invented arguments — a probe that could
104
+ * send a message or create a record is worse than no probe. The author names a
105
+ * safe endpoint or the tool stays unverified.
106
+ *
107
+ * `${VAR}` refs resolve from the caller's vault exactly as they do in `url` and
108
+ * `headers`, which is the whole point: the probe must carry the same credential
109
+ * the real calls carry. Any 2xx is a pass; 401/403 is a rejection; anything
110
+ * else is treated as the provider being unwell rather than the key being wrong.
111
+ */
112
+ export interface ToolHealthCheck {
113
+ /** Absolute URL to call. May contain `${VAR}` refs; SSRF-checked like `url`. */
114
+ url: string;
115
+ /** Default `GET`. A health check may not mutate, so nothing else is allowed. */
116
+ method?: 'GET';
117
+ /** Defaults to the manual's own `headers` — usually where the credential is. */
118
+ headers?: Record<string, string>;
119
+ }
120
+
121
+ /**
122
+ * One manual, reduced to exactly what testing its credential requires.
123
+ *
124
+ * Server-internal, like {@link ToolHealthCheck} and for the same reason: both
125
+ * `healthCheck.headers` and `callTemplate` can carry a literal token.
126
+ */
127
+ export interface ToolProbeTarget {
128
+ name: string;
129
+ type: ToolManualType;
130
+ /** `false` for a local-only tool this server is not the one that can reach it. */
131
+ remote?: boolean;
132
+ /** The probe the `.tool` declares, if any. */
133
+ healthCheck?: ToolHealthCheck;
134
+ /**
135
+ * The validated UTCP call template, or `null` when the manual doesn't produce
136
+ * a valid one. Only the `mcp` probe uses it — its handshake IS the test — but
137
+ * it is built here so the probe never has to walk the catalog again.
138
+ */
139
+ callTemplate: CallTemplate | null;
140
+ }
141
+
91
142
  /**
92
143
  * For a `type: mcp` tool, what an admin must do to make the remote server
93
144
  * reachable — derived from OAuth auto-discovery, which a non-mcp tool has no
@@ -140,6 +191,15 @@ export interface ToolManualDescriptorBase {
140
191
  variables?: ToolVariable[];
141
192
  /** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
142
193
  setup?: ToolManualSetup;
194
+ /**
195
+ * Optional credential probe — see {@link ToolHealthCheck}. Declared on
196
+ * `http`/`inline` manuals, and honoured on `type: mcp` too, where it
197
+ * OVERRIDES the default handshake probe (a server whose health endpoint is
198
+ * cheaper or more truthful than a full MCP handshake says so here).
199
+ * INTERNAL: lives on the descriptor, never on {@link ToolManualSummary},
200
+ * because it carries `headers`.
201
+ */
202
+ healthCheck?: ToolHealthCheck;
143
203
  }
144
204
 
145
205
  /** The spawn spec of a stdio-declared MCP server (a plugin `mcp.json` entry). */
@@ -197,6 +257,10 @@ export interface ToolManualSummary {
197
257
  remote?: boolean;
198
258
  /** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
199
259
  setup?: ToolManualSetup;
260
+ // NOTE: no `healthCheck` here, deliberately. This type is serialized straight
261
+ // to the browser by the tool endpoints, and a probe carries `headers` — which
262
+ // a `.tool` author may write as a literal token rather than a `${VAR}` ref.
263
+ // The server reads probe config through `IToolManualService.probeTargetFor`.
200
264
  }
201
265
 
202
266
  /** One thing an `inline` manual's embedded tool list says the assistant can do. */
@@ -269,6 +333,31 @@ export interface IToolManualService {
269
333
  * a `resolve` reads the shared row or the caller's row.
270
334
  */
271
335
  scopeOfVariable(effectiveKey: string): Promise<ToolVariableScope>;
336
+ /**
337
+ * Everything the credential probe needs about one readable manual, resolved
338
+ * in a SINGLE catalog + ACL pass.
339
+ *
340
+ * One accessor rather than three because the probe needs three facts that all
341
+ * come from the same file — is it local-only, does it declare a health check,
342
+ * and what call template would reach it — and asking for them separately made
343
+ * one probe walk the catalog four times, building call templates for every
344
+ * manual in the workspace to use exactly one of them.
345
+ *
346
+ * Deliberately NOT reachable through {@link ToolManualSummary}: that type is
347
+ * serialized straight to the browser by the tool endpoints, and a probe
348
+ * carries `headers` — which a `.tool` author may write as a literal token
349
+ * rather than a `${VAR}` ref. Probe config is internal to the server, so it
350
+ * travels by its own accessor and never rides a public DTO.
351
+ *
352
+ * Addressed by SLUG, which is what the route has: resolving the slug here
353
+ * rather than making the caller map it to a name first is what reduces a
354
+ * probe to one pass. `ToolProbeTarget.name` carries the UTCP namespace back
355
+ * out, since that is what the probe's `${VAR}` lookups are keyed by.
356
+ *
357
+ * `null` when no such manual exists OR the caller can't read it — the two are
358
+ * indistinguishable on purpose, as everywhere else in this contract.
359
+ */
360
+ probeTargetFor(userEmail: string, slug: string): Promise<ToolProbeTarget | null>;
272
361
  /**
273
362
  * The per-user (`user`-scoped) variables a manual declares, each with the vault
274
363
  * key (`<manual>_<VAR>`) the caller's value is stored under, its bare name, and
@@ -45,6 +45,8 @@ import {
45
45
  type ToolManualPreview,
46
46
  type ToolManualType,
47
47
  type ToolVariable,
48
+ type ToolHealthCheck,
49
+ type ToolProbeTarget,
48
50
  type ToolVariableScope,
49
51
  type ToolVariableOAuth,
50
52
  } from './tool-manuals.contract.js';
@@ -235,6 +237,41 @@ export class ToolManualService implements IToolManualService {
235
237
  return declared?.scope ?? 'admin';
236
238
  }
237
239
 
240
+ /**
241
+ * The probe config for a manual this caller can read — the internal
242
+ * counterpart to everything on `ToolManualSummary`.
243
+ *
244
+ * Access-gated through `accessibleManuals` like every other read, so this
245
+ * cannot become a way to learn what a `.tool` you can't see declares.
246
+ */
247
+ async probeTargetFor(userEmail: string, slug: string): Promise<ToolProbeTarget | null> {
248
+ const manuals = await this.accessibleManuals(userEmail);
249
+ const m = manuals.find((x) => x.slug === slug);
250
+ if (!m) return null;
251
+ // Built here, from the manual already in hand. `toManualCallTemplates`
252
+ // would validate every manual in the workspace to hand back the one we
253
+ // want; an invalid template for THIS manual is not an error, just a probe
254
+ // that has nothing to dial (the caller reports it as unverifiable).
255
+ //
256
+ // Only for the ONE path that dials it — an `mcp` manual, reachable from
257
+ // this process, that declared no health check of its own. Everywhere else
258
+ // the template is built, validated and thrown away, and an http tool with
259
+ // an unvalidatable template warn-logs on every probe about a value nothing
260
+ // was ever going to use.
261
+ const dialsTheTemplate = m.type === 'mcp' && !m.healthCheck && m.remote !== false;
262
+ let callTemplate: CallTemplate | null = null;
263
+ if (dialsTheTemplate) {
264
+ try {
265
+ callTemplate = callTemplateSerializer.validateDict(this.buildCallTemplateDict(m));
266
+ } catch (err) {
267
+ console.warn(
268
+ `[tool-manuals] no valid call template for "${m.path}": ${err instanceof Error ? err.message : String(err)}`,
269
+ );
270
+ }
271
+ }
272
+ return { name: m.name, type: m.type, remote: m.remote, healthCheck: m.healthCheck, callTemplate };
273
+ }
274
+
238
275
  async toManualCallTemplates(userEmail: string, opts?: { remoteOnly?: boolean }): Promise<CallTemplate[]> {
239
276
  const manuals = await this.accessibleManuals(userEmail);
240
277
  const out: CallTemplate[] = [];
@@ -415,6 +452,19 @@ export class ToolManualService implements IToolManualService {
415
452
  if (m.type === 'inline' && m.tools) {
416
453
  refs.push(...variableSubstitutor.findRequiredVariables(m.tools, m.name));
417
454
  }
455
+ // A probe-only credential still has to reach the secrets UI. The call
456
+ // template is built from `url`/`headers` and never carries `healthCheck`,
457
+ // so a `${VAR}` used solely by the probe would never be surfaced for
458
+ // provisioning — and `probeDeclared` would then report `unverifiable`
459
+ // forever on a variable no screen ever offered anyone to fill in.
460
+ if (m.healthCheck) {
461
+ refs.push(
462
+ ...variableSubstitutor.findRequiredVariables(
463
+ { url: m.healthCheck.url, headers: m.healthCheck.headers ?? {} },
464
+ m.name,
465
+ ),
466
+ );
467
+ }
418
468
  } catch {
419
469
  return; // a malformed template is reported elsewhere; never break the scan
420
470
  }
@@ -482,6 +532,20 @@ export class ToolManualService implements IToolManualService {
482
532
  }
483
533
  m.setup = { kind: 'oauth-auto' };
484
534
  m.headers = { ...(m.headers ?? {}), Authorization: `Bearer \${${MCP_OAUTH_VAR}}` };
535
+ // A declared healthCheck froze its headers during normalize —
536
+ // BEFORE this decoration — so it would dial without the bearer
537
+ // every real call now carries and read its own 401 as a rejected
538
+ // credential. The injected sign-in reaches the check too, unless
539
+ // the check declares its own Authorization.
540
+ if (
541
+ m.healthCheck &&
542
+ !Object.keys(m.healthCheck.headers ?? {}).some((h) => h.toLowerCase() === 'authorization')
543
+ ) {
544
+ m.healthCheck = {
545
+ ...m.healthCheck,
546
+ headers: { ...(m.healthCheck.headers ?? {}), Authorization: `Bearer \${${MCP_OAUTH_VAR}}` },
547
+ };
548
+ }
485
549
  m.variables = [
486
550
  ...(m.variables ?? []),
487
551
  {
@@ -848,20 +912,8 @@ export function normalizeToolManual(
848
912
  // literal internal host slips past as a templated scheme or userinfo.
849
913
  // Local-only (`remote: false`) `.tool`s are never fetched server-side, so
850
914
  // are exempt.
851
- const literalScheme = /^([a-zA-Z][a-zA-Z0-9+.-]*:)[\\/]{2}/.exec(url)?.[1];
852
- const sepMatch = /:[\\/]{2}/.exec(url);
853
- const sepIdx = sepMatch ? sepMatch.index : -1;
854
- const authority = sepIdx >= 0 ? url.slice(sepIdx + 3).split(/[/\\?#]/, 1)[0] : url;
855
- const hostPort = authority.slice(authority.lastIndexOf('@') + 1);
856
- const host = hostPort.startsWith('[') ? hostPort.slice(0, hostPort.indexOf(']') + 1) : hostPort.split(':')[0];
857
- if (descriptor.remote !== false && !host.includes('${')) {
858
- const checkUrl =
859
- literalScheme && !authority.includes('${')
860
- ? url // fully literal scheme + authority → validate the URL as-is
861
- : sepIdx >= 0
862
- ? `${literalScheme ?? 'http:'}//${host}` // templated scheme/userinfo/port, literal host
863
- : url; // no authority shape at all → parse raw (refuses malformed)
864
- assertSafeFetchUrl(checkUrl, { label: `\`.tool\` "${name}" url` });
915
+ if (descriptor.remote !== false) {
916
+ assertSafeManualFetchUrl(url, `\`.tool\` "${name}" url`);
865
917
  }
866
918
  if (obj.headers && typeof obj.headers === 'object' && !Array.isArray(obj.headers)) {
867
919
  descriptor.headers = obj.headers as Record<string, string>;
@@ -871,9 +923,139 @@ export function normalizeToolManual(
871
923
  descriptor.httpMethod = m === 'POST' ? 'POST' : 'GET';
872
924
  }
873
925
  }
926
+
927
+ // LAST, because it inherits the manual's own `headers` when it declares none
928
+ // — which is the common case, and the reason a one-line `healthCheck: {url}`
929
+ // authenticates exactly like a real call. Resolving that default here, where
930
+ // the whole descriptor is in hand, keeps the probe self-contained: nothing
931
+ // downstream has to re-derive which headers a manual would have sent.
932
+ //
933
+ // For `http`/`mcp` only. An `inline` manual's execution never sends its
934
+ // top-level `headers` — each embedded tool carries its own call template —
935
+ // so inheriting them would have the probe prove a request no real call
936
+ // makes. An inline probe declares its own headers on the healthCheck, or
937
+ // sends none.
938
+ const declaredHeaders =
939
+ type !== 'inline' && obj.headers && typeof obj.headers === 'object' && !Array.isArray(obj.headers)
940
+ ? (obj.headers as Record<string, string>)
941
+ : undefined;
942
+ const healthCheck = normalizeHealthCheck(obj.healthCheck, name, descriptor.remote, declaredHeaders);
943
+ if (healthCheck) descriptor.healthCheck = healthCheck;
944
+
874
945
  return descriptor;
875
946
  }
876
947
 
948
+ /**
949
+ * SSRF guard for a URL a `.tool` will make the SERVER fetch — its manual `url`
950
+ * and its declared `healthCheck.url` alike, which is why this is a function
951
+ * rather than an inline block: two fetch targets policed by one rule cannot
952
+ * drift apart.
953
+ *
954
+ * A remote-capable `.tool` triggers a server-side fetch (the MCP proxy, the
955
+ * in-process agent, headless routines, and now the credential probe), so a
956
+ * literal private/loopback/metadata host is refused at the producing boundary.
957
+ * Only a TEMPLATED HOSTNAME (resolved at call time) is uncheckable here — a
958
+ * `${...}` in the scheme, userinfo, port, path, or query still leaves a
959
+ * concrete network target (`${S}://169.254.169.254/x` and
960
+ * `http://${U}@169.254.169.254/x` target the metadata IP no matter what
961
+ * resolves), so the guard must still run against the literal host. The
962
+ * authority is therefore taken from `://` INDEPENDENT of the scheme being
963
+ * literal, and when the raw url can't parse (templated scheme or port) the
964
+ * check runs on a synthetic `<scheme-or-http>//host`. A BACKSLASH behaves as a
965
+ * slash for http(s) in WHATWG `new URL` — BOTH as the scheme separator
966
+ * (`${S}:\\169.254.169.254\\p` → the IP is the host) and inside the authority
967
+ * (`http://169.254.169.254\\@${HOST}/x` fetches the IP, the `\\@…` becoming
968
+ * path). So the authority separator is `:` + two `[\/]` (not just `://`), and
969
+ * `\` also terminates the authority alongside `/?#`; otherwise a literal
970
+ * internal host slips past as a templated scheme or userinfo.
971
+ *
972
+ * Callers gate on `remote !== false`: a local-only `.tool` is never fetched
973
+ * server-side, so it is exempt.
974
+ */
975
+ function assertSafeManualFetchUrl(url: string, label: string): void {
976
+ const literalScheme = /^([a-zA-Z][a-zA-Z0-9+.-]*:)[\\/]{2}/.exec(url)?.[1];
977
+ const sepMatch = /:[\\/]{2}/.exec(url);
978
+ const sepIdx = sepMatch ? sepMatch.index : -1;
979
+ const authority = sepIdx >= 0 ? url.slice(sepIdx + 3).split(/[/\\?#]/, 1)[0] : url;
980
+ const hostPort = authority.slice(authority.lastIndexOf('@') + 1);
981
+ const host = hostPort.startsWith('[') ? hostPort.slice(0, hostPort.indexOf(']') + 1) : hostPort.split(':')[0];
982
+ // A templated host resolves to something we can't know yet — nothing to check.
983
+ if (host.includes('${')) return;
984
+ const checkUrl =
985
+ literalScheme && !authority.includes('${')
986
+ ? url // fully literal scheme + authority → validate the URL as-is
987
+ : sepIdx >= 0
988
+ ? `${literalScheme ?? 'http:'}//${host}` // templated scheme/userinfo/port, literal host
989
+ : url; // no authority shape at all → parse raw (refuses malformed)
990
+ assertSafeFetchUrl(checkUrl, { label });
991
+ }
992
+
993
+ /**
994
+ * Parse the optional `healthCheck:` block — the read-only call that proves this
995
+ * tool's credential works (see {@link ToolHealthCheck}).
996
+ *
997
+ * Throws on a malformed block rather than dropping it, matching `variables`
998
+ * and for the same reason inverted: a silently-ignored probe would leave the
999
+ * tool reporting "can't verify" forever while its author believes they wired
1000
+ * one up, and the whole point of the field is to stop the UI overclaiming.
1001
+ *
1002
+ * `method` accepts only `GET`. A probe that can mutate is not a probe — it runs
1003
+ * unattended on every save and re-check, so `POST` is refused outright instead
1004
+ * of being quietly downgraded.
1005
+ */
1006
+ function normalizeHealthCheck(
1007
+ raw: unknown,
1008
+ manualName: string,
1009
+ remote: boolean | undefined,
1010
+ manualHeaders: Record<string, string> | undefined,
1011
+ ): ToolHealthCheck | undefined {
1012
+ if (raw === undefined || raw === null) return undefined;
1013
+ if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('`healthCheck` must be an object');
1014
+ const e = raw as Record<string, unknown>;
1015
+ const url = typeof e.url === 'string' ? e.url.trim() : '';
1016
+ if (!url) throw new Error('`healthCheck` must have a `url`');
1017
+ if (remote !== false) {
1018
+ assertSafeManualFetchUrl(url, `\`.tool\` "${manualName}" healthCheck.url`);
1019
+ }
1020
+ const check: ToolHealthCheck = { url };
1021
+ if (e.method !== undefined) {
1022
+ const m = typeof e.method === 'string' ? e.method.toUpperCase() : '';
1023
+ if (m !== 'GET') throw new Error('`healthCheck.method` must be `GET` — a health check may not mutate');
1024
+ check.method = 'GET';
1025
+ }
1026
+ // Whichever headers the probe ends up carrying get checked — declared OR
1027
+ // inherited. Validating only the declared branch left the common case
1028
+ // unguarded: `healthCheck: { url }` alone inherits the manual's headers, so a
1029
+ // YAML `Authorization: 1234` there still reached the probe untouched.
1030
+ if (e.headers !== undefined) {
1031
+ if (!e.headers || typeof e.headers !== 'object' || Array.isArray(e.headers)) {
1032
+ throw new Error('`healthCheck.headers` must be an object');
1033
+ }
1034
+ check.headers = assertStringHeaders(e.headers as Record<string, unknown>, 'healthCheck.headers');
1035
+ } else if (manualHeaders) {
1036
+ check.headers = assertStringHeaders(manualHeaders as Record<string, unknown>, 'headers');
1037
+ }
1038
+ return check;
1039
+ }
1040
+
1041
+ /**
1042
+ * The VALUES of a header map, not just its container.
1043
+ *
1044
+ * YAML types `Authorization: 1234` as a number, and a cast alone let it through
1045
+ * to the probe, where substitution calls `.matchAll` on it — surfacing
1046
+ * `text.matchAll is not a function` to the user as the reason their credential
1047
+ * is unhealthy. Caught at parse time, next to `url` and `method`, it reads as
1048
+ * what it is: a mistake in the `.tool` file.
1049
+ */
1050
+ function assertStringHeaders(headers: Record<string, unknown>, label: string): Record<string, string> {
1051
+ for (const [k, v] of Object.entries(headers)) {
1052
+ if (typeof v !== 'string') {
1053
+ throw new Error(`\`${label}.${k}\` must be a string (quote it if it looks like a number)`);
1054
+ }
1055
+ }
1056
+ return headers as Record<string, string>;
1057
+ }
1058
+
877
1059
  /**
878
1060
  * Parse the optional `variables:` block of a `.tool` file. Each entry names a
879
1061
  * `${VAR}` and who provisions it (`admin` default | `user`). Throws on a
@@ -0,0 +1,139 @@
1
+ import type { Server } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { describe, it, expect, afterEach, vi } from 'vitest';
5
+ import type { IWorkflowService } from '@bevel-software/platform-shared';
6
+ import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import type { AuthService } from '../../auth/auth.service.js';
8
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
+ import type { WorkflowEventBus } from '../event-bus.js';
10
+ import { createWorkflowRoutes } from '../workflow.routes.js';
11
+
12
+ // A file's history is its content with a time axis: the same default-deny
13
+ // read model that hides a file must hide its commit list, its diffs, and its
14
+ // content at any commit. These routes used to require only "is authenticated".
15
+
16
+ const USER = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
17
+ const WS = 'main';
18
+ const KB = 'knowledge-base';
19
+ const allow = (p: string) => !p.includes('Secret');
20
+
21
+ interface Harness {
22
+ server: Server;
23
+ canRead: ReturnType<typeof vi.fn>;
24
+ canReadAtRef: ReturnType<typeof vi.fn>;
25
+ workflow: {
26
+ listChangesForFile: ReturnType<typeof vi.fn>;
27
+ compareFile: ReturnType<typeof vi.fn>;
28
+ showFileAtChange: ReturnType<typeof vi.fn>;
29
+ fileAtChange: ReturnType<typeof vi.fn>;
30
+ };
31
+ baseUrl: string;
32
+ }
33
+
34
+ async function makeHarness(): Promise<Harness> {
35
+ const canRead = vi.fn(async (_w: string, _e: string, p: string) => allow(p));
36
+ const canReadAtRef = vi.fn(async (_w: string, _r: string, _e: string, p: string) => allow(p));
37
+ const accessControl = { canRead, canReadAtRef } as unknown as IAccessControl;
38
+ const workflow = {
39
+ listChangesForFile: vi.fn(async () => []),
40
+ compareFile: vi.fn(async () => ''),
41
+ showFileAtChange: vi.fn(async () => ''),
42
+ fileAtChange: vi.fn(async () => ({ baseline: null, current: 'x' })),
43
+ };
44
+ const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
45
+
46
+ const app = express();
47
+ app.use(express.json());
48
+ app.use('/api', (req, _res, next) => {
49
+ (req as unknown as { userId: string }).userId = USER.id;
50
+ next();
51
+ });
52
+ app.use(
53
+ '/api',
54
+ createWorkflowRoutes(
55
+ workflow as unknown as IWorkflowService,
56
+ {} as unknown as WorkspaceService,
57
+ authService,
58
+ { subscribe: vi.fn(), publish: vi.fn() } as unknown as WorkflowEventBus,
59
+ accessControl,
60
+ KB,
61
+ ),
62
+ );
63
+ const server = await new Promise<Server>((resolve) => {
64
+ const s = app.listen(0, () => resolve(s));
65
+ });
66
+ const addr = server.address() as AddressInfo;
67
+ return { server, canRead, canReadAtRef, workflow, baseUrl: `http://127.0.0.1:${addr.port}` };
68
+ }
69
+
70
+ function close(s: Server): Promise<void> {
71
+ return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
72
+ }
73
+
74
+ const OPEN = encodeURIComponent(`${KB}/Open/a.md`);
75
+ const SECRET = encodeURIComponent(`${KB}/Secret/x.md`);
76
+
77
+ describe('history routes enforce the read model', () => {
78
+ let h: Harness | null = null;
79
+ afterEach(async () => {
80
+ if (h) await close(h.server);
81
+ h = null;
82
+ });
83
+ const get = (p: string) => fetch(`${h!.baseUrl}/api/workspace/${WS}/workflow${p}`);
84
+
85
+ it.each([
86
+ ['/changes?path=%s', 'listChangesForFile'],
87
+ ['/show-file?sha=abc&path=%s', 'showFileAtChange'],
88
+ ['/file-at-change?sha=abc&path=%s', 'fileAtChange'],
89
+ ['/compare-file?from=a&to=b&path=%s', 'compareFile'],
90
+ ] as const)('%s: readable → 200, denied → 403 and git is never consulted', async (route, method) => {
91
+ h = await makeHarness();
92
+ expect((await get(route.replace('%s', OPEN))).status).toBe(200);
93
+ expect((await get(route.replace('%s', SECRET))).status).toBe(403);
94
+ // The denial happened BEFORE the service — nothing read the repository.
95
+ const calls = h.workflow[method].mock.calls as unknown[][];
96
+ expect(calls.length).toBe(1);
97
+ expect(String(calls[0][1])).toContain('Open');
98
+ });
99
+
100
+ it('a path without the repo prefix is refused outright — the git layer would read it as repo-relative', async () => {
101
+ // `stripRepoPrefix` makes `Secret/x.md` and `knowledge-base/Secret/x.md`
102
+ // name the same object, so an ungated "non-KB" branch would be a gate
103
+ // bypass by spelling. History is for tracked files only; no prefix, 400.
104
+ h = await makeHarness();
105
+ for (const p of ['Secret/x.md', 'scratch/notes.txt']) {
106
+ const res = await get(`/changes?path=${encodeURIComponent(p)}`);
107
+ expect(res.status).toBe(400);
108
+ }
109
+ expect(h.workflow.listChangesForFile).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('compare-file authorizes at every ref the comparison could serve', async () => {
113
+ // The comparison prefers a local ref (and the working tree) over
114
+ // `origin/<branch>`, so BOTH candidates are authorized per branch.
115
+ h = await makeHarness();
116
+ const res = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
117
+ expect(res.status).toBe(200);
118
+ const refs = h.canReadAtRef.mock.calls.map((c) => c[1]).sort();
119
+ expect(refs).toEqual(['a', 'b', 'origin/a', 'origin/b']);
120
+
121
+ // Any RESOLVABLE ref denying the read refuses the diff.
122
+ h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === 'b' ? false : true));
123
+ expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
124
+
125
+ // A branch none of whose refs resolve cannot be authorized: refused.
126
+ h.canReadAtRef.mockImplementation(async (_w: string, ref: string) =>
127
+ ref.endsWith('b') ? null : true,
128
+ );
129
+ expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
130
+ });
131
+
132
+ it('an access-model error fails closed, not open', async () => {
133
+ h = await makeHarness();
134
+ h.canRead.mockRejectedValueOnce(new Error('access tree unreadable'));
135
+ const res = await get(`/changes?path=${OPEN}`);
136
+ expect(res.status).toBeGreaterThanOrEqual(500);
137
+ expect(h.workflow.listChangesForFile).not.toHaveBeenCalled();
138
+ });
139
+ });