@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +22 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +10 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts +54 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
- package/dist/modules/audit/agent-audit.service.js +290 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
- package/dist/modules/audit/audit.contract.d.ts +145 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -0
- package/dist/modules/audit/audit.contract.js +35 -0
- package/dist/modules/audit/audit.contract.js.map +1 -0
- package/dist/modules/audit/audit.routes.d.ts +21 -0
- package/dist/modules/audit/audit.routes.d.ts.map +1 -0
- package/dist/modules/audit/audit.routes.js +157 -0
- package/dist/modules/audit/audit.routes.js.map +1 -0
- package/dist/modules/audit/event-classifier.d.ts +52 -0
- package/dist/modules/audit/event-classifier.d.ts.map +1 -0
- package/dist/modules/audit/event-classifier.js +123 -0
- package/dist/modules/audit/event-classifier.js.map +1 -0
- package/dist/modules/audit/index.d.ts +7 -0
- package/dist/modules/audit/index.d.ts.map +1 -0
- package/dist/modules/audit/index.js +7 -0
- package/dist/modules/audit/index.js.map +1 -0
- package/dist/modules/audit/request-audit.d.ts +57 -0
- package/dist/modules/audit/request-audit.d.ts.map +1 -0
- package/dist/modules/audit/request-audit.js +117 -0
- package/dist/modules/audit/request-audit.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +414 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +99 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +6 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
- package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +8 -1
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +9 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +48 -5
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +32 -6
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +22 -9
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +1 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +8 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +9 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +49 -13
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git-internals.d.ts +7 -1
- package/dist/shared/git-internals.d.ts.map +1 -1
- package/dist/shared/git-internals.js +67 -9
- package/dist/shared/git-internals.js.map +1 -1
- package/migrations/0012_agent_audit.sql +41 -0
- package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
- package/migrations/meta/0012_snapshot.json +2247 -0
- package/migrations/meta/0013_snapshot.json +2259 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/src/core/create-core-server.ts +28 -0
- package/src/core/create-core-services.ts +13 -0
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
- package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
- package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
- package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
- package/src/modules/audit/agent-audit.service.ts +316 -0
- package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
- package/src/modules/audit/audit.contract.ts +164 -0
- package/src/modules/audit/audit.routes.ts +168 -0
- package/src/modules/audit/event-classifier.ts +155 -0
- package/src/modules/audit/index.ts +14 -0
- package/src/modules/audit/request-audit.ts +141 -0
- package/src/modules/database/core-schema.ts +104 -0
- package/src/modules/diff/diff.service.ts +6 -1
- package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
- package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
- package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
- package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
- package/src/modules/mcp/mcp.routes.ts +11 -1
- package/src/modules/mcp/mcp.service.ts +62 -4
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
- package/src/modules/settings/deployment-settings.service.ts +47 -8
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
- package/src/modules/tool-auth/external-api-key.service.ts +25 -8
- package/src/modules/tool-auth/internal-token.service.ts +11 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
- package/src/modules/workspace/workspace.service.ts +9 -0
- package/src/modules/workspace/workspace.tools.ts +47 -11
- package/src/shared/__tests__/git-internals.test.ts +41 -0
- package/src/shared/git-internals.ts +61 -6
- package/dist/modules/access/access-errors.d.ts +0 -34
- package/dist/modules/access/access-errors.d.ts.map +0 -1
- package/dist/modules/access/access-errors.js +0 -40
- package/dist/modules/access/access-errors.js.map +0 -1
- package/dist/modules/access/access-splice.d.ts +0 -140
- package/dist/modules/access/access-splice.d.ts.map +0 -1
- package/dist/modules/access/access-splice.js +0 -389
- package/dist/modules/access/access-splice.js.map +0 -1
- package/dist/modules/access/group-files.d.ts +0 -83
- package/dist/modules/access/group-files.d.ts.map +0 -1
- package/dist/modules/access/group-files.js +0 -167
- package/dist/modules/access/group-files.js.map +0 -1
- package/dist/modules/access/kb-read-filter.d.ts +0 -41
- package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
- package/dist/modules/access/kb-read-filter.js +0 -60
- package/dist/modules/access/kb-read-filter.js.map +0 -1
- package/dist/modules/access/render-roles-yaml.d.ts +0 -22
- package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
- package/dist/modules/access/render-roles-yaml.js +0 -56
- package/dist/modules/access/render-roles-yaml.js.map +0 -1
- package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
- package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
- package/dist/modules/access/roles-yaml-guard.js +0 -79
- package/dist/modules/access/roles-yaml-guard.js.map +0 -1
- package/dist/modules/diff/diff-paths.d.ts +0 -2
- package/dist/modules/diff/diff-paths.d.ts.map +0 -1
- package/dist/modules/diff/diff-paths.js +0 -9
- package/dist/modules/diff/diff-paths.js.map +0 -1
- package/dist/modules/groups/group-provision.service.d.ts +0 -130
- package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
- package/dist/modules/groups/group-provision.service.js +0 -288
- package/dist/modules/groups/group-provision.service.js.map +0 -1
- package/dist/modules/groups/groups.contract.d.ts +0 -106
- package/dist/modules/groups/groups.contract.d.ts.map +0 -1
- package/dist/modules/groups/groups.contract.js +0 -36
- package/dist/modules/groups/groups.contract.js.map +0 -1
- package/dist/modules/groups/groups.routes.d.ts +0 -42
- package/dist/modules/groups/groups.routes.d.ts.map +0 -1
- package/dist/modules/groups/groups.routes.js +0 -379
- package/dist/modules/groups/groups.routes.js.map +0 -1
- package/dist/modules/groups/groups.service.d.ts +0 -60
- package/dist/modules/groups/groups.service.d.ts.map +0 -1
- package/dist/modules/groups/groups.service.js +0 -172
- package/dist/modules/groups/groups.service.js.map +0 -1
- package/dist/modules/groups/index.d.ts +0 -7
- package/dist/modules/groups/index.d.ts.map +0 -1
- package/dist/modules/groups/index.js +0 -6
- package/dist/modules/groups/index.js.map +0 -1
- package/dist/modules/groups/join-proposals.d.ts +0 -53
- package/dist/modules/groups/join-proposals.d.ts.map +0 -1
- package/dist/modules/groups/join-proposals.js +0 -67
- package/dist/modules/groups/join-proposals.js.map +0 -1
- package/dist/modules/groups/join-requests.service.d.ts +0 -81
- package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
- package/dist/modules/groups/join-requests.service.js +0 -135
- package/dist/modules/groups/join-requests.service.js.map +0 -1
- package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
- package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
- package/dist/modules/mcp/mcp-session-store.js +0 -131
- package/dist/modules/mcp/mcp-session-store.js.map +0 -1
- package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
- package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
- package/dist/modules/workflow/file-change-notifier.js +0 -22
- package/dist/modules/workflow/file-change-notifier.js.map +0 -1
- package/dist/modules/workflow/git/branch-name.d.ts +0 -10
- package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
- package/dist/modules/workflow/git/branch-name.js +0 -76
- package/dist/modules/workflow/git/branch-name.js.map +0 -1
- package/dist/modules/workflow/git/clone-config.d.ts +0 -61
- package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
- package/dist/modules/workflow/git/clone-config.js +0 -69
- package/dist/modules/workflow/git/clone-config.js.map +0 -1
- package/dist/modules/workflow/git/mutex.d.ts +0 -11
- package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
- package/dist/modules/workflow/git/mutex.js +0 -23
- package/dist/modules/workflow/git/mutex.js.map +0 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
- package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/locking-filesystem.js +0 -553
- package/dist/modules/workflow/locking-filesystem.js.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.js +0 -39
- package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
- package/dist/modules/workflow/workflow.errors.d.ts +0 -197
- package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
- package/dist/modules/workflow/workflow.errors.js +0 -298
- package/dist/modules/workflow/workflow.errors.js.map +0 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
- package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
- package/dist/modules/workspace/bevel-ignore.js +0 -61
- package/dist/modules/workspace/bevel-ignore.js.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.js +0 -2
- package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
- package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.service.js +0 -535
- package/dist/modules/workspace/kb-seed.service.js.map +0 -1
- package/dist/modules/workspace/plugins-migration.d.ts +0 -50
- package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
- package/dist/modules/workspace/plugins-migration.js +0 -379
- package/dist/modules/workspace/plugins-migration.js.map +0 -1
- package/dist/shared/fs-errors.d.ts +0 -8
- package/dist/shared/fs-errors.d.ts.map +0 -1
- package/dist/shared/fs-errors.js +0 -11
- package/dist/shared/fs-errors.js.map +0 -1
- package/dist/shared/fs-walk.d.ts +0 -23
- package/dist/shared/fs-walk.d.ts.map +0 -1
- package/dist/shared/fs-walk.js +0 -42
- package/dist/shared/fs-walk.js.map +0 -1
- package/dist/shared/hash-email.d.ts +0 -11
- package/dist/shared/hash-email.d.ts.map +0 -1
- package/dist/shared/hash-email.js +0 -14
- package/dist/shared/hash-email.js.map +0 -1
- package/dist/shared/kb-walk.d.ts +0 -52
- package/dist/shared/kb-walk.d.ts.map +0 -1
- package/dist/shared/kb-walk.js +0 -76
- 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
|
-
|
|
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: {
|
|
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) =>
|
|
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>): {
|
|
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
|
-
|
|
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
|
|
443
|
-
// Queue: SELECT finds a revoked row; then the
|
|
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
|
|
453
|
-
//
|
|
454
|
-
//
|
|
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).
|
|
457
|
-
expect((db as any).
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
}
|