@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.
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +12 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +21 -1
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +16 -1
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +5 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +75 -15
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -32
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-services.ts +17 -0
- package/src/index.ts +7 -0
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +16 -1
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +258 -1
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +10 -0
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +6 -0
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/workflow.service.ts +188 -40
- 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.
|
|
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-
|
|
59
|
-
"@bevel-software/platform-
|
|
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: {
|
|
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
|
|
28
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
55
|
+
this.invited.add(hook);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
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
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
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
|
|
876
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
*
|
|
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
|
+
});
|