@bevel-software/platform-core-backend 0.30.0 → 0.31.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 (156) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts.map +1 -1
  8. package/dist/core/create-core-services.js +4 -2
  9. package/dist/core/create-core-services.js.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  11. package/dist/modules/access/access-control.service.js +1 -5
  12. package/dist/modules/access/access-control.service.js.map +1 -1
  13. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  14. package/dist/modules/audit/agent-audit.service.js +66 -6
  15. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  16. package/dist/modules/audit/audit.contract.d.ts +52 -8
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  18. package/dist/modules/audit/audit.contract.js +37 -1
  19. package/dist/modules/audit/audit.contract.js.map +1 -1
  20. package/dist/modules/audit/audit.routes.d.ts +1 -1
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  22. package/dist/modules/audit/audit.routes.js +15 -13
  23. package/dist/modules/audit/audit.routes.js.map +1 -1
  24. package/dist/modules/audit/request-audit.d.ts +21 -10
  25. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  26. package/dist/modules/audit/request-audit.js +26 -10
  27. package/dist/modules/audit/request-audit.js.map +1 -1
  28. package/dist/modules/database/core-schema.d.ts +22 -3
  29. package/dist/modules/database/core-schema.d.ts.map +1 -1
  30. package/dist/modules/database/core-schema.js +15 -6
  31. package/dist/modules/database/core-schema.js.map +1 -1
  32. package/dist/modules/kb-fs/index.d.ts +1 -0
  33. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  34. package/dist/modules/kb-fs/index.js +1 -0
  35. package/dist/modules/kb-fs/index.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  37. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  38. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  39. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  40. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  41. package/dist/modules/kb-fs/mutex.js +14 -0
  42. package/dist/modules/kb-fs/mutex.js.map +1 -1
  43. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  44. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  45. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  46. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  47. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  48. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp.routes.js +67 -0
  50. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  51. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  52. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  53. package/dist/modules/mcp/mcp.service.js +257 -50
  54. package/dist/modules/mcp/mcp.service.js.map +1 -1
  55. package/dist/modules/mcp/request-context.d.ts +25 -0
  56. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  57. package/dist/modules/mcp/request-context.js +11 -0
  58. package/dist/modules/mcp/request-context.js.map +1 -0
  59. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  60. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  61. package/dist/modules/mcp/shared-definitions.js +117 -0
  62. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  63. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  64. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  65. package/dist/modules/mcp/surface-cache.js +104 -0
  66. package/dist/modules/mcp/surface-cache.js.map +1 -0
  67. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  68. package/dist/modules/tool-helpers/tool-context.js +33 -12
  69. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  70. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  71. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  72. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  73. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  74. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  75. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +470 -106
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  85. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  86. package/dist/modules/workflow/read-on-branch.js +58 -0
  87. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  88. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  89. package/dist/modules/workflow/workflow.routes.js +22 -41
  90. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  91. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  92. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/workflow.service.js +163 -16
  94. package/dist/modules/workflow/workflow.service.js.map +1 -1
  95. package/dist/modules/workspace/index.d.ts +2 -0
  96. package/dist/modules/workspace/index.d.ts.map +1 -1
  97. package/dist/modules/workspace/index.js +4 -0
  98. package/dist/modules/workspace/index.js.map +1 -1
  99. package/dist/shared/git.contract.d.ts +7 -0
  100. package/dist/shared/git.contract.d.ts.map +1 -1
  101. package/dist/shared/git.contract.js +9 -0
  102. package/dist/shared/git.contract.js.map +1 -1
  103. package/dist/shared/no-link-below.d.ts +10 -0
  104. package/dist/shared/no-link-below.d.ts.map +1 -0
  105. package/dist/shared/no-link-below.js +30 -0
  106. package/dist/shared/no-link-below.js.map +1 -0
  107. package/migrations/0018_agent_events_page.sql +11 -0
  108. package/migrations/meta/0018_snapshot.json +2429 -0
  109. package/migrations/meta/_journal.json +8 -1
  110. package/package.json +3 -3
  111. package/src/core/catalog-revision.ts +3 -3
  112. package/src/core/create-core-server.ts +2 -1
  113. package/src/core/create-core-services.ts +4 -2
  114. package/src/modules/access/access-control.service.ts +1 -6
  115. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  116. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  117. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  118. package/src/modules/audit/agent-audit.service.ts +66 -6
  119. package/src/modules/audit/audit.contract.ts +62 -10
  120. package/src/modules/audit/audit.routes.ts +14 -12
  121. package/src/modules/audit/request-audit.ts +28 -13
  122. package/src/modules/database/core-schema.ts +15 -6
  123. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  124. package/src/modules/kb-fs/index.ts +1 -0
  125. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  126. package/src/modules/kb-fs/mutex.ts +16 -0
  127. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  128. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  129. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  130. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  131. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  132. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  133. package/src/modules/mcp/mcp.routes.ts +68 -0
  134. package/src/modules/mcp/mcp.service.ts +315 -51
  135. package/src/modules/mcp/request-context.ts +34 -0
  136. package/src/modules/mcp/shared-definitions.ts +121 -0
  137. package/src/modules/mcp/surface-cache.ts +154 -0
  138. package/src/modules/tool-helpers/tool-context.ts +37 -18
  139. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  140. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  141. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  142. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  143. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  144. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  145. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  147. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  148. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  149. package/src/modules/workflow/git/git.service.ts +537 -106
  150. package/src/modules/workflow/read-on-branch.ts +79 -0
  151. package/src/modules/workflow/workflow.routes.ts +21 -39
  152. package/src/modules/workflow/workflow.service.ts +203 -16
  153. package/src/modules/workspace/index.ts +11 -0
  154. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  155. package/src/shared/git.contract.ts +10 -0
  156. package/src/shared/no-link-below.ts +29 -0
