@bevel-software/platform-core-backend 0.19.0 → 0.20.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 (255) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +22 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +10 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/audit/agent-audit.service.d.ts +54 -0
  9. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
  10. package/dist/modules/audit/agent-audit.service.js +290 -0
  11. package/dist/modules/audit/agent-audit.service.js.map +1 -0
  12. package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
  13. package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
  14. package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
  15. package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
  16. package/dist/modules/audit/audit.contract.d.ts +145 -0
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -0
  18. package/dist/modules/audit/audit.contract.js +35 -0
  19. package/dist/modules/audit/audit.contract.js.map +1 -0
  20. package/dist/modules/audit/audit.routes.d.ts +21 -0
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -0
  22. package/dist/modules/audit/audit.routes.js +157 -0
  23. package/dist/modules/audit/audit.routes.js.map +1 -0
  24. package/dist/modules/audit/event-classifier.d.ts +52 -0
  25. package/dist/modules/audit/event-classifier.d.ts.map +1 -0
  26. package/dist/modules/audit/event-classifier.js +123 -0
  27. package/dist/modules/audit/event-classifier.js.map +1 -0
  28. package/dist/modules/audit/index.d.ts +7 -0
  29. package/dist/modules/audit/index.d.ts.map +1 -0
  30. package/dist/modules/audit/index.js +7 -0
  31. package/dist/modules/audit/index.js.map +1 -0
  32. package/dist/modules/audit/request-audit.d.ts +57 -0
  33. package/dist/modules/audit/request-audit.d.ts.map +1 -0
  34. package/dist/modules/audit/request-audit.js +117 -0
  35. package/dist/modules/audit/request-audit.js.map +1 -0
  36. package/dist/modules/database/core-schema.d.ts +414 -0
  37. package/dist/modules/database/core-schema.d.ts.map +1 -1
  38. package/dist/modules/database/core-schema.js +99 -0
  39. package/dist/modules/database/core-schema.js.map +1 -1
  40. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  41. package/dist/modules/diff/diff.service.js +6 -1
  42. package/dist/modules/diff/diff.service.js.map +1 -1
  43. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
  44. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
  45. package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
  46. package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
  47. package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
  48. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
  50. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  51. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  52. package/dist/modules/mcp/mcp.routes.js +8 -1
  53. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  54. package/dist/modules/mcp/mcp.service.d.ts +9 -1
  55. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  56. package/dist/modules/mcp/mcp.service.js +48 -5
  57. package/dist/modules/mcp/mcp.service.js.map +1 -1
  58. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
  59. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  60. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
  61. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  62. package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
  63. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  64. package/dist/modules/settings/deployment-settings.service.js +32 -6
  65. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  66. package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
  67. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  68. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  69. package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
  70. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  71. package/dist/modules/tool-auth/external-api-key.service.js +22 -9
  72. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  73. package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
  74. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  75. package/dist/modules/tool-auth/internal-token.service.js +1 -0
  76. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  77. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
  78. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  81. package/dist/modules/workspace/workspace.service.d.ts +8 -0
  82. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  83. package/dist/modules/workspace/workspace.service.js +9 -0
  84. package/dist/modules/workspace/workspace.service.js.map +1 -1
  85. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  86. package/dist/modules/workspace/workspace.tools.js +49 -13
  87. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  88. package/dist/shared/git-internals.d.ts +7 -1
  89. package/dist/shared/git-internals.d.ts.map +1 -1
  90. package/dist/shared/git-internals.js +67 -9
  91. package/dist/shared/git-internals.js.map +1 -1
  92. package/migrations/0012_agent_audit.sql +41 -0
  93. package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
  94. package/migrations/meta/0012_snapshot.json +2247 -0
  95. package/migrations/meta/0013_snapshot.json +2259 -0
  96. package/migrations/meta/_journal.json +14 -0
  97. package/package.json +4 -4
  98. package/src/core/create-core-server.ts +28 -0
  99. package/src/core/create-core-services.ts +13 -0
  100. package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
  101. package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
  102. package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
  103. package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
  104. package/src/modules/audit/agent-audit.service.ts +316 -0
  105. package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
  106. package/src/modules/audit/audit.contract.ts +164 -0
  107. package/src/modules/audit/audit.routes.ts +168 -0
  108. package/src/modules/audit/event-classifier.ts +155 -0
  109. package/src/modules/audit/index.ts +14 -0
  110. package/src/modules/audit/request-audit.ts +141 -0
  111. package/src/modules/database/core-schema.ts +104 -0
  112. package/src/modules/diff/diff.service.ts +6 -1
  113. package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
  114. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
  115. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
  116. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
  117. package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
  118. package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
  119. package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
  120. package/src/modules/mcp/mcp.routes.ts +11 -1
  121. package/src/modules/mcp/mcp.service.ts +62 -4
  122. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
  123. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
  124. package/src/modules/settings/deployment-settings.service.ts +47 -8
  125. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
  126. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
  127. package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
  128. package/src/modules/tool-auth/external-api-key.service.ts +25 -8
  129. package/src/modules/tool-auth/internal-token.service.ts +11 -0
  130. package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
  131. package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
  132. package/src/modules/workspace/workspace.service.ts +9 -0
  133. package/src/modules/workspace/workspace.tools.ts +47 -11
  134. package/src/shared/__tests__/git-internals.test.ts +41 -0
  135. package/src/shared/git-internals.ts +61 -6
  136. package/dist/modules/access/access-errors.d.ts +0 -34
  137. package/dist/modules/access/access-errors.d.ts.map +0 -1
  138. package/dist/modules/access/access-errors.js +0 -40
  139. package/dist/modules/access/access-errors.js.map +0 -1
  140. package/dist/modules/access/access-splice.d.ts +0 -140
  141. package/dist/modules/access/access-splice.d.ts.map +0 -1
  142. package/dist/modules/access/access-splice.js +0 -389
  143. package/dist/modules/access/access-splice.js.map +0 -1
  144. package/dist/modules/access/group-files.d.ts +0 -83
  145. package/dist/modules/access/group-files.d.ts.map +0 -1
  146. package/dist/modules/access/group-files.js +0 -167
  147. package/dist/modules/access/group-files.js.map +0 -1
  148. package/dist/modules/access/kb-read-filter.d.ts +0 -41
  149. package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
  150. package/dist/modules/access/kb-read-filter.js +0 -60
  151. package/dist/modules/access/kb-read-filter.js.map +0 -1
  152. package/dist/modules/access/render-roles-yaml.d.ts +0 -22
  153. package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
  154. package/dist/modules/access/render-roles-yaml.js +0 -56
  155. package/dist/modules/access/render-roles-yaml.js.map +0 -1
  156. package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
  157. package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
  158. package/dist/modules/access/roles-yaml-guard.js +0 -79
  159. package/dist/modules/access/roles-yaml-guard.js.map +0 -1
  160. package/dist/modules/diff/diff-paths.d.ts +0 -2
  161. package/dist/modules/diff/diff-paths.d.ts.map +0 -1
  162. package/dist/modules/diff/diff-paths.js +0 -9
  163. package/dist/modules/diff/diff-paths.js.map +0 -1
  164. package/dist/modules/groups/group-provision.service.d.ts +0 -130
  165. package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
  166. package/dist/modules/groups/group-provision.service.js +0 -288
  167. package/dist/modules/groups/group-provision.service.js.map +0 -1
  168. package/dist/modules/groups/groups.contract.d.ts +0 -106
  169. package/dist/modules/groups/groups.contract.d.ts.map +0 -1
  170. package/dist/modules/groups/groups.contract.js +0 -36
  171. package/dist/modules/groups/groups.contract.js.map +0 -1
  172. package/dist/modules/groups/groups.routes.d.ts +0 -42
  173. package/dist/modules/groups/groups.routes.d.ts.map +0 -1
  174. package/dist/modules/groups/groups.routes.js +0 -379
  175. package/dist/modules/groups/groups.routes.js.map +0 -1
  176. package/dist/modules/groups/groups.service.d.ts +0 -60
  177. package/dist/modules/groups/groups.service.d.ts.map +0 -1
  178. package/dist/modules/groups/groups.service.js +0 -172
  179. package/dist/modules/groups/groups.service.js.map +0 -1
  180. package/dist/modules/groups/index.d.ts +0 -7
  181. package/dist/modules/groups/index.d.ts.map +0 -1
  182. package/dist/modules/groups/index.js +0 -6
  183. package/dist/modules/groups/index.js.map +0 -1
  184. package/dist/modules/groups/join-proposals.d.ts +0 -53
  185. package/dist/modules/groups/join-proposals.d.ts.map +0 -1
  186. package/dist/modules/groups/join-proposals.js +0 -67
  187. package/dist/modules/groups/join-proposals.js.map +0 -1
  188. package/dist/modules/groups/join-requests.service.d.ts +0 -81
  189. package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
  190. package/dist/modules/groups/join-requests.service.js +0 -135
  191. package/dist/modules/groups/join-requests.service.js.map +0 -1
  192. package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
  193. package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
  194. package/dist/modules/mcp/mcp-session-store.js +0 -131
  195. package/dist/modules/mcp/mcp-session-store.js.map +0 -1
  196. package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
  197. package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
  198. package/dist/modules/workflow/file-change-notifier.js +0 -22
  199. package/dist/modules/workflow/file-change-notifier.js.map +0 -1
  200. package/dist/modules/workflow/git/branch-name.d.ts +0 -10
  201. package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
  202. package/dist/modules/workflow/git/branch-name.js +0 -76
  203. package/dist/modules/workflow/git/branch-name.js.map +0 -1
  204. package/dist/modules/workflow/git/clone-config.d.ts +0 -61
  205. package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
  206. package/dist/modules/workflow/git/clone-config.js +0 -69
  207. package/dist/modules/workflow/git/clone-config.js.map +0 -1
  208. package/dist/modules/workflow/git/mutex.d.ts +0 -11
  209. package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
  210. package/dist/modules/workflow/git/mutex.js +0 -23
  211. package/dist/modules/workflow/git/mutex.js.map +0 -1
  212. package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
  213. package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
  214. package/dist/modules/workflow/locking-filesystem.js +0 -553
  215. package/dist/modules/workflow/locking-filesystem.js.map +0 -1
  216. package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
  217. package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
  218. package/dist/modules/workflow/read-only-filesystem.js +0 -39
  219. package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
  220. package/dist/modules/workflow/workflow.errors.d.ts +0 -197
  221. package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
  222. package/dist/modules/workflow/workflow.errors.js +0 -298
  223. package/dist/modules/workflow/workflow.errors.js.map +0 -1
  224. package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
  225. package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
  226. package/dist/modules/workspace/bevel-ignore.js +0 -61
  227. package/dist/modules/workspace/bevel-ignore.js.map +0 -1
  228. package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
  229. package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
  230. package/dist/modules/workspace/kb-seed.interface.js +0 -2
  231. package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
  232. package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
  233. package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
  234. package/dist/modules/workspace/kb-seed.service.js +0 -535
  235. package/dist/modules/workspace/kb-seed.service.js.map +0 -1
  236. package/dist/modules/workspace/plugins-migration.d.ts +0 -50
  237. package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
  238. package/dist/modules/workspace/plugins-migration.js +0 -379
  239. package/dist/modules/workspace/plugins-migration.js.map +0 -1
  240. package/dist/shared/fs-errors.d.ts +0 -8
  241. package/dist/shared/fs-errors.d.ts.map +0 -1
  242. package/dist/shared/fs-errors.js +0 -11
  243. package/dist/shared/fs-errors.js.map +0 -1
  244. package/dist/shared/fs-walk.d.ts +0 -23
  245. package/dist/shared/fs-walk.d.ts.map +0 -1
  246. package/dist/shared/fs-walk.js +0 -42
  247. package/dist/shared/fs-walk.js.map +0 -1
  248. package/dist/shared/hash-email.d.ts +0 -11
  249. package/dist/shared/hash-email.d.ts.map +0 -1
  250. package/dist/shared/hash-email.js +0 -14
  251. package/dist/shared/hash-email.js.map +0 -1
  252. package/dist/shared/kb-walk.d.ts +0 -52
  253. package/dist/shared/kb-walk.d.ts.map +0 -1
  254. package/dist/shared/kb-walk.js +0 -76
  255. package/dist/shared/kb-walk.js.map +0 -1
