@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
@@ -85,6 +85,20 @@
85
85
  "when": 1789903776474,
86
86
  "tag": "0011_plugin_join_request_claim",
87
87
  "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "7",
92
+ "when": 1790179003112,
93
+ "tag": "0012_agent_audit",
94
+ "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "7",
99
+ "when": 1790243482866,
100
+ "tag": "0013_agent_identity_and_key_soft_delete",
101
+ "breakpoints": true
88
102
  }
89
103
  ]
90
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,8 +52,8 @@
52
52
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
53
53
  "yaml": "^2.9.0",
54
54
  "zod": "^3.24.0",
55
- "@bevel-software/platform-mcp-core": "0.19.0",
56
- "@bevel-software/platform-shared": "0.19.0"
55
+ "@bevel-software/platform-mcp-core": "0.20.0",
56
+ "@bevel-software/platform-shared": "0.20.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/adm-zip": "^0.5.8",
@@ -79,7 +79,7 @@
79
79
  "access": "public"
80
80
  },
81
81
  "scripts": {
82
- "build": "tsc -p tsconfig.json",
82
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
83
83
  "typecheck": "tsc -p tsconfig.json --noEmit",
84
84
  "test": "vitest run",
85
85
  "test:live": "vitest run .live.test",
@@ -52,6 +52,9 @@ import { createGroupsAdminRoutes } from '../modules/access/groups-admin.routes.j
52
52
  import { createUpdateCheckRoutes } from '../modules/update-check/update-check.routes.js';
53
53
  import { createAccountRoutes } from '../modules/auth/account.routes.js';
54
54
  import { createConnectionKeysAdminRoutes } from '../modules/tool-auth/connection-keys-admin.routes.js';
55
+ import { createAuditRoutes } from '../modules/audit/audit.routes.js';
56
+ import { createAgentRestAuditMiddleware } from '../modules/audit/agent-rest-audit.middleware.js';
57
+ import { EXTERNAL_KB_MANUAL_NAME } from '../modules/tool-manuals/tool-manuals.contract.js';
55
58
  import { createSetupRoutes } from '../modules/settings/setup.routes.js';
56
59
  import { oidcRedirectUri } from '../modules/auth/oidc-auth-provider.js';
57
60
  import { repositoryConnectionCheck } from '../modules/settings/connection-check.js';
@@ -479,6 +482,24 @@ export async function createCoreServer(
479
482
  const toolsRouter = express.Router();
480
483
  const ta = core.toolAuthMiddleware;
481
484
  const th = core.toolHandlerFactory;
485
+ // The Audit log's recorder for the REST tool calls the LOCAL server makes
486
+ // directly (its skill reads, above all) — judged on `finish`, after each
487
+ // route's own auth has run, and only for the exchanged grant that names an
488
+ // agent connection, so nothing the hosted proxy already records is counted
489
+ // twice. Ahead of every tool route, on the same router.
490
+ toolsRouter.use(
491
+ '/agent/tools/:name',
492
+ createAgentRestAuditMiddleware({
493
+ recorder: core.agentAuditService,
494
+ kbManualName: EXTERNAL_KB_MANUAL_NAME,
495
+ skillFolders: async (userId) => {
496
+ const user = await core.authService.getUserById(userId);
497
+ if (!user) return [];
498
+ const skills = await core.skillService.listSkills(user.email);
499
+ return skills.map((s) => ({ name: s.name, path: s.path }));
500
+ },
501
+ }),
502
+ );
482
503
  // Shared ontology-session boundary gate config, consumed by every tool
483
504
  // surface that touches the KB (file tools + graph tools). The gate's
484
505
  // blocking decision runs through the workflow hooks: core registers none
@@ -729,6 +750,13 @@ export async function createCoreServer(
729
750
  core.authMiddleware,
730
751
  createConnectionKeysAdminRoutes(core.externalApiKeyService, core.adminAccess),
731
752
  );
753
+ // The Audit log: a person's agents and keys with what they called (admins:
754
+ // everyone's), and the revoke of either — owner- or admin-gated inside.
755
+ app.use(
756
+ '/api',
757
+ core.authMiddleware,
758
+ createAuditRoutes(core.agentAuditService, core.externalApiKeyService, core.adminAccess),
759
+ );
732
760
  // First-run setup. Mounted with the other authed routes but touching NO
733
761
  // workspace — it has to work on a deployment that has no knowledge base yet,
734
762
  // which is the whole reason it exists. The startup runner rides along for
@@ -130,6 +130,7 @@ import {
130
130
  } from '../modules/tool-auth/tool-auth.middleware.js';
131
131
  import { unmeteredLlmUsage, type ILlmUsageMeter } from '../modules/tool-auth/llm-usage-meter.js';
132
132
  import { McpService } from '../modules/mcp/mcp.service.js';
133
+ import { AgentAuditService, retentionDaysFrom } from '../modules/audit/agent-audit.service.js';
133
134
  import { readAgentPreamble, type AgentPreambleReader } from '../modules/agent-instructions/index.js';
134
135
  import { createMcpAuthMiddleware } from '../modules/mcp/mcp-auth.middleware.js';
135
136
  import { BevelOAuthProvider } from '../modules/mcp/oauth/bevel-oauth-provider.js';
@@ -284,6 +285,8 @@ export interface CoreServices {
284
285
  * advertise the same pointer the auth middleware does.
285
286
  */
286
287
  mcpResourceMetadataUrl: string;
288
+ /** The Audit log: what every connected agent and key called, and the revoke of an agent's grant. */
289
+ agentAuditService: AgentAuditService;
287
290
  toolRegistry: ToolRegistry;
288
291
  toolAuthMiddleware: ReturnType<typeof createToolAuthMiddleware>;
289
292
  manualAuthMiddleware: ReturnType<typeof createManualAuthMiddleware>;
@@ -915,6 +918,13 @@ export async function createCoreServices(
915
918
  // the proxy below composes in-process per request; the agent-facing route
916
919
  // serves the same composition to the local bridge and the frontend card.
917
920
  const readPreamble: AgentPreambleReader = () => readAgentPreamble(workspaceService, kbDirName, disk);
921
+ // The Audit log. Records through the proxy below (every call an external
922
+ // agent makes), reads keys through the key service so their shape is
923
+ // defined once, and prunes past the retention setting — read per sweep, so
924
+ // a change on the Deployment page applies without a restart.
925
+ const agentAuditService = new AgentAuditService(db, externalApiKeyService, () =>
926
+ retentionDaysFrom(settings.resolve('auditRetentionDays')),
927
+ );
918
928
  const mcpService = new McpService(
919
929
  {
920
930
  // Loopback to our own REST tool surface — 127.0.0.1 (not localhost) to pin
@@ -942,6 +952,8 @@ export async function createCoreServices(
942
952
  // provider is constructed just below (it needs nothing from McpService;
943
953
  // the binding is only dereferenced at call time, long after boot).
944
954
  (bearer) => mcpOAuthProvider.revokeByAccessToken(bearer),
955
+ // Every attributable call lands in the Audit log through this.
956
+ agentAuditService,
945
957
  );
946
958
  // A changed secret invalidates what the proxy built from the old value for
947
959
  // that user (null = shared secret → everyone): remembered manual failures,
@@ -1194,6 +1206,7 @@ export async function createCoreServices(
1194
1206
  mcpAuthMiddleware,
1195
1207
  mcpOAuthProvider,
1196
1208
  mcpResourceMetadataUrl,
1209
+ agentAuditService,
1197
1210
  toolRegistry,
1198
1211
  toolAuthMiddleware,
1199
1212
  manualAuthMiddleware,
@@ -0,0 +1,370 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- the fake drizzle chain below is untyped by design, like its siblings */
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { SQL } from 'drizzle-orm';
4
+ import { PgDialect } from 'drizzle-orm/pg-core';
5
+ import { AgentAuditService, retentionDaysFrom } from '../agent-audit.service.js';
6
+ import { AuditPrincipalNotFoundError, InvalidCursorError } from '../audit.contract.js';
7
+ import type { Database } from '../../database/connection.js';
8
+ import { agentConnections, oauthTokens } from '../../database/schema.js';
9
+ import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
10
+
11
+ /**
12
+ * The fake drizzle chain the other service tests use: every chainable method
13
+ * returns the chain, the chain is thenable, and each `db.insert / select /
14
+ * update / delete` pops the next queued result. `transaction` runs its
15
+ * callback against the same fake, so queued results are consumed in order.
16
+ * Every `where` predicate and `update` target is captured, so a test can pin
17
+ * WHICH rows a statement addressed (see {@link render}), not just that it ran.
18
+ */
19
+ function makeFakeDb(queue: unknown[]) {
20
+ const captured: { values: any[]; set: any[]; where: SQL[]; updateTargets: unknown[] } = {
21
+ values: [],
22
+ set: [],
23
+ where: [],
24
+ updateTargets: [],
25
+ };
26
+ const counts = { insert: 0, select: 0, update: 0, delete: 0 };
27
+
28
+ function nextChain() {
29
+ const result = queue.shift();
30
+ const chain: any = {};
31
+ const passthrough = (recorder?: (args: any[]) => void) =>
32
+ vi.fn((...args: any[]) => {
33
+ recorder?.(args);
34
+ return chain;
35
+ });
36
+ chain.values = passthrough((a) => captured.values.push(a[0]));
37
+ chain.set = passthrough((a) => captured.set.push(a[0]));
38
+ chain.where = passthrough((a) => captured.where.push(a[0]));
39
+ chain.limit = passthrough();
40
+ chain.innerJoin = passthrough();
41
+ chain.from = passthrough();
42
+ chain.orderBy = passthrough();
43
+ chain.groupBy = passthrough();
44
+ chain.returning = passthrough();
45
+ chain.then = (onF: any, onR: any) => Promise.resolve(result).then(onF, onR);
46
+ return chain;
47
+ }
48
+
49
+ const db: any = {
50
+ insert: vi.fn(() => (counts.insert++, nextChain())),
51
+ select: vi.fn(() => (counts.select++, nextChain())),
52
+ update: vi.fn((table: unknown) => (counts.update++, captured.updateTargets.push(table), nextChain())),
53
+ delete: vi.fn(() => (counts.delete++, nextChain())),
54
+ transaction: vi.fn((fn: (tx: any) => Promise<unknown>) => fn(db)),
55
+ };
56
+ return { db: db as Database, captured, counts };
57
+ }
58
+
59
+ /** A captured predicate as the SQL it would send, with its parameters. */
60
+ function render(fragment: SQL | undefined): { sql: string; params: unknown[] } {
61
+ if (!fragment) return { sql: '', params: [] };
62
+ const q = new PgDialect().sqlToQuery(fragment);
63
+ return { sql: q.sql, params: q.params };
64
+ }
65
+
66
+ const ALICE = { id: 'u-alice', email: 'alice@example.com', name: 'Alice' };
67
+ const BOB = { id: 'u-bob', email: 'bob@example.com', name: 'Bob' };
68
+ const DAY = 24 * 60 * 60_000;
69
+ const NOW = Date.UTC(2026, 8, 23, 12, 0, 0);
70
+
71
+ const key = (over: object) => ({
72
+ id: 'k-1',
73
+ label: 'CI pipeline',
74
+ kind: 'key',
75
+ createdAt: NOW - 10 * DAY,
76
+ lastUsedAt: NOW - DAY,
77
+ revokedAt: null,
78
+ revokedBy: null,
79
+ ...over,
80
+ });
81
+ const connection = (over: object) => ({
82
+ id: 'c-1',
83
+ userId: ALICE.id,
84
+ clientId: 'client-claude',
85
+ clientName: 'Claude',
86
+ connectedAt: new Date(NOW - 5 * DAY),
87
+ lastUsedAt: new Date(NOW - 60_000),
88
+ revokedAt: null,
89
+ revokedBy: null,
90
+ ...over,
91
+ });
92
+
93
+ function makeService(queue: unknown[], keys: Partial<IExternalApiKeyService> = {}, retention: number | null = 90) {
94
+ const fake = makeFakeDb(queue);
95
+ const keyService = {
96
+ listForUser: vi.fn(async () => []),
97
+ listForDeployment: vi.fn(async () => []),
98
+ ownerOf: vi.fn(async () => null),
99
+ ...keys,
100
+ } as unknown as IExternalApiKeyService;
101
+ const service = new AgentAuditService(fake.db, keyService, () => retention, () => NOW);
102
+ return { service, ...fake, keyService };
103
+ }
104
+
105
+ /** Let the fire-and-forget insert (and whatever it schedules) settle. */
106
+ const flush = () => new Promise((r) => setTimeout(r, 0));
107
+
108
+ describe('AgentAuditService.record', () => {
109
+ it('inserts the event with exactly one principal column set and never throws to the caller', async () => {
110
+ const { service, captured } = makeService([undefined /* insert */]);
111
+ service.record({
112
+ userId: ALICE.id,
113
+ principal: { kind: 'key', id: 'k-1' },
114
+ kind: 'tool',
115
+ manual: 'notion',
116
+ name: 'search',
117
+ outcome: 'ok',
118
+ durationMs: 42,
119
+ });
120
+ await flush();
121
+ expect(captured.values[0]).toMatchObject({
122
+ userId: ALICE.id,
123
+ keyId: 'k-1',
124
+ connectionId: null,
125
+ kind: 'tool',
126
+ manual: 'notion',
127
+ name: 'search',
128
+ outcome: 'ok',
129
+ durationMs: 42,
130
+ });
131
+
132
+ const agent = makeService([undefined]);
133
+ agent.service.record({
134
+ userId: ALICE.id,
135
+ principal: { kind: 'agent', id: 'c-1' },
136
+ kind: 'skill',
137
+ manual: 'Plugins/Sales/rfi',
138
+ name: 'rfi',
139
+ outcome: 'ok',
140
+ durationMs: null,
141
+ });
142
+ await flush();
143
+ expect(agent.captured.values[0]).toMatchObject({ keyId: null, connectionId: 'c-1', kind: 'skill' });
144
+ });
145
+
146
+ it('prunes past the retention window once per hour, not on every insert', async () => {
147
+ const { service, counts } = makeService([undefined, undefined, undefined, undefined], {}, 30);
148
+ const event = {
149
+ userId: ALICE.id,
150
+ principal: { kind: 'key' as const, id: 'k-1' },
151
+ kind: 'capability' as const,
152
+ manual: null,
153
+ name: 'read_file',
154
+ outcome: 'ok' as const,
155
+ durationMs: 3,
156
+ };
157
+ service.record(event);
158
+ await flush();
159
+ expect(counts.delete).toBe(1); // the first insert sweeps
160
+ service.record(event);
161
+ await flush();
162
+ expect(counts.delete).toBe(1); // the second, a moment later, does not
163
+ });
164
+
165
+ it('sweeps nothing on a deployment that keeps events forever', async () => {
166
+ const { service, counts } = makeService([undefined], {}, null);
167
+ service.record({
168
+ userId: ALICE.id,
169
+ principal: { kind: 'key', id: 'k-1' },
170
+ kind: 'capability',
171
+ manual: null,
172
+ name: 'read_file',
173
+ outcome: 'ok',
174
+ durationMs: 3,
175
+ });
176
+ await flush();
177
+ expect(counts.delete).toBe(0);
178
+ });
179
+
180
+ it('swallows an insert failure (logged), so a tool result never fails over its bookkeeping', async () => {
181
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
182
+ const { service } = makeService([Promise.reject(new Error('db down'))]);
183
+ expect(() =>
184
+ service.record({
185
+ userId: ALICE.id,
186
+ principal: { kind: 'key', id: 'k-1' },
187
+ kind: 'capability',
188
+ manual: null,
189
+ name: 'ask',
190
+ outcome: 'ok',
191
+ durationMs: 1,
192
+ }),
193
+ ).not.toThrow();
194
+ await flush();
195
+ expect(warn).toHaveBeenCalled();
196
+ warn.mockRestore();
197
+ });
198
+ });
199
+
200
+ describe('AgentAuditService.listPrincipals', () => {
201
+ it("lists a person's keys and agents with event counts, live first, most recently used first", async () => {
202
+ const { service } = makeService(
203
+ [
204
+ // keysForUser: the user row
205
+ [ALICE],
206
+ // connections joined with users
207
+ [
208
+ { connection: connection({ id: 'c-old', revokedAt: new Date(NOW - DAY), revokedBy: 'owner', lastUsedAt: new Date(NOW) }), user: ALICE },
209
+ { connection: connection({ id: 'c-1' }), user: ALICE },
210
+ ],
211
+ // event counts
212
+ [
213
+ { keyId: 'k-1', connectionId: null, n: 7 },
214
+ { keyId: null, connectionId: 'c-1', n: 3 },
215
+ ],
216
+ ],
217
+ { listForUser: vi.fn(async () => [key({ lastUsedAt: NOW - 2 * DAY })]) },
218
+ );
219
+
220
+ const rows = await service.listPrincipals({ userId: ALICE.id });
221
+
222
+ expect(rows.map((r) => `${r.kind}:${r.id}`)).toEqual(['agent:c-1', 'key:k-1', 'agent:c-old']);
223
+ expect(rows[0]).toMatchObject({ label: 'Claude', eventCount: 3, user: ALICE, revokedAt: null });
224
+ expect(rows[1]).toMatchObject({ label: 'CI pipeline', keyKind: 'key', eventCount: 7, user: ALICE });
225
+ // The revoked one sinks, still carrying who ended it and its (zero) count.
226
+ expect(rows[2]).toMatchObject({ revokedBy: 'owner', eventCount: 0 });
227
+ });
228
+
229
+ it('lists every account for the deployment, grouped by owner email', async () => {
230
+ const { service, keyService } = makeService(
231
+ [
232
+ [{ connection: connection({ id: 'c-bob', userId: BOB.id }), user: BOB }],
233
+ [],
234
+ ],
235
+ { listForDeployment: vi.fn(async () => [{ ...key({}), user: ALICE }]) },
236
+ );
237
+
238
+ const rows = await service.listPrincipals('all');
239
+
240
+ expect(rows.map((r) => r.user.email)).toEqual(['alice@example.com', 'bob@example.com']);
241
+ expect((keyService.listForDeployment as any)).toHaveBeenCalled();
242
+ expect((keyService.listForUser as any)).not.toHaveBeenCalled();
243
+ });
244
+ });
245
+
246
+ describe('AgentAuditService.listEvents', () => {
247
+ const eventId = (i: number) => `00000000-0000-4000-8000-00000000000${i}`;
248
+ const eventRow = (i: number) => ({
249
+ id: eventId(i),
250
+ kind: 'capability',
251
+ manual: null,
252
+ name: `tool-${i}`,
253
+ outcome: 'ok',
254
+ at: new Date(NOW - i * 1000),
255
+ });
256
+
257
+ it('pages newest first with a keyset cursor and reports the total', async () => {
258
+ const { service, captured } = makeService([
259
+ [eventRow(1), eventRow(2), eventRow(3)] /* limit 2 → 3 rows means more */,
260
+ [{ total: 12 }],
261
+ ]);
262
+
263
+ const page = await service.listEvents({ kind: 'key', id: 'k-1' }, { limit: 2 });
264
+
265
+ expect(page.events.map((e) => e.id)).toEqual([eventId(1), eventId(2)]);
266
+ expect(page.events[0]).toMatchObject({ kind: 'capability', name: 'tool-1', outcome: 'ok', at: NOW - 1000 });
267
+ expect(page.total).toBe(12);
268
+ expect(page.nextCursor).toBe(`${NOW - 2000}.${eventId(2)}`);
269
+ // The page and the total both read THIS principal's rows and nobody else's.
270
+ expect(render(captured.where[0]).params).toEqual(['k-1']);
271
+ expect(render(captured.where[1]).params).toEqual(['k-1']);
272
+
273
+ // A cursor page counts nothing: the reader holds the first page's total.
274
+ const last = makeService([[eventRow(3)]]);
275
+ const tail = await last.service.listEvents({ kind: 'key', id: 'k-1' }, { before: page.nextCursor!, limit: 2 });
276
+ expect(tail.events.map((e) => e.id)).toEqual([eventId(3)]);
277
+ expect(tail.nextCursor).toBeNull();
278
+ expect(tail.total).toBeNull();
279
+ expect(last.counts.select).toBe(1);
280
+ // The cursor became the keyset predicate: strictly older, or the same
281
+ // instant and a smaller id.
282
+ const after = render(last.captured.where[0]);
283
+ expect(after.sql).toMatch(/"at" < \$2 or \("agent_events"."at" = \$3 and "agent_events"."id" < \$4\)/);
284
+ // The instant rides twice (strictly-older, and same-instant) as whatever
285
+ // the driver spells a timestamp as; the id is the cursor's own.
286
+ expect(after.params[0]).toBe('k-1');
287
+ expect(after.params[1]).toEqual(after.params[2]);
288
+ expect(new Date(after.params[1] as string | Date).getTime()).toBe(NOW - 2000);
289
+ expect(after.params[3]).toBe(eventId(2));
290
+ });
291
+
292
+ it('refuses a cursor it did not issue instead of sending it to the database', async () => {
293
+ const { service, counts } = makeService([]);
294
+ for (const before of ['123.bad', 'notanumber.' + eventId(1), '1e5.' + eventId(1), eventId(1)]) {
295
+ await expect(service.listEvents({ kind: 'key', id: 'k-1' }, { before, limit: 2 })).rejects.toBeInstanceOf(
296
+ InvalidCursorError,
297
+ );
298
+ }
299
+ expect(counts.select).toBe(0);
300
+ });
301
+ });
302
+
303
+ describe('AgentAuditService.ownerOf', () => {
304
+ it('answers for a key through the key service, and for an agent from its connection row', async () => {
305
+ const { service, keyService } = makeService([[{ userId: BOB.id }]], { ownerOf: vi.fn(async () => ALICE.id) });
306
+ await expect(service.ownerOf({ kind: 'key', id: 'k-1' })).resolves.toBe(ALICE.id);
307
+ expect(keyService.ownerOf).toHaveBeenCalledWith('k-1');
308
+ await expect(service.ownerOf({ kind: 'agent', id: 'c-1' })).resolves.toBe(BOB.id);
309
+ });
310
+ });
311
+
312
+ describe('AgentAuditService.revokeConnection', () => {
313
+ it('marks the connection revoked by whom, and revokes every live token it holds', async () => {
314
+ const { service, captured } = makeService([
315
+ [{ id: 'c-1' }] /* connection update.returning */,
316
+ undefined /* tokens update */,
317
+ ]);
318
+
319
+ await service.revokeConnection('c-1', 'admin');
320
+
321
+ expect(captured.updateTargets).toEqual([agentConnections, oauthTokens]);
322
+ expect(captured.set[0]).toMatchObject({ revokedBy: 'admin' });
323
+ expect(captured.set[0].revokedAt).toBeInstanceOf(Date);
324
+ expect(captured.set[1].revokedAt).toBeInstanceOf(Date);
325
+ // An admin's revoke is unscoped by owner, and touches only a LIVE connection…
326
+ const connection = render(captured.where[0]);
327
+ expect(connection.sql).toMatch(/"agent_connections"."id" = \$1 and "agent_connections"."revoked_at" is null/);
328
+ expect(connection.params).toEqual(['c-1']);
329
+ // …and exactly its own live tokens, never another connection's.
330
+ const tokens = render(captured.where[1]);
331
+ expect(tokens.sql).toMatch(/"oauth_tokens"."connection_id" = \$1 and "oauth_tokens"."revoked_at" is null/);
332
+ expect(tokens.params).toEqual(['c-1']);
333
+ });
334
+
335
+ it("scopes an owner's revoke to their own connections", async () => {
336
+ const { service, captured } = makeService([[{ id: 'c-1' }], undefined]);
337
+ await service.revokeConnection('c-1', 'owner', ALICE.id);
338
+ const connection = render(captured.where[0]);
339
+ expect(connection.sql).toMatch(/"agent_connections"."user_id" = \$2/);
340
+ expect(connection.params).toEqual(['c-1', ALICE.id]);
341
+ expect(captured.set[0]).toMatchObject({ revokedBy: 'owner' });
342
+ });
343
+
344
+ it('is idempotent on an already-revoked connection and throws for one out of scope', async () => {
345
+ const already = makeService([[] /* nothing live */, [{ id: 'c-1' }] /* but it exists */]);
346
+ await expect(already.service.revokeConnection('c-1', 'owner', ALICE.id)).resolves.toBeUndefined();
347
+ expect(already.counts.update).toBe(1); // no token revoke on a no-op
348
+ // The existence probe carries the SAME owner scope as the revoke itself.
349
+ expect(render(already.captured.where[1]).params).toEqual(['c-1', ALICE.id]);
350
+
351
+ const foreign = makeService([[], []]);
352
+ await expect(foreign.service.revokeConnection('c-1', 'owner', BOB.id)).rejects.toBeInstanceOf(
353
+ AuditPrincipalNotFoundError,
354
+ );
355
+ });
356
+ });
357
+
358
+ describe('retentionDaysFrom', () => {
359
+ it('reads a positive whole number of days, and blank, zero or negative as forever', () => {
360
+ expect(retentionDaysFrom('30')).toBe(30);
361
+ expect(retentionDaysFrom(' 3650 ')).toBe(3650);
362
+ expect(retentionDaysFrom('99999')).toBe(99999);
363
+ expect(retentionDaysFrom('')).toBeNull();
364
+ expect(retentionDaysFrom('0')).toBeNull();
365
+ expect(retentionDaysFrom('-5')).toBeNull();
366
+ // Not a window at all: kept forever too, and said once on the log.
367
+ expect(retentionDaysFrom('1.5')).toBeNull();
368
+ expect(retentionDaysFrom('lots')).toBeNull();
369
+ });
370
+ });
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import type { Server } from 'node:http';
4
+ import { createAgentRestAuditMiddleware } from '../agent-rest-audit.middleware.js';
5
+ import type { AgentEventInput } from '../audit.contract.js';
6
+ import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
7
+
8
+ const KB = 'KNOWLEDGE_BASE';
9
+
10
+ /**
11
+ * The REST recorder, on a real router: a stand-in auth stamps whatever
12
+ * `toolAuth` a test wants (the way the real gate does, per route), a couple
13
+ * of routes answer as the platform tools do, and the recorder is watched.
14
+ */
15
+ function makeApp(auth: ToolAuth | null, skills: { name: string; path: string }[] = []) {
16
+ const events: AgentEventInput[] = [];
17
+ const skillFolders = vi.fn(async () => skills);
18
+ const app = express();
19
+ app.use(express.json());
20
+ const router = express.Router();
21
+ router.use(
22
+ '/agent/tools/:name',
23
+ createAgentRestAuditMiddleware({ recorder: { record: (e) => void events.push(e) }, kbManualName: KB, skillFolders }),
24
+ );
25
+ const stampAuth: express.RequestHandler = (req, res, next) => {
26
+ if (!auth) {
27
+ res.status(401).json({ error: 'nope' });
28
+ return;
29
+ }
30
+ req.toolAuth = auth;
31
+ next();
32
+ };
33
+ router.post('/agent/tools/get_skill', stampAuth, (_req, res) => res.json({ ok: true }));
34
+ router.post('/agent/tools/read_file', stampAuth, (_req, res) => res.json({ content: 'x' }));
35
+ router.post('/agent/tools/list_skills', stampAuth, (_req, res) => res.json({ skills }));
36
+ router.post('/agent/tools/boom', stampAuth, (_req, res) => res.status(500).json({ error: 'kaboom' }));
37
+ app.use('/api', router);
38
+ return { app, events, skillFolders };
39
+ }
40
+
41
+ let server: Server;
42
+ afterEach(() => server?.close());
43
+
44
+ async function listen(app: express.Express): Promise<string> {
45
+ await new Promise<void>((resolve) => {
46
+ server = app.listen(0, resolve);
47
+ });
48
+ const address = server.address();
49
+ if (typeof address === 'string' || !address) throw new Error('no port');
50
+ return `http://127.0.0.1:${address.port}`;
51
+ }
52
+
53
+ const post = (base: string, name: string, body: unknown) =>
54
+ fetch(`${base}/api/agent/tools/${name}`, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(body),
58
+ });
59
+
60
+ /** Recording lands after the response; give it a turn. */
61
+ const settle = () => new Promise((r) => setTimeout(r, 20));
62
+
63
+ const EXCHANGED_GRANT: ToolAuth = { source: 'external', userId: 'u-1', connectionId: 'conn-1', scope: 'write' };
64
+
65
+ describe('the REST recorder for the local server', () => {
66
+ it("records a skill read made with an exchanged grant as that agent's skill event, with the outcome", async () => {
67
+ const { app, events } = makeApp(EXCHANGED_GRANT, [{ name: 'rfi', path: 'Plugins/Sales/rfi' }]);
68
+ const base = await listen(app);
69
+ expect((await post(base, 'get_skill', { name: 'rfi' })).status).toBe(200);
70
+ expect((await post(base, 'boom', {})).status).toBe(500);
71
+ await settle();
72
+ expect(events).toEqual([
73
+ expect.objectContaining({
74
+ userId: 'u-1',
75
+ principal: { kind: 'agent', id: 'conn-1' },
76
+ kind: 'skill',
77
+ manual: 'Plugins/Sales/rfi',
78
+ name: 'rfi',
79
+ outcome: 'ok',
80
+ }),
81
+ expect.objectContaining({ kind: 'capability', name: 'boom', outcome: 'error' }),
82
+ ]);
83
+ expect(typeof events[0]!.durationMs).toBe('number');
84
+ });
85
+
86
+ it('fetches the skill catalog only for a read that names a path, and classifies the read by it', async () => {
87
+ const { app, events, skillFolders } = makeApp(EXCHANGED_GRANT, [{ name: 'rfi', path: 'Plugins/Sales/rfi' }]);
88
+ const base = await listen(app);
89
+ await post(base, 'list_skills', {});
90
+ await post(base, 'read_file', { path: 'Plugins/Sales/rfi/SKILL.md', branch: 'main' });
91
+ await settle();
92
+ expect(skillFolders).toHaveBeenCalledTimes(1);
93
+ expect(events.map((e) => `${e.kind}:${e.name}`)).toEqual(['capability:list_skills', 'skill:rfi']);
94
+ });
95
+
96
+ it('records nothing for a connection key, a loopback token, or a refused call — those are the proxy\'s to record, or nobody\'s', async () => {
97
+ const key = makeApp({ source: 'external', userId: 'u-1', tokenId: 'k-1', scope: 'write' });
98
+ let base = await listen(key.app);
99
+ await post(base, 'get_skill', { name: 'rfi' });
100
+ server.close();
101
+ const loopback = makeApp({ source: 'external', userId: 'u-1', scope: 'write' });
102
+ base = await listen(loopback.app);
103
+ await post(base, 'get_skill', { name: 'rfi' });
104
+ server.close();
105
+ const refused = makeApp(null);
106
+ base = await listen(refused.app);
107
+ expect((await post(base, 'get_skill', { name: 'rfi' })).status).toBe(401);
108
+ await settle();
109
+ expect(key.events).toEqual([]);
110
+ expect(loopback.events).toEqual([]);
111
+ expect(refused.events).toEqual([]);
112
+ });
113
+ });