@bevel-software/platform-core-backend 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/core/create-core-services.d.ts +7 -0
  2. package/dist/core/create-core-services.d.ts.map +1 -1
  3. package/dist/core/create-core-services.js +12 -1
  4. package/dist/core/create-core-services.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/modules/access-model/index.d.ts +14 -0
  10. package/dist/modules/access-model/index.d.ts.map +1 -0
  11. package/dist/modules/access-model/index.js +13 -0
  12. package/dist/modules/access-model/index.js.map +1 -0
  13. package/dist/modules/auth/account-hooks.d.ts +4 -6
  14. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  15. package/dist/modules/auth/account-hooks.js +7 -17
  16. package/dist/modules/auth/account-hooks.js.map +1 -1
  17. package/dist/modules/auth/auth.service.d.ts +9 -8
  18. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  19. package/dist/modules/auth/auth.service.js +19 -18
  20. package/dist/modules/auth/auth.service.js.map +1 -1
  21. package/dist/modules/database/core-schema.d.ts +21 -1
  22. package/dist/modules/database/core-schema.d.ts.map +1 -1
  23. package/dist/modules/database/core-schema.js +16 -1
  24. package/dist/modules/database/core-schema.js.map +1 -1
  25. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  26. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  27. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  28. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  29. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  30. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  31. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  32. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  33. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  34. package/dist/modules/workflow/agent-tools/workflow.tools.js +5 -0
  35. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  36. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  37. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  38. package/dist/modules/workflow/change-request-hooks.js +15 -0
  39. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  40. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  41. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  42. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  43. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  44. package/dist/modules/workflow/workflow.service.d.ts +75 -15
  45. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  46. package/dist/modules/workflow/workflow.service.js +163 -32
  47. package/dist/modules/workflow/workflow.service.js.map +1 -1
  48. package/dist/shared/listener-seam.d.ts +34 -0
  49. package/dist/shared/listener-seam.d.ts.map +1 -0
  50. package/dist/shared/listener-seam.js +42 -0
  51. package/dist/shared/listener-seam.js.map +1 -0
  52. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  53. package/migrations/meta/0019_snapshot.json +2435 -0
  54. package/migrations/meta/_journal.json +7 -0
  55. package/package.json +3 -3
  56. package/src/core/create-core-services.ts +17 -0
  57. package/src/index.ts +7 -0
  58. package/src/modules/access-model/index.ts +13 -0
  59. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  60. package/src/modules/auth/account-hooks.ts +13 -21
  61. package/src/modules/auth/auth.service.ts +19 -18
  62. package/src/modules/database/core-schema.ts +16 -1
  63. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  64. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  65. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  66. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  67. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  68. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  69. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +258 -1
  70. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +10 -0
  71. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  72. package/src/modules/workflow/agent-tools/workflow.tools.ts +6 -0
  73. package/src/modules/workflow/change-request-hooks.ts +96 -0
  74. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  75. package/src/modules/workflow/workflow.service.ts +188 -40
  76. package/src/shared/listener-seam.ts +52 -0
@@ -134,6 +134,13 @@
134
134
  "when": 1792106401545,
135
135
  "tag": "0018_agent_events_page",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1792404850653,
142
+ "tag": "0019_oauth_token_rotated_at",
143
+ "breakpoints": true
137
144
  }
138
145
  ]
139
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
56
56
  "yaml": "^2.9.0",
57
57
  "zod": "^3.24.0",
58
- "@bevel-software/platform-shared": "0.31.0",
59
- "@bevel-software/platform-mcp-core": "0.31.0"
58
+ "@bevel-software/platform-mcp-core": "0.32.0",
59
+ "@bevel-software/platform-shared": "0.32.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/adm-zip": "^0.5.8",
@@ -127,6 +127,7 @@ import { FileChangeNotifier } from '../modules/kb-fs/file-change-notifier.js';
127
127
  import { StarterPackService } from '../modules/onboarding/starter-pack.service.js';
128
128
  import { WorkflowService } from '../modules/workflow/workflow.service.js';
129
129
  import { WorkflowHooks } from '../modules/workflow/workflow-hooks.js';
130
+ import { ChangeRequestHooks, type IChangeRequestHooks } from '../modules/workflow/change-request-hooks.js';
130
131
  import { PendingCommitsService } from '../modules/workflow/pending-commits.service.js';
