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