@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.
Files changed (210) 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 +7 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +16 -3
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +1 -5
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access-model/index.d.ts +14 -0
  19. package/dist/modules/access-model/index.d.ts.map +1 -0
  20. package/dist/modules/access-model/index.js +13 -0
  21. package/dist/modules/access-model/index.js.map +1 -0
  22. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  23. package/dist/modules/audit/agent-audit.service.js +66 -6
  24. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  25. package/dist/modules/audit/audit.contract.d.ts +52 -8
  26. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  27. package/dist/modules/audit/audit.contract.js +37 -1
  28. package/dist/modules/audit/audit.contract.js.map +1 -1
  29. package/dist/modules/audit/audit.routes.d.ts +1 -1
  30. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  31. package/dist/modules/audit/audit.routes.js +15 -13
  32. package/dist/modules/audit/audit.routes.js.map +1 -1
  33. package/dist/modules/audit/request-audit.d.ts +21 -10
  34. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  35. package/dist/modules/audit/request-audit.js +26 -10
  36. package/dist/modules/audit/request-audit.js.map +1 -1
  37. package/dist/modules/auth/account-hooks.d.ts +4 -6
  38. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  39. package/dist/modules/auth/account-hooks.js +7 -17
  40. package/dist/modules/auth/account-hooks.js.map +1 -1
  41. package/dist/modules/auth/auth.service.d.ts +9 -8
  42. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.service.js +19 -18
  44. package/dist/modules/auth/auth.service.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +43 -4
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +31 -7
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/kb-fs/index.d.ts +1 -0
  50. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  51. package/dist/modules/kb-fs/index.js +1 -0
  52. package/dist/modules/kb-fs/index.js.map +1 -1
  53. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  54. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  55. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  56. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  57. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/mutex.js +14 -0
  59. package/dist/modules/kb-fs/mutex.js.map +1 -1
  60. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  61. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  62. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  63. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  64. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  65. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  66. package/dist/modules/mcp/mcp.routes.js +67 -0
  67. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  68. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  69. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  70. package/dist/modules/mcp/mcp.service.js +257 -50
  71. package/dist/modules/mcp/mcp.service.js.map +1 -1
  72. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  73. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  74. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  75. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  76. package/dist/modules/mcp/request-context.d.ts +25 -0
  77. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  78. package/dist/modules/mcp/request-context.js +11 -0
  79. package/dist/modules/mcp/request-context.js.map +1 -0
  80. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  81. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  82. package/dist/modules/mcp/shared-definitions.js +117 -0
  83. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  84. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  85. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  86. package/dist/modules/mcp/surface-cache.js +104 -0
  87. package/dist/modules/mcp/surface-cache.js.map +1 -0
  88. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  89. package/dist/modules/tool-helpers/tool-context.js +33 -12
  90. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  91. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  92. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  93. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  94. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  95. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  96. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  97. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  98. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  99. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  100. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  101. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  102. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  103. package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
  104. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  105. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  106. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  107. package/dist/modules/workflow/change-request-hooks.js +15 -0
  108. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  109. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  110. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  111. package/dist/modules/workflow/git/git.service.js +470 -106
  112. package/dist/modules/workflow/git/git.service.js.map +1 -1
  113. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  114. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  116. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  117. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  118. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  119. package/dist/modules/workflow/read-on-branch.js +58 -0
  120. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  121. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  122. package/dist/modules/workflow/workflow.routes.js +22 -41
  123. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  124. package/dist/modules/workflow/workflow.service.d.ts +124 -24
  125. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  126. package/dist/modules/workflow/workflow.service.js +326 -48
  127. package/dist/modules/workflow/workflow.service.js.map +1 -1
  128. package/dist/modules/workspace/index.d.ts +2 -0
  129. package/dist/modules/workspace/index.d.ts.map +1 -1
  130. package/dist/modules/workspace/index.js +4 -0
  131. package/dist/modules/workspace/index.js.map +1 -1
  132. package/dist/shared/git.contract.d.ts +7 -0
  133. package/dist/shared/git.contract.d.ts.map +1 -1
  134. package/dist/shared/git.contract.js +9 -0
  135. package/dist/shared/git.contract.js.map +1 -1
  136. package/dist/shared/listener-seam.d.ts +34 -0
  137. package/dist/shared/listener-seam.d.ts.map +1 -0
  138. package/dist/shared/listener-seam.js +42 -0
  139. package/dist/shared/listener-seam.js.map +1 -0
  140. package/dist/shared/no-link-below.d.ts +10 -0
  141. package/dist/shared/no-link-below.d.ts.map +1 -0
  142. package/dist/shared/no-link-below.js +30 -0
  143. package/dist/shared/no-link-below.js.map +1 -0
  144. package/migrations/0018_agent_events_page.sql +11 -0
  145. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  146. package/migrations/meta/0018_snapshot.json +2429 -0
  147. package/migrations/meta/0019_snapshot.json +2435 -0
  148. package/migrations/meta/_journal.json +15 -1
  149. package/package.json +3 -3
  150. package/src/core/catalog-revision.ts +3 -3
  151. package/src/core/create-core-server.ts +2 -1
  152. package/src/core/create-core-services.ts +21 -2
  153. package/src/index.ts +7 -0
  154. package/src/modules/access/access-control.service.ts +1 -6
  155. package/src/modules/access-model/index.ts +13 -0
  156. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  157. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  158. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  159. package/src/modules/audit/agent-audit.service.ts +66 -6
  160. package/src/modules/audit/audit.contract.ts +62 -10
  161. package/src/modules/audit/audit.routes.ts +14 -12
  162. package/src/modules/audit/request-audit.ts +28 -13
  163. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  164. package/src/modules/auth/account-hooks.ts +13 -21
  165. package/src/modules/auth/auth.service.ts +19 -18
  166. package/src/modules/database/core-schema.ts +31 -7
  167. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  168. package/src/modules/kb-fs/index.ts +1 -0
  169. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  170. package/src/modules/kb-fs/mutex.ts +16 -0
  171. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  172. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  173. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  174. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  175. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  176. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  177. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  178. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  179. package/src/modules/mcp/mcp.routes.ts +68 -0
  180. package/src/modules/mcp/mcp.service.ts +315 -51
  181. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  182. package/src/modules/mcp/request-context.ts +34 -0
  183. package/src/modules/mcp/shared-definitions.ts +121 -0
  184. package/src/modules/mcp/surface-cache.ts +154 -0
  185. package/src/modules/tool-helpers/tool-context.ts +37 -18
  186. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  187. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  188. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  189. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  190. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  191. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  192. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  193. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
  194. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
  195. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  196. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  197. package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
  198. package/src/modules/workflow/change-request-hooks.ts +96 -0
  199. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  200. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  201. package/src/modules/workflow/git/git.service.ts +537 -106
  202. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  203. package/src/modules/workflow/read-on-branch.ts +79 -0
  204. package/src/modules/workflow/workflow.routes.ts +21 -39
  205. package/src/modules/workflow/workflow.service.ts +390 -55
  206. package/src/modules/workspace/index.ts +11 -0
  207. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  208. package/src/shared/git.contract.ts +10 -0
  209. package/src/shared/listener-seam.ts +52 -0
  210. package/src/shared/no-link-below.ts +29 -0
