@bevel-software/platform-core-backend 0.30.0 → 0.32.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/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +16 -3
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +43 -4
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +31 -7
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +124 -24
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +326 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +21 -2
- package/src/index.ts +7 -0
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +31 -7
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +390 -55
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/listener-seam.ts +52 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AuditPrincipalNotFoundError,
|
|
8
8
|
InvalidCursorError,
|
|
9
9
|
isUuid,
|
|
10
|
+
parsePagePrincipalId,
|
|
10
11
|
type AuditPrincipalRef,
|
|
11
12
|
type IAgentAuditService,
|
|
12
13
|
} from './audit.contract.js';
|
|
@@ -20,7 +21,7 @@ const DEFAULT_EVENT_PAGE = 50;
|
|
|
20
21
|
/**
|
|
21
22
|
* The Audit log's routes, JWT-only (mounted behind the session middleware):
|
|
22
23
|
*
|
|
23
|
-
* GET /api/audit/principals?scope=me|all — agents
|
|
24
|
+
* GET /api/audit/principals?scope=me|all — agents, keys and pages, with owners and event counts
|
|
24
25
|
* GET /api/audit/principals/:kind/:id/events — one principal's events, newest first, cursor-paged
|
|
25
26
|
* DELETE /api/audit/agents/:id — revoke an agent (owner or admin)
|
|
26
27
|
* DELETE /api/audit/keys/:id — revoke a key (owner or admin)
|
|
@@ -44,14 +45,20 @@ export function createAuditRoutes(
|
|
|
44
45
|
const principalOf = (req: express.Request): AuditPrincipalRef | null => {
|
|
45
46
|
const kind = String(req.params.kind);
|
|
46
47
|
const id = String(req.params.id);
|
|
48
|
+
if (kind === 'page') return parsePagePrincipalId(id) ? { kind, id } : null;
|
|
47
49
|
if ((kind !== 'key' && kind !== 'agent') || !isUuid(id)) return null;
|
|
48
50
|
return { kind, id };
|
|
49
51
|
};
|
|
50
52
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Whether the caller may read `principal`'s events: its owner, or an admin.
|
|
55
|
+
* False both when there is no such principal and when it is someone else's,
|
|
56
|
+
* so the refusal does not tell a member which of another person's pages,
|
|
57
|
+
* agents or keys exist.
|
|
58
|
+
*/
|
|
59
|
+
const mayRead = async (req: express.Request, principal: AuditPrincipalRef): Promise<boolean> => {
|
|
53
60
|
const owner = await audit.ownerOf(principal);
|
|
54
|
-
if (owner === null) return
|
|
61
|
+
if (owner === null) return false;
|
|
55
62
|
if (owner === req.userId) return true;
|
|
56
63
|
return isAdmin(req);
|
|
57
64
|
};
|
|
@@ -74,17 +81,12 @@ export function createAuditRoutes(
|
|
|
74
81
|
router.get('/audit/principals/:kind/:id/events', async (req, res) => {
|
|
75
82
|
const principal = principalOf(req);
|
|
76
83
|
if (!principal) {
|
|
77
|
-
res.status(404).json({ error: 'No such agent or
|
|
84
|
+
res.status(404).json({ error: 'No such agent, key or page' });
|
|
78
85
|
return;
|
|
79
86
|
}
|
|
80
87
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
res.status(404).json({ error: 'No such agent or key' });
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (!allowed) {
|
|
87
|
-
res.status(403).json({ error: 'Not yours' });
|
|
88
|
+
if (!(await mayRead(req, principal))) {
|
|
89
|
+
res.status(404).json({ error: 'No such agent, key or page' });
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
92
|
const rawLimit = Number(req.query.limit);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CodeModeUtcpClient } from '@utcp/code-mode';
|
|
2
2
|
import { logger } from '../../shared/logging.js';
|
|
3
|
-
import type { AgentEventOutcome,
|
|
3
|
+
import type { AgentEventOutcome, IAgentEventRecorder, RecordedPrincipal } from './audit.contract.js';
|
|
4
4
|
import {
|
|
5
5
|
classifyToolCall,
|
|
6
6
|
skillReadPath,
|
|
@@ -13,11 +13,11 @@ const log = logger('audit');
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* The Audit log's view of ONE MCP request: who is calling (the user and the
|
|
16
|
-
* key
|
|
17
|
-
* they call. The MCP proxy creates one per request that has a principal
|
|
18
|
-
* routes every call through it; a request with none (a browser JWT
|
|
19
|
-
* internal token minted before connections existed) gets no
|
|
20
|
-
* records nothing.
|
|
16
|
+
* key, agent connection or page the request arrived through), and how to name
|
|
17
|
+
* what they call. The MCP proxy creates one per request that has a principal
|
|
18
|
+
* and routes every call through it; a request with none (a browser JWT that
|
|
19
|
+
* names no page, an internal token minted before connections existed) gets no
|
|
20
|
+
* instance and records nothing.
|
|
21
21
|
*
|
|
22
22
|
* Holds nothing that outlives the request. The skill catalog it may need to
|
|
23
23
|
* tell a skill read from a file read is fetched at most once, and only when
|
|
@@ -29,7 +29,7 @@ export class RequestAudit {
|
|
|
29
29
|
constructor(
|
|
30
30
|
private readonly recorder: IAgentEventRecorder,
|
|
31
31
|
private readonly userId: string,
|
|
32
|
-
private readonly principal:
|
|
32
|
+
private readonly principal: RecordedPrincipal,
|
|
33
33
|
private readonly ctx: {
|
|
34
34
|
kbManualName: string;
|
|
35
35
|
/** Read at classification time — the surface (and its catalog names) is built lazily. */
|
|
@@ -93,9 +93,13 @@ export class RequestAudit {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
/**
|
|
97
|
+
* A call refused before it ran: `denied` when the caller's sign-in for its
|
|
98
|
+
* manual is missing, `refused` when the person viewing a page answered its
|
|
99
|
+
* request to write with Deny.
|
|
100
|
+
*/
|
|
101
|
+
async denied(utcpName: string, args: Record<string, unknown>, outcome: 'denied' | 'refused' = 'denied'): Promise<void> {
|
|
102
|
+
this.emit(await this.classify(utcpName, args), outcome, null);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -106,11 +110,22 @@ export class RequestAudit {
|
|
|
106
110
|
* The direct MCP path (`callToolStreaming`) is deliberately NOT wrapped —
|
|
107
111
|
* the proxy's tool handler records those itself, with the result's
|
|
108
112
|
* `isError` in hand.
|
|
113
|
+
*
|
|
114
|
+
* Installed ONCE, when the client is built, because the client is shared by
|
|
115
|
+
* every request of its person: `current` names the request a call belongs
|
|
116
|
+
* to, at call time, and a call outside any request (or in one attributed to
|
|
117
|
+
* nothing) is recorded nowhere. Rebinding the client per request instead
|
|
118
|
+
* would hand one request's calls to another's audit, and stack a wrapper
|
|
119
|
+
* per request.
|
|
109
120
|
*/
|
|
110
|
-
instrumentChainCalls(client: CodeModeUtcpClient): void {
|
|
121
|
+
static instrumentChainCalls(client: CodeModeUtcpClient, current: () => RequestAudit | null | undefined): void {
|
|
111
122
|
const callTool = client.callTool.bind(client);
|
|
112
|
-
client.callTool = (toolName: string, toolArgs: Record<string, unknown>) =>
|
|
113
|
-
|
|
123
|
+
client.callTool = (toolName: string, toolArgs: Record<string, unknown>) => {
|
|
124
|
+
const audit = current();
|
|
125
|
+
return audit
|
|
126
|
+
? audit.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped)
|
|
127
|
+
: callTool(toolName, toolArgs);
|
|
128
|
+
};
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
private skillCatalog(): Promise<readonly SkillFolder[] | null> {
|
|
@@ -25,10 +25,17 @@ import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
|
|
|
25
25
|
* shows up as `db:`.
|
|
26
26
|
*/
|
|
27
27
|
function makeFakeDb(queue: unknown[]) {
|
|
28
|
-
const captured: {
|
|
28
|
+
const captured: {
|
|
29
|
+
values: Record<string, unknown>[];
|
|
30
|
+
set: Record<string, unknown>[];
|
|
31
|
+
order: string[];
|
|
32
|
+
/** Every `where` predicate, rendered to the SQL it would send. */
|
|
33
|
+
where: { sql: string; params: unknown[] }[];
|
|
34
|
+
} = {
|
|
29
35
|
values: [],
|
|
30
36
|
set: [],
|
|
31
37
|
order: [],
|
|
38
|
+
where: [],
|
|
32
39
|
};
|
|
33
40
|
const dialect = new PgDialect();
|
|
34
41
|
function nextChain(): unknown {
|
|
@@ -42,7 +49,10 @@ function makeFakeDb(queue: unknown[]) {
|
|
|
42
49
|
Object.assign(chain, {
|
|
43
50
|
values: passthrough((a) => captured.values.push(a)),
|
|
44
51
|
set: passthrough((a) => captured.set.push(a)),
|
|
45
|
-
where: passthrough()
|
|
52
|
+
where: passthrough((a) => {
|
|
53
|
+
const { sql, params } = dialect.sqlToQuery(a as unknown as SQL);
|
|
54
|
+
captured.where.push({ sql, params });
|
|
55
|
+
}),
|
|
46
56
|
limit: passthrough(),
|
|
47
57
|
from: passthrough(),
|
|
48
58
|
orderBy: passthrough(),
|
|
@@ -124,6 +134,32 @@ describe('signing in with a switched-off account', () => {
|
|
|
124
134
|
});
|
|
125
135
|
});
|
|
126
136
|
|
|
137
|
+
describe('the accounts a change request\'s approvers resolve to', () => {
|
|
138
|
+
it('are read in one indexed query through the email blind index, live accounts only, keyed by canonical address', async () => {
|
|
139
|
+
const { db, captured } = makeFakeDb([[{ id: 'user-1', email: 'Alice@Example.com', name: 'Alice' }]]);
|
|
140
|
+
const svc = new AuthService(db, makeConfig());
|
|
141
|
+
const found = await svc.findActiveAccountsByEmail(['ALICE@example.com', 'alice@example.com', 'bob@example.com']);
|
|
142
|
+
// One read, by the blind index, with the addresses canonicalised and
|
|
143
|
+
// deduplicated, and a switched-off account excluded in the query itself.
|
|
144
|
+
expect((db as unknown as { select: ReturnType<typeof vi.fn> }).select).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(captured.where).toHaveLength(1);
|
|
146
|
+
expect(captured.where[0]!.sql).toMatch(/"users"."email_bidx" in \(\$1, \$2\) and "users"."deactivated_at" is null/);
|
|
147
|
+
// The blind-index column hashes each address with the handle's key as
|
|
148
|
+
// the query is sent; what was asked for is the canonical plain address.
|
|
149
|
+
expect(captured.where[0]!.params.map((p) => (p as { plain: string }).plain)).toEqual(['alice@example.com', 'bob@example.com']);
|
|
150
|
+
// Keyed by the canonical address, whatever spelling the row holds.
|
|
151
|
+
expect([...found.keys()]).toEqual(['alice@example.com']);
|
|
152
|
+
expect(found.get('alice@example.com')).toEqual({ id: 'user-1', email: 'Alice@Example.com', name: 'Alice' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('asks nothing for no addresses', async () => {
|
|
156
|
+
const { db } = makeFakeDb([]);
|
|
157
|
+
const svc = new AuthService(db, makeConfig());
|
|
158
|
+
expect(await svc.findActiveAccountsByEmail([])).toEqual(new Map());
|
|
159
|
+
expect((db as unknown as { select: ReturnType<typeof vi.fn> }).select).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
127
163
|
describe('a session that outlives a deactivation', () => {
|
|
128
164
|
it('stops being accepted once the account is switched off', async () => {
|
|
129
165
|
const { db } = makeFakeDb([[ROW], [{ deactivatedAt: new Date() }]]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
2
2
|
import { logger } from '../../shared/logging.js';
|
|
3
|
+
import { ListenerSeam, type NotifyingListener } from '../../shared/listener-seam.js';
|
|
3
4
|
|
|
4
5
|
const log = logger('accounts');
|
|
5
6
|
|
|
@@ -24,13 +25,10 @@ export interface AccountInvitedEvent {
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* A listener answers how it reached the person — a non-empty channel name,
|
|
27
|
-
* `'email'` say — or null when it did not
|
|
28
|
-
*
|
|
29
|
-
* truthfully, so a listener bounds its own wait. A throw is logged and
|
|
30
|
-
* counts as null; it never fails the invite. An empty name is no channel and
|
|
31
|
-
* counts as null too.
|
|
28
|
+
* `'email'` say — or null when it did not; the rule is {@link NotifyingListener}'s:
|
|
29
|
+
* awaited, a throw logged and counted as null, never failing the invite.
|
|
32
30
|
*/
|
|
33
|
-
export type AccountInvitedHook =
|
|
31
|
+
export type AccountInvitedHook = NotifyingListener<AccountInvitedEvent>;
|
|
34
32
|
|
|
35
33
|
/**
|
|
36
34
|
* Core's seam for "an account was invited": a deployment (the cloud with its
|
|
@@ -46,24 +44,18 @@ export interface IAccountHooks {
|
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
export class AccountHooks implements IAccountHooks {
|
|
49
|
-
|
|
47
|
+
// The account exists whether or not the person was told: the admin's
|
|
48
|
+
// answer says they were not, and the admin sends the link themselves.
|
|
49
|
+
private readonly invited = new ListenerSeam<AccountInvitedEvent>(
|
|
50
|
+
log,
|
|
51
|
+
'an account-invited listener failed; the person was not notified by it:',
|
|
52
|
+
);
|
|
50
53
|
|
|
51
54
|
onAccountInvited(hook: AccountInvitedHook): void {
|
|
52
|
-
this.invited.
|
|
55
|
+
this.invited.add(hook);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for (const hook of this.invited) {
|
|
58
|
-
try {
|
|
59
|
-
const channel = await hook(event);
|
|
60
|
-
if (channel !== null && channel !== '') notified.push(channel);
|
|
61
|
-
} catch (err) {
|
|
62
|
-
// The account exists whether or not the person was told: the admin's
|
|
63
|
-
// answer says they were not, and the admin sends the link themselves.
|
|
64
|
-
log.error('an account-invited listener failed; the person was not notified by it:', { err });
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return notified;
|
|
58
|
+
runAccountInvited(event: AccountInvitedEvent): Promise<string[]> {
|
|
59
|
+
return this.invited.run(event);
|
|
68
60
|
}
|
|
69
61
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
|
-
import { and, eq, getTableColumns, isNotNull, isNull, sql } from 'drizzle-orm';
|
|
2
|
+
import { and, eq, getTableColumns, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
|
3
3
|
import type { Database } from '../database/connection.js';
|
|
4
4
|
import { users } from '../database/schema.js';
|
|
5
5
|
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
6
|
-
import { canonicalEmail
|
|
6
|
+
import { canonicalEmail } from '../../shared/email-identity.js';
|
|
7
7
|
import {
|
|
8
8
|
AccountAdmissionRefusedError,
|
|
9
9
|
AccountChangeRefusedError,
|
|
@@ -863,26 +863,27 @@ export class AuthService implements IAccountSwitchGuard {
|
|
|
863
863
|
}
|
|
864
864
|
|
|
865
865
|
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
866
|
+
* The live accounts behind a set of addresses — the approvers of a change
|
|
867
|
+
* request just opened, which the workflow module hands to the deployment's
|
|
868
|
+
* notification listeners as people with accounts (`ApproverAccounts`).
|
|
869
869
|
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
870
|
+
* One indexed read through the email's blind index, never a scan of every
|
|
871
|
+
* account: this is asked on every change request opened, and a deployment
|
|
872
|
+
* is as large as it is. Switched-off accounts are left out — a person who
|
|
873
|
+
* cannot act on a request is not told of it — so a caller that needs them
|
|
874
|
+
* asks another way. Keyed by the canonical address asked for.
|
|
874
875
|
*/
|
|
875
|
-
async
|
|
876
|
-
|
|
876
|
+
async findActiveAccountsByEmail(
|
|
877
|
+
emails: readonly string[],
|
|
877
878
|
): Promise<Map<string, { id: string; email: string; name: string }>> {
|
|
878
879
|
const result = new Map<string, { id: string; email: string; name: string }>();
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
880
|
+
const canonical = [...new Set(emails.map((e) => canonicalEmail(e)).filter(Boolean))];
|
|
881
|
+
if (canonical.length === 0) return result;
|
|
882
|
+
const rows = await this.db
|
|
883
|
+
.select({ id: users.id, email: users.email, name: users.name })
|
|
884
|
+
.from(users)
|
|
885
|
+
.where(and(inArray(users.emailBidx, canonical), isNull(users.deactivatedAt)));
|
|
886
|
+
for (const row of rows) result.set(canonicalEmail(row.email), { id: row.id, email: row.email, name: row.name });
|
|
886
887
|
return result;
|
|
887
888
|
}
|
|
888
889
|
|
|
@@ -550,7 +550,10 @@ export const agentConnections = pgTable('agent_connections', {
|
|
|
550
550
|
* `api_tokens`: plaintext shown only in the token response, SHA-256 hashes
|
|
551
551
|
* stored, revoked-not-deleted so `last_used_at` keeps its audit value and a
|
|
552
552
|
* revoked token can't be re-issued. One row per pair; refresh rotation
|
|
553
|
-
*
|
|
553
|
+
* retires the old row (`rotated_at`) and inserts a new one. A retired row's
|
|
554
|
+
* access token still verifies until its own expiry, unless the row is then
|
|
555
|
+
* revoked: by a revoke, a sign-out, or its refresh token's one allowed replay
|
|
556
|
+
* within the reuse grace, which revokes it in the statement that admits it.
|
|
554
557
|
*
|
|
555
558
|
* `connection_id` names the {@link agentConnections} row the pair belongs to,
|
|
556
559
|
* so every call made with the token is attributed to that agent and revoking
|
|
@@ -569,6 +572,18 @@ export const oauthTokens = pgTable('oauth_tokens', {
|
|
|
569
572
|
expiresAt: timestamp('expires_at').notNull(),
|
|
570
573
|
refreshExpiresAt: timestamp('refresh_expires_at'),
|
|
571
574
|
revokedAt: timestamp('revoked_at'),
|
|
575
|
+
/**
|
|
576
|
+
* When a refresh of this row's refresh token minted its successor. Set by
|
|
577
|
+
* rotation and never by a revoke, so the two are told apart: a rotated
|
|
578
|
+
* row's access token still verifies until its own expiry, and its refresh
|
|
579
|
+
* token is honoured once more within the reuse grace (see
|
|
580
|
+
* `exchangeRefreshToken`), a replay that revokes the row — access token
|
|
581
|
+
* included — in the statement that admits it; a revoked row does neither.
|
|
582
|
+
* Null on a row whose refresh token has not been used, and on rows from
|
|
583
|
+
* before the column existed, which rotation then marks as it does any
|
|
584
|
+
* other.
|
|
585
|
+
*/
|
|
586
|
+
rotatedAt: timestamp('rotated_at'),
|
|
572
587
|
lastUsedAt: timestamp('last_used_at'),
|
|
573
588
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
574
589
|
}, (t) => ({
|
|
@@ -589,22 +604,30 @@ export const oauthTokens = pgTable('oauth_tokens', {
|
|
|
589
604
|
* which agent, when" — not "what was said".
|
|
590
605
|
*
|
|
591
606
|
* Exactly one principal per row (the CHECK): the connection key or the agent
|
|
592
|
-
* connection the call arrived through
|
|
593
|
-
*
|
|
594
|
-
*
|
|
607
|
+
* connection the call arrived through, or the knowledge-base page whose
|
|
608
|
+
* script made the call as the person viewing it (`page_path`). Key and
|
|
609
|
+
* connection cascade, so deleting a key for good, revoking-then-erasing a
|
|
610
|
+
* person, or dropping a connection takes its events along — the same contract
|
|
611
|
+
* the LLM-usage rows already keep; a page's events go with the person.
|
|
595
612
|
*/
|
|
596
613
|
export const agentEvents = pgTable('agent_events', {
|
|
597
614
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
598
615
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
599
616
|
keyId: uuid('key_id').references(() => externalApiKeys.id, { onDelete: 'cascade' }),
|
|
600
617
|
connectionId: uuid('connection_id').references(() => agentConnections.id, { onDelete: 'cascade' }),
|
|
618
|
+
/** The knowledge-base path of the HTML page that made the call, for a page's calls; null otherwise. */
|
|
619
|
+
pagePath: text('page_path'),
|
|
601
620
|
/** `capability` | `tool` | `skill`. */
|
|
602
621
|
kind: text('kind').notNull(),
|
|
603
622
|
/** The MCP server / `.tool` manual (catalog name) for a tool, the skill folder for a skill; null for a capability. */
|
|
604
623
|
manual: text('manual'),
|
|
605
624
|
/** The tool's bare name, or the skill's name. */
|
|
606
625
|
name: text('name').notNull(),
|
|
607
|
-
/**
|
|
626
|
+
/**
|
|
627
|
+
* `ok` | `error` | `denied` | `refused`. Denied: the caller's sign-in for the
|
|
628
|
+
* tool was missing, so nothing ran. Refused: the viewer answered a page's
|
|
629
|
+
* request to write with Deny, so nothing ran.
|
|
630
|
+
*/
|
|
608
631
|
outcome: text('outcome').notNull(),
|
|
609
632
|
durationMs: integer('duration_ms'),
|
|
610
633
|
at: timestamp('at').defaultNow().notNull(),
|
|
@@ -612,14 +635,15 @@ export const agentEvents = pgTable('agent_events', {
|
|
|
612
635
|
// The two read paths: one principal's events, newest first.
|
|
613
636
|
byKey: index('agent_events_by_key').on(t.keyId, t.at),
|
|
614
637
|
byConnection: index('agent_events_by_connection').on(t.connectionId, t.at),
|
|
638
|
+
byPage: index('agent_events_by_page').on(t.userId, t.pagePath, t.at),
|
|
615
639
|
// The retention prune, and per-user counts.
|
|
616
640
|
byAt: index('agent_events_by_at').on(t.at),
|
|
617
641
|
byUser: index('agent_events_by_user').on(t.userId),
|
|
618
642
|
kindCheck: check('agent_events_kind', sql`${t.kind} IN ('capability', 'tool', 'skill')`),
|
|
619
|
-
outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied')`),
|
|
643
|
+
outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied', 'refused')`),
|
|
620
644
|
principalCheck: check(
|
|
621
645
|
'agent_events_principal',
|
|
622
|
-
sql`(${t.keyId}
|
|
646
|
+
sql`num_nonnulls(${t.keyId}, ${t.connectionId}, ${t.pagePath}) = 1`,
|
|
623
647
|
),
|
|
624
648
|
}));
|
|
625
649
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { WorkspaceIdMutex, WorkspaceMutex } from '../mutex.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `WorkspaceIdMutex`: a workspace id in either spelling — a route's decoded
|
|
6
|
+
* `alice/feature`, a tool's `alice%2Ffeature` — takes the one key.
|
|
7
|
+
*/
|
|
8
|
+
describe('WorkspaceIdMutex', () => {
|
|
9
|
+
/** A task that signals when it starts and ends only when released. */
|
|
10
|
+
const held = () => {
|
|
11
|
+
let release: () => void = () => {};
|
|
12
|
+
let started: () => void = () => {};
|
|
13
|
+
const begun = new Promise<void>((r) => { started = r; });
|
|
14
|
+
const task = () =>
|
|
15
|
+
new Promise<void>((resolve) => {
|
|
16
|
+
release = resolve;
|
|
17
|
+
started();
|
|
18
|
+
});
|
|
19
|
+
return { task, begun, release: () => release() };
|
|
20
|
+
};
|
|
21
|
+
const settled = () => new Promise<void>((r) => setImmediate(r));
|
|
22
|
+
|
|
23
|
+
it('queues the decoded and the encoded spelling of one workspace on the same key', async () => {
|
|
24
|
+
const mutex = new WorkspaceIdMutex();
|
|
25
|
+
const first = held();
|
|
26
|
+
const order: string[] = [];
|
|
27
|
+
const a = mutex.run('alice/feature', first.task).then(() => { order.push('decoded'); });
|
|
28
|
+
await first.begun;
|
|
29
|
+
const b = mutex.run('alice%2Ffeature', async () => { order.push('encoded'); });
|
|
30
|
+
await settled();
|
|
31
|
+
// The encoded spelling waits on the decoded one's reservation: nothing
|
|
32
|
+
// has run until the first is released; then both do.
|
|
33
|
+
expect(order).toEqual([]);
|
|
34
|
+
first.release();
|
|
35
|
+
await Promise.all([a, b]);
|
|
36
|
+
expect([...order].sort()).toEqual(['decoded', 'encoded']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is what the plain mutex is not: two spellings, two keys, no wait', async () => {
|
|
40
|
+
const mutex = new WorkspaceMutex();
|
|
41
|
+
const first = held();
|
|
42
|
+
const order: string[] = [];
|
|
43
|
+
const a = mutex.run('alice/feature', first.task).then(() => { order.push('decoded'); });
|
|
44
|
+
await first.begun;
|
|
45
|
+
await mutex.run('alice%2Ffeature', async () => { order.push('encoded'); });
|
|
46
|
+
expect(order).toEqual(['encoded']);
|
|
47
|
+
first.release();
|
|
48
|
+
await a;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('leaves a protected branch (no slash, one spelling) and a malformed id as they are', async () => {
|
|
52
|
+
const mutex = new WorkspaceIdMutex();
|
|
53
|
+
const seen: string[] = [];
|
|
54
|
+
await mutex.runAll(['main', 'bad%'], async () => { seen.push('ran'); });
|
|
55
|
+
expect(seen).toEqual(['ran']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
* workspace needs only the shape of the gate every write passes first.
|
|
5
5
|
*/
|
|
6
6
|
export type { WriteValidator } from './locking-filesystem.js';
|
|
7
|
+
export { WorkspaceMutex, WorkspaceIdMutex } from './mutex.js';
|
|
7
8
|
export { readRefSha, readRevSha, type RefFileAnswer } from './ref-files.js';
|
|
8
9
|
export { gitDirOf, readGitfile } from './git-dir.js';
|
|
@@ -52,6 +52,7 @@ import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
|
|
|
52
52
|
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
53
53
|
import { isAbsence } from '../../shared/fs.contract.js';
|
|
54
54
|
import { DestinationTakenError, lstatOrNull, renameNoReplace, takenError } from '../../shared/rename-no-replace.js';
|
|
55
|
+
import { linkedAncestor } from '../../shared/no-link-below.js';
|
|
55
56
|
import { assertInsideRepo } from './repo-path.js';
|
|
56
57
|
import { GitGuardedFilesystem } from './git-guarded-filesystem.js';
|
|
57
58
|
import type { CreationGrantPlan, ICreatorAccess } from '../access-model/creator.js';
|
|
@@ -644,18 +645,8 @@ export class LockingFilesystem extends GitGuardedFilesystem {
|
|
|
644
645
|
private async assertNoLinkBelowBase(inputPath: string): Promise<void> {
|
|
645
646
|
const absolute = this.resolveAbsolutePath(inputPath);
|
|
646
647
|
if (!absolute) return;
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const ancestors: string[] = [];
|
|
650
|
-
while (current.startsWith(base) && current !== base) {
|
|
651
|
-
ancestors.push(current);
|
|
652
|
-
current = path.dirname(current);
|
|
653
|
-
}
|
|
654
|
-
for (const ancestor of ancestors) {
|
|
655
|
-
const stat = await lstatOrNull(ancestor);
|
|
656
|
-
if (stat?.isSymbolicLink()) {
|
|
657
|
-
throw new Error(`"${inputPath}" is reached through a link, so nothing was moved.`);
|
|
658
|
-
}
|
|
648
|
+
if ((await linkedAncestor(this.basePath, absolute)) !== null) {
|
|
649
|
+
throw new Error(`"${inputPath}" is reached through a link, so nothing was moved.`);
|
|
659
650
|
}
|
|
660
651
|
}
|
|
661
652
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { canonicalWorkspaceId } from '../../shared/workspace-id.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Serializes async operations by key. Two `run(k, ...)` calls with the same key
|
|
3
5
|
* execute sequentially in call order; different keys run independently.
|
|
@@ -65,3 +67,17 @@ export class WorkspaceMutex {
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A `WorkspaceMutex` whose keys are WORKSPACE IDS, taken in their canonical
|
|
73
|
+
* spelling: a route's Express-decoded `:id` (`alice/feature`) and a tool's
|
|
74
|
+
* `alice%2Ffeature` name the same clone and must queue on the same key — two
|
|
75
|
+
* spellings would let a route's read run beside a tool's merge of the same
|
|
76
|
+
* checkout. `canonicalWorkspaceId` collapses both to the encoded form every
|
|
77
|
+
* other keyed store (the file locks, the pending commits) already uses.
|
|
78
|
+
*/
|
|
79
|
+
export class WorkspaceIdMutex extends WorkspaceMutex {
|
|
80
|
+
override async runAll<T>(keys: string[], fn: () => Promise<T>): Promise<T> {
|
|
81
|
+
return super.runAll(keys.map(canonicalWorkspaceId), fn);
|
|
82
|
+
}
|
|
83
|
+
}
|