@bevel-software/platform-core-backend 0.19.0 → 0.20.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 (255) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +22 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +10 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/audit/agent-audit.service.d.ts +54 -0
  9. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
  10. package/dist/modules/audit/agent-audit.service.js +290 -0
  11. package/dist/modules/audit/agent-audit.service.js.map +1 -0
  12. package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
  13. package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
  14. package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
  15. package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
  16. package/dist/modules/audit/audit.contract.d.ts +145 -0
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -0
  18. package/dist/modules/audit/audit.contract.js +35 -0
  19. package/dist/modules/audit/audit.contract.js.map +1 -0
  20. package/dist/modules/audit/audit.routes.d.ts +21 -0
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -0
  22. package/dist/modules/audit/audit.routes.js +157 -0
  23. package/dist/modules/audit/audit.routes.js.map +1 -0
  24. package/dist/modules/audit/event-classifier.d.ts +52 -0
  25. package/dist/modules/audit/event-classifier.d.ts.map +1 -0
  26. package/dist/modules/audit/event-classifier.js +123 -0
  27. package/dist/modules/audit/event-classifier.js.map +1 -0
  28. package/dist/modules/audit/index.d.ts +7 -0
  29. package/dist/modules/audit/index.d.ts.map +1 -0
  30. package/dist/modules/audit/index.js +7 -0
  31. package/dist/modules/audit/index.js.map +1 -0
  32. package/dist/modules/audit/request-audit.d.ts +57 -0
  33. package/dist/modules/audit/request-audit.d.ts.map +1 -0
  34. package/dist/modules/audit/request-audit.js +117 -0
  35. package/dist/modules/audit/request-audit.js.map +1 -0
  36. package/dist/modules/database/core-schema.d.ts +414 -0
  37. package/dist/modules/database/core-schema.d.ts.map +1 -1
  38. package/dist/modules/database/core-schema.js +99 -0
  39. package/dist/modules/database/core-schema.js.map +1 -1
  40. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  41. package/dist/modules/diff/diff.service.js +6 -1
  42. package/dist/modules/diff/diff.service.js.map +1 -1
  43. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
  44. package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
  45. package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
  46. package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
  47. package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
  48. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
  50. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  51. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  52. package/dist/modules/mcp/mcp.routes.js +8 -1
  53. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  54. package/dist/modules/mcp/mcp.service.d.ts +9 -1
  55. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  56. package/dist/modules/mcp/mcp.service.js +48 -5
  57. package/dist/modules/mcp/mcp.service.js.map +1 -1
  58. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
  59. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  60. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
  61. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  62. package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
  63. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  64. package/dist/modules/settings/deployment-settings.service.js +32 -6
  65. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  66. package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
  67. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  68. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  69. package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
  70. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  71. package/dist/modules/tool-auth/external-api-key.service.js +22 -9
  72. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  73. package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
  74. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  75. package/dist/modules/tool-auth/internal-token.service.js +1 -0
  76. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  77. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
  78. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  81. package/dist/modules/workspace/workspace.service.d.ts +8 -0
  82. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  83. package/dist/modules/workspace/workspace.service.js +9 -0
  84. package/dist/modules/workspace/workspace.service.js.map +1 -1
  85. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  86. package/dist/modules/workspace/workspace.tools.js +49 -13
  87. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  88. package/dist/shared/git-internals.d.ts +7 -1
  89. package/dist/shared/git-internals.d.ts.map +1 -1
  90. package/dist/shared/git-internals.js +67 -9
  91. package/dist/shared/git-internals.js.map +1 -1
  92. package/migrations/0012_agent_audit.sql +41 -0
  93. package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
  94. package/migrations/meta/0012_snapshot.json +2247 -0
  95. package/migrations/meta/0013_snapshot.json +2259 -0
  96. package/migrations/meta/_journal.json +14 -0
  97. package/package.json +4 -4
  98. package/src/core/create-core-server.ts +28 -0
  99. package/src/core/create-core-services.ts +13 -0
  100. package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
  101. package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
  102. package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
  103. package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
  104. package/src/modules/audit/agent-audit.service.ts +316 -0
  105. package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
  106. package/src/modules/audit/audit.contract.ts +164 -0
  107. package/src/modules/audit/audit.routes.ts +168 -0
  108. package/src/modules/audit/event-classifier.ts +155 -0
  109. package/src/modules/audit/index.ts +14 -0
  110. package/src/modules/audit/request-audit.ts +141 -0
  111. package/src/modules/database/core-schema.ts +104 -0
  112. package/src/modules/diff/diff.service.ts +6 -1
  113. package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
  114. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
  115. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
  116. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
  117. package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
  118. package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
  119. package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
  120. package/src/modules/mcp/mcp.routes.ts +11 -1
  121. package/src/modules/mcp/mcp.service.ts +62 -4
  122. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
  123. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
  124. package/src/modules/settings/deployment-settings.service.ts +47 -8
  125. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
  126. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
  127. package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
  128. package/src/modules/tool-auth/external-api-key.service.ts +25 -8
  129. package/src/modules/tool-auth/internal-token.service.ts +11 -0
  130. package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
  131. package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
  132. package/src/modules/workspace/workspace.service.ts +9 -0
  133. package/src/modules/workspace/workspace.tools.ts +47 -11
  134. package/src/shared/__tests__/git-internals.test.ts +41 -0
  135. package/src/shared/git-internals.ts +61 -6
  136. package/dist/modules/access/access-errors.d.ts +0 -34
  137. package/dist/modules/access/access-errors.d.ts.map +0 -1
  138. package/dist/modules/access/access-errors.js +0 -40
  139. package/dist/modules/access/access-errors.js.map +0 -1
  140. package/dist/modules/access/access-splice.d.ts +0 -140
  141. package/dist/modules/access/access-splice.d.ts.map +0 -1
  142. package/dist/modules/access/access-splice.js +0 -389
  143. package/dist/modules/access/access-splice.js.map +0 -1
  144. package/dist/modules/access/group-files.d.ts +0 -83
  145. package/dist/modules/access/group-files.d.ts.map +0 -1
  146. package/dist/modules/access/group-files.js +0 -167
  147. package/dist/modules/access/group-files.js.map +0 -1
  148. package/dist/modules/access/kb-read-filter.d.ts +0 -41
  149. package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
  150. package/dist/modules/access/kb-read-filter.js +0 -60
  151. package/dist/modules/access/kb-read-filter.js.map +0 -1
  152. package/dist/modules/access/render-roles-yaml.d.ts +0 -22
  153. package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
  154. package/dist/modules/access/render-roles-yaml.js +0 -56
  155. package/dist/modules/access/render-roles-yaml.js.map +0 -1
  156. package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
  157. package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
  158. package/dist/modules/access/roles-yaml-guard.js +0 -79
  159. package/dist/modules/access/roles-yaml-guard.js.map +0 -1
  160. package/dist/modules/diff/diff-paths.d.ts +0 -2
  161. package/dist/modules/diff/diff-paths.d.ts.map +0 -1
  162. package/dist/modules/diff/diff-paths.js +0 -9
  163. package/dist/modules/diff/diff-paths.js.map +0 -1
  164. package/dist/modules/groups/group-provision.service.d.ts +0 -130
  165. package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
  166. package/dist/modules/groups/group-provision.service.js +0 -288
  167. package/dist/modules/groups/group-provision.service.js.map +0 -1
  168. package/dist/modules/groups/groups.contract.d.ts +0 -106
  169. package/dist/modules/groups/groups.contract.d.ts.map +0 -1
  170. package/dist/modules/groups/groups.contract.js +0 -36
  171. package/dist/modules/groups/groups.contract.js.map +0 -1
  172. package/dist/modules/groups/groups.routes.d.ts +0 -42
  173. package/dist/modules/groups/groups.routes.d.ts.map +0 -1
  174. package/dist/modules/groups/groups.routes.js +0 -379
  175. package/dist/modules/groups/groups.routes.js.map +0 -1
  176. package/dist/modules/groups/groups.service.d.ts +0 -60
  177. package/dist/modules/groups/groups.service.d.ts.map +0 -1
  178. package/dist/modules/groups/groups.service.js +0 -172
  179. package/dist/modules/groups/groups.service.js.map +0 -1
  180. package/dist/modules/groups/index.d.ts +0 -7
  181. package/dist/modules/groups/index.d.ts.map +0 -1
  182. package/dist/modules/groups/index.js +0 -6
  183. package/dist/modules/groups/index.js.map +0 -1
  184. package/dist/modules/groups/join-proposals.d.ts +0 -53
  185. package/dist/modules/groups/join-proposals.d.ts.map +0 -1
  186. package/dist/modules/groups/join-proposals.js +0 -67
  187. package/dist/modules/groups/join-proposals.js.map +0 -1
  188. package/dist/modules/groups/join-requests.service.d.ts +0 -81
  189. package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
  190. package/dist/modules/groups/join-requests.service.js +0 -135
  191. package/dist/modules/groups/join-requests.service.js.map +0 -1
  192. package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
  193. package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
  194. package/dist/modules/mcp/mcp-session-store.js +0 -131
  195. package/dist/modules/mcp/mcp-session-store.js.map +0 -1
  196. package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
  197. package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
  198. package/dist/modules/workflow/file-change-notifier.js +0 -22
  199. package/dist/modules/workflow/file-change-notifier.js.map +0 -1
  200. package/dist/modules/workflow/git/branch-name.d.ts +0 -10
  201. package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
  202. package/dist/modules/workflow/git/branch-name.js +0 -76
  203. package/dist/modules/workflow/git/branch-name.js.map +0 -1
  204. package/dist/modules/workflow/git/clone-config.d.ts +0 -61
  205. package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
  206. package/dist/modules/workflow/git/clone-config.js +0 -69
  207. package/dist/modules/workflow/git/clone-config.js.map +0 -1
  208. package/dist/modules/workflow/git/mutex.d.ts +0 -11
  209. package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
  210. package/dist/modules/workflow/git/mutex.js +0 -23
  211. package/dist/modules/workflow/git/mutex.js.map +0 -1
  212. package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
  213. package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
  214. package/dist/modules/workflow/locking-filesystem.js +0 -553
  215. package/dist/modules/workflow/locking-filesystem.js.map +0 -1
  216. package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
  217. package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
  218. package/dist/modules/workflow/read-only-filesystem.js +0 -39
  219. package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
  220. package/dist/modules/workflow/workflow.errors.d.ts +0 -197
  221. package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
  222. package/dist/modules/workflow/workflow.errors.js +0 -298
  223. package/dist/modules/workflow/workflow.errors.js.map +0 -1
  224. package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
  225. package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
  226. package/dist/modules/workspace/bevel-ignore.js +0 -61
  227. package/dist/modules/workspace/bevel-ignore.js.map +0 -1
  228. package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
  229. package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
  230. package/dist/modules/workspace/kb-seed.interface.js +0 -2
  231. package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
  232. package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
  233. package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
  234. package/dist/modules/workspace/kb-seed.service.js +0 -535
  235. package/dist/modules/workspace/kb-seed.service.js.map +0 -1
  236. package/dist/modules/workspace/plugins-migration.d.ts +0 -50
  237. package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
  238. package/dist/modules/workspace/plugins-migration.js +0 -379
  239. package/dist/modules/workspace/plugins-migration.js.map +0 -1
  240. package/dist/shared/fs-errors.d.ts +0 -8
  241. package/dist/shared/fs-errors.d.ts.map +0 -1
  242. package/dist/shared/fs-errors.js +0 -11
  243. package/dist/shared/fs-errors.js.map +0 -1
  244. package/dist/shared/fs-walk.d.ts +0 -23
  245. package/dist/shared/fs-walk.d.ts.map +0 -1
  246. package/dist/shared/fs-walk.js +0 -42
  247. package/dist/shared/fs-walk.js.map +0 -1
  248. package/dist/shared/hash-email.d.ts +0 -11
  249. package/dist/shared/hash-email.d.ts.map +0 -1
  250. package/dist/shared/hash-email.js +0 -14
  251. package/dist/shared/hash-email.js.map +0 -1
  252. package/dist/shared/kb-walk.d.ts +0 -52
  253. package/dist/shared/kb-walk.d.ts.map +0 -1
  254. package/dist/shared/kb-walk.js +0 -76
  255. package/dist/shared/kb-walk.js.map +0 -1
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { logger } from '../../../shared/logging.js';
3
3
 