@@ -0,0 +1,141 @@
1
+ import type { CodeModeUtcpClient } from '@utcp/code-mode';
2
+ import { logger } from '../../shared/logging.js';
3
+ import type { AgentEventOutcome, AuditPrincipalRef, IAgentEventRecorder } from './audit.contract.js';
4
+ import {
5
+ classifyToolCall,
6
+ skillReadPath,
7
+ type ClassifiedCall,
8
+ type ClassifierContext,
9
+ type SkillFolder,
10
+ } from './event-classifier.js';
11
+
12
+ const log = logger('audit');
13
+
14
+ /**
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.
21
+ *
22
+ * Holds nothing that outlives the request. The skill catalog it may need to
23
+ * tell a skill read from a file read is fetched at most once, and only when
24
+ * a platform read names a path.
25
+ */
26
+ export class RequestAudit {
27
+ private skills: Promise<readonly SkillFolder[] | null> | undefined;
28
+
29
+ constructor(
30
+ private readonly recorder: IAgentEventRecorder,
31
+ private readonly userId: string,
32
+ private readonly principal: AuditPrincipalRef,
33
+ private readonly ctx: {
34
+ kbManualName: string;
35
+ /** Read at classification time — the surface (and its catalog names) is built lazily. */
36
+ catalogNames: () => ReadonlyMap<string, string>;
37
+ /** The caller's skill catalog, for telling a skill read from a file read. */
38
+ loadSkills: () => Promise<readonly SkillFolder[]>;
39
+ },
40
+ ) {}
41
+
42
+ /** Record one already-classified call. Never throws. */
43
+ emit(call: ClassifiedCall, outcome: AgentEventOutcome, durationMs: number | null): void {
44
+ try {
45
+ this.recorder.record({
46
+ userId: this.userId,
47
+ principal: this.principal,
48
+ kind: call.kind,
49
+ manual: call.manual,
50
+ name: call.name,
51
+ outcome,
52
+ durationMs,
53
+ });
54
+ } catch (err) {
55
+ log.warn('recording an agent event failed:', { err });
56
+ }
57
+ }
58
+
59
+ /** What a UTCP call is logged as — fetching the skill catalog first when the call might be a skill read. */
60
+ async classify(utcpName: string, args: Record<string, unknown>): Promise<ClassifiedCall> {
61
+ const ctx: ClassifierContext = { kbManualName: this.ctx.kbManualName, catalogNames: this.ctx.catalogNames() };
62
+ const wantsSkills = skillReadPath(utcpName, args, ctx.kbManualName) !== null;
63
+ const skills = wantsSkills ? await this.skillCatalog() : null;
64
+ return classifyToolCall(utcpName, args, ctx, skills);
65
+ }
66
+
67
+ /**
68
+ * Run a UTCP call and record it: `error` when it throws or `isError` says
69
+ * the result is one, `ok` otherwise. The result (or the throw) reaches the
70
+ * caller unchanged — recording is a side effect, never a filter.
71
+ *
72
+ * The call starts FIRST and is classified alongside: naming a read may cost
73
+ * a loopback fetch of the skill catalog, and that is the log's business,
74
+ * not the tool's — a tool result never waits on its own bookkeeping.
75
+ */
76
+ async call<T>(
77
+ utcpName: string,
78
+ args: Record<string, unknown>,
79
+ run: () => Promise<T>,
80
+ isError: (result: T) => boolean,
81
+ ): Promise<T> {
82
+ const started = performance.now();
83
+ const classified = this.classify(utcpName, args);
84
+ try {
85
+ const result = await run();
86
+ const duration = elapsed(started);
87
+ this.emit(await classified, isError(result) ? 'error' : 'ok', duration);
88
+ return result;
89
+ } catch (err) {
90
+ const duration = elapsed(started);
91
+ this.emit(await classified, 'error', duration);
92
+ throw err;
93
+ }
94
+ }
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);
99
+ }
100
+
101
+ /**
102
+ * Record every tool call made from INSIDE `call_tool_chain`: the chain
103
+ * bridges each in-isolate tool function to `client.callTool`, so wrapping
104
+ * that one entry point catches them all. Installed after the downstream
105
+ * routing wrapper so the timing covers the pool lease and any retry too.
106
+ * The direct MCP path (`callToolStreaming`) is deliberately NOT wrapped —
107
+ * the proxy's tool handler records those itself, with the result's
108
+ * `isError` in hand.
109
+ */
110
+ instrumentChainCalls(client: CodeModeUtcpClient): void {
111
+ const callTool = client.callTool.bind(client);
112
+ client.callTool = (toolName: string, toolArgs: Record<string, unknown>) =>
113
+ this.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped);
114
+ }
115
+
116
+ private skillCatalog(): Promise<readonly SkillFolder[] | null> {
117
+ // A failed fetch is remembered as "no catalog" for the rest of the
118
+ // request: the read is then logged as a capability, and one hiccup does
119
+ // not cost a loopback round-trip per subsequent call.
120
+ this.skills ??= this.ctx.loadSkills().catch((err) => {
121
+ log.warn('skill catalog unavailable for audit classification:', { err });
122
+ return null;
123
+ });
124
+ return this.skills;
125
+ }
126
+ }
127
+
128
+ function elapsed(started: number): number {
129
+ return Math.max(0, Math.round(performance.now() - started));
130
+ }
131
+
132
+ /**
133
+ * A chain-internal result that is the tool saying it failed. A downstream
134
+ * MCP server answers a failed call with `{ isError: true, content }` as a
135
+ * VALUE (the UTCP mcp protocol hands it through rather than throwing), and
136
+ * the direct path records that as an error — so must the chain path, or the
137
+ * same failure would read `ok` inside a chain and `error` outside one.
138
+ */
139
+ function isErrorShaped(result: unknown): boolean {
140
+ return typeof result === 'object' && result !== null && (result as { isError?: unknown }).isError === true;
141
+ }
@@ -211,6 +211,16 @@ export const externalApiKeys = pgTable('api_tokens', {
211
211
  * reconnect a key that was taken from them.
212
212
  */
213
213
  revokedBy: text('revoked_by'),
214
+ /**
215
+ * When the owner deleted the key "for good" from their own pages. A
216
+ * DELETE in name only: the row stays, because the Audit log's events hang
217
+ * off it and a log its subject can erase is not one. Hidden from the
218
+ * owner's listings, shown to admins as deleted; gone for real only when
219
+ * the account is erased (the cascade) or its events have long been
220
+ * pruned. Always set on an already-revoked row — a live key is never
221
+ * deleted in one step.
222
+ */
223
+ deletedAt: timestamp('deleted_at'),
214
224
  }, (t) => ({
215
225
  byUser: index('api_tokens_by_user').on(t.userId),
216
226
  }));