@@ -127,6 +127,20 @@
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
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1792404850653,
142
+ "tag": "0019_oauth_token_rotated_at",
143
+ "breakpoints": true
130
144
  }
131
145
  ]
132
- }
146
+ }
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.32.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-mcp-core": "0.32.0",
59
+ "@bevel-software/platform-shared": "0.32.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';
@@ -127,6 +127,7 @@ import { FileChangeNotifier } from '../modules/kb-fs/file-change-notifier.js';
127
127
  import { StarterPackService } from '../modules/onboarding/starter-pack.service.js';
128
128
  import { WorkflowService } from '../modules/workflow/workflow.service.js';
129
129
  import { WorkflowHooks } from '../modules/workflow/workflow-hooks.js';
130
+ import { ChangeRequestHooks, type IChangeRequestHooks } from '../modules/workflow/change-request-hooks.js';
130
131
  import { PendingCommitsService } from '../modules/workflow/pending-commits.service.js';
131
132
  import {
132
133
  PendingCommitsWorker,
@@ -324,6 +325,12 @@ export interface CoreServices {
324
325
  reviewWorkflowService: ReviewWorkflowService;
325
326
  fileLockService: FileLockService;
326
327
  workflowService: WorkflowService;
328
+ /**
329
+ * The seam for "a change request was opened": a deployment registers here
330
+ * how the reviewers are told (the cloud's mail, say), and the open's answer
331
+ * says whether somebody did. Core registers nothing.
332
+ */
333
+ changeRequestHooks: IChangeRequestHooks;
327
334
  /** Remote sync (`POST /api/sync`): a git host or pipeline makes the clones catch up. */
328
335
  kbSyncService: KbSyncService;
329
336
  eventBus: WorkflowEventBus;
@@ -578,7 +585,9 @@ export async function createCoreServices(
578
585
  */
579
586
  // Shared mutex so git and diff operations on the same workspace serialize
580
587
  // against each other — a backup-reseed races a concurrent commit otherwise.
581
- const workspaceMutex = new WorkspaceMutex();
588
+ // Keyed by the CANONICAL workspace id: a route's Express-decoded `:id` and
589
+ // a tool's encoded one reserve the same clone (see `WorkspaceIdMutex`).
590
+ const workspaceMutex = new WorkspaceIdMutex();
582
591
  /**
583
592
  * The knowledge base's ONE repository: every branch workspace is a worktree
584
593
  * of it (see `kb-repository.ts`). One instance, handed to the workspace
@@ -948,6 +957,10 @@ export async function createCoreServices(
948
957
  // id-repair keeps the id namespace collision-free. Subscribers are registered
949
958
  // below, once the reacting services exist.
950
959
  const fileChangeNotifier = new FileChangeNotifier();
960
+ // Who tells the reviewers of an opened change request: the composition root
961
+ // of a distribution registers its listener here, after this returns (see
962
+ // `ChangeRequestHooks`).
963
+ const changeRequestHooks = new ChangeRequestHooks();
951
964
  const workflowService = new WorkflowService(
952
965
  db,
953
966
  gitService,
@@ -965,6 +978,11 @@ export async function createCoreServices(
965
978
  // the agent-access gate consult, so a deployment's registrations against
966
979
  // it reach every hook point.
967
980
  workflowHooks,
981
+ // Exposed as `changeRequestHooks`, the same instance, for the same reason.
982
+ changeRequestHooks,
983
+ // The opened event names the approvers as accounts; auth answers which
984
+ // of the addresses the access model names have one.
985
+ authService,
968
986
  );
969
987
  // The leftover-branch cleanup asks the Deployment page at every round, so
970
988
  // switching it off there applies without a restart.
@@ -1666,6 +1684,7 @@ export async function createCoreServices(
1666
1684
  reviewWorkflowService,
1667
1685
  fileLockService,
1668
1686
  workflowService,
1687
+ changeRequestHooks,
1669
1688
  kbSyncService,
1670
1689
  eventBus,
1671
1690
  fileChangeNotifier,
package/src/index.ts CHANGED
@@ -151,3 +151,10 @@ export {
151
151
  type AgentReadHook,
152
152
  type PreWriteHook,
153
153
  } from './modules/workflow/workflow-hooks.js';
154
+ export {
155
+ ChangeRequestHooks,
156
+ type ChangeRequestOpenedEvent,
157
+ type ChangeRequestOpenedHook,
158
+ type ChangeRequestPerson,
159
+ type IChangeRequestHooks,
160
+ } from './modules/workflow/change-request-hooks.js';
@@ -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
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The access model's public entry: what another module imports of it.
3
+ *
4
+ * The module is the grammar of `access.md` (how principals, roles, groups
5
+ * and verbs are spelled), the errors a refused access is reported with, and
6
+ * the gate a change is read through. A consumer outside the module imports
7
+ * from here, not from the files; the files are the module's own layout and
8
+ * free to move. The `access` module's own files predate this entry and still
9
+ * import the files directly — they move here as they are touched.
10
+ */
11
+ export * from './access-grammar.js';
12
+ export * from './access-errors.js';
13
+ export type { ChangeReadVerdict, ChangeTargetKind, IChangeReadGate } from './change-gate.js';
@@ -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
  }