@bevel-software/platform-core-backend 0.30.0 → 0.31.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.map +1 -1
- package/dist/core/create-core-services.js +4 -2
- package/dist/core/create-core-services.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/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/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- 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/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/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 +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- 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/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 +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- 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/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/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -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 +4 -2
- package/src/modules/access/access-control.service.ts +1 -6
- 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/database/core-schema.ts +15 -6
- 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__/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/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__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- 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/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- 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/no-link-below.ts +29 -0
|
@@ -127,6 +127,13 @@
|
|
|
127
127
|
"when": 1791359019161,
|
|
128
128
|
"tag": "0017_atlassian_account_links",
|
|
129
129
|
"breakpoints": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"idx": 18,
|
|
133
|
+
"version": "7",
|
|
134
|
+
"when": 1792106401545,
|
|
135
|
+
"tag": "0018_agent_events_page",
|
|
136
|
+
"breakpoints": true
|
|
130
137
|
}
|
|
131
138
|
]
|
|
132
|
-
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bevel-software/platform-core-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
|
56
56
|
"yaml": "^2.9.0",
|
|
57
57
|
"zod": "^3.24.0",
|
|
58
|
-
"@bevel-software/platform-
|
|
59
|
-
"@bevel-software/platform-
|
|
58
|
+
"@bevel-software/platform-shared": "0.31.0",
|
|
59
|
+
"@bevel-software/platform-mcp-core": "0.31.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/adm-zip": "^0.5.8",
|
|
@@ -12,9 +12,9 @@ const log = logger('catalog-revision');
|
|
|
12
12
|
* skill the default branch serves them, right now.
|
|
13
13
|
*
|
|
14
14
|
* It exists for a client that holds a long-lived connection and cannot be told
|
|
15
|
-
* anything: the local `hexis-mcp` server. The hosted endpoint
|
|
16
|
-
* every request
|
|
17
|
-
* (`mcp.
|
|
15
|
+
* anything: the local `hexis-mcp` server. The hosted endpoint reads it too, on
|
|
16
|
+
* every request, to pick the caller's cached tool client: a new revision is a
|
|
17
|
+
* new build (`mcp.surface-cache.test.ts` pins that). A process on someone's laptop
|
|
18
18
|
* registered the deployment's manual ONCE, at startup, and would otherwise
|
|
19
19
|
* serve that startup's toolset until it is restarted. Polling this tells it —
|
|
20
20
|
* cheaply, and without a second way for the catalogs to be read — exactly when
|
|
@@ -470,7 +470,8 @@ export async function createCoreServer(
|
|
|
470
470
|
// `get_skill`. Warnings only; it never refuses a save.
|
|
471
471
|
const allowedToolsChecker = new AllowedToolsChecker(core.toolRegistry, core.toolManualService, core.kb);
|
|
472
472
|
// `delete_branch` sees change requests as `get_change_request` does.
|
|
473
|
-
|
|
473
|
+
// The history tools take the per-file read the file itself takes (`accessControl`).
|
|
474
|
+
registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th, core.kb, changeRequestScope(core.accessControl, core.kb).maySee, core.accessControl, agentAccessGate);
|
|
474
475
|
// The five read tools over change requests. Separate from the workflow tools
|
|
475
476
|
// because they are the only ones that gate their whole payload on the
|
|
476
477
|
// caller's read access, so they take the access service and nothing else.
|
|
@@ -117,7 +117,7 @@ import { ConnectionProbeService } from '../modules/connection-probe/index.js';
|
|
|
117
117
|
import { GitService } from '../modules/workflow/git/git.service.js';
|
|
118
118
|
import { NodeGitRunner } from '../modules/workflow/git/node-git-runner.js';
|
|
119
119
|
import { PullRequestService } from '../modules/workflow/git/pull-request.service.js';
|
|
120
|
-
import { WorkspaceMutex } from '../modules/kb-fs/mutex.js';
|
|
120
|
+
import { WorkspaceIdMutex, WorkspaceMutex } from '../modules/kb-fs/mutex.js';
|
|
121
121
|
import { assertGitVersion } from '../modules/workflow/git/git-version.js';
|
|
122
122
|
import { DiffService } from '../modules/diff/diff.service.js';
|
|
123
123
|
import { ReviewWorkflowService } from '../modules/workflow/review-workflow/review-workflow.service.js';
|
|
@@ -578,7 +578,9 @@ export async function createCoreServices(
|
|
|
578
578
|
*/
|
|
579
579
|
// Shared mutex so git and diff operations on the same workspace serialize
|
|
580
580
|
// against each other — a backup-reseed races a concurrent commit otherwise.
|
|
581
|
-
|
|
581
|
+
// Keyed by the CANONICAL workspace id: a route's Express-decoded `:id` and
|
|
582
|
+
// a tool's encoded one reserve the same clone (see `WorkspaceIdMutex`).
|
|
583
|
+
const workspaceMutex = new WorkspaceIdMutex();
|
|
582
584
|
/**
|
|
583
585
|
* The knowledge base's ONE repository: every branch workspace is a worktree
|
|
584
586
|
* of it (see `kb-repository.ts`). One instance, handed to the workspace
|
|
@@ -5,7 +5,7 @@ import { logger } from '../../shared/logging.js';
|
|
|
5
5
|
const log = logger('access');
|
|
6
6
|
|
|
7
7
|
import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.contract.js';
|
|
8
|
-
import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
8
|
+
import { assertOneSpecPerLine, isCommitSha, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
9
9
|
import { hashEmail } from '../../shared/email-identity.js';
|
|
10
10
|
import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
|
|
11
11
|
import { readRevSha } from '../kb-fs/index.js';
|
|
@@ -67,11 +67,6 @@ import {
|
|
|
67
67
|
DIRECTORY_SYNC_BOT_NAME,
|
|
68
68
|
} from './directory-sync-bot.js';
|
|
69
69
|
|
|
70
|
-
/** A full object id, which is what every at-ref gate resolves its ref to before reading. */
|
|
71
|
-
function isCommitSha(ref: string): boolean {
|
|
72
|
-
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
70
|
/**
|
|
76
71
|
* What one spec of a batched read came back as: a blob's text, nothing at
|
|
77
72
|
* that path, or an object that is not a file (a directory, say). The last
|
|
@@ -358,7 +358,7 @@ describe('the HTML views section', () => {
|
|
|
358
358
|
expect(ids.indexOf('html-views')).toBe(ids.indexOf('tool-manuals') + 1);
|
|
359
359
|
});
|
|
360
360
|
|
|
361
|
-
it('describes the frame, the
|
|
361
|
+
it('describes the frame, the members core puts on window.bevel, and a fragment that scrolls the page', async () => {
|
|
362
362
|
const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '## HTML views').replace(/\s+/g, ' ');
|
|
363
363
|
expect(text).toContain('**No network.**');
|
|
364
364
|
expect(text).toContain('**Inline scripts and styles only.**');
|
|
@@ -374,6 +374,27 @@ describe('the HTML views section', () => {
|
|
|
374
374
|
expect(docs).toContain('/workspace/main/Docs/Alice.md');
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
+
it('documents window.bevel.tools: its four functions, the null check, the consent rule and requireTools with its card', async () => {
|
|
378
|
+
const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '## HTML views').replace(/\s+/g, ' ');
|
|
379
|
+
for (const member of [
|
|
380
|
+
'`window.bevel.tools.searchTools(query)`',
|
|
381
|
+
'`window.bevel.tools.toolsInfo(names)`',
|
|
382
|
+
'`window.bevel.tools.callTool(name, args)`',
|
|
383
|
+
'`window.bevel.tools.callToolChain(code)`',
|
|
384
|
+
'`window.bevel.requireTools()`',
|
|
385
|
+
]) {
|
|
386
|
+
expect(text).toContain(member);
|
|
387
|
+
}
|
|
388
|
+
expect(text).toContain('`window.bevel.tools` is `null` wherever tools are not available');
|
|
389
|
+
expect(text).toContain('Check it before every use');
|
|
390
|
+
expect(text).toContain('**Reading runs at once; writing asks first.**');
|
|
391
|
+
expect(text).toContain('"<page name> wants to write to the knowledge base"');
|
|
392
|
+
expect(text).toContain('Allow while open and Deny');
|
|
393
|
+
expect(text).toContain('rejects the call with `denied`');
|
|
394
|
+
expect(text).toContain('"<page name> uses tools that are only available in the platform"');
|
|
395
|
+
expect(text).toContain('Continue without tools and Open in the platform');
|
|
396
|
+
});
|
|
397
|
+
|
|
377
398
|
it('lets a distribution replace the section or drop it', async () => {
|
|
378
399
|
const replaced = await composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) =>
|
|
379
400
|
sections.map((s) => (s.id === 'html-views' ? { id: s.id, body: '## HTML views\n\nOurs, graph-aware.\n' } : s)),
|
|
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import type { SQL } from 'drizzle-orm';
|
|
4
4
|
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
5
5
|
import { AgentAuditService, retentionDaysFrom } from '../agent-audit.service.js';
|
|
6
|
-
import { AuditPrincipalNotFoundError, InvalidCursorError } from '../audit.contract.js';
|
|
6
|
+
import { AuditPrincipalNotFoundError, InvalidCursorError, pagePrincipalId, parsePagePrincipalId } from '../audit.contract.js';
|
|
7
7
|
import type { Database } from '../../database/connection.js';
|
|
8
8
|
import { agentConnections, oauthTokens } from '../../database/schema.js';
|
|
9
9
|
import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
|
|
@@ -217,6 +217,8 @@ describe('AgentAuditService.listPrincipals', () => {
|
|
|
217
217
|
{ keyId: 'k-1', connectionId: null, n: 7 },
|
|
218
218
|
{ keyId: null, connectionId: 'c-1', n: 3 },
|
|
219
219
|
],
|
|
220
|
+
// pages
|
|
221
|
+
[],
|
|
220
222
|
],
|
|
221
223
|
{ listForUser: vi.fn(async () => [key({ lastUsedAt: NOW - 2 * DAY })]) },
|
|
222
224
|
);
|
|
@@ -235,6 +237,7 @@ describe('AgentAuditService.listPrincipals', () => {
|
|
|
235
237
|
[
|
|
236
238
|
[{ connection: connection({ id: 'c-bob', userId: BOB.id }), user: BOB }],
|
|
237
239
|
[],
|
|
240
|
+
[],
|
|
238
241
|
],
|
|
239
242
|
{ listForDeployment: vi.fn(async () => [{ ...key({}), user: ALICE }]) },
|
|
240
243
|
);
|
|
@@ -247,6 +250,80 @@ describe('AgentAuditService.listPrincipals', () => {
|
|
|
247
250
|
});
|
|
248
251
|
});
|
|
249
252
|
|
|
253
|
+
describe('AgentAuditService — a page as a principal', () => {
|
|
254
|
+
const ALICE_ID = '11111111-1111-4111-8111-111111111111';
|
|
255
|
+
const PAGE = 'Dashboards/Roadmap.html';
|
|
256
|
+
|
|
257
|
+
it("records a page's call under its path, with neither a key nor a connection", async () => {
|
|
258
|
+
const { service, captured } = makeService([undefined]);
|
|
259
|
+
service.record({
|
|
260
|
+
userId: ALICE_ID,
|
|
261
|
+
principal: { kind: 'page', path: PAGE },
|
|
262
|
+
kind: 'capability',
|
|
263
|
+
manual: null,
|
|
264
|
+
name: 'edit_file',
|
|
265
|
+
outcome: 'refused',
|
|
266
|
+
durationMs: null,
|
|
267
|
+
});
|
|
268
|
+
await flush();
|
|
269
|
+
expect(captured.values[0]).toMatchObject({
|
|
270
|
+
userId: ALICE_ID,
|
|
271
|
+
keyId: null,
|
|
272
|
+
connectionId: null,
|
|
273
|
+
pagePath: PAGE,
|
|
274
|
+
name: 'edit_file',
|
|
275
|
+
outcome: 'refused',
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('lists each page a person opened as a row of its own, labelled by its path, never revocable', async () => {
|
|
280
|
+
const { service } = makeService([
|
|
281
|
+
[{ ...ALICE, id: ALICE_ID }],
|
|
282
|
+
[],
|
|
283
|
+
[],
|
|
284
|
+
[
|
|
285
|
+
{
|
|
286
|
+
userId: ALICE_ID,
|
|
287
|
+
pagePath: PAGE,
|
|
288
|
+
n: 4,
|
|
289
|
+
first: new Date(NOW - DAY),
|
|
290
|
+
last: new Date(NOW - 60_000),
|
|
291
|
+
user: { ...ALICE, id: ALICE_ID },
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
]);
|
|
295
|
+
const rows = await service.listPrincipals({ userId: ALICE_ID });
|
|
296
|
+
expect(rows).toHaveLength(1);
|
|
297
|
+
expect(rows[0]).toMatchObject({
|
|
298
|
+
kind: 'page',
|
|
299
|
+
id: pagePrincipalId(ALICE_ID, PAGE),
|
|
300
|
+
label: PAGE,
|
|
301
|
+
eventCount: 4,
|
|
302
|
+
createdAt: NOW - DAY,
|
|
303
|
+
lastUsedAt: NOW - 60_000,
|
|
304
|
+
revokedAt: null,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("reads a page's events by its person and path, and owns it by that person", async () => {
|
|
309
|
+
const id = pagePrincipalId(ALICE_ID, PAGE);
|
|
310
|
+
expect(parsePagePrincipalId(id)).toEqual({ userId: ALICE_ID, path: PAGE });
|
|
311
|
+
|
|
312
|
+
const events = makeService([[], [{ total: 0 }]]);
|
|
313
|
+
await events.service.listEvents({ kind: 'page', id }, { limit: 10 });
|
|
314
|
+
expect(render(events.captured.where[0]).params).toEqual([ALICE_ID, PAGE]);
|
|
315
|
+
|
|
316
|
+
const owned = makeService([[{ id: 'e-1' }]]);
|
|
317
|
+
await expect(owned.service.ownerOf({ kind: 'page', id })).resolves.toBe(ALICE_ID);
|
|
318
|
+
const never = makeService([[]]);
|
|
319
|
+
await expect(never.service.ownerOf({ kind: 'page', id })).resolves.toBeNull();
|
|
320
|
+
// An id that names no person and path is no page, without asking the database.
|
|
321
|
+
const bogus = makeService([]);
|
|
322
|
+
await expect(bogus.service.ownerOf({ kind: 'page', id: 'not-a-page' })).resolves.toBeNull();
|
|
323
|
+
expect(bogus.counts.select).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
250
327
|
describe('AgentAuditService.listEvents', () => {
|
|
251
328
|
const eventId = (i: number) => `00000000-0000-4000-8000-00000000000${i}`;
|
|
252
329
|
const eventRow = (i: number) => ({
|
|
@@ -5,6 +5,7 @@ import { createAuditRoutes } from '../audit.routes.js';
|
|
|
5
5
|
import {
|
|
6
6
|
AuditPrincipalNotFoundError,
|
|
7
7
|
InvalidCursorError,
|
|
8
|
+
pagePrincipalId,
|
|
8
9
|
type AuditPrincipal,
|
|
9
10
|
type IAgentAuditService,
|
|
10
11
|
} from '../audit.contract.js';
|
|
@@ -31,7 +32,9 @@ const ALICE_KEY: AuditPrincipal = {
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
/** Who owns what, as the fake service answers `ownerOf`. */
|
|
34
|
-
|
|
35
|
+
/** A page Alice's views have called tools from; its id names a person by uuid, so it is minted for one. */
|
|
36
|
+
const ALICE_PAGE_ID = pagePrincipalId('33333333-4444-4555-8666-777777777777', 'Dashboards/Roadmap.html');
|
|
37
|
+
const OWNERS: Record<string, string> = { [KEY_ID]: ALICE.id, [AGENT_ID]: BOB.id, [ALICE_AGENT_ID]: ALICE.id, [ALICE_PAGE_ID]: ALICE.id };
|
|
35
38
|
|
|
36
39
|
const audit = {
|
|
37
40
|
listPrincipals: vi.fn(async () => [ALICE_KEY]),
|
|
@@ -103,6 +106,24 @@ describe('audit routes — events', () => {
|
|
|
103
106
|
expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'key', id: KEY_ID }, { before: '123.e-9', limit: 200 });
|
|
104
107
|
});
|
|
105
108
|
|
|
109
|
+
it("serves a page's events to its viewer, and 404s an id that names no page", async () => {
|
|
110
|
+
const base = await listen(makeApp({ admin: false }));
|
|
111
|
+
const res = await fetch(`${base}/api/audit/principals/page/${ALICE_PAGE_ID}/events`);
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'page', id: ALICE_PAGE_ID }, { before: undefined, limit: 50 });
|
|
114
|
+
const notAPage = await fetch(`${base}/api/audit/principals/page/not-a-page/events`);
|
|
115
|
+
expect(notAPage.status).toBe(404);
|
|
116
|
+
expect(await notAPage.json()).toEqual({ error: 'No such agent, key or page' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('404s a page id whose path carries a line break of any kind', async () => {
|
|
120
|
+
const base = await listen(makeApp({ admin: false }));
|
|
121
|
+
for (const brk of ['\n', '\r', '\u0085', '\u2028', '\u2029']) {
|
|
122
|
+
const id = pagePrincipalId('33333333-4444-4555-8666-777777777777', `Dashboards/Road${brk}map.html`);
|
|
123
|
+
expect((await fetch(`${base}/api/audit/principals/page/${id}/events`)).status).toBe(404);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
106
127
|
it('answers a cursor the service does not recognise with 400, not 500', async () => {
|
|
107
128
|
const base = await listen(makeApp({ admin: false }));
|
|
108
129
|
audit.listEvents.mockRejectedValueOnce(new InvalidCursorError());
|
|
@@ -110,9 +131,23 @@ describe('audit routes — events', () => {
|
|
|
110
131
|
expect(res.status).toBe(400);
|
|
111
132
|
});
|
|
112
133
|
|
|
113
|
-
it("refuses another person's principal to a member, and serves it to an admin", async () => {
|
|
114
|
-
const
|
|
115
|
-
|
|
134
|
+
it("refuses another person's principal to a member exactly as one that does not exist, and serves it to an admin", async () => {
|
|
135
|
+
const asBob = await listen(makeApp({ admin: false, as: BOB }));
|
|
136
|
+
const missingPage = pagePrincipalId('33333333-4444-4555-8666-777777777777', 'Dashboards/Nothing.html');
|
|
137
|
+
const refusals = [];
|
|
138
|
+
const missing = '00000000-0000-4000-8000-000000000000';
|
|
139
|
+
for (const path of [
|
|
140
|
+
`page/${ALICE_PAGE_ID}`,
|
|
141
|
+
`page/${missingPage}`,
|
|
142
|
+
`key/${KEY_ID}`,
|
|
143
|
+
`key/${missing}`,
|
|
144
|
+
`agent/${ALICE_AGENT_ID}`,
|
|
145
|
+
`agent/${missing}`,
|
|
146
|
+
]) {
|
|
147
|
+
const res = await fetch(`${asBob}/api/audit/principals/${path}/events`);
|
|
148
|
+
refusals.push({ status: res.status, body: await res.json() });
|
|
149
|
+
}
|
|
150
|
+
expect(refusals).toEqual(Array(6).fill({ status: 404, body: { error: 'No such agent, key or page' } }));
|
|
116
151
|
expect(audit.listEvents).not.toHaveBeenCalled();
|
|
117
152
|
server.close();
|
|
118
153
|
|
|
@@ -166,7 +201,10 @@ describe('audit routes — revoke an agent', () => {
|
|
|
166
201
|
it('404s when the connection vanished between the ownership check and the revoke', async () => {
|
|
167
202
|
const base = await listen(makeApp({ admin: false, as: BOB }));
|
|
168
203
|
audit.revokeConnection.mockRejectedValueOnce(new AuditPrincipalNotFoundError());
|
|
169
|
-
|
|
204
|
+
const res = await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' });
|
|
205
|
+
expect(res.status).toBe(404);
|
|
206
|
+
// Only an agent revoke throws it, so it names an agent, as the route's other 404s do.
|
|
207
|
+
expect(await res.json()).toEqual({ error: 'No such agent' });
|
|
170
208
|
});
|
|
171
209
|
});
|
|
172
210
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, count, desc, eq, isNotNull, isNull, lt, or, type SQL } from 'drizzle-orm';
|
|
1
|
+
import { and, count, desc, eq, isNotNull, isNull, lt, max, min, or, type SQL } from 'drizzle-orm';
|
|
2
2
|
import { logger } from '../../shared/logging.js';
|
|
3
3
|
import type { Database } from '../database/connection.js';
|
|
4
4
|
import { agentConnections, agentEvents, externalApiKeys, oauthTokens, users } from '../database/schema.js';
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
AuditPrincipalNotFoundError,
|
|
8
8
|
InvalidCursorError,
|
|
9
9
|
isUuid,
|
|
10
|
+
pagePrincipalId,
|
|
11
|
+
parsePagePrincipalId,
|
|
10
12
|
parseRetentionWindow,
|
|
11
13
|
type AgentEventInput,
|
|
12
14
|
type AgentEventPage,
|
|
@@ -59,6 +61,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
59
61
|
userId: event.userId,
|
|
60
62
|
keyId: event.principal.kind === 'key' ? event.principal.id : null,
|
|
61
63
|
connectionId: event.principal.kind === 'agent' ? event.principal.id : null,
|
|
64
|
+
pagePath: event.principal.kind === 'page' ? event.principal.path : null,
|
|
62
65
|
kind: event.kind,
|
|
63
66
|
manual: event.manual,
|
|
64
67
|
name: event.name,
|
|
@@ -93,7 +96,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
93
96
|
|
|
94
97
|
async listPrincipals(scope: AuditScope): Promise<AuditPrincipal[]> {
|
|
95
98
|
const mine = scope === 'all' ? null : scope.userId;
|
|
96
|
-
const [keyRows, connectionRows, countRows] = await Promise.all([
|
|
99
|
+
const [keyRows, connectionRows, countRows, pageRows] = await Promise.all([
|
|
97
100
|
mine === null ? this.keys.listForDeployment() : this.keysForUser(mine),
|
|
98
101
|
this.db
|
|
99
102
|
.select({
|
|
@@ -111,6 +114,22 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
111
114
|
.from(agentEvents)
|
|
112
115
|
.where(mine === null ? undefined : eq(agentEvents.userId, mine))
|
|
113
116
|
.groupBy(agentEvents.keyId, agentEvents.connectionId),
|
|
117
|
+
// A page has no row of its own: it is every event recorded under one
|
|
118
|
+
// person and one path, so its "created" is its first call and its
|
|
119
|
+
// "last used" its latest.
|
|
120
|
+
this.db
|
|
121
|
+
.select({
|
|
122
|
+
userId: agentEvents.userId,
|
|
123
|
+
pagePath: agentEvents.pagePath,
|
|
124
|
+
n: count(),
|
|
125
|
+
first: min(agentEvents.at),
|
|
126
|
+
last: max(agentEvents.at),
|
|
127
|
+
user: { id: users.id, email: users.email, name: users.name },
|
|
128
|
+
})
|
|
129
|
+
.from(agentEvents)
|
|
130
|
+
.innerJoin(users, eq(agentEvents.userId, users.id))
|
|
131
|
+
.where(and(isNotNull(agentEvents.pagePath), mine === null ? undefined : eq(agentEvents.userId, mine)))
|
|
132
|
+
.groupBy(agentEvents.userId, agentEvents.pagePath, users.id, users.email, users.name),
|
|
114
133
|
]);
|
|
115
134
|
|
|
116
135
|
const counts = new Map<string, number>();
|
|
@@ -144,6 +163,24 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
144
163
|
eventCount: counts.get(c.id) ?? 0,
|
|
145
164
|
user,
|
|
146
165
|
})),
|
|
166
|
+
...pageRows.flatMap((r) =>
|
|
167
|
+
r.pagePath
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
kind: 'page' as const,
|
|
171
|
+
id: pagePrincipalId(r.userId, r.pagePath),
|
|
172
|
+
label: r.pagePath,
|
|
173
|
+
createdAt: toMs(r.first),
|
|
174
|
+
lastUsedAt: r.last ? toMs(r.last) : null,
|
|
175
|
+
// A page cannot be revoked: it acts only while its viewer has it open.
|
|
176
|
+
revokedAt: null,
|
|
177
|
+
revokedBy: null,
|
|
178
|
+
eventCount: Number(r.n),
|
|
179
|
+
user: r.user,
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
: [],
|
|
183
|
+
),
|
|
147
184
|
];
|
|
148
185
|
// Per account (by email), live before revoked, most recently used first,
|
|
149
186
|
// newest first among the never-used — the order the page shows without
|
|
@@ -175,6 +212,17 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
175
212
|
}
|
|
176
213
|
|
|
177
214
|
async ownerOf(principal: AuditPrincipalRef): Promise<string | null> {
|
|
215
|
+
if (principal.kind === 'page') {
|
|
216
|
+
// The person is in the id; the page exists for them once it has called something.
|
|
217
|
+
const page = parsePagePrincipalId(principal.id);
|
|
218
|
+
if (!page) return null;
|
|
219
|
+
const [row] = await this.db
|
|
220
|
+
.select({ id: agentEvents.id })
|
|
221
|
+
.from(agentEvents)
|
|
222
|
+
.where(and(eq(agentEvents.userId, page.userId), eq(agentEvents.pagePath, page.path)))
|
|
223
|
+
.limit(1);
|
|
224
|
+
return row ? page.userId : null;
|
|
225
|
+
}
|
|
178
226
|
if (principal.kind === 'agent') {
|
|
179
227
|
const [row] = await this.db
|
|
180
228
|
.select({ userId: agentConnections.userId })
|
|
@@ -192,10 +240,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
192
240
|
page: { before?: string; limit: number },
|
|
193
241
|
): Promise<AgentEventPage> {
|
|
194
242
|
const limit = Math.max(1, Math.min(MAX_EVENT_PAGE, Math.floor(page.limit)));
|
|
195
|
-
const byPrincipal =
|
|
196
|
-
principal.kind === 'key'
|
|
197
|
-
? eq(agentEvents.keyId, principal.id)
|
|
198
|
-
: eq(agentEvents.connectionId, principal.id);
|
|
243
|
+
const byPrincipal = principalFilter(principal);
|
|
199
244
|
// Keyset on (at, id): a page boundary between two events at the same
|
|
200
245
|
// instant is still a boundary, and a row inserted while the reader pages
|
|
201
246
|
// never shifts what the next page returns.
|
|
@@ -340,6 +385,21 @@ function formatCursor(at: Date, id: string): string {
|
|
|
340
385
|
* compare it with anything else) and the instant a whole number of epoch
|
|
341
386
|
* milliseconds a `timestamp` can hold.
|
|
342
387
|
*/
|
|
388
|
+
/** The events of one principal. A page id that names no page matches nothing. */
|
|
389
|
+
function principalFilter(principal: AuditPrincipalRef): SQL {
|
|
390
|
+
if (principal.kind === 'key') return eq(agentEvents.keyId, principal.id);
|
|
391
|
+
if (principal.kind === 'agent') return eq(agentEvents.connectionId, principal.id);
|
|
392
|
+
const page = parsePagePrincipalId(principal.id);
|
|
393
|
+
if (!page) return isNull(agentEvents.id);
|
|
394
|
+
return and(eq(agentEvents.userId, page.userId), eq(agentEvents.pagePath, page.path))!;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** An aggregate's timestamp, which a driver may hand back as a Date or as the text Postgres sent. */
|
|
398
|
+
function toMs(value: Date | string | null): number {
|
|
399
|
+
if (value === null) return 0;
|
|
400
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
401
|
+
}
|
|
402
|
+
|
|
343
403
|
function parseCursor(raw: string): { at: Date; id: string } | null {
|
|
344
404
|
const dot = raw.indexOf('.');
|
|
345
405
|
if (dot < 0) return null;
|
|
@@ -24,19 +24,71 @@ declare global {
|
|
|
24
24
|
/** What was called: the platform's own tool, a manual's tool, or a skill read. */
|
|
25
25
|
export type AgentEventKind = 'capability' | 'tool' | 'skill';
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* How the call ended. `denied`: the caller's sign-in for the tool was
|
|
29
|
+
* missing, so nothing ran. `refused`: a page made a call that needs the
|
|
30
|
+
* viewer's approval (a writing tool, a tool the manual does not list, or a
|
|
31
|
+
* chain) and the person viewing it answered Deny, so nothing ran.
|
|
32
|
+
*/
|
|
33
|
+
export type AgentEventOutcome = 'ok' | 'error' | 'denied' | 'refused';
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Who a call is attributed to: the connection key it was authenticated with,
|
|
32
|
-
*
|
|
37
|
+
* the agent connection behind its OAuth grant, or the knowledge-base page
|
|
38
|
+
* whose script made it as the person viewing it. Exactly one.
|
|
39
|
+
*
|
|
40
|
+
* A page has no row of its own: it is a person and a path, and its `id` is
|
|
41
|
+
* both, encoded by {@link pagePrincipalId} so the Audit log's routes can name
|
|
42
|
+
* it like the other two.
|
|
43
|
+
*/
|
|
44
|
+
export type AuditPrincipalRef = { kind: 'key'; id: string } | { kind: 'agent'; id: string } | { kind: 'page'; id: string };
|
|
45
|
+
|
|
46
|
+
/** Who a call is attributed to, as the recorder takes it: a page by its path, the recorder knows the person. */
|
|
47
|
+
export type RecordedPrincipal = { kind: 'key'; id: string } | { kind: 'agent'; id: string } | { kind: 'page'; path: string };
|
|
48
|
+
|
|
49
|
+
/** The longest page path a page principal may carry. A path is a file in the knowledge base, never longer. */
|
|
50
|
+
export const MAX_PAGE_PATH_LENGTH = 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A page principal's id: the person and the page's path, encoded so it is one
|
|
54
|
+
* URL segment. Opaque to the client, which only ever echoes it back.
|
|
33
55
|
*/
|
|
34
|
-
export
|
|
56
|
+
export function pagePrincipalId(userId: string, path: string): string {
|
|
57
|
+
return Buffer.from(`${userId}\n${path}`, 'utf8').toString('base64url');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The person and the path a page principal's id names, or null when it names none. */
|
|
61
|
+
export function parsePagePrincipalId(id: string): { userId: string; path: string } | null {
|
|
62
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) return null;
|
|
63
|
+
const decoded = Buffer.from(id, 'base64url').toString('utf8');
|
|
64
|
+
const cut = decoded.indexOf('\n');
|
|
65
|
+
if (cut < 0) return null;
|
|
66
|
+
const userId = decoded.slice(0, cut);
|
|
67
|
+
const path = decoded.slice(cut + 1);
|
|
68
|
+
if (!isUuid(userId) || !isPagePath(path)) return null;
|
|
69
|
+
return { userId, path };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `path` can be a page principal: a non-empty knowledge-base path of
|
|
74
|
+
* printable characters, bounded. A page names itself through a request
|
|
75
|
+
* header, so this is the check that keeps a line break or a control character
|
|
76
|
+
* out of the log and its listing — the C1 controls (NEL among them) and the
|
|
77
|
+
* Unicode line and paragraph separators included.
|
|
78
|
+
*/
|
|
79
|
+
export function isPagePath(path: string): boolean {
|
|
80
|
+
return (
|
|
81
|
+
path.length > 0 &&
|
|
82
|
+
path.length <= MAX_PAGE_PATH_LENGTH &&
|
|
83
|
+
// eslint-disable-next-line no-control-regex -- the point is to refuse control characters
|
|
84
|
+
!/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(path)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
35
87
|
|
|
36
88
|
/** One recorded call, as the recorder takes it. */
|
|
37
89
|
export interface AgentEventInput {
|
|
38
90
|
userId: string;
|
|
39
|
-
principal:
|
|
91
|
+
principal: RecordedPrincipal;
|
|
40
92
|
kind: AgentEventKind;
|
|
41
93
|
/** The MCP server / `.tool` manual for a tool, the skill folder for a skill; null for a capability. */
|
|
42
94
|
manual: string | null;
|
|
@@ -54,18 +106,18 @@ export interface IAgentEventRecorder {
|
|
|
54
106
|
record(event: AgentEventInput): void;
|
|
55
107
|
}
|
|
56
108
|
|
|
57
|
-
/** One row of the Audit log: an agent connection
|
|
109
|
+
/** One row of the Audit log: an agent connection, a connection key or a page, with its owner. */
|
|
58
110
|
export interface AuditPrincipal {
|
|
59
|
-
kind: 'key' | 'agent';
|
|
111
|
+
kind: 'key' | 'agent' | 'page';
|
|
60
112
|
id: string;
|
|
61
|
-
/** The key's label,
|
|
113
|
+
/** The key's label, the agent's registered client name, or the page's knowledge-base path. */
|
|
62
114
|
label: string;
|
|
63
115
|
/**
|
|
64
116
|
* For a key: what it was minted as (`key` by hand, `github-link` for a
|
|
65
117
|
* Claude link). Absent for an agent.
|
|
66
118
|
*/
|
|
67
119
|
keyKind?: string;
|
|
68
|
-
/** When the key was created / the agent connected. Epoch ms. */
|
|
120
|
+
/** When the key was created / the agent connected / the page first called a tool. Epoch ms. */
|
|
69
121
|
createdAt: number;
|
|
70
122
|
lastUsedAt: number | null;
|
|
71
123
|
revokedAt: number | null;
|
|
@@ -149,7 +201,7 @@ export interface IAgentConnectionStatus {
|
|
|
149
201
|
|
|
150
202
|
export class AuditPrincipalNotFoundError extends Error {
|
|
151
203
|
constructor() {
|
|
152
|
-
super('No such agent
|
|
204
|
+
super('No such agent');
|
|
153
205
|
this.name = 'AuditPrincipalNotFoundError';
|
|
154
206
|
}
|
|
155
207
|
}
|
|
@@ -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);
|