@@ -446,12 +456,58 @@ export const oauthAuthCodes = pgTable('oauth_auth_codes', {
446
456
  byExpiry: index('oauth_auth_codes_by_expiry').on(t.expiresAt),
447
457
  }));
448
458
 
459
+ /**
460
+ * "This person connected this agent" — the durable record behind the Audit
461
+ * log's agent rows. An OAuth grant cannot be that record: its token row is
462
+ * replaced on every refresh (hourly) and pruned once its refresh window
463
+ * closes, so nothing about it outlives a month. This row is made the first
464
+ * time a token is minted for a (user, client) pair, kept across every refresh
465
+ * (each new token row points at it), and REVOKED rather than deleted when the
466
+ * person or an admin cuts the agent off — its events stay readable under it.
467
+ *
468
+ * A connection is the AGENT, per person — not the OAuth client registration.
469
+ * Claude registers a fresh client on every re-authorisation (dynamic client
470
+ * registration mints a new id each time), so keying by registration gave one
471
+ * person five "Claude" rows for one agent. `agent_key` is what a connection is
472
+ * keyed by instead: the registered client name, folded (a name-less client
473
+ * falls back to its id), so every registration of "Claude" by one person
474
+ * lands on the same live row; `client_id` records the registration that
475
+ * first made it. `client_name` is that registration's name as shown.
476
+ *
477
+ * One LIVE connection per (user, agent): the partial unique index below. A
478
+ * reconnect after a revoke is a new row, so the old one's history and its
479
+ * "revoked by an admin" mark are never overwritten.
480
+ */
481
+ export const agentConnections = pgTable('agent_connections', {
482
+ id: uuid('id').defaultRandom().primaryKey(),
483
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
484
+ clientId: text('client_id').notNull().references(() => oauthClients.clientId),
485
+ clientName: text('client_name'),
486
+ /** The folded client name, or the client id when the registration carried none. */
487
+ agentKey: text('agent_key').notNull(),
488
+ connectedAt: timestamp('connected_at').defaultNow().notNull(),
489
+ lastUsedAt: timestamp('last_used_at'),
490
+ revokedAt: timestamp('revoked_at'),
491
+ /** `owner` | `admin` — who ended it; null while live. Same vocabulary as `api_tokens.revoked_by`. */
492
+ revokedBy: text('revoked_by'),
493
+ }, (t) => ({
494
+ byUser: index('agent_connections_by_user').on(t.userId),
495
+ liveUnq: uniqueIndex('agent_connections_live_unq')
496
+ .on(t.userId, t.agentKey)
497
+ .where(sql`${t.revokedAt} is null`),
498
+ }));
499
+
449
500
  /**
450
501
  * OAuth access/refresh token pairs minted by the token endpoint. Like
451
502
  * `api_tokens`: plaintext shown only in the token response, SHA-256 hashes
452
503
  * stored, revoked-not-deleted so `last_used_at` keeps its audit value and a
453
504
  * revoked token can't be re-issued. One row per pair; refresh rotation
454
505
  * revokes the old row and inserts a new one.
506
+ *
507
+ * `connection_id` names the {@link agentConnections} row the pair belongs to,
508
+ * so every call made with the token is attributed to that agent and revoking
509
+ * the agent can revoke exactly its tokens. Null only on rows minted before
510
+ * the column existed.
455
511
  */
