@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.
Files changed (156) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts.map +1 -1
  8. package/dist/core/create-core-services.js +4 -2
  9. package/dist/core/create-core-services.js.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  11. package/dist/modules/access/access-control.service.js +1 -5
  12. package/dist/modules/access/access-control.service.js.map +1 -1
  13. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  14. package/dist/modules/audit/agent-audit.service.js +66 -6
  15. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  16. package/dist/modules/audit/audit.contract.d.ts +52 -8
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  18. package/dist/modules/audit/audit.contract.js +37 -1
  19. package/dist/modules/audit/audit.contract.js.map +1 -1
  20. package/dist/modules/audit/audit.routes.d.ts +1 -1
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  22. package/dist/modules/audit/audit.routes.js +15 -13
  23. package/dist/modules/audit/audit.routes.js.map +1 -1
  24. package/dist/modules/audit/request-audit.d.ts +21 -10
  25. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  26. package/dist/modules/audit/request-audit.js +26 -10
  27. package/dist/modules/audit/request-audit.js.map +1 -1
  28. package/dist/modules/database/core-schema.d.ts +22 -3
  29. package/dist/modules/database/core-schema.d.ts.map +1 -1
  30. package/dist/modules/database/core-schema.js +15 -6
  31. package/dist/modules/database/core-schema.js.map +1 -1
  32. package/dist/modules/kb-fs/index.d.ts +1 -0
  33. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  34. package/dist/modules/kb-fs/index.js +1 -0
  35. package/dist/modules/kb-fs/index.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  37. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  38. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  39. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  40. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  41. package/dist/modules/kb-fs/mutex.js +14 -0
  42. package/dist/modules/kb-fs/mutex.js.map +1 -1
  43. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  44. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  45. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  46. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  47. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  48. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp.routes.js +67 -0
  50. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  51. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  52. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  53. package/dist/modules/mcp/mcp.service.js +257 -50
  54. package/dist/modules/mcp/mcp.service.js.map +1 -1
  55. package/dist/modules/mcp/request-context.d.ts +25 -0
  56. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  57. package/dist/modules/mcp/request-context.js +11 -0
  58. package/dist/modules/mcp/request-context.js.map +1 -0
  59. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  60. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  61. package/dist/modules/mcp/shared-definitions.js +117 -0
  62. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  63. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  64. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  65. package/dist/modules/mcp/surface-cache.js +104 -0
  66. package/dist/modules/mcp/surface-cache.js.map +1 -0
  67. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  68. package/dist/modules/tool-helpers/tool-context.js +33 -12
  69. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  70. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  71. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  72. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  73. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  74. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  75. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +470 -106
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  85. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  86. package/dist/modules/workflow/read-on-branch.js +58 -0
  87. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  88. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  89. package/dist/modules/workflow/workflow.routes.js +22 -41
  90. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  91. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  92. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/workflow.service.js +163 -16
  94. package/dist/modules/workflow/workflow.service.js.map +1 -1
  95. package/dist/modules/workspace/index.d.ts +2 -0
  96. package/dist/modules/workspace/index.d.ts.map +1 -1
  97. package/dist/modules/workspace/index.js +4 -0
  98. package/dist/modules/workspace/index.js.map +1 -1
  99. package/dist/shared/git.contract.d.ts +7 -0
  100. package/dist/shared/git.contract.d.ts.map +1 -1
  101. package/dist/shared/git.contract.js +9 -0
  102. package/dist/shared/git.contract.js.map +1 -1
  103. package/dist/shared/no-link-below.d.ts +10 -0
  104. package/dist/shared/no-link-below.d.ts.map +1 -0
  105. package/dist/shared/no-link-below.js +30 -0
  106. package/dist/shared/no-link-below.js.map +1 -0
  107. package/migrations/0018_agent_events_page.sql +11 -0
  108. package/migrations/meta/0018_snapshot.json +2429 -0
  109. package/migrations/meta/_journal.json +8 -1
  110. package/package.json +3 -3
  111. package/src/core/catalog-revision.ts +3 -3
  112. package/src/core/create-core-server.ts +2 -1
  113. package/src/core/create-core-services.ts +4 -2
  114. package/src/modules/access/access-control.service.ts +1 -6
  115. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  116. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  117. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  118. package/src/modules/audit/agent-audit.service.ts +66 -6
  119. package/src/modules/audit/audit.contract.ts +62 -10
  120. package/src/modules/audit/audit.routes.ts +14 -12
  121. package/src/modules/audit/request-audit.ts +28 -13
  122. package/src/modules/database/core-schema.ts +15 -6
  123. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  124. package/src/modules/kb-fs/index.ts +1 -0
  125. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  126. package/src/modules/kb-fs/mutex.ts +16 -0
  127. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  128. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  129. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  130. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  131. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  132. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  133. package/src/modules/mcp/mcp.routes.ts +68 -0
  134. package/src/modules/mcp/mcp.service.ts +315 -51
  135. package/src/modules/mcp/request-context.ts +34 -0
  136. package/src/modules/mcp/shared-definitions.ts +121 -0
  137. package/src/modules/mcp/surface-cache.ts +154 -0
  138. package/src/modules/tool-helpers/tool-context.ts +37 -18
  139. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  140. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  141. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  142. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  143. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  144. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  145. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  147. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  148. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  149. package/src/modules/workflow/git/git.service.ts +537 -106
  150. package/src/modules/workflow/read-on-branch.ts +79 -0
  151. package/src/modules/workflow/workflow.routes.ts +21 -39
  152. package/src/modules/workflow/workflow.service.ts +203 -16
  153. package/src/modules/workspace/index.ts +11 -0
  154. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  155. package/src/shared/git.contract.ts +10 -0
  156. 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.30.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-mcp-core": "0.30.0",
