@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +22 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +10 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts +54 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
- package/dist/modules/audit/agent-audit.service.js +290 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
- package/dist/modules/audit/audit.contract.d.ts +145 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -0
- package/dist/modules/audit/audit.contract.js +35 -0
- package/dist/modules/audit/audit.contract.js.map +1 -0
- package/dist/modules/audit/audit.routes.d.ts +21 -0
- package/dist/modules/audit/audit.routes.d.ts.map +1 -0
- package/dist/modules/audit/audit.routes.js +157 -0
- package/dist/modules/audit/audit.routes.js.map +1 -0
- package/dist/modules/audit/event-classifier.d.ts +52 -0
- package/dist/modules/audit/event-classifier.d.ts.map +1 -0
- package/dist/modules/audit/event-classifier.js +123 -0
- package/dist/modules/audit/event-classifier.js.map +1 -0
- package/dist/modules/audit/index.d.ts +7 -0
- package/dist/modules/audit/index.d.ts.map +1 -0
- package/dist/modules/audit/index.js +7 -0
- package/dist/modules/audit/index.js.map +1 -0
- package/dist/modules/audit/request-audit.d.ts +57 -0
- package/dist/modules/audit/request-audit.d.ts.map +1 -0
- package/dist/modules/audit/request-audit.js +117 -0
- package/dist/modules/audit/request-audit.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +414 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +99 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +6 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
- package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +8 -1
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +9 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +48 -5
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +32 -6
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +22 -9
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +1 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +8 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +9 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +49 -13
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git-internals.d.ts +7 -1
- package/dist/shared/git-internals.d.ts.map +1 -1
- package/dist/shared/git-internals.js +67 -9
- package/dist/shared/git-internals.js.map +1 -1
- package/migrations/0012_agent_audit.sql +41 -0
- package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
- package/migrations/meta/0012_snapshot.json +2247 -0
- package/migrations/meta/0013_snapshot.json +2259 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/src/core/create-core-server.ts +28 -0
- package/src/core/create-core-services.ts +13 -0
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
- package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
- package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
- package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
- package/src/modules/audit/agent-audit.service.ts +316 -0
- package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
- package/src/modules/audit/audit.contract.ts +164 -0
- package/src/modules/audit/audit.routes.ts +168 -0
- package/src/modules/audit/event-classifier.ts +155 -0
- package/src/modules/audit/index.ts +14 -0
- package/src/modules/audit/request-audit.ts +141 -0
- package/src/modules/database/core-schema.ts +104 -0
- package/src/modules/diff/diff.service.ts +6 -1
- package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
- package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
- package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
- package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
- package/src/modules/mcp/mcp.routes.ts +11 -1
- package/src/modules/mcp/mcp.service.ts +62 -4
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
- package/src/modules/settings/deployment-settings.service.ts +47 -8
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
- package/src/modules/tool-auth/external-api-key.service.ts +25 -8
- package/src/modules/tool-auth/internal-token.service.ts +11 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
- package/src/modules/workspace/workspace.service.ts +9 -0
- package/src/modules/workspace/workspace.tools.ts +47 -11
- package/src/shared/__tests__/git-internals.test.ts +41 -0
- package/src/shared/git-internals.ts +61 -6
- package/dist/modules/access/access-errors.d.ts +0 -34
- package/dist/modules/access/access-errors.d.ts.map +0 -1
- package/dist/modules/access/access-errors.js +0 -40
- package/dist/modules/access/access-errors.js.map +0 -1
- package/dist/modules/access/access-splice.d.ts +0 -140
- package/dist/modules/access/access-splice.d.ts.map +0 -1
- package/dist/modules/access/access-splice.js +0 -389
- package/dist/modules/access/access-splice.js.map +0 -1
- package/dist/modules/access/group-files.d.ts +0 -83
- package/dist/modules/access/group-files.d.ts.map +0 -1
- package/dist/modules/access/group-files.js +0 -167
- package/dist/modules/access/group-files.js.map +0 -1
- package/dist/modules/access/kb-read-filter.d.ts +0 -41
- package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
- package/dist/modules/access/kb-read-filter.js +0 -60
- package/dist/modules/access/kb-read-filter.js.map +0 -1
- package/dist/modules/access/render-roles-yaml.d.ts +0 -22
- package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
- package/dist/modules/access/render-roles-yaml.js +0 -56
- package/dist/modules/access/render-roles-yaml.js.map +0 -1
- package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
- package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
- package/dist/modules/access/roles-yaml-guard.js +0 -79
- package/dist/modules/access/roles-yaml-guard.js.map +0 -1
- package/dist/modules/diff/diff-paths.d.ts +0 -2
- package/dist/modules/diff/diff-paths.d.ts.map +0 -1
- package/dist/modules/diff/diff-paths.js +0 -9
- package/dist/modules/diff/diff-paths.js.map +0 -1
- package/dist/modules/groups/group-provision.service.d.ts +0 -130
- package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
- package/dist/modules/groups/group-provision.service.js +0 -288
- package/dist/modules/groups/group-provision.service.js.map +0 -1
- package/dist/modules/groups/groups.contract.d.ts +0 -106
- package/dist/modules/groups/groups.contract.d.ts.map +0 -1
- package/dist/modules/groups/groups.contract.js +0 -36
- package/dist/modules/groups/groups.contract.js.map +0 -1
- package/dist/modules/groups/groups.routes.d.ts +0 -42
- package/dist/modules/groups/groups.routes.d.ts.map +0 -1
- package/dist/modules/groups/groups.routes.js +0 -379
- package/dist/modules/groups/groups.routes.js.map +0 -1
- package/dist/modules/groups/groups.service.d.ts +0 -60
- package/dist/modules/groups/groups.service.d.ts.map +0 -1
- package/dist/modules/groups/groups.service.js +0 -172
- package/dist/modules/groups/groups.service.js.map +0 -1
- package/dist/modules/groups/index.d.ts +0 -7
- package/dist/modules/groups/index.d.ts.map +0 -1
- package/dist/modules/groups/index.js +0 -6
- package/dist/modules/groups/index.js.map +0 -1
- package/dist/modules/groups/join-proposals.d.ts +0 -53
- package/dist/modules/groups/join-proposals.d.ts.map +0 -1
- package/dist/modules/groups/join-proposals.js +0 -67
- package/dist/modules/groups/join-proposals.js.map +0 -1
- package/dist/modules/groups/join-requests.service.d.ts +0 -81
- package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
- package/dist/modules/groups/join-requests.service.js +0 -135
- package/dist/modules/groups/join-requests.service.js.map +0 -1
- package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
- package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
- package/dist/modules/mcp/mcp-session-store.js +0 -131
- package/dist/modules/mcp/mcp-session-store.js.map +0 -1
- package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
- package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
- package/dist/modules/workflow/file-change-notifier.js +0 -22
- package/dist/modules/workflow/file-change-notifier.js.map +0 -1
- package/dist/modules/workflow/git/branch-name.d.ts +0 -10
- package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
- package/dist/modules/workflow/git/branch-name.js +0 -76
- package/dist/modules/workflow/git/branch-name.js.map +0 -1
- package/dist/modules/workflow/git/clone-config.d.ts +0 -61
- package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
- package/dist/modules/workflow/git/clone-config.js +0 -69
- package/dist/modules/workflow/git/clone-config.js.map +0 -1
- package/dist/modules/workflow/git/mutex.d.ts +0 -11
- package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
- package/dist/modules/workflow/git/mutex.js +0 -23
- package/dist/modules/workflow/git/mutex.js.map +0 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
- package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/locking-filesystem.js +0 -553
- package/dist/modules/workflow/locking-filesystem.js.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.js +0 -39
- package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
- package/dist/modules/workflow/workflow.errors.d.ts +0 -197
- package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
- package/dist/modules/workflow/workflow.errors.js +0 -298
- package/dist/modules/workflow/workflow.errors.js.map +0 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
- package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
- package/dist/modules/workspace/bevel-ignore.js +0 -61
- package/dist/modules/workspace/bevel-ignore.js.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.js +0 -2
- package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
- package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.service.js +0 -535
- package/dist/modules/workspace/kb-seed.service.js.map +0 -1
- package/dist/modules/workspace/plugins-migration.d.ts +0 -50
- package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
- package/dist/modules/workspace/plugins-migration.js +0 -379
- package/dist/modules/workspace/plugins-migration.js.map +0 -1
- package/dist/shared/fs-errors.d.ts +0 -8
- package/dist/shared/fs-errors.d.ts.map +0 -1
- package/dist/shared/fs-errors.js +0 -11
- package/dist/shared/fs-errors.js.map +0 -1
- package/dist/shared/fs-walk.d.ts +0 -23
- package/dist/shared/fs-walk.d.ts.map +0 -1
- package/dist/shared/fs-walk.js +0 -42
- package/dist/shared/fs-walk.js.map +0 -1
- package/dist/shared/hash-email.d.ts +0 -11
- package/dist/shared/hash-email.d.ts.map +0 -1
- package/dist/shared/hash-email.js +0 -14
- package/dist/shared/hash-email.js.map +0 -1
- package/dist/shared/kb-walk.d.ts +0 -52
- package/dist/shared/kb-walk.d.ts.map +0 -1
- package/dist/shared/kb-walk.js +0 -76
- 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.
|
|
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.
|
|
56
|
-
"@bevel-software/platform-shared": "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
|
+
});
|