@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
@@ -32,9 +32,27 @@ import { signAuthRequest, type McpAuthRequestState } from './oauth-state.js';
32
32
  // (`<tenant>-int_`).
33
33
  const TOKEN_BYTES = 32;
34
34
 
35
+ /** A transaction's handle, for the statements a refresh must land together. */
36
+ type Tx = Parameters<Parameters<Database['transaction']>[0]>[0];
37
+
35
38
  const ACCESS_TTL_MS = 60 * 60_000; // 1 hour
36
39
  const REFRESH_TTL_MS = 30 * 24 * 60 * 60_000; // 30 days, rotated on use
37
40
  const CODE_TTL_MS = 60_000; // exchanged within seconds of Finish
41
+ /**
42
+ * How long a refresh token that was just rotated is honoured once more.
43
+ *
44
+ * Rotation makes a refresh token single-use, and a client that refreshes
45
+ * from two places at once — Claude's hosted apps refresh proactively before
46
+ * expiry AND reactively on a 401, and a scheduled task runs beside the chat
47
+ * that shares its connector — presents the same token twice. The second
48
+ * presentation used to be answered `invalid_grant`, which a client reads as
49
+ * "the credential is dead" and turns into a re-authorization prompt every
50
+ * day or two. Within this window the loser is answered as the winner was,
51
+ * with a fresh pair; past it, reuse is refused, which is what makes a stolen
52
+ * token's later replay fail. Sixty seconds is the window Okta allows at most
53
+ * and Auth0 recommends for exactly this case.
54
+ */
55
+ const REFRESH_REUSE_GRACE_MS = 60_000;
38
56
  /**
39
57
  * How often, at most, one process stamps an agent connection's `last_used_at`.
40
58
  * The Audit log shows the instant relatively ("4 minutes ago"), so a minute's
@@ -261,34 +279,54 @@ export class BevelOAuthProvider implements OAuthServerProvider {
261
279
  _resource?: URL,
262
280
  ): Promise<OAuthTokens> {
263
281
  const now = new Date();
264
- // Rotation: atomically revoke the presented refresh token; the winner of
265
- // a concurrent double-refresh gets the new pair, the loser a clean 400.
266
- // `clientId` is IN the predicate so a caller presenting a valid refresh
267
- // token under the WRONG client can't revoke (kill) another client's
268
- // session — the update matches nothing and their token stays live.
269
- const [row] = await this.deps.db
270
- .update(oauthTokens)
271
- .set({ revokedAt: now })
272
- .where(
273
- and(
274
- eq(oauthTokens.refreshTokenHash, hashToken(refreshToken)),
275
- eq(oauthTokens.clientId, client.client_id),
276
- isNull(oauthTokens.revokedAt),
277
- ),
278
- )
279
- .returning();
280
- if (!row) {
281
- throw new InvalidGrantError('Invalid or revoked refresh token');
282
+ const hash = hashToken(refreshToken);
283
+ const clientId = client.client_id;
284
+ // The row is READ first and judged whole, and consumed only once every
285
+ // check has passed: a refusal must leave a token the client could still
286
+ // use with a different request (a narrower scope) usable, and nothing
287
+ // may be minted or retired on a token that is expired, revoked, or
288
+ // bound to a revoked agent. Looked up by hash AND client, so a caller
289
+ // presenting a valid refresh token under the WRONG client learns
290
+ // nothing and changes nothing.
291
+ const [seen] = await this.deps.db
292
+ .select()
293
+ .from(oauthTokens)
294
+ .where(and(eq(oauthTokens.refreshTokenHash, hash), eq(oauthTokens.clientId, clientId)))
295
+ .limit(1);
296
+ if (!seen) return this.refuseRefresh('unknown refresh token for this client', 'Invalid or revoked refresh token', { clientId });
297
+ const rotatedAgoMs = seen.rotatedAt ? now.getTime() - seen.rotatedAt.getTime() : null;
298
+ const rotatedAgo = rotatedAgoMs === null ? null : `${Math.round(rotatedAgoMs / 1000)} s ago`;
299
+ if (seen.revokedAt) {
300
+ return this.refuseRefresh(
301
+ rotatedAgo ? `refresh token rotated ${rotatedAgo} and already replayed once, or revoked since` : 'refresh token revoked',
302
+ 'Invalid or revoked refresh token',
303
+ seen,
304
+ );
305
+ }
306
+ if (rotatedAgoMs !== null && rotatedAgoMs > REFRESH_REUSE_GRACE_MS) {
307
+ return this.refuseRefresh(
308
+ `refresh token rotated ${rotatedAgo}, past the ${REFRESH_REUSE_GRACE_MS / 1000} s reuse grace`,
309
+ 'Invalid or revoked refresh token',
310
+ seen,
311
+ );
282
312
  }
283
- if (row.refreshExpiresAt && row.refreshExpiresAt <= now) {
284
- throw new InvalidGrantError('Refresh token expired');
313
+ if (seen.refreshExpiresAt && seen.refreshExpiresAt <= now) {
314
+ return this.refuseRefresh('refresh token expired', 'Refresh token expired', seen);
285
315
  }
286
316
  // Scope may only narrow on refresh (RFC 6749 §6).
287
- let scope = row.scope;
317
+ let scope = seen.scope;
288
318
  if (scopes?.length) {
289
- const granted = new Set((row.scope ?? '').split(' ').filter(Boolean));
319
+ const granted = new Set((seen.scope ?? '').split(' ').filter(Boolean));
290
320
  if (!scopes.every((s) => granted.has(s))) {
291
- throw new InvalidScopeError('Requested scope exceeds the originally granted scope');
321
+ // `invalid_scope` to the client (RFC 6749 §5.2), since the token
322
+ // itself is fine — and untouched, so the same token refreshes with
323
+ // the scope it was granted.
324
+ return this.refuseRefresh(
325
+ 'requested scope exceeds the originally granted scope',
326
+ 'Requested scope exceeds the originally granted scope',
327
+ seen,
328
+ (message) => new InvalidScopeError(message),
329
+ );
292
330
  }
293
331
  scope = scopes.join(' ');
294
332
  }
@@ -299,13 +337,115 @@ export class BevelOAuthProvider implements OAuthServerProvider {
299
337
  // mint an unrevoked pair — the revoke undone by a millisecond. A token
300
338
  // from before connections existed has none to stay on and is placed
301
339
  // under one as a first mint would be.
302
- if (row.connectionId) {
303
- if (!(await this.isConnectionLive(row.connectionId))) {
304
- throw new InvalidGrantError('Access for this agent was revoked');
340
+ if (seen.connectionId && !(await this.isConnectionLive(seen.connectionId))) {
341
+ return this.refuseRefresh('access for this agent was revoked', 'Access for this agent was revoked', seen);
342
+ }
343
+
344
+ // Consume, with the state the row was judged in as the predicate, so two
345
+ // refreshes racing on one row have exactly one winner. A fresh token is
346
+ // RETIRED (`rotatedAt`), not revoked: its pair's access token stays good
347
+ // until its own expiry, an hour at most, so a runner still holding it is
348
+ // not cut off mid-task by a refresh another runner made, while a revoke
349
+ // or a sign-out still ends it at once. A token already retired within
350
+ // the reuse grace — the loser of a double refresh, which is what a client
351
+ // refreshing from two places at once produces — is honoured ONCE more,
352
+ // and the replay revokes the row in the same statement that admits it,
353
+ // so a third presentation, or two replays racing, find nothing. That does
354
+ // end the retired pair's access token: the runner that replayed holds the
355
+ // fresh pair from here, and the rotation's winner holds its own.
356
+ //
357
+ // The consume and the mint are ONE transaction: a token is retired or
358
+ // replayed only in the same commit that hands its holder the new pair, so
359
+ // a mint that fails leaves the token exactly as it was — never spent with
360
+ // nothing to show for it, which for a replayed token would be the lockout
361
+ // this whole path exists to prevent. A token from before connections
362
+ // existed is placed under one first, outside the transaction, as a first
363
+ // mint would place it: finding or making a connection is idempotent.
364
+ const connectionId = seen.connectionId ?? (await this.ensureConnection(seen.userId, seen.clientId));
365
+ // The opportunistic prune too runs BEFORE the transaction opens, on the
366
+ // pool: inside it, this request would hold a transaction's client while
367
+ // waiting for a second one, and a burst of refreshes the size of the pool
368
+ // would have every one of them wait out the connect timeout.
369
+ await this.pruneTokenTables(now.getTime());
370
+ const { consumed, tokens, replayed } = await this.deps.db.transaction(async (tx) => {
371
+ let row = rotatedAgoMs === null ? await this.retireFresh(tx, hash, clientId, now) : undefined;
372
+ let replayed = rotatedAgoMs !== null;
373
+ if (!row) {
374
+ // Either a retired token within the grace, or a fresh one that lost
375
+ // its rotation to a concurrent refresh between the read above and the
376
+ // retire — which makes it the loser of a double refresh, and so
377
+ // replayable now.
378
+ row = await this.replayRetired(tx, hash, clientId, now);
379
+ replayed = true;
380
+ }
381
+ if (!row) {
382
+ return this.refuseRefresh('refresh token rotated or revoked by a concurrent request', 'Invalid or revoked refresh token', seen);
305
383
  }
306
- this.noteConnectionUse(row.connectionId);
384
+ const tokens = await this.mintTokens(row.userId, row.clientId, scope, row.resource, connectionId, tx);
385
+ return { consumed: row, tokens, replayed };
386
+ });
387
+ if (replayed) {
388
+ log.info(
389
+ `refresh replayed ${Math.round((now.getTime() - consumed.rotatedAt!.getTime()) / 1000)} s after its rotation, within the reuse grace; a fresh pair is minted`,
390
+ { clientId, connectionId, userId: consumed.userId },
391
+ );
307
392
  }
308
- return this.mintTokens(row.userId, row.clientId, scope, row.resource, row.connectionId ?? undefined);
393
+ this.noteConnectionUse(connectionId);
394
+ return tokens;
395
+ }
396
+
397
+ /** Retire a fresh refresh token: the one statement that wins a rotation. */
398
+ private async retireFresh(tx: Tx, hash: string, clientId: string, now: Date) {
399
+ const [row] = await tx
400
+ .update(oauthTokens)
401
+ .set({ rotatedAt: now })
402
+ .where(
403
+ and(
404
+ eq(oauthTokens.refreshTokenHash, hash),
405
+ eq(oauthTokens.clientId, clientId),
406
+ isNull(oauthTokens.revokedAt),
407
+ isNull(oauthTokens.rotatedAt),
408
+ ),
409
+ )
410
+ .returning();
411
+ return row;
412
+ }
413
+
414
+ /** Consume a retired refresh token's one replay: revoked in the statement that admits it, within the grace only. */
415
+ private async replayRetired(tx: Tx, hash: string, clientId: string, now: Date) {
416
+ const since = new Date(now.getTime() - REFRESH_REUSE_GRACE_MS);
417
+ const [row] = await tx
418
+ .update(oauthTokens)
419
+ .set({ revokedAt: now })
420
+ .where(
421
+ and(
422
+ eq(oauthTokens.refreshTokenHash, hash),
423
+ eq(oauthTokens.clientId, clientId),
424
+ isNull(oauthTokens.revokedAt),
425
+ gt(oauthTokens.rotatedAt, since),
426
+ ),
427
+ )
428
+ .returning();
429
+ return row;
430
+ }
431
+
432
+ /**
433
+ * The one place a refresh is refused: logged with its reason, never the
434
+ * token, then the client's error — `invalid_grant` unless the caller says
435
+ * otherwise (a widened scope is `invalid_scope`).
436
+ */
437
+ private refuseRefresh(
438
+ reason: string,
439
+ clientMessage: string,
440
+ row: { clientId: string; connectionId?: string | null; userId?: string },
441
+ error: (message: string) => Error = (message) => new InvalidGrantError(message),
442
+ ): never {
443
+ log.warn(`refresh refused: ${reason}`, {
444
+ clientId: row.clientId,
445
+ connectionId: row.connectionId ?? null,
446
+ userId: row.userId ?? null,
447
+ });
448
+ throw error(clientMessage);
309
449
  }