59
- "@bevel-software/platform-shared": "0.30.0"
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 is stateless, so
16
- * every request there already rebuilds its surface from the live registry
17
- * (`mcp.routes.stateless.test.ts` pins that). A process on someone's laptop
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
- registerWorkflowTools(core.toolRegistry, toolsRouter, ta, th, core.kb, changeRequestScope(core.accessControl, core.kb).maySee);
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
- const workspaceMutex = new WorkspaceMutex();
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 two members core puts on window.bevel, and a fragment that scrolls the page', async () => {
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
- const OWNERS: Record<string, string> = { [KEY_ID]: ALICE.id, [AGENT_ID]: BOB.id, [ALICE_AGENT_ID]: ALICE.id };
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 asAlice = await listen(makeApp({ admin: false }));
115
- expect((await fetch(`${asAlice}/api/audit/principals/agent/${AGENT_ID}/events`)).status).toBe(403);
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
- expect((await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' })).status).toBe(404);
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
- /** How the call ended. `denied`: the caller's sign-in for the tool was missing, so nothing ran. */
28
- export type AgentEventOutcome = 'ok' | 'error' | 'denied';
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
- * or the agent connection behind its OAuth grant. Exactly one.
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 type AuditPrincipalRef = { kind: 'key'; id: string } | { kind: 'agent'; id: string };
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: AuditPrincipalRef;
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 or a connection key, with its owner. */
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, or the agent's registered client name. */
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 or key');
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 + keys, with owners and event counts
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
- /** Whether the caller may read or revoke `principal`: its owner, or an admin. Null when there is no such principal. */
52
- const mayAct = async (req: express.Request, principal: AuditPrincipalRef): Promise<boolean | null> => {
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 null;
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 key' });
84
+ res.status(404).json({ error: 'No such agent, key or page' });
78
85
  return;
79
86
  }
80
87
  try {
81
- const allowed = await mayAct(req, principal);
82
- if (allowed === null) {
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);