456
512
  export const oauthTokens = pgTable('oauth_tokens', {
457
513
  id: uuid('id').defaultRandom().primaryKey(),
@@ -459,6 +515,7 @@ export const oauthTokens = pgTable('oauth_tokens', {
459
515
  refreshTokenHash: text('refresh_token_hash').unique(),
460
516
  clientId: text('client_id').notNull().references(() => oauthClients.clientId),
461
517
  userId: uuid('user_id').notNull().references(() => users.id),
518
+ connectionId: uuid('connection_id').references(() => agentConnections.id, { onDelete: 'cascade' }),
462
519
  scope: text('scope'),
463
520
  resource: text('resource'),
464
521
  expiresAt: timestamp('expires_at').notNull(),
@@ -469,6 +526,53 @@ export const oauthTokens = pgTable('oauth_tokens', {
469
526
  }, (t) => ({
470
527
  byUser: index('oauth_tokens_by_user').on(t.userId),
471
528
  byExpiry: index('oauth_tokens_by_expiry').on(t.expiresAt),
529
+ byConnection: index('oauth_tokens_by_connection').on(t.connectionId),
530
+ }));
531
+
532
+ /**
533
+ * The Audit log's events: one row per thing an external agent called through
534
+ * the platform — a hexis capability (the platform's own tools, `read_file`,
535
+ * `call_tool_chain`, …), a tool from a `.tool` manual or a connected MCP
536
+ * server, or a skill it read. Append-only; pruned past the deployment's
537
+ * retention window (the `auditRetentionDays` setting).
538
+ *
539
+ * Deliberately WITHOUT arguments or results: a call's inputs can carry
540
+ * secrets and personal data, and the log's question is "what was used, by
541
+ * which agent, when" — not "what was said".
542
+ *
543
+ * Exactly one principal per row (the CHECK): the connection key or the agent
544
+ * connection the call arrived through. Both cascade, so deleting a key for
545
+ * good, revoking-then-erasing a person, or dropping a connection takes its
546
+ * events along — the same contract the LLM-usage rows already keep.
547
+ */
548
+ export const agentEvents = pgTable('agent_events', {
549
+ id: uuid('id').defaultRandom().primaryKey(),
550
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
551
+ keyId: uuid('key_id').references(() => externalApiKeys.id, { onDelete: 'cascade' }),
552
+ connectionId: uuid('connection_id').references(() => agentConnections.id, { onDelete: 'cascade' }),
553
+ /** `capability` | `tool` | `skill`. */
554
+ kind: text('kind').notNull(),
555
+ /** The MCP server / `.tool` manual (catalog name) for a tool, the skill folder for a skill; null for a capability. */
556
+ manual: text('manual'),
557
+ /** The tool's bare name, or the skill's name. */
558
+ name: text('name').notNull(),
559
+ /** `ok` | `error` | `denied` (denied: the caller's sign-in for the tool was missing, so nothing ran). */
560
+ outcome: text('outcome').notNull(),
561
+ durationMs: integer('duration_ms'),
562
+ at: timestamp('at').defaultNow().notNull(),
563
+ }, (t) => ({
564
+ // The two read paths: one principal's events, newest first.
565
+ byKey: index('agent_events_by_key').on(t.keyId, t.at),
566
+ byConnection: index('agent_events_by_connection').on(t.connectionId, t.at),
567
+ // The retention prune, and per-user counts.
568
+ byAt: index('agent_events_by_at').on(t.at),
569
+ byUser: index('agent_events_by_user').on(t.userId),
570
+ kindCheck: check('agent_events_kind', sql`${t.kind} IN ('capability', 'tool', 'skill')`),
571
+ outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied')`),
572
+ principalCheck: check(
573
+ 'agent_events_principal',
574
+ sql`(${t.keyId} IS NULL) <> (${t.connectionId} IS NULL)`,
575
+ ),
472
576
  }));
473
577
 
474
578
  export const sessionOntologyTouches = pgTable('session_ontology_touches', {
@@ -350,7 +350,12 @@ export class DiffService implements IDiffService {
350
350
  // reject, the ledger sync — so the git folder is refused for all of them
351
351
  // in one place, links into it included. FIRST, so a path into the folder
352
352
  // gets the one sanitized refusal rather than a containment error.
353
- await assertNotGitInternals(workspaceDir, relativePath, fileAbs);
353
+ //
354
+ // The caller's SPELLING is what is judged, not `fileAbs`: `path.resolve`
355
+ // reads a backslash as an ordinary character and carries a leading climb
356
+ // out of the workspace, so a link reached either way resolved to nothing
357
+ // here and passed. The check reads every way the spelling could land.
358
+ await assertNotGitInternals(workspaceDir, relativePath);
354
359
  assertWithinDirectory(fileAbs, workspaceDir);
355
360
  assertWithinDirectory(backupAbs, backupDir);
356
361
  return { workspaceDir, backupDir, fileAbs, backupAbs };
@@ -24,10 +24,23 @@ import { assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-
24
24
  * the floor under them, for whatever reaches the filesystem another way.
25
25
  */
26
26
  export class GitGuardedFilesystem extends LocalFilesystem {
27
- /** Refuse `inputPath` when it names the git folder or resolves into it. */
27
+ /**
28
+ * Refuse `inputPath` when it names the git folder or resolves into it.
29
+ *
30
+ * The caller's SPELLING is judged first, every way it could land: this
31
+ * filesystem's own resolver reads a backslash as an ordinary character and
32
+ * carries a leading climb out of the workspace, so a link reached either way
33
+ * used to resolve to nothing and pass. Where this filesystem would actually
34
+ * put the path is then judged too, when that is somewhere else again.
35
+ */
28
36
  async assertNotGitInternals(inputPath: string): Promise<void> {
29
- const absolutePath = this.resolveAbsolutePath(inputPath) ?? path.resolve(this.basePath, inputPath.replace(/^[\\/]+/, ''));
30
- await assertNotGitInternals(this.basePath, inputPath, absolutePath);
37
+ // ONE call carrying both the spelling and the place this filesystem
38
+ // would put it. Both are still judged — a link this resolver cannot
39
+ // place (a backslash-spelled one, a leading climb) is caught by the
40
+ // spelling's own readings, a link it follows by the place — but the rule
41
+ // walks the root once and probes a place both name once, where two calls
42
+ // walked the root twice and probed that place twice.
43
+ await assertNotGitInternals(this.basePath, inputPath, this.resolveAbsolutePath(inputPath));
31
44
  }
32
45
 
33
46
  override async readFile(inputPath: string, options?: ReadOptions): Promise<string | Buffer> {
@@ -2,9 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { InvalidGrantError, InvalidScopeError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
4
4
  import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
5
- import { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
5
+ import type { SQL } from 'drizzle-orm';
6
+ import { PgDialect } from 'drizzle-orm/pg-core';
7
+ import { BevelOAuthProvider, agentKeyFor } from '../oauth/bevel-oauth-provider.js';
6
8
  import { signAuthRequest, verifyAuthRequest } from '../oauth/oauth-state.js';
7
9
  import type { Database } from '../../database/connection.js';
10
+ import { agentConnections, oauthTokens } from '../../database/schema.js';
8
11
 
9
12
  const STATE_SECRET = 'test-state-secret';
10
13
  const PREFIX = 'bevel-mcp_';
@@ -12,9 +15,19 @@ const FRONTEND = 'https://app.example.com';
12
15
 
13
16
  /** Same fake drizzle chain as external-api-key.service.test.ts: every
14
17
  * chainable method returns the chain, the chain is thenable, and each
15
- * db.insert/select/update pops the next queued result. */
18
+ * db.insert/select/update pops the next queued result. What the provider
19
+ * WRITES and WHERE it writes it are captured too — `set`, `values`, the
20
+ * `update` target and every `where` predicate (rendered to SQL by
21
+ * {@link render}) — so a test can pin the row a statement lands on, not
22
+ * merely that a statement ran. */
16
23
  function makeFakeDb(queue: any[]) {
17
- const captured: { values: any[]; set: any[] } = { values: [], set: [] };
24
+ const captured: {
25
+ values: any[];
26
+ set: any[];
27
+ where: SQL[];
28
+ updateTargets: unknown[];
29
+ onConflict: any[];
30
+ } = { values: [], set: [], where: [], updateTargets: [], onConflict: [] };
18
31
 
19
32
  function nextChain() {
20
33
  const result = queue.shift();
@@ -26,11 +39,13 @@ function makeFakeDb(queue: any[]) {
26
39
  });
27
40
  chain.values = passthrough((a) => captured.values.push(a[0]));
28
41
  chain.set = passthrough((a) => captured.set.push(a[0]));
29
- chain.where = passthrough();
42
+ chain.where = passthrough((a) => captured.where.push(a[0]));
30
43
  chain.limit = passthrough();
31
44
  chain.innerJoin = passthrough();
45
+ chain.leftJoin = passthrough();
32
46
  chain.from = passthrough();
33
47
  chain.returning = passthrough();
48
+ chain.onConflictDoNothing = passthrough((a) => captured.onConflict.push(a[0]));
34
49
  chain.then = (onF: any, onR: any) => Promise.resolve(result).then(onF, onR);
35
50
  return chain;
36
51
  }
@@ -38,12 +53,23 @@ function makeFakeDb(queue: any[]) {
38
53
  const db = {
39
54
  insert: vi.fn(() => nextChain()),
40
55
  select: vi.fn(() => nextChain()),
41
- update: vi.fn(() => nextChain()),
56
+ update: vi.fn((table: unknown) => {
57
+ captured.updateTargets.push(table);
58
+ return nextChain();
59
+ }),
60
+ // The mint-time prune of expired token rows; nothing here queues for it.
61
+ delete: vi.fn(() => nextChain()),
42
62
  } as unknown as Database;
43
63
 
44
64
  return { db, captured };
45
65
  }
46
66
 
67
+ /** A captured predicate as the SQL it would send, with its parameters. */
68
+ function render(fragment: SQL): { sql: string; params: unknown[] } {
69
+ const q = new PgDialect().sqlToQuery(fragment);
70
+ return { sql: q.sql, params: q.params };
71
+ }
72
+
47
73
  function makeProvider(queue: any[] = []) {
48
74
  const { db, captured } = makeFakeDb(queue);
49
75
  const provider = new BevelOAuthProvider({
@@ -183,7 +209,15 @@ describe('BevelOAuthProvider', () => {
183
209
  clientId: 'client-1', userId: 'user-1', redirectUri: 'https://agent.example.com/callback',
184
210
  scope: 'mcp', resource: null,
185
211
  };
186
- const { provider, captured } = makeProvider([[consumed] /* update.returning */, undefined /* insert */]);
212
+ const { provider, captured } = makeProvider([
213
+ [consumed] /* update.returning */,
214
+ undefined /* prune: expired tokens */,
215
+ undefined /* prune: expired codes */,
216
+ [{ clientName: 'Claude' }] /* the client's registration */,
217
+ [] /* no live agent connection yet for (user, "claude") */,
218
+ [{ id: 'conn-1' }] /* connection insert.returning */,
219
+ undefined /* token insert */,
220
+ ]);
187
221
 
188
222
  const tokens = await provider.exchangeAuthorizationCode(
189
223
  CLIENT, 'the-code', undefined, 'https://agent.example.com/callback',
@@ -194,15 +228,51 @@ describe('BevelOAuthProvider', () => {
194
228
  expect(tokens.refresh_token!.startsWith(`${PREFIX}r_`)).toBe(true);
195
229
  expect(tokens.expires_in).toBe(3600);
196
230
  expect(tokens.scope).toBe('mcp');
197
- // The token row stores hashes of exactly what was returned.
198
- expect(captured.values[0]).toMatchObject({
231
+ // A first mint for (user, agent) makes the durable agent connection,
232
+ // keyed by the client's folded name and snapshotting the name shown…
233
+ expect(captured.values[0]).toEqual({ userId: 'user-1', clientId: 'client-1', clientName: 'Claude', agentKey: 'claude' });
234
+ // …looked up by that key, so a later registration of "Claude" by this
235
+ // person lands on the same row instead of making a sixth one.
236
+ // (where[0] consumed the code, [1]/[2] are the prunes, [3] read the client.)
237
+ expect(render(captured.where[4])).toMatchObject({ params: ['user-1', 'claude'] });
238
+ expect(render(captured.where[4]).sql).toMatch(/"agent_connections"."agent_key" = \$2/);
239
+ // …and the token row stores hashes of exactly what was returned, bound to it.
240
+ expect(captured.values[1]).toMatchObject({
199
241
  accessTokenHash: sha256(tokens.access_token),
200
242
  refreshTokenHash: sha256(tokens.refresh_token!),
201
243
  clientId: 'client-1',
202
244
  userId: 'user-1',
245
+ connectionId: 'conn-1',
203
246
  });
204
247
  });
205
248
 
249
+ it('a mint that loses the race for the first connection re-reads the winner instead of failing', async () => {
250
+ const consumed = { clientId: 'client-1', userId: 'user-1', redirectUri: 'https://agent.example.com/callback', scope: null, resource: null };
251
+ const { provider, captured } = makeProvider([
252
+ [consumed],
253
+ undefined, undefined, /* prunes */
254
+ [{ clientName: 'Claude' }],
255
+ [] /* no live connection at first look */,
256
+ [] /* insert: ON CONFLICT DO NOTHING returned no row */,
257
+ [{ id: 'conn-winner' }] /* the re-read finds the other mint's row */,
258
+ undefined /* token insert */,
259
+ ]);
260
+ await provider.exchangeAuthorizationCode(CLIENT, 'the-code', undefined, 'https://agent.example.com/callback');
261
+ expect(captured.values[1]).toMatchObject({ connectionId: 'conn-winner' });
262
+ // The insert really did stand down on the conflict — on the live-agent
263
+ // index, not any other — rather than the fake merely answering "no row".
264
+ expect(captured.onConflict).toHaveLength(1);
265
+ expect(captured.onConflict[0].target).toEqual([agentConnections.userId, agentConnections.agentKey]);
266
+ expect(render(captured.onConflict[0].where).sql).toMatch(/"revoked_at" is null/);
267
+ });
268
+
269
+ it('keys a connection by the folded client name, so re-registrations of one agent share a row; a nameless client keys by its id', () => {
270
+ expect(agentKeyFor('Claude', 'c-1')).toBe('claude');
271
+ expect(agentKeyFor(' Claude Code ', 'c-2')).toBe('claude code');
272
+ expect(agentKeyFor(null, 'c-3')).toBe('c-3');
273
+ expect(agentKeyFor(' ', 'c-4')).toBe('c-4');
274
+ });
275
+
206
276
  it('exchangeAuthorizationCode rejects a spent/unknown code and a redirect_uri mismatch', async () => {
207
277
  await expect(
208
278
  makeProvider([[]]).provider.exchangeAuthorizationCode(CLIENT, 'spent'),
@@ -216,12 +286,30 @@ describe('BevelOAuthProvider', () => {
216
286
 
217
287
  it('exchangeRefreshToken rotates (revokes old row, mints new pair) and only narrows scope', async () => {
218
288
  const oldRow = {
219
- clientId: 'client-1', userId: 'user-1', scope: 'mcp extra', resource: null,
289
+ clientId: 'client-1', userId: 'user-1', connectionId: 'conn-1', scope: 'mcp extra', resource: null,
220
290
  refreshExpiresAt: new Date(Date.now() + 60_000),
221
291
  };
222
- const { provider } = makeProvider([[oldRow] /* revoke.returning */, undefined /* insert */]);
292
+ const { provider, captured, db } = makeProvider([
293
+ [oldRow] /* revoke.returning */,
294
+ [{ id: 'conn-1' }] /* the token's connection, still live */,
295
+ undefined /* the (throttled) use stamp */,
296
+ undefined, undefined, /* prunes */
297
+ undefined /* token insert */,
298
+ ]);
223
299
  const tokens = await provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old', ['mcp']);
224
300
  expect(tokens.scope).toBe('mcp');
301
+ // A refresh STAYS on its token's connection: one connection spans every
302
+ // token the agent holds, nothing is looked up by (user, agent) and no
303
+ // second row is inserted for it.
304
+ expect(captured.values).toHaveLength(1);
305
+ expect(captured.values[0]).toMatchObject({ connectionId: 'conn-1' });
306
+ // The liveness check is a read of THAT connection, live rows only…
307
+ expect((db as any).select).toHaveBeenCalledTimes(1);
308
+ expect(render(captured.where[1])).toMatchObject({ params: ['conn-1'] });
309
+ expect(render(captured.where[1]).sql).toMatch(/"agent_connections"."revoked_at" is null/);
310
+ // …and the use is stamped separately, off the refresh's own path.
311
+ expect(captured.updateTargets).toEqual([oauthTokens, agentConnections]);
312
+ expect(Object.keys(captured.set[1])).toEqual(['lastUsedAt']);
225
313
 
226
314
  // Widening is refused.
227
315
  await expect(
@@ -234,18 +322,100 @@ describe('BevelOAuthProvider', () => {
234
322
  ).rejects.toBeInstanceOf(InvalidGrantError);
235
323
  });
236
324
 
325
+ it('exchangeRefreshToken refuses a token whose agent connection was revoked, minting nothing', async () => {
326
+ // The race a revoke cannot otherwise win: the refresh consumed its old
327
+ // token a moment before the revoke landed. Looking the connection up
328
+ // afresh would find none live and make a new one; staying on the token's
329
+ // own connection finds it revoked and stops.
330
+ const oldRow = {
331
+ clientId: 'client-1', userId: 'user-1', connectionId: 'conn-1', scope: 'mcp', resource: null,
332
+ refreshExpiresAt: new Date(Date.now() + 60_000),
333
+ };
334
+ const { provider, captured } = makeProvider([[oldRow], [] /* connection revoked or gone */]);
335
+ await expect(provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old')).rejects.toBeInstanceOf(InvalidGrantError);
336
+ expect(captured.values).toHaveLength(0);
337
+ expect(captured.onConflict).toHaveLength(0);
338
+ });
339
+
340
+ it('exchangeRefreshToken places a token from before connections existed under one, as a first mint would', async () => {
341
+ const oldRow = {
342
+ clientId: 'client-1', userId: 'user-1', connectionId: null, scope: 'mcp', resource: null,
343
+ refreshExpiresAt: new Date(Date.now() + 60_000),
344
+ };
345
+ const { provider, captured } = makeProvider([
346
+ [oldRow],
347
+ undefined, undefined, /* prunes */
348
+ [{ clientName: 'Claude' }],
349
+ [] /* no live connection */,
350
+ [{ id: 'conn-new' }] /* connection insert.returning */,
351
+ undefined /* token insert */,
352
+ ]);
353
+ await provider.exchangeRefreshToken(CLIENT, 'bevel-mcp_r_old');
354
+ expect(captured.values[0]).toEqual({ userId: 'user-1', clientId: 'client-1', clientName: 'Claude', agentKey: 'claude' });
355
+ expect(captured.values[1]).toMatchObject({ connectionId: 'conn-new' });
356
+ });
357
+
237
358
  it('verifyAccessToken resolves a live token to AuthInfo carrying the Bevel user', async () => {
238
359
  const row = {
239
- id: 'tok-row-1', clientId: 'client-1', scope: 'mcp', resource: null,
360
+ id: 'tok-row-1', clientId: 'client-1', connectionId: 'conn-1', scope: 'mcp', resource: null,
240
361
  expiresAt: new Date(Date.now() + 60_000), userId: 'user-1', userEmail: 'alice@example.com',
241
362
  };
242
- const { provider } = makeProvider([[row] /* select */, undefined /* lastUsedAt touch */]);
363
+ const { provider, captured } = makeProvider([
364
+ [row] /* select */,
365
+ undefined /* token lastUsedAt touch */,
366
+ undefined /* connection lastUsedAt touch */,
367
+ [row] /* a second verify a moment later */,
368
+ undefined /* its token touch — and NO second connection touch */,
369
+ ]);
243
370
 
244
371
  const info = await provider.verifyAccessToken('bevel-mcp_live');
245
372
 
246
373
  expect(info.clientId).toBe('client-1');
247
374
  expect(info.scopes).toEqual(['mcp']);
248
- expect(info.extra).toMatchObject({ userId: 'user-1', userEmail: 'alice@example.com' });
375
+ // The agent connection rides along, for the auth middleware to bind.
376
+ expect(info.extra).toMatchObject({ userId: 'user-1', userEmail: 'alice@example.com', connectionId: 'conn-1' });
377
+ // The lookup admits a token only while its connection is unrevoked — or
378
+ // has none to be revoked (a token from before connections existed).
379
+ const lookup = render(captured.where[0]).sql;
380
+ expect(lookup).toMatch(/"oauth_tokens"."connection_id" is null or "agent_connections"."revoked_at" is null/);
381
+ // Both "last used" marks are touched: the token's row, and THIS token's
382
+ // agent connection.
383
+ await new Promise((r) => setTimeout(r, 0));
384
+ expect(captured.updateTargets).toEqual([oauthTokens, agentConnections]);
385
+ expect(captured.set.map((s) => Object.keys(s))).toEqual([['lastUsedAt'], ['lastUsedAt']]);
386
+ expect(render(captured.where[1])).toMatchObject({ params: ['tok-row-1'] });
387
+ expect(render(captured.where[2])).toMatchObject({ params: ['conn-1'] });
388
+ expect(render(captured.where[2]).sql).toMatch(/"agent_connections"."id" = \$1/);
389
+ // The connection's stamp is throttled: a verify a moment later touches
390
+ // the token row again but not the connection — a minute has not passed.
391
+ await provider.verifyAccessToken('bevel-mcp_live');
392
+ await new Promise((r) => setTimeout(r, 0));
393
+ expect(captured.updateTargets).toEqual([oauthTokens, agentConnections, oauthTokens]);
394
+ });
395
+
396
+ it('revokeToken (RFC 7009) ends the agent connection once no live token remains on it, as the owner\'s doing', async () => {
397
+ const { provider, captured } = makeProvider([
398
+ [{ connectionId: 'conn-1' }] /* the revoked pair's row */,
399
+ [{ live: 0 }] /* nothing else live on the connection */,
400
+ undefined /* connection close */,
401
+ ]);
402
+ await provider.revokeToken(CLIENT, { token: 'bevel-mcp_r_old', token_type_hint: 'refresh_token' });
403
+ expect(captured.updateTargets).toEqual([oauthTokens, agentConnections]);
404
+ expect(captured.set[1]).toMatchObject({ revokedBy: 'owner' });
405
+ expect(captured.set[1].revokedAt).toBeInstanceOf(Date);
406
+ expect(render(captured.where[2])).toMatchObject({ params: ['conn-1'] });
407
+ expect(render(captured.where[2]).sql).toMatch(/"agent_connections"."revoked_at" is null/);
408
+
409
+ // Another live pair on the connection (the agent signed in twice): the
410
+ // connection stays.
411
+ const busy = makeProvider([[{ connectionId: 'conn-1' }], [{ live: 1 }]]);
412
+ await busy.provider.revokeToken(CLIENT, { token: 'bevel-mcp_r_other' });
413
+ expect(busy.captured.updateTargets).toEqual([oauthTokens]);
414
+
415
+ // An unknown or foreign token revokes nothing and touches no connection.
416
+ const unknown = makeProvider([[]]);
417
+ await unknown.provider.revokeToken(CLIENT, { token: 'bevel-mcp_r_gone' });
418
+ expect(unknown.captured.updateTargets).toEqual([oauthTokens]);
249
419
  });
250
420
 
251
421
  it('verifyAccessToken throws InvalidTokenError for unknown/revoked/expired tokens', async () => {