@@ -1,6 +1,6 @@
1
1
  import type { CodeModeUtcpClient } from '@utcp/code-mode';
2
2
  import { logger } from '../../shared/logging.js';
3
- import type { AgentEventOutcome, AuditPrincipalRef, IAgentEventRecorder } from './audit.contract.js';
3
+ import type { AgentEventOutcome, IAgentEventRecorder, RecordedPrincipal } from './audit.contract.js';
4
4
  import {
5
5
  classifyToolCall,
6
6
  skillReadPath,
@@ -13,11 +13,11 @@ const log = logger('audit');
13
13
 
14
14
  /**
15
15
  * The Audit log's view of ONE MCP request: who is calling (the user and the
16
- * key or agent connection the request arrived through), and how to name what
17
- * they call. The MCP proxy creates one per request that has a principal and
18
- * routes every call through it; a request with none (a browser JWT, an
19
- * internal token minted before connections existed) gets no instance and
20
- * records nothing.
16
+ * key, agent connection or page the request arrived through), and how to name
17
+ * what they call. The MCP proxy creates one per request that has a principal
18
+ * and routes every call through it; a request with none (a browser JWT that
19
+ * names no page, an internal token minted before connections existed) gets no
20
+ * instance and records nothing.
21
21
  *
22
22
  * Holds nothing that outlives the request. The skill catalog it may need to
23
23
  * tell a skill read from a file read is fetched at most once, and only when
@@ -29,7 +29,7 @@ export class RequestAudit {
29
29
  constructor(
30
30
  private readonly recorder: IAgentEventRecorder,
31
31
  private readonly userId: string,
32
- private readonly principal: AuditPrincipalRef,
32
+ private readonly principal: RecordedPrincipal,
33
33
  private readonly ctx: {
34
34
  kbManualName: string;
35
35
  /** Read at classification time — the surface (and its catalog names) is built lazily. */
@@ -93,9 +93,13 @@ export class RequestAudit {
93
93
  }
94
94
  }
95
95
 