131
132
  import {
132
133
  PendingCommitsWorker,
@@ -324,6 +325,12 @@ export interface CoreServices {
324
325
  reviewWorkflowService: ReviewWorkflowService;
325
326
  fileLockService: FileLockService;
326
327
  workflowService: WorkflowService;
328
+ /**
329
+ * The seam for "a change request was opened": a deployment registers here
330
+ * how the reviewers are told (the cloud's mail, say), and the open's answer
331
+ * says whether somebody did. Core registers nothing.
332
+ */
333
+ changeRequestHooks: IChangeRequestHooks;
327
334
  /** Remote sync (`POST /api/sync`): a git host or pipeline makes the clones catch up. */
328
335
  kbSyncService: KbSyncService;
329
336
  eventBus: WorkflowEventBus;
@@ -950,6 +957,10 @@ export async function createCoreServices(
950
957
  // id-repair keeps the id namespace collision-free. Subscribers are registered
951
958
  // below, once the reacting services exist.
952
959
  const fileChangeNotifier = new FileChangeNotifier();
960
+ // Who tells the reviewers of an opened change request: the composition root
961
+ // of a distribution registers its listener here, after this returns (see
962
+ // `ChangeRequestHooks`).
963
+ const changeRequestHooks = new ChangeRequestHooks();
953
964
  const workflowService = new WorkflowService(
954
965
  db,
955
966
  gitService,
@@ -967,6 +978,11 @@ export async function createCoreServices(
967
978
  // the agent-access gate consult, so a deployment's registrations against
968
979
  // it reach every hook point.
969
980
  workflowHooks,
981
+ // Exposed as `changeRequestHooks`, the same instance, for the same reason.
982
+ changeRequestHooks,
983
+ // The opened event names the approvers as accounts; auth answers which
984
+ // of the addresses the access model names have one.
985
+ authService,
970
986
  );
971
987
  // The leftover-branch cleanup asks the Deployment page at every round, so
972
988
  // switching it off there applies without a restart.
@@ -1668,6 +1684,7 @@ export async function createCoreServices(
1668
1684
  reviewWorkflowService,
1669
1685
  fileLockService,
1670
1686
  workflowService,
1687
+ changeRequestHooks,
1671
1688
  kbSyncService,
1672
1689
  eventBus,
1673
1690
  fileChangeNotifier,
package/src/index.ts CHANGED
@@ -151,3 +151,10 @@ export {
151
151
  type AgentReadHook,
152
152
  type PreWriteHook,
153
153
  } from './modules/workflow/workflow-hooks.js';
154
+ export {
155
+ ChangeRequestHooks,
156
+ type ChangeRequestOpenedEvent,
157
+ type ChangeRequestOpenedHook,
158
+ type ChangeRequestPerson,
159
+ type IChangeRequestHooks,
160
+ } from './modules/workflow/change-request-hooks.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The access model's public entry: what another module imports of it.
3
+ *
4
+ * The module is the grammar of `access.md` (how principals, roles, groups
5
+ * and verbs are spelled), the errors a refused access is reported with, and
6
+ * the gate a change is read through. A consumer outside the module imports
7
+ * from here, not from the files; the files are the module's own layout and
8
+ * free to move. The `access` module's own files predate this entry and still
9
+ * import the files directly — they move here as they are touched.
10
+ */
11
+ export * from './access-grammar.js';
12
+ export * from './access-errors.js';
13
+ export type { ChangeReadVerdict, ChangeTargetKind, IChangeReadGate } from './change-gate.js';
@@ -25,10 +25,17 @@ import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
25
25
  * shows up as `db:`.
26
26
  */
27
27
  function makeFakeDb(queue: unknown[]) {
28
- const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[]; order: string[] } = {
28
+ const captured: {
29
+ values: Record<string, unknown>[];
30
+ set: Record<string, unknown>[];
31
+ order: string[];
32
+ /** Every `where` predicate, rendered to the SQL it would send. */
33
+ where: { sql: string; params: unknown[] }[];
34
+ } = {
29
35
  values: [],
30
36
  set: [],
31
37
  order: [],
38
+ where: [],
32
39
  };
33
40
  const dialect = new PgDialect();
34
41
  function nextChain(): unknown {
@@ -42,7 +49,10 @@ function makeFakeDb(queue: unknown[]) {
42
49
  Object.assign(chain, {
43
50
  values: passthrough((a) => captured.values.push(a)),
44
51
  set: passthrough((a) => captured.set.push(a)),
45
- where: passthrough(),
52
+ where: passthrough((a) => {
53
+ const { sql, params } = dialect.sqlToQuery(a as unknown as SQL);
54
+ captured.where.push({ sql, params });
55
+ }),
46
56
  limit: passthrough(),
47
57
  from: passthrough(),
48
58
  orderBy: passthrough(),
@@ -124,6 +134,32 @@ describe('signing in with a switched-off account', () => {
124
134
  });
125
135
  });
126
136
 
137
+ describe('the accounts a change request\'s approvers resolve to', () => {
138
+ it('are read in one indexed query through the email blind index, live accounts only, keyed by canonical address', async () => {
139
+ const { db, captured } = makeFakeDb([[{ id: 'user-1', email: 'Alice@Example.com', name: 'Alice' }]]);
140
+ const svc = new AuthService(db, makeConfig());
141
+ const found = await svc.findActiveAccountsByEmail(['ALICE@example.com', 'alice@example.com', 'bob@example.com']);
142
+ // One read, by the blind index, with the addresses canonicalised and
143
+ // deduplicated, and a switched-off account excluded in the query itself.
144
+ expect((db as unknown as { select: ReturnType<typeof vi.fn> }).select).toHaveBeenCalledTimes(1);
145
+ expect(captured.where).toHaveLength(1);
146
+ expect(captured.where[0]!.sql).toMatch(/"users"."email_bidx" in \(\$1, \$2\) and "users"."deactivated_at" is null/);
147
+ // The blind-index column hashes each address with the handle's key as
148
+ // the query is sent; what was asked for is the canonical plain address.
149
+ expect(captured.where[0]!.params.map((p) => (p as { plain: string }).plain)).toEqual(['alice@example.com', 'bob@example.com']);
150
+ // Keyed by the canonical address, whatever spelling the row holds.
151
+ expect([...found.keys()]).toEqual(['alice@example.com']);
152
+ expect(found.get('alice@example.com')).toEqual({ id: 'user-1', email: 'Alice@Example.com', name: 'Alice' });
153
+ });
154
+
155
+ it('asks nothing for no addresses', async () => {
156
+ const { db } = makeFakeDb([]);
157
+ const svc = new AuthService(db, makeConfig());
158
+ expect(await svc.findActiveAccountsByEmail([])).toEqual(new Map());
159
+ expect((db as unknown as { select: ReturnType<typeof vi.fn> }).select).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
127
163
  describe('a session that outlives a deactivation', () => {
128
164
  it('stops being accepted once the account is switched off', async () => {
129
165
  const { db } = makeFakeDb([[ROW], [{ deactivatedAt: new Date() }]]);
@@ -1,5 +1,6 @@
1
1
  import type { AuthUser } from '@bevel-software/platform-shared';
2
2
  import { logger } from '../../shared/logging.js';
3
+ import { ListenerSeam, type NotifyingListener } from '../../shared/listener-seam.js';
3
4
 
4
5
  const log = logger('accounts');
5
6
 
@@ -24,13 +25,10 @@ export interface AccountInvitedEvent {
24
25
 
25
26
  /**
26
27
  * A listener answers how it reached the person — a non-empty channel name,
27
- * `'email'` say — or null when it did not (nothing configured, the send
28
- * failed). It is awaited: the admin's answer says who was notified,
29
- * truthfully, so a listener bounds its own wait. A throw is logged and
30
- * counts as null; it never fails the invite. An empty name is no channel and
31
- * counts as null too.
28
+ * `'email'` say — or null when it did not; the rule is {@link NotifyingListener}'s:
29
+ * awaited, a throw logged and counted as null, never failing the invite.
32
30
  */
33
- export type AccountInvitedHook = (event: AccountInvitedEvent) => Promise<string | null>;
31
+ export type AccountInvitedHook = NotifyingListener<AccountInvitedEvent>;
34
32
 
35
33
  /**
36
34
  * Core's seam for "an account was invited": a deployment (the cloud with its
@@ -46,24 +44,18 @@ export interface IAccountHooks {
46
44
  }
47
45
 
48
46
  export class AccountHooks implements IAccountHooks {
49
- private readonly invited: AccountInvitedHook[] = [];
47
+ // The account exists whether or not the person was told: the admin's
48
+ // answer says they were not, and the admin sends the link themselves.
49
+ private readonly invited = new ListenerSeam<AccountInvitedEvent>(
50
+ log,
51
+ 'an account-invited listener failed; the person was not notified by it:',
52
+ );
50
53
 
51
54
  onAccountInvited(hook: AccountInvitedHook): void {
52
- this.invited.push(hook);
55
+ this.invited.add(hook);
53
56
  }
54
57
 
55
- async runAccountInvited(event: AccountInvitedEvent): Promise<string[]> {
56
- const notified: string[] = [];
57
- for (const hook of this.invited) {
58
- try {
59
- const channel = await hook(event);
60
- if (channel !== null && channel !== '') notified.push(channel);
61
- } catch (err) {
62
- // The account exists whether or not the person was told: the admin's
63
- // answer says they were not, and the admin sends the link themselves.
64
- log.error('an account-invited listener failed; the person was not notified by it:', { err });
65
- }
66
- }
67
- return notified;
58
+ runAccountInvited(event: AccountInvitedEvent): Promise<string[]> {
59
+ return this.invited.run(event);
68
60
  }
69
61
  }
@@ -1,9 +1,9 @@
1
1
  import jwt from 'jsonwebtoken';
2
- import { and, eq, getTableColumns, isNotNull, isNull, sql } from 'drizzle-orm';
2
+ import { and, eq, getTableColumns, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
3
3
  import type { Database } from '../database/connection.js';
4
4
  import { users } from '../database/schema.js';
5
5
  import type { AuthUser } from '@bevel-software/platform-shared';
6
- import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
6
+ import { canonicalEmail } from '../../shared/email-identity.js';
7
7
  import {
8
8
  AccountAdmissionRefusedError,
9
9
  AccountChangeRefusedError,
@@ -863,26 +863,27 @@ export class AuthService implements IAccountSwitchGuard {
863
863
  }
864
864
 
865
865
  /**
866
- * Resolve a set of `authorId` hashes back to the app users that produced
867
- * them. Used by the PR list to display the human who triggered each PR
868
- * instead of the shared GitHub service account.
866
+ * The live accounts behind a set of addresses — the approvers of a change
867
+ * request just opened, which the workflow module hands to the deployment's
868
+ * notification listeners as people with accounts (`ApproverAccounts`).
869
869
  *
870
- * Loads all users and hashes them in-process rather than indexing on a
871
- * stored hash column — the user table is small (one row per team member)
872
- * and refreshes are infrequent (PR list polls at 60s), so the cost is
873
- * negligible and we avoid carrying a denormalised hash column.
870
+ * One indexed read through the email's blind index, never a scan of every
871
+ * account: this is asked on every change request opened, and a deployment
872
+ * is as large as it is. Switched-off accounts are left out — a person who
873
+ * cannot act on a request is not told of it — so a caller that needs them
874
+ * asks another way. Keyed by the canonical address asked for.
874
875
  */
875
- async findUsersByEmailHash(
876
- hashes: Set<string>,
876
+ async findActiveAccountsByEmail(
877
+ emails: readonly string[],
877
878
  ): Promise<Map<string, { id: string; email: string; name: string }>> {
878
879
  const result = new Map<string, { id: string; email: string; name: string }>();
879
- if (hashes.size === 0) return result;
880
- const rows = await this.db.select().from(users);
881
- for (const row of rows) {
882
- const h = hashEmail(row.email);
883
- if (!hashes.has(h)) continue;
884
- result.set(h, { id: row.id, email: row.email, name: row.name });
885
- }
880
+ const canonical = [...new Set(emails.map((e) => canonicalEmail(e)).filter(Boolean))];
881
+ if (canonical.length === 0) return result;
882
+ const rows = await this.db
883
+ .select({ id: users.id, email: users.email, name: users.name })
884
+ .from(users)
885
+ .where(and(inArray(users.emailBidx, canonical), isNull(users.deactivatedAt)));
886
+ for (const row of rows) result.set(canonicalEmail(row.email), { id: row.id, email: row.email, name: row.name });
886
887
  return result;
887
888
  }
888
889
 
@@ -550,7 +550,10 @@ export const agentConnections = pgTable('agent_connections', {
550
550
  * `api_tokens`: plaintext shown only in the token response, SHA-256 hashes
551
551
  * stored, revoked-not-deleted so `last_used_at` keeps its audit value and a
552
552
  * revoked token can't be re-issued. One row per pair; refresh rotation
553
- * revokes the old row and inserts a new one.
553
+ * retires the old row (`rotated_at`) and inserts a new one. A retired row's
554
+ * access token still verifies until its own expiry, unless the row is then
555
+ * revoked: by a revoke, a sign-out, or its refresh token's one allowed replay
556
+ * within the reuse grace, which revokes it in the statement that admits it.
554
557
  *
555
558
  * `connection_id` names the {@link agentConnections} row the pair belongs to,
556
559
  * so every call made with the token is attributed to that agent and revoking
@@ -569,6 +572,18 @@ export const oauthTokens = pgTable('oauth_tokens', {
569
572
  expiresAt: timestamp('expires_at').notNull(),
570
573
  refreshExpiresAt: timestamp('refresh_expires_at'),
571
574
  revokedAt: timestamp('revoked_at'),
575
+ /**
576
+ * When a refresh of this row's refresh token minted its successor. Set by
577
+ * rotation and never by a revoke, so the two are told apart: a rotated
578
+ * row's access token still verifies until its own expiry, and its refresh
579
+ * token is honoured once more within the reuse grace (see
580
+ * `exchangeRefreshToken`), a replay that revokes the row — access token
581
+ * included — in the statement that admits it; a revoked row does neither.
582
+ * Null on a row whose refresh token has not been used, and on rows from
583
+ * before the column existed, which rotation then marks as it does any
584
+ * other.
585
+ */
586
+ rotatedAt: timestamp('rotated_at'),
572
587
  lastUsedAt: timestamp('last_used_at'),
573
588
  createdAt: timestamp('created_at').defaultNow().notNull(),
574
589
  }, (t) => ({
@@ -0,0 +1,279 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { eq } from 'drizzle-orm';
4
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
5
+ import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
6
+ import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
7
+ import { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
8
+ import { closeDb, createDb, type Database } from '../../database/connection.js';
9
+ import { agentConnections, oauthClients, oauthTokens, users } from '../../database/core-schema.js';
10
+ import { coreMigrationsDir } from '../../../assets.js';
11
+ import { PG_ADMIN_URL, PG_TEST_TIMEOUT, dropScratchDatabases, scratchDatabaseUrl } from '../../../__tests__/pg-scratch.js';
12
+
13
+ /**
14
+ * The provider mints its tokens from `randomBytes`. One case below needs a
15
+ * mint to FAIL after the consume, with the database as the one saying no:
16
+ * while `forced` is set, every random draw is this buffer, so the minted
17
+ * access token's hash is one a seeded row already holds and the insert hits
18
+ * the unique index. Null the rest of the time, when the real generator runs.
19
+ */
20
+ const { forcedRandom } = vi.hoisted(() => ({ forcedRandom: { bytes: null as Buffer | null } }));
21
+ vi.mock('node:crypto', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('node:crypto')>();
23
+ return { ...actual, randomBytes: (size: number) => forcedRandom.bytes ?? actual.randomBytes(size) };
24
+ });
25
+
26
+ /**
27
+ * Refresh-token rotation as the DATABASE ends up holding it — on a real
28
+ * Postgres, through the provider's public surface alone (a sign-in's pair
29
+ * seeded as the token endpoint leaves one, then `exchangeRefreshToken`,
30
+ * `verifyAccessToken` and `revokeToken`), with the clock moved by hand.
31
+ *
32
+ * The fake-database suite beside this pins which statements run and what
33
+ * their predicates say; this one pins what those statements DO to a row over
34
+ * time: a retired pair's access token verifying for the rest of its hour and
35
+ * not after, the one replay a retired refresh token gets, a revoke ending a
36
+ * retired pair at once, and the prune keeping a retired row for exactly as
37
+ * long as it can still authenticate. Skipped without `TEST_DATABASE_URL`, as
38
+ * every `*.pg.test.ts` is; CI provides one.
39
+ */
40
+
41
+ const PREFIX = 'bevel-mcp_';
42
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
43
+ const T0 = new Date('2026-10-19T10:00:00Z');
44
+ const HOUR = 60 * 60_000;
45
+
46
+ describe.skipIf(!PG_ADMIN_URL)('refresh-token rotation, on a real Postgres', () => {
47
+ let db: Database;
48
+ let provider: BevelOAuthProvider;
49
+
50
+ beforeAll(async () => {
51
+ // A handle that holds a personal-data key, as the application's does:
52
+ // the seeded person's email and name are encrypted columns.
53
+ db = createDb(await scratchDatabaseUrl('hexis_oauth'), { piiKey: randomBytes(32).toString('base64') });
54
+ await migrate(db, { migrationsFolder: coreMigrationsDir() });
55
+ provider = new BevelOAuthProvider({
56
+ db,
57
+ crypto: null,
58
+ stateSecret: 'test-state-secret',
59
+ publicFrontendUrl: 'https://app.example.com',
60
+ tokenPrefix: PREFIX,
61
+ });
62
+ }, PG_TEST_TIMEOUT);
63
+
64
+ afterAll(async () => {
65
+ await closeDb(db);
66
+ await dropScratchDatabases();
67
+ }, PG_TEST_TIMEOUT);
68
+
69
+ beforeEach(() => {
70
+ // Only the clock is faked: the driver's own timers keep running.
71
+ vi.useFakeTimers({ toFake: ['Date'] });
72
+ vi.setSystemTime(T0);
73
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
74
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
75
+ });
76
+ afterEach(() => {
77
+ vi.useRealTimers();
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ /** Move the clock `n` minutes on. */
82
+ const minutes = (n: number) => vi.setSystemTime(new Date(Date.now() + n * 60_000));
83
+
84
+ /**
85
+ * The mint refused by the unique index on `access_token_hash`, and by
86
+ * nothing else: Drizzle's wrapper around the insert, with Postgres's
87
+ * unique-violation code (23505) as the cause.
88
+ */
89
+ async function expectRefusedByUniqueIndex(call: Promise<unknown>): Promise<void> {
90
+ const err = await call.then(
91
+ () => {
92
+ throw new Error('the mint was not refused');
93
+ },
94
+ (e: unknown) => e as Error & { cause?: { code?: string } },
95
+ );
96
+ expect(err.message).toMatch(/Failed query: insert into "oauth_tokens"/);
97
+ expect(err.cause?.code).toBe('23505');
98
+ }
99
+
100
+ /** The row a refresh token's hash resolves to, or undefined once pruned. */
101
+ const rowOf = async (refreshToken: string) =>
102
+ (await db.select().from(oauthTokens).where(eq(oauthTokens.refreshTokenHash, sha256(refreshToken))))[0];
103
+
104
+ /**
105
+ * A person, a registered client, the agent connection their sign-in made,
106
+ * and the pair that sign-in minted — one of each per case, named by `tag`.
107
+ */
108
+ async function seed(tag: string) {
109
+ const [user] = await db
110
+ .insert(users)
111
+ .values({ email: `${tag}@example.com`, emailBidx: `${tag}@example.com`, name: tag })
112
+ .returning({ id: users.id });
113
+ const clientId = `client-${tag}`;
114
+ await db.insert(oauthClients).values({ clientId, redirectUris: ['https://agent.example.com/callback'] });
115
+ const [connection] = await db
116
+ .insert(agentConnections)
117
+ .values({ userId: user!.id, clientId, clientName: 'Claude', agentKey: 'claude' })
118
+ .returning({ id: agentConnections.id });
119
+ const access = `${PREFIX}a-${tag}`;
120
+ const refresh = `${PREFIX}r_${tag}`;
121
+ await db.insert(oauthTokens).values({
122
+ accessTokenHash: sha256(access),
123
+ refreshTokenHash: sha256(refresh),
124
+ clientId,
125
+ userId: user!.id,
126
+ connectionId: connection!.id,
127
+ scope: 'mcp',
128
+ expiresAt: new Date(Date.now() + HOUR),
129
+ refreshExpiresAt: new Date(Date.now() + 30 * 24 * HOUR),
130
+ });
131
+ const client: OAuthClientInformationFull = {
132
+ client_id: clientId,
133
+ redirect_uris: ['https://agent.example.com/callback'],
134
+ token_endpoint_auth_method: 'none',
135
+ };
136
+ return { client, access, refresh };
137
+ }
138
+
139
+ it("a retired pair's access token verifies for the rest of its hour, and not after", async () => {
140
+ const { client, access, refresh } = await seed('hour');
141
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
142
+
143
+ const next = await provider.exchangeRefreshToken(client, refresh);
144
+ const retired = await rowOf(refresh);
145
+ expect(retired?.rotatedAt).toBeInstanceOf(Date);
146
+ expect(retired?.revokedAt).toBeNull();
147
+ // Retired, not revoked: the old access token is still served…
148
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
149
+ minutes(59);
150
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
151
+ // …until its own expiry, like any access token.
152
+ minutes(2);
153
+ await expect(provider.verifyAccessToken(access)).rejects.toBeInstanceOf(InvalidTokenError);
154
+ // The pair the rotation minted carries on: its refresh token is good for 30 days.
155
+ await expect(provider.exchangeRefreshToken(client, next.refresh_token!)).resolves.toMatchObject({ token_type: 'Bearer' });
156
+ }, PG_TEST_TIMEOUT);
157
+
158
+ it('a retired refresh token is honoured once more within the grace — which ends the retired pair — and refused after that, or later', async () => {
159
+ const { client, access, refresh } = await seed('replay');
160
+ const winner = await provider.exchangeRefreshToken(client, refresh);
161
+ // The loser of a double refresh, a moment later: a fresh pair of its own.
162
+ const loser = await provider.exchangeRefreshToken(client, refresh);
163
+ expect(loser.refresh_token).not.toBe(winner.refresh_token);
164
+ // The replay revoked the retired row, access token included.
165
+ expect((await rowOf(refresh))?.revokedAt).toBeInstanceOf(Date);
166
+ await expect(provider.verifyAccessToken(access)).rejects.toBeInstanceOf(InvalidTokenError);
167
+ // Once: a third presentation is refused.
168
+ await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toBeInstanceOf(InvalidGrantError);
169
+ // Both pairs are live and refresh on their own.
170
+ const winnerNext = await provider.exchangeRefreshToken(client, winner.refresh_token!);
171
+ await expect(provider.exchangeRefreshToken(client, loser.refresh_token!)).resolves.toMatchObject({ token_type: 'Bearer' });
172
+ // Past the grace, a retired token is not honoured again at all.
173
+ minutes(2);
174
+ await expect(provider.exchangeRefreshToken(client, winner.refresh_token!)).rejects.toBeInstanceOf(InvalidGrantError);
175
+ await expect(provider.exchangeRefreshToken(client, winnerNext.refresh_token!)).resolves.toMatchObject({ token_type: 'Bearer' });
176
+ }, PG_TEST_TIMEOUT);
177
+
178
+ it('three refreshes of one token at the same instant: exactly two pairs, the third refused — the row lock decides', async () => {
179
+ // What a client refreshing from several places at once actually sends.
180
+ // All three read the token fresh; the row lock serialises their retires,
181
+ // so one wins and the other two find it rotated under them; of those,
182
+ // the replay statement admits exactly one, and the last finds the row
183
+ // revoked. Nothing here is mocked: the database's own locking is the
184
+ // mechanism, and this is the one place it is exercised.
185
+ const { client, refresh } = await seed('race');
186
+ const outcomes = await Promise.allSettled([
187
+ provider.exchangeRefreshToken(client, refresh),
188
+ provider.exchangeRefreshToken(client, refresh),
189
+ provider.exchangeRefreshToken(client, refresh),
190
+ ]);
191
+ const fulfilled = outcomes.filter((o): o is PromiseFulfilledResult<Awaited<ReturnType<typeof provider.exchangeRefreshToken>>> => o.status === 'fulfilled');
192
+ const rejected = outcomes.filter((o): o is PromiseRejectedResult => o.status === 'rejected');
193
+ expect(fulfilled).toHaveLength(2);
194
+ expect(rejected).toHaveLength(1);
195
+ expect(rejected[0]!.reason).toBeInstanceOf(InvalidGrantError);
196
+ // Two distinct pairs, both live.
197
+ const [a, b] = fulfilled.map((o) => o.value.refresh_token!);
198
+ expect(a).not.toBe(b);
199
+ await expect(provider.exchangeRefreshToken(client, a)).resolves.toMatchObject({ token_type: 'Bearer' });
200
+ await expect(provider.exchangeRefreshToken(client, b)).resolves.toMatchObject({ token_type: 'Bearer' });
201
+ // The original is spent for good: retired by the winner, revoked by the replay.
202
+ const row = await rowOf(refresh);
203
+ expect(row?.rotatedAt).toBeInstanceOf(Date);
204
+ expect(row?.revokedAt).toBeInstanceOf(Date);
205
+ await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toBeInstanceOf(InvalidGrantError);
206
+ }, PG_TEST_TIMEOUT);
207
+
208
+ it('a mint that fails after the consume rolls the consume back: the token is exactly as it was, and still refreshes', async () => {
209
+ // The consume and the mint are one transaction, and this is what that
210
+ // buys: the database refuses the insert (the minted access token's hash
211
+ // is one a seeded row already holds), the retire — then, on a second
212
+ // try, the replay — is rolled back with it, and the token that was
213
+ // presented is untouched rather than spent for nothing.
214
+ const { client, refresh } = await seed('rollback');
215
+ const collider = Buffer.alloc(32, 7);
216
+ const { client: other } = await seed('collider');
217
+ await db
218
+ .insert(oauthTokens)
219
+ .values({
220
+ accessTokenHash: sha256(PREFIX + collider.toString('base64url')),
221
+ refreshTokenHash: sha256(`${PREFIX}r_collider-pair`),
222
+ clientId: other.client_id,
223
+ userId: (await db.select({ id: users.id }).from(users).limit(1))[0]!.id,
224
+ scope: 'mcp',
225
+ expiresAt: new Date(Date.now() + HOUR),
226
+ });
227
+
228
+ // A fresh token: the retire is rolled back with the failed mint.
229
+ forcedRandom.bytes = collider;
230
+ try {
231
+ await expectRefusedByUniqueIndex(provider.exchangeRefreshToken(client, refresh));
232
+ } finally {
233
+ forcedRandom.bytes = null;
234
+ }
235
+ const untouched = await rowOf(refresh);
236
+ expect(untouched?.rotatedAt).toBeNull();
237
+ expect(untouched?.revokedAt).toBeNull();
238
+ const winner = await provider.exchangeRefreshToken(client, refresh);
239
+
240
+ // A retired token within the grace: the replay is rolled back with the failed mint too.
241
+ forcedRandom.bytes = collider;
242
+ try {
243
+ await expectRefusedByUniqueIndex(provider.exchangeRefreshToken(client, refresh));
244
+ } finally {
245
+ forcedRandom.bytes = null;
246
+ }
247
+ const stillRetired = await rowOf(refresh);
248
+ expect(stillRetired?.rotatedAt).toBeInstanceOf(Date);
249
+ expect(stillRetired?.revokedAt).toBeNull();
250
+ // Its one replay is still owed.
251
+ const loser = await provider.exchangeRefreshToken(client, refresh);
252
+ expect(loser.refresh_token).not.toBe(winner.refresh_token);
253
+ }, PG_TEST_TIMEOUT);
254
+
255
+ it("a revoke ends a retired pair's access token at once, hour or no hour", async () => {
256
+ const { client, access, refresh } = await seed('revoke');
257
+ await provider.exchangeRefreshToken(client, refresh);
258
+ await expect(provider.verifyAccessToken(access)).resolves.toMatchObject({ clientId: client.client_id });
259
+ await provider.revokeToken(client, { token: access, token_type_hint: 'access_token' });
260
+ await expect(provider.verifyAccessToken(access)).rejects.toBeInstanceOf(InvalidTokenError);
261
+ await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toBeInstanceOf(InvalidGrantError);
262
+ }, PG_TEST_TIMEOUT);
263
+
264
+ it('the prune keeps a retired row for its hour and the grace, then removes it', async () => {
265
+ const { client, refresh } = await seed('prune');
266
+ const next = await provider.exchangeRefreshToken(client, refresh);
267
+ // The mint prunes, and the row it just retired is kept: it can still verify and be replayed.
268
+ expect(await rowOf(refresh)).toBeDefined();
269
+ minutes(30);
270
+ const later = await provider.exchangeRefreshToken(client, next.refresh_token!);
271
+ expect(await rowOf(refresh)).toBeDefined();
272
+ // 62 minutes after its rotation it can do neither, and the next mint's prune drops it.
273
+ minutes(32);
274
+ await provider.exchangeRefreshToken(client, later.refresh_token!);
275
+ expect(await rowOf(refresh)).toBeUndefined();
276
+ // The row retired 32 minutes ago is still there.
277
+ expect(await rowOf(next.refresh_token!)).toBeDefined();
278
+ }, PG_TEST_TIMEOUT);
279
+ });