4
4
  const log = logger('mcp-oauth');
5
- import { and, eq, gt, isNull, lt, or } from 'drizzle-orm';
5
+ import { and, count, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
6
6
  import type { Response } from 'express';
7
7
  import type {
8
8
  AuthorizationParams,
@@ -21,7 +21,7 @@ import type {
21
21
  OAuthTokens,
22
22
  } from '@modelcontextprotocol/sdk/shared/auth.js';
23
23
  import type { Database } from '../../database/connection.js';
24
- import { oauthAuthCodes, oauthClients, oauthTokens, users } from '../../database/schema.js';
24
+ import { agentConnections, oauthAuthCodes, oauthClients, oauthTokens, users } from '../../database/schema.js';
25
25
  import type { TokenCrypto } from '../../../shared/token-crypto.js';
26
26
  import { signAuthRequest, type McpAuthRequestState } from './oauth-state.js';
27
27
 
@@ -34,6 +34,13 @@ const TOKEN_BYTES = 32;
34
34
  const ACCESS_TTL_MS = 60 * 60_000; // 1 hour
35
35
  const REFRESH_TTL_MS = 30 * 24 * 60 * 60_000; // 30 days, rotated on use
36
36
  const CODE_TTL_MS = 60_000; // exchanged within seconds of Finish
37
+ /**
38
+ * How often, at most, one process stamps an agent connection's `last_used_at`.
39
+ * The Audit log shows the instant relatively ("4 minutes ago"), so a minute's
40
+ * staleness is invisible, while a write per request on a chain-heavy session
41
+ * would serialise hundreds of requests a minute on one row.
42
+ */
43
+ const CONNECTION_TOUCH_INTERVAL_MS = 60_000;
37
44
 
38
45
  export interface BevelOAuthProviderDeps {
39
46
  db: Database;
@@ -66,6 +73,8 @@ export interface BevelOAuthProviderDeps {
66
73
  */
67
74
  export class BevelOAuthProvider implements OAuthServerProvider {
68
75
  private readonly store: OAuthRegisteredClientsStore;
76
+ /** When this process last stamped each connection's use — see {@link noteConnectionUse}. */
77
+ private readonly connectionTouchedAt = new Map<string, number>();
69
78
 
70
79
  constructor(private readonly deps: BevelOAuthProviderDeps) {
71
80
  this.store = {
@@ -276,15 +285,36 @@ export class BevelOAuthProvider implements OAuthServerProvider {
276
285
  }
277
286
  scope = scopes.join(' ');
278
287
  }
279
- return this.mintTokens(row.userId, row.clientId, scope, row.resource);
288
+ // A refresh STAYS on its token's agent connection and is refused once
289
+ // that connection is revoked. The connection is the authority on whether
290
+ // the agent may hold tokens at all: were a refresh to look the connection
291
+ // up afresh, one racing a revoke would find none live, make a new one and
292
+ // mint an unrevoked pair — the revoke undone by a millisecond. A token
293
+ // from before connections existed has none to stay on and is placed
294
+ // under one as a first mint would be.
295
+ if (row.connectionId) {
296
+ if (!(await this.isConnectionLive(row.connectionId))) {
297
+ throw new InvalidGrantError('Access for this agent was revoked');
298
+ }
299
+ this.noteConnectionUse(row.connectionId);
300
+ }
301
+ return this.mintTokens(row.userId, row.clientId, scope, row.resource, row.connectionId ?? undefined);
280
302
  }
281
303
 
282
304
  async verifyAccessToken(token: string): Promise<AuthInfo> {
283
305
  const now = new Date();
306
+ // The agent connection is joined so a token of a REVOKED connection fails
307
+ // here whatever its own row says: revoking an agent marks its tokens too,
308
+ // but the connection is the authority, and a token minted a moment after
309
+ // the revoke (a refresh that raced it) is dead on arrival rather than
310
+ // live until its own expiry. A token from before connections existed
311
+ // (null `connection_id`) has no connection to be revoked and verifies on
312
+ // its own row alone.
284
313
  const [row] = await this.deps.db
285
314
  .select({
286
315
  id: oauthTokens.id,
287
316
  clientId: oauthTokens.clientId,
317
+ connectionId: oauthTokens.connectionId,
288
318
  scope: oauthTokens.scope,
289
319
  resource: oauthTokens.resource,
290
320
  expiresAt: oauthTokens.expiresAt,
@@ -293,11 +323,13 @@ export class BevelOAuthProvider implements OAuthServerProvider {
293
323
  })
294
324
  .from(oauthTokens)
295
325
  .innerJoin(users, eq(oauthTokens.userId, users.id))
326
+ .leftJoin(agentConnections, eq(oauthTokens.connectionId, agentConnections.id))
296
327
  .where(
297
328
  and(
298
329
  eq(oauthTokens.accessTokenHash, hashToken(token)),
299
330
  isNull(oauthTokens.revokedAt),
300
331
  gt(oauthTokens.expiresAt, now),
332
+ or(isNull(oauthTokens.connectionId), isNull(agentConnections.revokedAt)),
301
333
  ),
302
334
  )
303
335
  .limit(1);
@@ -311,13 +343,22 @@ export class BevelOAuthProvider implements OAuthServerProvider {
311
343
  .set({ lastUsedAt: now })
312
344
  .where(eq(oauthTokens.id, row.id))
313
345
  .then(undefined, (err) => log.warn('touch lastUsedAt failed:', { err }));
346
+ // The agent connection's own "last used" — what the Audit log shows for
347
+ // the agent, across every token it has held. Throttled, never awaited.
348
+ if (row.connectionId) this.noteConnectionUse(row.connectionId);
314
349
  return {
315
350
  token,
316
351
  clientId: row.clientId,
317
352
  scopes: (row.scope ?? '').split(' ').filter(Boolean),
318
353
  expiresAt: Math.floor(row.expiresAt.getTime() / 1000),
319
354
  resource: row.resource ? new URL(row.resource) : undefined,
320
- extra: { userId: row.userId, userEmail: row.userEmail },
355
+ extra: {
356
+ userId: row.userId,
357
+ userEmail: row.userEmail,
358
+ // Null on a token minted before connections existed; the auth
359
+ // middleware then binds no agent and the call goes unrecorded.
360
+ connectionId: row.connectionId,
361
+ },
321
362
  };
322
363
  }
323
364
 
@@ -343,7 +384,7 @@ export class BevelOAuthProvider implements OAuthServerProvider {
343
384
  // RFC 7009: revoking an unknown/foreign/already-revoked token is a no-op.
344
385
  // The token may be either half of a pair — one UPDATE matching either hash.
345
386
  const hash = hashToken(request.token);
346
- await this.deps.db
387
+ const revoked = await this.deps.db
347
388
  .update(oauthTokens)
348
389
  .set({ revokedAt: new Date() })
349
390
  .where(
@@ -352,14 +393,39 @@ export class BevelOAuthProvider implements OAuthServerProvider {
352
393
  isNull(oauthTokens.revokedAt),
353
394
  or(eq(oauthTokens.accessTokenHash, hash), eq(oauthTokens.refreshTokenHash, hash)),
354
395
  ),
355
- );
396
+ )
397
+ .returning({ connectionId: oauthTokens.connectionId });
398
+ // A client revoking its own token is signing out. When that leaves the
399
+ // agent connection with no live token at all, the connection is over
400
+ // too — disconnected by its owner, as the Audit log then says — rather
401
+ // than listed as live forever with nothing behind it. (The proxy's own
402
+ // reset of a broken sign-in goes through `revokeByAccessToken`, not
403
+ // here, and must NOT close the connection: that agent is being sent
404
+ // back to re-authorise into the same one.)
405
+ const connectionId = revoked.find((r) => r.connectionId)?.connectionId;
406
+ if (!connectionId) return;
407
+ const [{ live }] = await this.deps.db
408
+ .select({ live: count() })
409
+ .from(oauthTokens)
410
+ .where(and(eq(oauthTokens.connectionId, connectionId), isNull(oauthTokens.revokedAt)));
411
+ if (Number(live) > 0) return;
412
+ await this.deps.db
413
+ .update(agentConnections)
414
+ .set({ revokedAt: new Date(), revokedBy: 'owner' })
415
+ .where(and(eq(agentConnections.id, connectionId), isNull(agentConnections.revokedAt)));
356
416
  }
357
417
 
418
+ /**
419
+ * @param connectionId The agent connection the pair belongs to, when the
420
+ * caller already holds one (a refresh: the consumed token's). Absent on a
421
+ * first mint, which finds or makes the live connection for (user, client).
422
+ */
358
423
  private async mintTokens(
359
424
  userId: string,
360
425
  clientId: string,
361
426
  scope: string | null,
362
427
  resource: string | null,
428
+ connectionId?: string,
363
429
  ): Promise<OAuthTokens> {
364
430
  const accessToken = this.deps.tokenPrefix + randomBytes(TOKEN_BYTES).toString('base64url');
365
431
  const refreshToken = this.refreshPrefix + randomBytes(TOKEN_BYTES).toString('base64url');
@@ -382,11 +448,13 @@ export class BevelOAuthProvider implements OAuthServerProvider {
382
448
  } catch (err) {
383
449
  log.warn('token-table prune failed (non-fatal):', { err });
384
450
  }
451
+ const boundTo = connectionId ?? (await this.ensureConnection(userId, clientId));
385
452
  await this.deps.db.insert(oauthTokens).values({
386
453
  accessTokenHash: hashToken(accessToken),
387
454
  refreshTokenHash: hashToken(refreshToken),
388
455
  clientId,
389
456
  userId,
457
+ connectionId: boundTo,
390
458
  scope,
391
459
  resource,
392
460
  expiresAt: new Date(now + ACCESS_TTL_MS),
@@ -400,6 +468,116 @@ export class BevelOAuthProvider implements OAuthServerProvider {
400
468
  ...(scope ? { scope } : {}),
401
469
  };
402
470
  }
471
+
472
+ /**
473
+ * The live `agent_connections` row for (user, client) — found, or made on
474
+ * the first mint for the pair. A refresh finds the row the sign-in made, so
475
+ * one connection spans every token the agent ever holds; a mint after a
476
+ * revoke finds nothing live and makes a fresh row, which is what keeps the
477
+ * revoked one's history intact.
478
+ *
479
+ * Two concurrent first mints (a client racing its own token exchange) both
480
+ * miss the select; the partial unique index lets exactly one insert land,
481
+ * the other's `ON CONFLICT DO NOTHING` returns no row, and it re-reads the
482
+ * winner's. The client's display name is snapshotted from its registration
483
+ * at that moment.
484
+ */
485
+ /**
486
+ * Whether an agent connection still admits requests: it exists and nobody
487
+ * has revoked it. Asked on every refresh, and by the MCP auth middleware on
488
+ * every request through the local server's exchanged grant — the one path
489
+ * where a token verifies statelessly and would otherwise outlive the
490
+ * revoke. A read, not a write: the use is stamped separately, throttled.
491
+ */
492
+ async isConnectionLive(connectionId: string): Promise<boolean> {
493
+ const [row] = await this.deps.db
494
+ .select({ id: agentConnections.id })
495
+ .from(agentConnections)
496
+ .where(and(eq(agentConnections.id, connectionId), isNull(agentConnections.revokedAt)))
497
+ .limit(1);
498
+ return row !== undefined;
499
+ }
500
+
501
+ /**
502
+ * Record that an agent connection is in use — its `last_used_at` on the
503
+ * Audit log — at most once a minute per connection per process, and never
504
+ * on the caller's critical path. The stamp is what the page shows; the
505
+ * liveness decision above never depends on it.
506
+ */
507
+ noteConnectionUse(connectionId: string): void {
508
+ const now = Date.now();
509
+ const last = this.connectionTouchedAt.get(connectionId);
510
+ if (last !== undefined && now - last < CONNECTION_TOUCH_INTERVAL_MS) return;
511
+ this.connectionTouchedAt.set(connectionId, now);
512
+ this.deps.db
513
+ .update(agentConnections)
514
+ .set({ lastUsedAt: new Date(now) })
515
+ .where(eq(agentConnections.id, connectionId))
516
+ .then(undefined, (err) => log.warn('touch connection lastUsedAt failed:', { err }));
517
+ }
518
+
519
+ /**
520
+ * The live `agent_connections` row for (user, agent) — found, or made on
521
+ * the first mint for the pair. The AGENT is the registered client's name,
522
+ * folded (see the schema): a client that registers itself afresh on every
523
+ * re-authorisation, as Claude does, lands every registration on the one
524
+ * row rather than a row per client id. A refresh finds the row the sign-in
525
+ * made, so one connection spans every token the agent ever holds; a mint
526
+ * after a revoke finds nothing live and makes a fresh row, which is what
527
+ * keeps the revoked one's history intact.
528
+ *
529
+ * Two concurrent first mints (a client racing its own token exchange) both
530
+ * miss the select; the partial unique index lets exactly one insert land,
531
+ * the other's `ON CONFLICT DO NOTHING` returns no row, and it re-reads the
532
+ * winner's. The client's display name is snapshotted from its registration
533
+ * at that moment.
534
+ */
535
+ private async ensureConnection(userId: string, clientId: string): Promise<string> {
536
+ const [client] = await this.deps.db
537
+ .select({ clientName: oauthClients.clientName })
538
+ .from(oauthClients)
539
+ .where(eq(oauthClients.clientId, clientId))
540
+ .limit(1);
541
+ const clientName = client?.clientName ?? null;
542
+ const agentKey = agentKeyFor(clientName, clientId);
543
+ const live = () =>
544
+ this.deps.db
545
+ .select({ id: agentConnections.id })
546
+ .from(agentConnections)
547
+ .where(
548
+ and(
549
+ eq(agentConnections.userId, userId),
550
+ eq(agentConnections.agentKey, agentKey),
551
+ isNull(agentConnections.revokedAt),
552
+ ),
553
+ )
554
+ .limit(1);
555
+ const [existing] = await live();
556
+ if (existing) return existing.id;
557
+ const [inserted] = await this.deps.db
558
+ .insert(agentConnections)
559
+ .values({ userId, clientId, clientName, agentKey })
560
+ .onConflictDoNothing({
561
+ target: [agentConnections.userId, agentConnections.agentKey],
562
+ where: sql`${agentConnections.revokedAt} is null`,
563
+ })
564
+ .returning({ id: agentConnections.id });
565
+ if (inserted) return inserted.id;
566
+ const [winner] = await live();
567
+ if (!winner) throw new Error('agent connection vanished between insert and re-read');
568
+ return winner.id;
569
+ }
570
+ }
571
+
572
+ /**
573
+ * What an agent connection is keyed by: the registered client name, folded
574
+ * (case and surrounding whitespace are not identity), or the client id for
575
+ * a registration that carried no name. The SAME fold the migration applied
576
+ * to the rows that existed before the key did.
577
+ */
578
+ export function agentKeyFor(clientName: string | null, clientId: string): string {
579
+ const folded = (clientName ?? '').trim().toLowerCase();
580
+ return folded || clientId;
403
581
  }
404
582
 
405
583
  function hashToken(plaintext: string): string {
@@ -84,6 +84,41 @@ describe('DeploymentSettingsService — precedence', () => {
84
84
  });
85
85
  });
86
86
 
87
+ describe('DeploymentSettingsService — a blank that means the default', () => {
88
+ /**
89
+ * The rule everywhere else: a blank field leaves a setting alone. The Audit
90
+ * log's retention window is the exception that declares itself — its
91
+ * readers already treat "unset" as the default, so clearing the field is
92
+ * the one way back to that default, and a blank saves as a clear.
93
+ */
94
+ it('clears a blank-means-default setting on a blank save and leaves every other blank alone', async () => {
95
+ const { db, rows } = makeDb();
96
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
97
+ await settings.save({ auditRetentionDays: '30', kbRepoUrl: 'https://example.com/stored.git' }, null);
98
+ expect(settings.resolve('auditRetentionDays')).toBe('30');
99
+
100
+ await settings.save({ auditRetentionDays: '', kbRepoUrl: '' }, null);
101
+
102
+ expect(settings.resolve('auditRetentionDays')).toBe('');
103
+ expect(settings.sourceOf('auditRetentionDays')).toBe('unset');
104
+ // The repository address was blank too, and blank still means "leave it".
105
+ expect(settings.resolve('kbRepoUrl')).toBe('https://example.com/stored.git');
106
+ expect(rows.some((r) => r.key === 'kbRepoUrl')).toBe(true);
107
+ });
108
+
109
+ it('holds the retention window to the same rule on save as the runtime reader does', async () => {
110
+ const { db } = makeDb();
111
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
112
+ for (const bad of ['1.5', 'lots', 'ten days']) {
113
+ await expect(settings.save({ auditRetentionDays: bad }, null)).rejects.toBeInstanceOf(SettingsValidationError);
114
+ }
115
+ // A number of days, or zero / negative for "forever" — the reader's rule.
116
+ for (const ok of ['3650', '0', '-1', '99999']) {
117
+ await expect(settings.save({ auditRetentionDays: ok }, null)).resolves.toBeDefined();
118
+ }
119
+ });
120
+ });
121
+
87
122
  describe('DeploymentSettingsService — secrets', () => {
88
123
  it('stores the token as ciphertext and reads it back', async () => {
89
124
  const { db, rows } = makeDb();
@@ -15,6 +15,7 @@ import {
15
15
  import { createHmac } from 'node:crypto';
16
16
  import { TokenCrypto } from '../../shared/token-crypto.js';
17
17
  import { assertKbDirNameFree } from '../kb-fs/repo-path.js';
18
+ import { parseRetentionWindow } from '../audit/audit.contract.js';
18
19
  import { normalizeIssuerUrl } from './oidc-check.js';
19
20
 
20
21
  /**
@@ -39,7 +40,7 @@ export interface SettingDef {
39
40
  */
40
41
  envVar?: string;
41
42
  /** Which block of the setup screen it belongs to. */
42
- section: 'knowledge-base' | 'sign-in';
43
+ section: 'knowledge-base' | 'sign-in' | 'audit';
43
44
  secret?: boolean;
44
45
  /** Applied on save; the message is shown against the field. */
45
46
  validate?(value: string): string | null;
@@ -49,6 +50,14 @@ export interface SettingDef {
49
50
  * was copied into a service at construction is not.
50
51
  */
51
52
  restartToApply?: boolean;
53
+ /**
54
+ * A blank field on save CLEARS the stored value, putting the default back.
55
+ * The rule everywhere else is that blank means "leave it alone" — a stray
56
+ * Enter must not unconfigure a repository — and that stays the rule; this
57
+ * is for a setting whose readers already treat "unset" as its default, so
58
+ * clearing it is the one way back to that default and never a loss.
59
+ */
60
+ blankMeansDefault?: boolean;
52
61
  /**
53
62
  * What an UNSET setting already means to the code that reads it — the
54
63
  * layout's defaults, the pointer consent's "on unless turned off".
@@ -285,6 +294,23 @@ export const CORE_SETTINGS: SettingDef[] = [
285
294
  section: 'sign-in',
286
295
  restartToApply: true,
287
296
  },
297
+
298
+ {
299
+ /**
300
+ * How long the Audit log keeps an agent's events: a number of days, or
301
+ * — blank, zero, negative — forever. Read at every prune, so it applies
302
+ * without a restart. The window's rule lives with the audit service and
303
+ * is applied here on save and there on every read, so the environment
304
+ * variable is held to exactly what the Deployment page is. A blanked
305
+ * field clears the stored value, which is how "forever" is chosen back.
306
+ */
307
+ key: 'auditRetentionDays',
308
+ envVar: 'AUDIT_RETENTION_DAYS',
309
+ section: 'audit',
310
+ blankMeansDefault: true,
311
+ validate: (v) =>
312
+ parseRetentionWindow(v) === null ? 'Enter a whole number of days, or 0 to keep events forever.' : null,
313
+ },
288
314
  ];
289
315
 
290
316
  /**
@@ -577,10 +603,13 @@ export class DeploymentSettingsService {
577
603
  entries: Record<string, string>,
578
604
  updatedBy: string | null,
579
605
  ): Promise<{ restartRequired: boolean; restartKeys: string[] }> {
580
- const toWrite = this.plan(entries);
606
+ const { toWrite, toClear } = this.plan(entries);
581
607
 
582
608
  /** The settings this save changed that a running server cannot pick up. */
583
609
  const restartKeys: string[] = [];
610
+ // A blanked blank-means-default setting: its row goes, and its readers
611
+ // are back on the default from the next read.
612
+ for (const key of toClear) await this.clear(key);
584
613
  for (const { key, value, def } of toWrite) {
585
614
  // Compared against the EFFECTIVE value: a setting that was unset was
586
615
  // already running on whatever its readers make of "unset" — the layout
@@ -615,14 +644,19 @@ export class DeploymentSettingsService {
615
644
  * before letting the save happen.
616
645
  */
617
646
  resolveAfter(entries: Record<string, string>): (key: string) => string {
618
- const toWrite = this.plan(entries);
619
- return (key) => toWrite.find((w) => w.key === key)?.value ?? this.resolve(key);
647
+ const { toWrite, toClear } = this.plan(entries);
648
+ return (key) =>
649
+ toClear.includes(key) ? '' : (toWrite.find((w) => w.key === key)?.value ?? this.resolve(key));
620
650
  }
621
651
 
622
- /** Validate a batch and return the writes it amounts to; throws on any problem. */
623
- private plan(entries: Record<string, string>): { key: string; value: string; def: SettingDef }[] {
652
+ /** Validate a batch and return the writes (and the clears) it amounts to; throws on any problem. */
653
+ private plan(entries: Record<string, string>): {
654
+ toWrite: { key: string; value: string; def: SettingDef }[];
655
+ toClear: string[];
656
+ } {
624
657
  const problems: Record<string, string> = {};
625
658
  const toWrite: { key: string; value: string; def: SettingDef }[] = [];
659
+ const toClear: string[] = [];
626
660
 
627
661
  for (const [key, raw] of Object.entries(entries)) {
628
662
  const def = this.defs.get(key);
@@ -638,7 +672,12 @@ export class DeploymentSettingsService {
638
672
  // An empty field means "leave it alone", not "erase it". Clearing a
639
673
  // setting is not something the setup screen offers, and treating a blank
640
674
  // input as a delete would let a stray Enter unconfigure a deployment.
641
- if (!value) continue;
675
+ // The one exception is a setting that SAYS a blank is its default (see
676
+ // `SettingDef.blankMeansDefault`): for it a blank clears the stored row.
677
+ if (!value) {
678
+ if (def.blankMeansDefault) toClear.push(key);
679
+ continue;
680
+ }
642
681
  const problem = def.validate?.(value);
643
682
  if (problem) {
644
683
  problems[key] = problem;
@@ -722,7 +761,7 @@ export class DeploymentSettingsService {
722
761
  }
723
762
 
724
763
  if (Object.keys(problems).length > 0) throw new SettingsValidationError(problems);
725
- return toWrite;
764
+ return { toWrite, toClear };
726
765
  }
727
766
 
728
767
  /** Drop stored rows for settings this build no longer defines. */
@@ -149,6 +149,7 @@ describe('ExternalApiKeyService', () => {
149
149
  lastUsedAt: null,
150
150
  revokedAt: null,
151
151
  revokedBy: null,
152
+ deletedAt: null,
152
153
  });
153
154
  });
154
155
 
@@ -303,6 +304,7 @@ describe('ExternalApiKeyService', () => {
303
304
  lastUsedAt: rows[0].lastUsedAt!.getTime(),
304
305
  revokedAt: null,
305
306
  revokedBy: null,
307
+ deletedAt: null,
306
308
  });
307
309
  expect(summaries[1].revokedAt).toBe(rows[1].revokedAt!.getTime());
308
310
  // sanity-check that ordering was requested (we can't introspect the
@@ -333,6 +335,7 @@ describe('ExternalApiKeyService', () => {
333
335
  lastUsedAt: aliceKey.lastUsedAt!.getTime(),
334
336
  revokedAt: null,
335
337
  revokedBy: null,
338
+ deletedAt: null,
336
339
  user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' },
337
340
  },
338
341
  {
@@ -343,6 +346,7 @@ describe('ExternalApiKeyService', () => {
343
346
  lastUsedAt: null,
344
347
  revokedAt: bobKey.revokedAt!.getTime(),
345
348
  revokedBy: null,
349
+ deletedAt: null,
346
350
  user: { id: 'u-bob', email: 'bob@example.com', name: 'Bob' },
347
351
  },
348
352
  ]);
@@ -439,9 +443,9 @@ describe('ExternalApiKeyService', () => {
439
443
  });
440
444
 
441
445
  describe('remove', () => {
442
- it('validates then deletes the key (dependents cascade at the DB layer)', async () => {
443
- // Queue: SELECT finds a revoked row; then the key delete consumes one.
444
- const { db } = makeFakeDb([
446
+ it('validates then marks the key deleted — the row stays, for the Audit log', async () => {
447
+ // Queue: SELECT finds a revoked row; then the marking update consumes one.
448
+ const { db, calls } = makeFakeDb([
445
449
  [{ revokedAt: new Date('2026-02-01T00:00:00Z') }],
446
450
  undefined,
447
451
  ]);
@@ -449,12 +453,29 @@ describe('ExternalApiKeyService', () => {
449
453
 
450
454
  await service.remove('tok-1', 'user-1');
451
455
 
452
- // Validation SELECT first, then ONE delete of the key row. Dependent
453
- // rows (e.g. llm_usage metering) are removed by ON DELETE CASCADE —
454
- // this service must not know those tables exist.
456
+ // Validation SELECT first, then ONE update of the key row and never a
457
+ // delete: the Audit log's events hang off this row, and a key's owner
458
+ // deleting it must not erase what it did. Only a revoked, not-yet-
459
+ // deleted row of the owner's is marked.
455
460
  expect((db as any).select).toHaveBeenCalledTimes(1);
456
- expect((db as any).delete).toHaveBeenCalledTimes(1);
457
- expect((db as any).delete).toHaveBeenCalledWith(externalApiKeys);
461
+ expect((db as any).delete).not.toHaveBeenCalled();
462
+ expect((db as any).update).toHaveBeenCalledTimes(1);
463
+ expect((db as any).update).toHaveBeenCalledWith(externalApiKeys);
464
+ expect(calls.set[0]![0].deletedAt).toBeInstanceOf(Date);
465
+ const mark = renderSql(calls.where[1]![0]).sql;
466
+ expect(mark).toMatch(/"api_tokens"."user_id" = \$2/);
467
+ expect(mark).toMatch(/"api_tokens"."revoked_at" is not null/);
468
+ expect(mark).toMatch(/"api_tokens"."deleted_at" is null/);
469
+ });
470
+
471
+ it('treats an already-deleted key as absent for its owner', async () => {
472
+ // The validation SELECT carries the not-deleted filter, so a deleted
473
+ // row answers nothing — and nothing is marked twice.
474
+ const { db, calls } = makeFakeDb([[]]);
475
+ const service = new ExternalApiKeyService(db, 'bevel_');
476
+ await expect(service.remove('tok-1', 'user-1')).rejects.toBeInstanceOf(TokenNotFoundError);
477
+ expect(renderSql(calls.where[0]![0]).sql).toMatch(/"api_tokens"."deleted_at" is null/);
478
+ expect((db as any).update).not.toHaveBeenCalled();
458
479
  });
459
480
 
460
481
  it('throws TokenStillActiveError when the token exists but was never disconnected', async () => {
@@ -22,6 +22,12 @@ describe('InternalTokenService', () => {
22
22
  expect(svc.verify(token)).toEqual({ userId: 'user-A', externalProxy: true });
23
23
  });
24
24
 
25
+ it('round-trips the connectionId claim (the agent behind an exchanged OAuth grant)', () => {
26
+ const svc = new InternalTokenService({ secret: 'test-secret' });
27
+ const token = svc.mint({ userId: 'user-A', externalProxy: true, connectionId: 'conn-1' });
28
+ expect(svc.verify(token)).toEqual({ userId: 'user-A', externalProxy: true, connectionId: 'conn-1' });
29
+ });
30
+
25
31
  it('round-trips the focusedBranch claim (the in-process agent workspace branch)', () => {
26
32
  const svc = new InternalTokenService({ secret: 'test-secret' });
27
33
  const token = svc.mint({ userId: 'user-A', sessionId: 'run-1', focusedBranch: 'main' });
@@ -37,6 +37,12 @@ export interface ExternalApiKeySummary {
37
37
  * before this was recorded, which read as the owner's doing).
38
38
  */
39
39
  revokedBy: RevokedBy | null;
40
+ /**
41
+ * When the owner deleted the key for good from their own pages. Such a
42
+ * key is absent from the owner's listings and shown to admins as deleted;
43
+ * its row stays so the Audit log's events keep their principal.
44
+ */
45
+ deletedAt: number | null;
40
46
  }
41
47
 
42
48
  /** Who revoked a key: its owner, or an admin acting across the deployment. */
@@ -137,6 +143,13 @@ export interface IExternalApiKeyService {
137
143
  /** Active + revoked tokens for the user, newest-first. */
138
144
  listForUser(userId: string): Promise<ExternalApiKeySummary[]>;
139
145
 
146
+ /**
147
+ * The account a key belongs to, live or revoked, or null when no such key
148
+ * exists. The ownership check behind a per-key read or revoke that is
149
+ * offered to owners and admins alike (the Audit log's).
150
+ */
151
+ ownerOf(id: string): Promise<string | null>;
152
+
140
153
  /**
141
154
  * Mark a token revoked. Idempotent — revoking an already-revoked token
142
155
  * is a no-op (the row's `revokedAt` is not overwritten). Throws
@@ -161,11 +174,15 @@ export interface IExternalApiKeyService {
161
174
  revokeAny(id: string): Promise<void>;
162
175
 
163
176
  /**
164
- * Permanently delete a token row, dropping its audit trail. Only permitted
165
- * on an already-revoked token — an active key must be disconnected first,
166
- * so a live agent's access is never yanked by a single click. Throws
167
- * TokenNotFoundError if the token doesn't belong to the user, and
168
- * TokenStillActiveError if it hasn't been revoked yet.
177
+ * Delete a token for good, from the owner's point of view: it leaves their
178
+ * listings and can never be used or reconnected. The row itself stays,
179
+ * marked deleted, so the Audit log keeps the key's events under their
180
+ * principal — a log its subject could erase would not be one. Only
181
+ * permitted on an already-revoked token — an active key must be
182
+ * disconnected first, so a live agent's access is never yanked by a single
183
+ * click. Throws TokenNotFoundError if the token doesn't belong to the user
184
+ * (or is already deleted), and TokenStillActiveError if it hasn't been
185
+ * revoked yet.
169
186
  */
170
187
  remove(id: string, userId: string): Promise<void>;
171
188
  }