96
- /** A call refused before it ran, because the caller's sign-in for its manual is missing. */
97
- async denied(utcpName: string, args: Record<string, unknown>): Promise<void> {
98
- this.emit(await this.classify(utcpName, args), 'denied', null);
96
+ /**
97
+ * A call refused before it ran: `denied` when the caller's sign-in for its
98
+ * manual is missing, `refused` when the person viewing a page answered its
99
+ * request to write with Deny.
100
+ */
101
+ async denied(utcpName: string, args: Record<string, unknown>, outcome: 'denied' | 'refused' = 'denied'): Promise<void> {
102
+ this.emit(await this.classify(utcpName, args), outcome, null);
99
103
  }
100
104
 
101
105
  /**
@@ -106,11 +110,22 @@ export class RequestAudit {
106
110
  * The direct MCP path (`callToolStreaming`) is deliberately NOT wrapped —
107
111
  * the proxy's tool handler records those itself, with the result's
108
112
  * `isError` in hand.
113
+ *
114
+ * Installed ONCE, when the client is built, because the client is shared by
115
+ * every request of its person: `current` names the request a call belongs
116
+ * to, at call time, and a call outside any request (or in one attributed to
117
+ * nothing) is recorded nowhere. Rebinding the client per request instead
118
+ * would hand one request's calls to another's audit, and stack a wrapper
119
+ * per request.
109
120
  */
110
- instrumentChainCalls(client: CodeModeUtcpClient): void {
121
+ static instrumentChainCalls(client: CodeModeUtcpClient, current: () => RequestAudit | null | undefined): void {
111
122
  const callTool = client.callTool.bind(client);
112
- client.callTool = (toolName: string, toolArgs: Record<string, unknown>) =>
113
- this.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped);
123
+ client.callTool = (toolName: string, toolArgs: Record<string, unknown>) => {
124
+ const audit = current();
125
+ return audit
126
+ ? audit.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped)
127
+ : callTool(toolName, toolArgs);
128
+ };
114
129
  }
115
130
 