310
450
 
311
451
  async verifyAccessToken(token: string): Promise<AuthInfo> {
@@ -413,10 +553,15 @@ export class BevelOAuthProvider implements OAuthServerProvider {
413
553
  // back to re-authorise into the same one.)
414
554
  const connectionId = revoked.find((r) => r.connectionId)?.connectionId;
415
555
  if (!connectionId) return;
556
+ // A rotated row is not a live token: its successor is the live one, and
557
+ // counting it would keep a connection open on a refresh token nobody can
558
+ // use any more.
416
559
  const [{ live }] = await this.deps.db
417
560
  .select({ live: count() })
418
561
  .from(oauthTokens)
419
- .where(and(eq(oauthTokens.connectionId, connectionId), isNull(oauthTokens.revokedAt)));
562
+ .where(
563
+ and(eq(oauthTokens.connectionId, connectionId), isNull(oauthTokens.revokedAt), isNull(oauthTokens.rotatedAt)),
564
+ );
420
565
  if (Number(live) > 0) return;
421
566
  await this.deps.db
422
567
  .update(agentConnections)
@@ -428,6 +573,12 @@ export class BevelOAuthProvider implements OAuthServerProvider {
428
573
  * @param connectionId The agent connection the pair belongs to, when the
429
574
  * caller already holds one (a refresh: the consumed token's). Absent on a
430
575
  * first mint, which finds or makes the live connection for (user, client).
576
+ * @param tx The transaction the insert belongs to, when the mint must land
577
+ * with the statement that consumed the token it replaces (a refresh).
578
+ * The prune is then the caller's, before the transaction opens: a
579
+ * best-effort statement that fails inside a transaction would abort the
580
+ * mint it is meant to spare, and one on the pool from inside it would
581
+ * hold this request's client while waiting for another.
431
582
  */
432
583
  private async mintTokens(
433
584
  userId: string,
@@ -435,30 +586,17 @@ export class BevelOAuthProvider implements OAuthServerProvider {
435
586
  scope: string | null,
436
587
  resource: string | null,
437
588
  connectionId?: string,
589
+ tx?: Tx,
438
590
  ): Promise<OAuthTokens> {
439
591
  const accessToken = this.deps.tokenPrefix + randomBytes(TOKEN_BYTES).toString('base64url');
440
592
  const refreshToken = this.refreshPrefix + randomBytes(TOKEN_BYTES).toString('base64url');
441
593
  const now = Date.now();
442
- // Opportunistic pruning, piggybacked on mint (one per sign-in / refresh —
443
- // no scheduler needed) so the oauth tables stay bounded: a token row whose
444
- // refresh window has closed can never authenticate again (revoked-but-live
445
- // rows are kept until then, so revocation stays observable), and an auth
446
- // code past its 60s TTL can never be exchanged. Best-effort — a cleanup
447
- // hiccup must not fail the token issuance itself.
448
- try {
449
- const cutoff = new Date(now);
450
- await this.deps.db.delete(oauthTokens).where(
451
- or(
452
- lt(oauthTokens.refreshExpiresAt, cutoff),
453
- and(isNull(oauthTokens.refreshExpiresAt), lt(oauthTokens.expiresAt, cutoff)),
454
- ),
455
- );
456
- await this.deps.db.delete(oauthAuthCodes).where(lt(oauthAuthCodes.expiresAt, cutoff));
457
- } catch (err) {
458
- log.warn('token-table prune failed (non-fatal):', { err });
459
- }
594
+ // A mint of its own (a sign-in) prunes here; a refresh pruned before its
595
+ // transaction opened.
596
+ if (!tx) await this.pruneTokenTables(now);
460
597
  const boundTo = connectionId ?? (await this.ensureConnection(userId, clientId));
461
- await this.deps.db.insert(oauthTokens).values({
598
+ const sink: Tx | Database = tx ?? this.deps.db;
599
+ await sink.insert(oauthTokens).values({
462
600
  accessTokenHash: hashToken(accessToken),
463
601
  refreshTokenHash: hashToken(refreshToken),
464
602
  clientId,
@@ -478,6 +616,34 @@ export class BevelOAuthProvider implements OAuthServerProvider {
478
616
  };
479
617
  }
480
618
 
619
+ /**
620
+ * Opportunistic pruning, piggybacked on every mint (one per sign-in /
621
+ * refresh — no scheduler needed) so the oauth tables stay bounded: a token
622
+ * row whose refresh window has closed can never authenticate again
623
+ * (revoked-but-live rows are kept until then, so revocation stays
624
+ * observable), a rotated row can authenticate for its access token's hour
625
+ * and be replayed for the reuse grace and past both is history only, and
626
+ * an auth code past its 60s TTL can never be exchanged. Best-effort, on the
627
+ * pool and never inside a transaction — a cleanup hiccup must not fail the
628
+ * token issuance itself.
629
+ */
630
+ private async pruneTokenTables(now: number): Promise<void> {
631
+ try {
632
+ const cutoff = new Date(now);
633
+ const rotatedCutoff = new Date(now - ACCESS_TTL_MS - REFRESH_REUSE_GRACE_MS);
634
+ await this.deps.db.delete(oauthTokens).where(
635
+ or(
636
+ lt(oauthTokens.refreshExpiresAt, cutoff),
637
+ and(isNull(oauthTokens.refreshExpiresAt), lt(oauthTokens.expiresAt, cutoff)),
638
+ lt(oauthTokens.rotatedAt, rotatedCutoff),
639
+ ),
640
+ );
641
+ await this.deps.db.delete(oauthAuthCodes).where(lt(oauthAuthCodes.expiresAt, cutoff));
642
+ } catch (err) {
643
+ log.warn('token-table prune failed (non-fatal):', { err });
644
+ }
645
+ }
646
+
481
647
  /**
482
648
  * The live `agent_connections` row for (user, client) — found, or made on
483
649
  * the first mint for the pair. A refresh finds the row the sign-in made, so
@@ -0,0 +1,34 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { RequestAudit } from '../audit/index.js';
3
+
4
+ /**
5
+ * What ONE MCP request brings to the tool client it runs on: the bearer its
6
+ * loopback calls carry, and the Audit log's view of it.
7
+ *
8
+ * The client is cached across requests (see {@link SurfaceCache}), so neither
9
+ * may live on the client. A connection-key request's loopback bearer is its
10
+ * own key — per-key metering rides on it, and a revoked key must stop working
11
+ * on its very next request even while the person's client is warm. An OAuth
12
+ * or session request's bearer is an internal token minted for that request,
13
+ * with a lifetime: baked into a cached client it would outlive nothing and
14
+ * expire under a page kept open for a day. So the client reads both from
15
+ * here, at call time, and a request supplies its own.
16
+ */
17
+ export interface McpRequestContext {
18
+ /** The bearer this request's loopback calls carry (`${CONNECTION_KEY}` of every platform-hosted manual). */
19
+ loopbackBearer: string;
20
+ /** The request's Audit log view, or null when the request is attributed to nothing. */
21
+ audit: RequestAudit | null;
22
+ }
23
+
24
+ const storage = new AsyncLocalStorage<McpRequestContext>();
25
+
26
+ /** Run `fn` as part of the request `ctx`: every tool call it makes, however deep, reads `ctx`. */
27
+ export function runInRequest<T>(ctx: McpRequestContext, fn: () => T): T {
28
+ return storage.run(ctx, fn);
29
+ }
30
+
31
+ /** The request the current call belongs to, or undefined outside one. */
32
+ export function currentRequest(): McpRequestContext | undefined {
33
+ return storage.getStore();
34
+ }
@@ -0,0 +1,121 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { CallTemplate, Tool as UtcpTool } from '@utcp/sdk';
3
+ import type { CodeModeUtcpClient } from '@utcp/code-mode';
4
+ import type { ProxiedTool } from '@bevel-software/platform-mcp-core';
5
+
6
+ /**
7
+ * Tool definitions held once for every cached client that discovered the same
8
+ * ones.
9
+ *
10
+ * Two hundred people whose catalogs are identical would otherwise hold two
11
+ * hundred copies of the same descriptions and schemas: a client's repository
12
+ * keeps what discovery parsed, and every build parses its own. After a build,
13
+ * each manual's discovered tools are swapped for the first copy of identical
14
+ * tools this process has seen, so the descriptions, schemas and call templates
15
+ * are one object graph no matter how many clients point at it. A client keeps
16
+ * only what is its own: the repository's per-tool shells, its variables and
17
+ * its secrets loader.
18
+ *
19
+ * Identity is the CONTENT of the definitions, not the manual's name or its
20
+ * template: the knowledge base's manual is filtered per caller, so two people
21
+ * can get different tools from the same template, and only identical content
22
+ * may be shared. Definitions are tool schemas and call TEMPLATES — `${VAR}`
23
+ * placeholders, never a resolved value — so sharing them shares no secret.
24
+ *
25
+ * Nothing here is read back for correctness: a definition dropped from these
26
+ * maps (they are bounded) only stops the NEXT build from sharing it.
27
+ */
28
+ export class SharedDefinitions {
29
+ private readonly byContent = new Map<string, unknown>();
30
+ private readonly interfaceCaches = new Map<string, Map<string, string>>();
31
+
32
+ /** `maxEntries` bounds each map; 0 shares nothing. */
33
+ constructor(private readonly maxEntries = 1000) {}
34
+
35
+ /**
36
+ * The first-seen copy of `value` (by its JSON), or `value` itself when it is
37
+ * the first. Kinds share no key space: a tool list and a listing could
38
+ * otherwise serialise alike.
39
+ */
40
+ intern<T>(kind: string, value: T): T {
41
+ const key = `${kind}\u0000${fingerprint(value)}`;
42
+ const held = this.byContent.get(key);
43
+ if (held !== undefined) {
44
+ // Recently used goes last, so the first entry is the one to drop.
45
+ this.byContent.delete(key);
46
+ this.byContent.set(key, held);
47
+ return held as T;
48
+ }
49
+ if (this.maxEntries <= 0) return value;
50
+ bound(this.byContent, this.maxEntries);
51
+ this.byContent.set(key, value);
52
+ return value;
53
+ }
54
+
55
+ /**
56
+ * Point `client`'s repository at shared definitions: every manual's tools,
57
+ * and the client's cache of generated TypeScript interfaces.
58
+ *
59
+ * `rawTemplates` are the manuals as fetched, by the name they are registered
60
+ * under. Registration stores the template AFTER variable substitution — for
61
+ * a platform-hosted manual, with the bearer of the request that built the
62
+ * client in its headers. Nothing calls through a stored template (each
63
+ * tool's own template is substituted at call time), but a cached client
64
+ * must hold no request's credential, so the stored template is put back to
65
+ * its placeholders.
66
+ */
67
+ async share(client: CodeModeUtcpClient, rawTemplates: ReadonlyMap<string, CallTemplate>): Promise<void> {
68
+ const repo = client.config.tool_repository;
69
+ const templates = await repo.getManualCallTemplates();
70
+ const everyTool: UtcpTool[] = [];
71
+ for (const stored of templates) {
72
+ const name = String(stored.name ?? '');
73
+ const manual = await repo.getManual(name);
74
+ if (!manual) continue;
75
+ const tools = this.intern('tools', manual.tools);
76
+ everyTool.push(...tools);
77
+ const raw = rawTemplates.get(name);
78
+ const template = raw ? ({ ...raw, name } as CallTemplate) : stored;
79
+ await repo.saveManual(template, { ...manual, tools });
80
+ }
81
+ // `toolFunctionCache` is filled lazily by `tools_info` and every chain,
82
+ // keyed by tool name and derived from the definitions alone. Shared by
83
+ // every client holding the very same tool set, it is generated once for
84
+ // all of them instead of once per person.
85
+ (client as unknown as { toolFunctionCache: Map<string, string> }).toolFunctionCache = this.interfaceCache(
86
+ fingerprint(everyTool),
87
+ );
88
+ }
89
+
90
+ /** The flattened catalog a surface lists, shared the same way. */
91
+ listing(tools: ProxiedTool[]): ProxiedTool[] {
92
+ return this.intern('listing', tools);
93
+ }
94
+
95
+ /** The generated-interface cache of one exact tool set. */
96
+ private interfaceCache(toolSet: string): Map<string, string> {
97
+ let cache = this.interfaceCaches.get(toolSet);
98
+ if (cache) {
99
+ this.interfaceCaches.delete(toolSet);
100
+ } else {
101
+ cache = new Map();
102
+ if (this.maxEntries <= 0) return cache;
103
+ bound(this.interfaceCaches, this.maxEntries);
104
+ }
105
+ this.interfaceCaches.set(toolSet, cache);
106
+ return cache;
107
+ }
108
+ }
109
+
110
+ /** Make room for one more entry, dropping the least recently used. */
111
+ function bound(map: Map<string, unknown>, maxEntries: number): void {
112
+ while (map.size >= maxEntries) {
113
+ const oldest = map.keys().next().value;
114
+ if (oldest === undefined) break;
115
+ map.delete(oldest);
116
+ }
117
+ }
118
+
119
+ function fingerprint(value: unknown): string {
120
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
121
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The hosted MCP server's memory of built tool surfaces: one per caller and
3
+ * catalog revision, built once and reused until something it was built from
4
+ * changes.
5
+ *
6
+ * Keyed by whatever the service says identifies a surface — the person, the
7
+ * kind of caller, the key's scope and the catalog revision. A catalog change
8
+ * needs no listener: it is a new revision, so a new key, and the old entry
9
+ * ages out unused. What the revision cannot see (a secret) is evicted
10
+ * explicitly through {@link evictWhere} / {@link clear}.
11
+ *
12
+ * Bounded twice: an entry unused for `idleTtlMs` is dropped, and at most
13
+ * `maxEntries` are held, the least recently used going first. `maxEntries: 0`
14
+ * keeps nothing, which is the per-request build this replaced: the escape
15
+ * hatch if the cache ever has to be switched off.
16
+ *
17
+ * Single-flight: concurrent misses on one key share one build. A build that
18
+ * fails is not remembered, so the next request tries again. A build that was
19
+ * in flight when its key was evicted answers the requests waiting on it but is
20
+ * not kept: it was built from what the eviction said is stale. A build still
21
+ * running after `buildTimeoutMs` is let go the same way, so a discovery that
22
+ * hangs holds up the requests already waiting on it, not every later one.
23
+ *
24
+ * Holds references only. A surface owns no connection (its `mcp` manuals route
25
+ * to the downstream pool, which owns and closes those), so dropping an entry
26
+ * is the whole teardown.
27
+ */
28
+ export interface SurfaceCacheOptions {
29
+ /** Drop an entry unused for this long. Default one hour. */
30
+ idleTtlMs?: number;
31
+ /** Hold at most this many entries. Default 500; 0 keeps nothing. */
32
+ maxEntries?: number;
33
+ /** Let go of a build still running after this long; the next request starts another. Default two minutes. */
34
+ buildTimeoutMs?: number;
35
+ /** Clock, for tests. */
36
+ now?: () => number;
37
+ }
38
+
39
+ export const SURFACE_IDLE_TTL_MS = 60 * 60 * 1000;
40
+ export const SURFACE_BUILD_TIMEOUT_MS = 2 * 60 * 1000;
41
+ export const SURFACE_MAX_ENTRIES = 500;
42
+
43
+ interface Entry<V> {
44
+ value: Promise<V>;
45
+ startedAt: number;
46
+ lastUsed: number;
47
+ /** An absolute end, for a value that must not live its full idle time (see `maxAgeMs`). */
48
+ expiresAt: number;
49
+ /** False once the entry was evicted while its build was still running. */
50
+ live: boolean;
51
+ settled: boolean;
52
+ }
53
+
54
+ export class SurfaceCache<V> {
55
+ private readonly entries = new Map<string, Entry<V>>();
56
+ private readonly idleTtlMs: number;
57
+ private readonly maxEntries: number;
58
+ private readonly buildTimeoutMs: number;
59
+ private readonly now: () => number;
60
+
61
+ constructor(opts: SurfaceCacheOptions = {}) {
62
+ this.idleTtlMs = opts.idleTtlMs ?? SURFACE_IDLE_TTL_MS;
63
+ this.maxEntries = opts.maxEntries ?? SURFACE_MAX_ENTRIES;
64
+ this.buildTimeoutMs = opts.buildTimeoutMs ?? SURFACE_BUILD_TIMEOUT_MS;
65
+ this.now = opts.now ?? Date.now;
66
+ }
67
+
68
+ /**
69
+ * The value for `key`, built by `build` when none is held. `hit` says
70
+ * whether this call found one (or joined a build already running).
71
+ *
72
+ * `maxAgeMs`, read off a built value, ends that entry early whatever its
73
+ * use: a surface built while one of its manuals was failing is kept only
74
+ * as long as that failure would be remembered anyway.
75
+ */
76
+ async get(
77
+ key: string,
78
+ build: () => Promise<V>,
79
+ maxAgeMs?: (value: V) => number | undefined,
80
+ ): Promise<{ value: V; hit: boolean }> {
81
+ this.sweep();
82
+ const now = this.now();
83
+ const held = this.entries.get(key);
84
+ if (held) {
85
+ held.lastUsed = now;
86
+ // Most recently used goes last, so the first entry is always the one to drop.
87
+ this.entries.delete(key);
88
+ this.entries.set(key, held);
89
+ return { value: await held.value, hit: true };
90
+ }
91
+ if (this.maxEntries <= 0) return { value: await build(), hit: false };
92
+ while (this.entries.size >= this.maxEntries) {
93
+ const oldest = this.entries.keys().next().value;
94
+ if (oldest === undefined) break;
95
+ this.drop(oldest);
96
+ }
97
+ const entry: Entry<V> = {
98
+ value: build(),
99
+ startedAt: now,
100
+ lastUsed: now,
101
+ expiresAt: Number.POSITIVE_INFINITY,
102
+ live: true,
103
+ settled: false,
104
+ };
105
+ this.entries.set(key, entry);
106
+ try {
107
+ const value = await entry.value;
108
+ entry.settled = true;
109
+ const maxAge = maxAgeMs?.(value);
110
+ if (maxAge !== undefined) entry.expiresAt = this.now() + maxAge;
111
+ if (!entry.live) this.forget(key, entry);
112
+ return { value, hit: false };
113
+ } catch (err) {
114
+ this.forget(key, entry);
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ /** Drop every entry whose key matches. A build in flight still answers its waiters, and is not kept. */
120
+ evictWhere(predicate: (key: string) => boolean): void {
121
+ for (const key of [...this.entries.keys()]) {
122
+ if (predicate(key)) this.drop(key);
123
+ }
124
+ }
125
+
126
+ /** Drop everything. */
127
+ clear(): void {
128
+ this.evictWhere(() => true);
129
+ }
130
+
131
+ private drop(key: string): void {
132
+ const entry = this.entries.get(key);
133
+ if (!entry) return;
134
+ entry.live = false;
135
+ this.entries.delete(key);
136
+ }
137
+
138
+ /** Remove `entry` only if it is still the one under `key` (a newer build may have taken its place). */
139
+ private forget(key: string, entry: Entry<V>): void {
140
+ if (this.entries.get(key) === entry) this.entries.delete(key);
141
+ }
142
+
143
+ /** Drop what has been idle too long, or outlived its own end, and let go of a build that has run too long. */
144
+ private sweep(): void {
145
+ const now = this.now();
146
+ for (const [key, entry] of this.entries) {
147
+ if (!entry.settled) {
148
+ if (now - entry.startedAt >= this.buildTimeoutMs) this.drop(key);
149
+ continue;
150
+ }
151
+ if (now - entry.lastUsed >= this.idleTtlMs || now >= entry.expiresAt) this.drop(key);
152
+ }
153
+ }
154
+ }