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