116
131
  private skillCatalog(): Promise<readonly SkillFolder[] | null> {
@@ -589,22 +589,30 @@ export const oauthTokens = pgTable('oauth_tokens', {
589
589
  * which agent, when" — not "what was said".
590
590
  *
591
591
  * Exactly one principal per row (the CHECK): the connection key or the agent
592
- * connection the call arrived through. Both cascade, so deleting a key for
593
- * good, revoking-then-erasing a person, or dropping a connection takes its
594
- * events along — the same contract the LLM-usage rows already keep.
592
+ * connection the call arrived through, or the knowledge-base page whose
593
+ * script made the call as the person viewing it (`page_path`). Key and
594
+ * connection cascade, so deleting a key for good, revoking-then-erasing a
595
+ * person, or dropping a connection takes its events along — the same contract
596
+ * the LLM-usage rows already keep; a page's events go with the person.
595
597
  */
596
598
  export const agentEvents = pgTable('agent_events', {
597
599
  id: uuid('id').defaultRandom().primaryKey(),
598
600
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
599
601
  keyId: uuid('key_id').references(() => externalApiKeys.id, { onDelete: 'cascade' }),
600
602
  connectionId: uuid('connection_id').references(() => agentConnections.id, { onDelete: 'cascade' }),
603
+ /** The knowledge-base path of the HTML page that made the call, for a page's calls; null otherwise. */
604
+ pagePath: text('page_path'),
601
605
  /** `capability` | `tool` | `skill`. */
602
606
  kind: text('kind').notNull(),
603
607
  /** The MCP server / `.tool` manual (catalog name) for a tool, the skill folder for a skill; null for a capability. */
604
608
  manual: text('manual'),
605
609
  /** The tool's bare name, or the skill's name. */
606
610
  name: text('name').notNull(),
607
- /** `ok` | `error` | `denied` (denied: the caller's sign-in for the tool was missing, so nothing ran). */
611
+ /**
612
+ * `ok` | `error` | `denied` | `refused`. Denied: the caller's sign-in for the
613
+ * tool was missing, so nothing ran. Refused: the viewer answered a page's
614
+ * request to write with Deny, so nothing ran.
615
+ */
608
616
  outcome: text('outcome').notNull(),
609
617
  durationMs: integer('duration_ms'),
610
618
  at: timestamp('at').defaultNow().notNull(),
@@ -612,14 +620,15 @@ export const agentEvents = pgTable('agent_events', {
612
620
  // The two read paths: one principal's events, newest first.
613
621
  byKey: index('agent_events_by_key').on(t.keyId, t.at),
614
622
  byConnection: index('agent_events_by_connection').on(t.connectionId, t.at),
623
+ byPage: index('agent_events_by_page').on(t.userId, t.pagePath, t.at),
615
624
  // The retention prune, and per-user counts.
616
625
  byAt: index('agent_events_by_at').on(t.at),
617
626
  byUser: index('agent_events_by_user').on(t.userId),
618
627
  kindCheck: check('agent_events_kind', sql`${t.kind} IN ('capability', 'tool', 'skill')`),
619
- outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied')`),
628
+ outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied', 'refused')`),
620
629
  principalCheck: check(
621
630
  'agent_events_principal',
622
- sql`(${t.keyId} IS NULL) <> (${t.connectionId} IS NULL)`,
631
+ sql`num_nonnulls(${t.keyId}, ${t.connectionId}, ${t.pagePath}) = 1`,
623
632
  ),
624
633
  }));
625
634
 
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { WorkspaceIdMutex, WorkspaceMutex } from '../mutex.js';
3
+
4
+ /**
5
+ * `WorkspaceIdMutex`: a workspace id in either spelling — a route's decoded
6
+ * `alice/feature`, a tool's `alice%2Ffeature` — takes the one key.
7
+ */
8
+ describe('WorkspaceIdMutex', () => {
9
+ /** A task that signals when it starts and ends only when released. */
10
+ const held = () => {
11
+ let release: () => void = () => {};
12
+ let started: () => void = () => {};
13
+ const begun = new Promise<void>((r) => { started = r; });
14
+ const task = () =>
15
+ new Promise<void>((resolve) => {
16
+ release = resolve;
17
+ started();
18
+ });
19
+ return { task, begun, release: () => release() };
20
+ };
21
+ const settled = () => new Promise<void>((r) => setImmediate(r));
22
+
23
+ it('queues the decoded and the encoded spelling of one workspace on the same key', async () => {
24
+ const mutex = new WorkspaceIdMutex();
25
+ const first = held();
26
+ const order: string[] = [];
27
+ const a = mutex.run('alice/feature', first.task).then(() => { order.push('decoded'); });
28
+ await first.begun;
29
+ const b = mutex.run('alice%2Ffeature', async () => { order.push('encoded'); });
30
+ await settled();
31
+ // The encoded spelling waits on the decoded one's reservation: nothing
32
+ // has run until the first is released; then both do.
33
+ expect(order).toEqual([]);
34
+ first.release();
35
+ await Promise.all([a, b]);
36
+ expect([...order].sort()).toEqual(['decoded', 'encoded']);
37
+ });
38
+
39
+ it('is what the plain mutex is not: two spellings, two keys, no wait', async () => {
40
+ const mutex = new WorkspaceMutex();
41
+ const first = held();
42
+ const order: string[] = [];
43
+ const a = mutex.run('alice/feature', first.task).then(() => { order.push('decoded'); });
44
+ await first.begun;
45
+ await mutex.run('alice%2Ffeature', async () => { order.push('encoded'); });
46
+ expect(order).toEqual(['encoded']);
47
+ first.release();
48
+ await a;
49
+ });
50
+
51
+ it('leaves a protected branch (no slash, one spelling) and a malformed id as they are', async () => {
52
+ const mutex = new WorkspaceIdMutex();
53
+ const seen: string[] = [];
54
+ await mutex.runAll(['main', 'bad%'], async () => { seen.push('ran'); });
55
+ expect(seen).toEqual(['ran']);
56
+ });
57
+ });
@@ -4,5 +4,6 @@
4
4
  * workspace needs only the shape of the gate every write passes first.
5
5
  */
6
6
  export type { WriteValidator } from './locking-filesystem.js';
7
+ export { WorkspaceMutex, WorkspaceIdMutex } from './mutex.js';
7
8
  export { readRefSha, readRevSha, type RefFileAnswer } from './ref-files.js';
8
9
  export { gitDirOf, readGitfile } from './git-dir.js';
@@ -52,6 +52,7 @@ import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
52
52
  import type { FileChangeNotifier } from './file-change-notifier.js';
53
53
  import { isAbsence } from '../../shared/fs.contract.js';
54
54
  import { DestinationTakenError, lstatOrNull, renameNoReplace, takenError } from '../../shared/rename-no-replace.js';
55
+ import { linkedAncestor } from '../../shared/no-link-below.js';
55
56
  import { assertInsideRepo } from './repo-path.js';
56
57
  import { GitGuardedFilesystem } from './git-guarded-filesystem.js';
57
58
  import type { CreationGrantPlan, ICreatorAccess } from '../access-model/creator.js';
@@ -644,18 +645,8 @@ export class LockingFilesystem extends GitGuardedFilesystem {
644
645
  private async assertNoLinkBelowBase(inputPath: string): Promise<void> {
645
646
  const absolute = this.resolveAbsolutePath(inputPath);
646
647
  if (!absolute) return;
647
- const base = path.resolve(this.basePath);
648
- let current = path.resolve(absolute);
649
- const ancestors: string[] = [];
650
- while (current.startsWith(base) && current !== base) {
651
- ancestors.push(current);
652
- current = path.dirname(current);
653
- }
654
- for (const ancestor of ancestors) {
655
- const stat = await lstatOrNull(ancestor);
656
- if (stat?.isSymbolicLink()) {
657
- throw new Error(`"${inputPath}" is reached through a link, so nothing was moved.`);
658
- }
648
+ if ((await linkedAncestor(this.basePath, absolute)) !== null) {
649
+ throw new Error(`"${inputPath}" is reached through a link, so nothing was moved.`);
659
650
  }
660
651
  }
661
652
 
@@ -1,3 +1,5 @@
1
+ import { canonicalWorkspaceId } from '../../shared/workspace-id.js';
2
+
1
3
  /**
2
4
  * Serializes async operations by key. Two `run(k, ...)` calls with the same key
3
5
  * execute sequentially in call order; different keys run independently.
@@ -65,3 +67,17 @@ export class WorkspaceMutex {
65
67
  }
66
68
  }
67
69
  }
70
+
71
+ /**
72
+ * A `WorkspaceMutex` whose keys are WORKSPACE IDS, taken in their canonical
73
+ * spelling: a route's Express-decoded `:id` (`alice/feature`) and a tool's
74
+ * `alice%2Ffeature` name the same clone and must queue on the same key — two
75
+ * spellings would let a route's read run beside a tool's merge of the same
76
+ * checkout. `canonicalWorkspaceId` collapses both to the encoded form every
77
+ * other keyed store (the file locks, the pending commits) already uses.
78
+ */
79
+ export class WorkspaceIdMutex extends WorkspaceMutex {
80
+ override async runAll<T>(keys: string[], fn: () => Promise<T>): Promise<T> {
81
+ return super.runAll(keys.map(canonicalWorkspaceId), fn);
82
+ }
83
+ }
@@ -2,6 +2,7 @@ import type { Server as HttpServer } from 'node:http';
2
2
  import type { RequestHandler } from 'express';
3
3
  import express from 'express';
4
4
  import { createMcpRoutes } from '../mcp.routes.js';
5
+ import type { McpCallerKind } from '../mcp-auth.middleware.js';
5
6
 
6
7
  /**
7
8
  * The shared mount for every `createMcpRoutes` suite.
@@ -19,7 +20,8 @@ import { createMcpRoutes } from '../mcp.routes.js';
19
20
  */
20
21
 
21
22
  /**
22
- * Auth stand-in binding the user named by `x-test-user`, defaulting to user-A.
23
+ * Auth stand-in binding the user named by `x-test-user`, defaulting to user-A,
24
+ * and the credential kind named by `x-test-kind`, when one is.
23
25
  *
24
26
  * The header lets a suite act as a second user (the cross-user checks need
25
27
  * one); suites that never send it see the single fixed user their assertions
@@ -27,6 +29,10 @@ import { createMcpRoutes } from '../mcp.routes.js';
27
29
  */
28
30
  export const fakeAuth: RequestHandler = (req, _res, next) => {
29
31
  req.userId = (req.headers['x-test-user'] as string | undefined) ?? 'user-A';
32
+ // The credential kind the real middleware would have recognised, when a
33
+ // suite needs one (the page header is a session's alone).
34
+ const kind = req.headers['x-test-kind'] as McpCallerKind | undefined;
35
+ if (kind) req.mcpCallerKind = kind;
30
36
  next();
31
37
  };
32
38
 
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import type { McpCaller } from '../mcp.service.js';
5
+ import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
6
+
7
+ /**
8
+ * The page a call comes from: an HTML page of the knowledge base whose script
9
+ * calls the viewer's tools through the app. The app names it in
10
+ * `X-Bevel-Page`, and only the app — a session — may: a key, an OAuth token or
11
+ * an internal token belongs to an agent with an Audit log row of its own.
12
+ */
13
+
14
+ afterEach(async () => {
15
+ await closeMountedRoutes();
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ function makeMcpService() {
20
+ const callers: McpCaller[] = [];
21
+ return {
22
+ callers,
23
+ createRequestServer: vi.fn(async (caller: McpCaller) => {
24
+ callers.push(caller);
25
+ const server = new Server({ name: 'stub', version: '0' }, { capabilities: { tools: {} } });
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
27
+ return server;
28
+ }),
29
+ recordPageRefusal: vi.fn(async () => {}),
30
+ };
31
+ }
32
+
33
+ const TOOLS_LIST = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
34
+ const PAGE = 'Dashboards/Road map — Q4.html';
35
+
36
+ function post(baseUrl: string, path: string, body: unknown, headers: Record<string, string>) {
37
+ return fetch(`${baseUrl}/api${path}`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
40
+ body: JSON.stringify(body),
41
+ });
42
+ }
43
+
44
+ describe('POST /mcp with X-Bevel-Page', () => {
45
+ it("hands a session's page to the service, decoded", async () => {
46
+ const mcpService = makeMcpService();
47
+ const baseUrl = await mountMcpRoutes({ mcpService });
48
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent(PAGE) });
49
+ expect(res.status).toBe(200);
50
+ expect(mcpService.callers[0]).toMatchObject({ userId: 'user-A', page: PAGE });
51
+ });
52
+
53
+ it.each(['key', 'oauth', 'internal'])('refuses the header from a %s caller, building nothing', async (kind) => {
54
+ const mcpService = makeMcpService();
55
+ const baseUrl = await mountMcpRoutes({ mcpService });
56
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': kind, 'x-bevel-page': encodeURIComponent(PAGE) });
57
+ expect(res.status).toBe(400);
58
+ expect(((await res.json()) as { error: { message: string } }).error.message).toContain('X-Bevel-Page');
59
+ expect(mcpService.createRequestServer).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('refuses a page that is not a path', async () => {
63
+ const mcpService = makeMcpService();
64
+ const baseUrl = await mountMcpRoutes({ mcpService });
65
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent('a\nb') });
66
+ expect(res.status).toBe(400);
67
+ });
68
+
69
+ it('names no page when the header is absent, as before', async () => {
70
+ const mcpService = makeMcpService();
71
+ const baseUrl = await mountMcpRoutes({ mcpService });
72
+ await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session' });
73
+ expect(mcpService.callers[0]).not.toHaveProperty('page');
74
+ });
75
+ });
76
+
77
+ describe('POST /mcp/page-refusals', () => {
78
+ it("records a session's refused call under its page", async () => {
79
+ const mcpService = makeMcpService();
80
+ const baseUrl = await mountMcpRoutes({ mcpService });
81
+ const res = await post(
82
+ baseUrl,
83
+ '/mcp/page-refusals',
84
+ { page: PAGE, tool: 'edit_file', args: { body: { path: 'a.md' } } },
85
+ { 'x-test-kind': 'session', Authorization: 'Bearer eyJ.session' },
86
+ );
87
+ expect(res.status).toBe(204);
88
+ expect(mcpService.recordPageRefusal).toHaveBeenCalledWith(
89
+ { userId: 'user-A', bearer: 'eyJ.session' },
90
+ PAGE,
91
+ 'edit_file',
92
+ { body: { path: 'a.md' } },
93
+ );
94
+ });
95
+
96
+ it('is the app\'s alone: an agent credential is refused', async () => {
97
+ const mcpService = makeMcpService();
98
+ const baseUrl = await mountMcpRoutes({ mcpService });
99
+ const res = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool: 'edit_file' }, { 'x-test-kind': 'key' });
100
+ expect(res.status).toBe(403);
101
+ expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('wants a page and a tool', async () => {
105
+ const mcpService = makeMcpService();
106
+ const baseUrl = await mountMcpRoutes({ mcpService });
107
+ const res = await post(baseUrl, '/mcp/page-refusals', { tool: 'edit_file' }, { 'x-test-kind': 'session' });
108
+ expect(res.status).toBe(400);
109
+ for (const tool of [undefined, '']) {
110
+ const noTool = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool }, { 'x-test-kind': 'session' });
111
+ expect(noTool.status).toBe(400);
112
+ }
113
+ expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
114
+ });
115
+ });