@bevel-software/platform-core-backend 0.12.1 → 0.13.3
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/THIRD-PARTY-NOTICES.md +5 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +123 -7
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +4 -3
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/__tests__/access-own-read-grant.test.ts +116 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +199 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +123 -6
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +5 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { assertInsideRepo, isInsideRepo } from '../repo-path.js';
|
|
3
|
+
import { WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
4
|
+
|
|
5
|
+
const KB = 'knowledge-base';
|
|
6
|
+
|
|
7
|
+
describe('isInsideRepo', () => {
|
|
8
|
+
it('accepts the repository folder and anything under it', () => {
|
|
9
|
+
expect(isInsideRepo('knowledge-base', KB)).toBe(true);
|
|
10
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/Foo.md', KB)).toBe(true);
|
|
11
|
+
expect(isInsideRepo('knowledge-base/roles.yaml', KB)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('refuses a repo-relative path: it would land beside the repository, where git never looks', () => {
|
|
15
|
+
expect(isInsideRepo('KnowledgeBase/Reviews/PR-12.html', KB)).toBe(false);
|
|
16
|
+
expect(isInsideRepo('review.log', KB)).toBe(false);
|
|
17
|
+
expect(isInsideRepo('tmp/spill.txt', KB)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('matches the folder as a whole segment, not as a string prefix', () => {
|
|
21
|
+
expect(isInsideRepo('knowledge-based/x.md', KB)).toBe(false);
|
|
22
|
+
expect(isInsideRepo('knowledge-base-old/x.md', KB)).toBe(false);
|
|
23
|
+
expect(isInsideRepo('knowledge-base.bak', KB)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('refuses traversal that starts under the prefix and climbs back out', () => {
|
|
27
|
+
// `knowledge-base/../stray.md` starts with the clone folder but resolves
|
|
28
|
+
// beside it: the containment check is against the WORKSPACE dir, so the
|
|
29
|
+
// filesystem would accept it and the bytes would land outside git.
|
|
30
|
+
expect(isInsideRepo('knowledge-base/../stray.md', KB)).toBe(false);
|
|
31
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/../../stray.md', KB)).toBe(false);
|
|
32
|
+
expect(isInsideRepo('knowledge-base/./x.md', KB)).toBe(false);
|
|
33
|
+
expect(isInsideRepo('knowledge-base//x.md', KB)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('refuses backslashes anywhere: on Windows they separate segments too, and the commit layer rejects them', () => {
|
|
37
|
+
expect(isInsideRepo('knowledge-base/foo\\..\\..\\outside.md', KB)).toBe(false);
|
|
38
|
+
expect(isInsideRepo('knowledge-base\\KnowledgeBase\\x.md', KB)).toBe(false);
|
|
39
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/a\\b.md', KB)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('tolerates a trailing slash on a directory path', () => {
|
|
43
|
+
expect(isInsideRepo('knowledge-base/', KB)).toBe(true);
|
|
44
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/Projects/', KB)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('refuses dressed-up forms of the prefix that the commit layer would reject anyway', () => {
|
|
48
|
+
expect(isInsideRepo('./knowledge-base/x.md', KB)).toBe(false);
|
|
49
|
+
expect(isInsideRepo('/knowledge-base/x.md', KB)).toBe(false);
|
|
50
|
+
expect(isInsideRepo('', KB)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('assertInsideRepo', () => {
|
|
55
|
+
it('passes silently for a path inside the repository', () => {
|
|
56
|
+
expect(() => assertInsideRepo('knowledge-base/KnowledgeBase/Foo.md', KB)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws a 400 that names the prefix and spells out the corrected path', () => {
|
|
60
|
+
let err: unknown;
|
|
61
|
+
try {
|
|
62
|
+
assertInsideRepo('KnowledgeBase/Reviews/PR-12.html', KB);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
err = e;
|
|
65
|
+
}
|
|
66
|
+
expect(err).toBeInstanceOf(WorkflowValidationError);
|
|
67
|
+
const e = err as WorkflowValidationError;
|
|
68
|
+
expect(e.status).toBe(400);
|
|
69
|
+
// The offending path, the rule, and the exact path to use instead: an
|
|
70
|
+
// agent reading this error can retry without guessing.
|
|
71
|
+
expect(e.message).toContain('"KnowledgeBase/Reviews/PR-12.html"');
|
|
72
|
+
expect(e.message).toContain('"knowledge-base/"');
|
|
73
|
+
expect(e.message).toContain('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
74
|
+
expect(e.payload).toMatchObject({
|
|
75
|
+
kind: 'path-outside-repo',
|
|
76
|
+
path: 'KnowledgeBase/Reviews/PR-12.html',
|
|
77
|
+
kbDirName: KB,
|
|
78
|
+
// On its own as well: the pending-commits worker's notice reads it from
|
|
79
|
+
// here, because the message it keeps is truncated.
|
|
80
|
+
corrected: 'knowledge-base/KnowledgeBase/Reviews/PR-12.html',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('suggests the path with the traversal collapsed, back under the prefix', () => {
|
|
85
|
+
expect(() => assertInsideRepo('knowledge-base/../stray.md', KB)).toThrow('"knowledge-base/stray.md"');
|
|
86
|
+
expect(() => assertInsideRepo('knowledge-base/../../escape.md', KB)).toThrow('"knowledge-base/escape.md"');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('suggests a forward-slash path for a backslash one', () => {
|
|
90
|
+
expect(() => assertInsideRepo('knowledge-base/foo\\..\\..\\outside.md', KB)).toThrow('"knowledge-base/outside.md"');
|
|
91
|
+
expect(() => assertInsideRepo('KnowledgeBase\\Reviews\\PR-12.html', KB)).toThrow('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('never suggests a path that still climbs: a bare `..` corrects to the repository folder', () => {
|
|
95
|
+
for (const p of ['..', '../', '../..', '../../', 'knowledge-base/..']) {
|
|
96
|
+
let message = '';
|
|
97
|
+
try {
|
|
98
|
+
assertInsideRepo(p, KB);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
message = (e as Error).message;
|
|
101
|
+
}
|
|
102
|
+
expect(message, p).toContain('Use "knowledge-base/" instead');
|
|
103
|
+
expect(message, p).not.toMatch(/Use "[^"]*\.\.[^"]*" instead/);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
import type { AuthUser, Change, IWorkflowService } from '@bevel-software/platform-shared';
|
|
45
45
|
import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
|
|
46
46
|
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
47
|
+
import { assertInsideRepo } from './repo-path.js';
|
|
47
48
|
import type { CreationGrantPlan, ICreatorAccess } from '../access-model/creator.js';
|
|
48
49
|
|
|
49
50
|
/** How many times to retry a contended acquire before giving up. */
|
|
@@ -62,6 +63,16 @@ export interface LockingFilesystemContext {
|
|
|
62
63
|
workspaceId: string;
|
|
63
64
|
branch: string;
|
|
64
65
|
user: AuthUser;
|
|
66
|
+
/**
|
|
67
|
+
* The clone folder at the workspace root (`knowledge-base`). The filesystem
|
|
68
|
+
* is rooted one level ABOVE the repository, so containment alone lets a
|
|
69
|
+
* repo-relative path (`KnowledgeBase/…`) land beside the clone, where git
|
|
70
|
+
* never sees it and the release commit quietly no-ops. Every op that
|
|
71
|
+
* creates or modifies bytes is refused unless its path starts with this
|
|
72
|
+
* folder; removals stay open so a stray the old behaviour left there can
|
|
73
|
+
* still be cleaned up.
|
|
74
|
+
*/
|
|
75
|
+
kbDirName: string;
|
|
65
76
|
/**
|
|
66
77
|
* Optional pre-disk write validator. Invoked with the (workspace-relative
|
|
67
78
|
* path, full content) of every WHOLE-FILE write BEFORE the bytes land. Throw
|
|
@@ -104,6 +115,7 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
104
115
|
content: FileContent,
|
|
105
116
|
options?: WriteOptions,
|
|
106
117
|
): Promise<void> {
|
|
118
|
+
this.assertInsideRepo(inputPath);
|
|
107
119
|
// Pre-disk gate (e.g. reject a roles.yaml edit that would lock out admins).
|
|
108
120
|
// Runs OUTSIDE the lock so a refusal never acquires/holds one.
|
|
109
121
|
this.lockContext.validateWrite?.(inputPath, content);
|
|
@@ -121,6 +133,7 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
override async appendFile(inputPath: string, content: FileContent): Promise<void> {
|
|
136
|
+
this.assertInsideRepo(inputPath);
|
|
124
137
|
return this.withLock(inputPath, () => super.appendFile(inputPath, content));
|
|
125
138
|
}
|
|
126
139
|
|
|
@@ -129,12 +142,16 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
override async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
|
145
|
+
this.assertInsideRepo(dest);
|
|
132
146
|
// Lock on `dest`. `src` is read-only from this op's perspective — copy
|
|
133
147
|
// creates a new file at dest without disturbing src on disk.
|
|
134
148
|
return this.withLock(dest, () => super.copyFile(src, dest, options));
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
override async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
|
152
|
+
// Only the destination is gated: moving a stray INTO the repository is how
|
|
153
|
+
// a file the old behaviour left beside the clone gets rescued.
|
|
154
|
+
this.assertInsideRepo(dest);
|
|
138
155
|
// A move mutates two paths: the source is deleted and the destination
|
|
139
156
|
// is created. Only locking `dest` would let a concurrent writer hold
|
|
140
157
|
// the source's lock and mutate it under us, and would also leave the
|
|
@@ -183,6 +200,9 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
183
200
|
deletes: string[] = [],
|
|
184
201
|
): Promise<Change | null> {
|
|
185
202
|
const { workflow, workspaceId, branch, user } = this.lockContext;
|
|
203
|
+
// Fail the whole batch before any plan, validator or lock: one stray path
|
|
204
|
+
// must not let its siblings land while it silently misses git.
|
|
205
|
+
for (const w of writes) this.assertInsideRepo(w.path);
|
|
186
206
|
// Creator read grants for the batch: transform new markdown files'
|
|
187
207
|
// content in place, and fold any subtree access.md seeds into the SAME
|
|
188
208
|
// atomic batch (deduped — several files landing in one new folder share
|
|
@@ -479,6 +499,7 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
479
499
|
}
|
|
480
500
|
|
|
481
501
|
override async mkdir(inputPath: string, options?: { recursive?: boolean }): Promise<void> {
|
|
502
|
+
this.assertInsideRepo(inputPath);
|
|
482
503
|
// Creator read grant: planned BEFORE the dir exists (the plan's
|
|
483
504
|
// new-directory detection needs the pre-creation tree), seeded right
|
|
484
505
|
// after — so the new folder's access.md names the acting user under
|
|
@@ -527,6 +548,15 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
527
548
|
);
|
|
528
549
|
}
|
|
529
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Refuse a path that would put bytes beside the repository instead of in
|
|
553
|
+
* it. Runs before any side effect (plan, validator, lock, disk) so a refusal
|
|
554
|
+
* leaves nothing behind. See `LockingFilesystemContext.kbDirName`.
|
|
555
|
+
*/
|
|
556
|
+
private assertInsideRepo(inputPath: string): void {
|
|
557
|
+
assertInsideRepo(inputPath, this.lockContext.kbDirName);
|
|
558
|
+
}
|
|
559
|
+
|
|
530
560
|
/**
|
|
531
561
|
* Land a `seed-access-md` creation-grant plan: under the access.md's own
|
|
532
562
|
* lock (single acquire+release cycle → one commit), re-read the CURRENT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Workspace-relative paths and the repository folder.
|
|
6
|
+
*
|
|
7
|
+
* A workspace directory holds the git clone as `<kbDirName>/` (plus transient
|
|
8
|
+
* scratch such as `tmp/`). The agent filesystem, the lock keys and the human
|
|
9
|
+
* editor all speak WORKSPACE-relative paths, so a path reaches git only when
|
|
10
|
+
* it starts with that folder. `KnowledgeBase/Foo.md` on its own is not a
|
|
11
|
+
* shorthand for `knowledge-base/KnowledgeBase/Foo.md`: it is a different
|
|
12
|
+
* location, beside the clone, that nothing commits and nothing pushes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when `wsPath` is the repository folder or lies under it.
|
|
17
|
+
*
|
|
18
|
+
* Judged segment by segment, not by string prefix: `knowledge-base/../x.md`
|
|
19
|
+
* starts with the folder yet resolves beside it, and the filesystem's own
|
|
20
|
+
* containment is against the WORKSPACE dir, so it would accept that path.
|
|
21
|
+
* `.` and `..` segments (and empty ones from `//`) are therefore refused
|
|
22
|
+
* outright, and so is any backslash: on Windows it separates segments too,
|
|
23
|
+
* so `foo\..\..` would climb the same way. Both match what the commit layer
|
|
24
|
+
* accepts. A single trailing slash on a directory path is tolerated.
|
|
25
|
+
*/
|
|
26
|
+
export function isInsideRepo(wsPath: string, kbDirName: string): boolean {
|
|
27
|
+
if (typeof wsPath !== 'string' || wsPath.includes('\\')) return false;
|
|
28
|
+
const segments = wsPath.replace(/\/$/, '').split('/');
|
|
29
|
+
if (segments[0] !== kbDirName) return false;
|
|
30
|
+
return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refuse a workspace-relative path that would land outside the repository.
|
|
35
|
+
* The message carries the corrected path so an agent can retry without
|
|
36
|
+
* guessing; the payload carries a typed discriminator for callers that
|
|
37
|
+
* switch on it, plus the same corrected path on its own, for a consumer
|
|
38
|
+
* that cannot rely on the message surviving intact (the pending-commits
|
|
39
|
+
* worker stores a sanitized, truncated copy).
|
|
40
|
+
*/
|
|
41
|
+
export function assertInsideRepo(wsPath: string, kbDirName: string): void {
|
|
42
|
+
if (isInsideRepo(wsPath, kbDirName)) return;
|
|
43
|
+
// The suggestion collapses what made the path wrong: backslashes, leading
|
|
44
|
+
// `./` or `/`, and any `..` climbing back out (a bare `..` included, so the
|
|
45
|
+
// suggestion is never itself a climb). The agent gets a path it can use.
|
|
46
|
+
const normalized = path.posix
|
|
47
|
+
.normalize(String(wsPath).replace(/\\/g, '/').replace(/^(\.\/|\/)+/, ''))
|
|
48
|
+
.replace(/^(\.\.(\/|$))+/, '')
|
|
49
|
+
.replace(/^\.$/, '');
|
|
50
|
+
const corrected = isInsideRepo(normalized, kbDirName) ? normalized : `${kbDirName}/${normalized}`;
|
|
51
|
+
throw new WorkflowValidationError(
|
|
52
|
+
`"${wsPath}" is outside the knowledge base repository: paths are workspace-relative, must start with "${kbDirName}/" and may not contain "." or ".." segments or backslashes. ` +
|
|
53
|
+
`Use "${corrected}" instead. A file written without that prefix lands beside the repository, where it is never committed or pushed.`,
|
|
54
|
+
{ kind: 'path-outside-repo', path: wsPath, kbDirName, corrected },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -151,6 +151,96 @@ describe('DbSecretsVaultService — PKCE + public-client tool OAuth', () => {
|
|
|
151
151
|
expect(stored.tokens.access_token).toBe('at-1');
|
|
152
152
|
expect(stored.pendingVerifier).toBeUndefined();
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('beginToolOAuthByKey remembers the scopes it asked for, and completeOAuth takes them as granted when the provider echoes none', async () => {
|
|
156
|
+
// RFC 6749 §5.1: `scope` in the token response is OPTIONAL when identical
|
|
157
|
+
// to what was requested. A provider that omits it (HubSpot) granted the
|
|
158
|
+
// request, not nothing — reading it as nothing would flag every declared
|
|
159
|
+
// scope as missing and block the tool behind a permanent "sign in again".
|
|
160
|
+
const begin = makeFakeDb([[sharedRow()], [], [{ id: 'user-row-1' }]]);
|
|
161
|
+
const svc = new DbSecretsVaultService(begin.db, ENC_KEY);
|
|
162
|
+
const { url } = await svc.beginToolOAuthByKey({
|
|
163
|
+
userId: 'user-1',
|
|
164
|
+
key: KEY,
|
|
165
|
+
redirectUri: 'https://bevel.example.com/cb',
|
|
166
|
+
state: 's',
|
|
167
|
+
scopes: ['crm.objects.contacts.read', 'crm.objects.companies.read'],
|
|
168
|
+
});
|
|
169
|
+
expect(new URL(url).searchParams.get('scope')).toBe('crm.objects.contacts.read crm.objects.companies.read');
|
|
170
|
+
const pending = JSON.parse(crypto.decrypt(begin.captured.values[0].valueEncrypted));
|
|
171
|
+
expect(pending.pendingScopes).toBe('crm.objects.contacts.read crm.objects.companies.read');
|
|
172
|
+
|
|
173
|
+
const complete = async (tokenResponse: Record<string, unknown>) => {
|
|
174
|
+
const userRow = sharedRow({
|
|
175
|
+
id: 'user-row-1',
|
|
176
|
+
userId: 'user-1',
|
|
177
|
+
valueEncrypted: crypto.encrypt(
|
|
178
|
+
JSON.stringify({ pendingVerifier: 'v', pendingScopes: 'crm.objects.contacts.read crm.objects.companies.read' }),
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
const { db, captured } = makeFakeDb([[userRow], undefined]);
|
|
182
|
+
vi.stubGlobal(
|
|
183
|
+
'fetch',
|
|
184
|
+
vi.fn(async () => new Response(JSON.stringify(tokenResponse), { status: 200, headers: { 'Content-Type': 'application/json' } })),
|
|
185
|
+
);
|
|
186
|
+
await new DbSecretsVaultService(db, ENC_KEY).completeOAuth('user-1', 'user-row-1', 'code', 'https://bevel.example.com/cb');
|
|
187
|
+
return JSON.parse(crypto.decrypt(captured.set[0].valueEncrypted));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// No `scope` echoed → the requested scopes are the granted ones.
|
|
191
|
+
const silent = await complete({ access_token: 'at-1', expires_in: 3600 });
|
|
192
|
+
expect(silent.tokens.scope).toBe('crm.objects.contacts.read crm.objects.companies.read');
|
|
193
|
+
expect(silent.pendingScopes).toBeUndefined(); // one-time, like the verifier
|
|
194
|
+
// An echoed `scope` is the provider's word and wins — a narrower grant stays visible.
|
|
195
|
+
const echoed = await complete({ access_token: 'at-2', expires_in: 3600, scope: 'crm.objects.contacts.read' });
|
|
196
|
+
expect(echoed.tokens.scope).toBe('crm.objects.contacts.read');
|
|
197
|
+
// …and so does an EXPLICIT empty grant: only an absent field means "as requested".
|
|
198
|
+
const empty = await complete({ access_token: 'at-3', expires_in: 3600, scope: '' });
|
|
199
|
+
expect(empty.tokens.scope).toBe('');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('beginOAuth never writes a stale blob over tokens a concurrent refresh just rotated', async () => {
|
|
203
|
+
// The standalone flow stashes the pending verifier/scopes with a
|
|
204
|
+
// read-modify-write. Between its read and its write a refresh persists
|
|
205
|
+
// rotated tokens; the guarded update misses (0 rows), the row is re-read,
|
|
206
|
+
// and the pending fields are merged onto THOSE tokens.
|
|
207
|
+
const stale = sharedRow({
|
|
208
|
+
id: 'secret-1',
|
|
209
|
+
userId: 'user-1',
|
|
210
|
+
oauthMeta: { ...PUBLIC_META, pkce: false },
|
|
211
|
+
valueEncrypted: crypto.encrypt(JSON.stringify({ tokens: { access_token: 'old', refresh_token: 'rt-old' } })),
|
|
212
|
+
});
|
|
213
|
+
const rotated = {
|
|
214
|
+
...stale,
|
|
215
|
+
valueEncrypted: crypto.encrypt(JSON.stringify({ tokens: { access_token: 'new', refresh_token: 'rt-new' } })),
|
|
216
|
+
};
|
|
217
|
+
const { db, captured } = makeFakeDb([
|
|
218
|
+
[stale], // requireRow
|
|
219
|
+
[], // guarded update: the ciphertext changed underneath us
|
|
220
|
+
[rotated], // re-read
|
|
221
|
+
[{ id: 'secret-1' }], // guarded update against the fresh ciphertext
|
|
222
|
+
]);
|
|
223
|
+
const url = await new DbSecretsVaultService(db, ENC_KEY).beginOAuth('user-1', 'secret-1', 'https://bevel.example.com/cb', 's');
|
|
224
|
+
expect(new URL(url).searchParams.get('scope')).toBe('mcp.read');
|
|
225
|
+
expect(captured.set).toHaveLength(2);
|
|
226
|
+
const persisted = JSON.parse(crypto.decrypt(captured.set[1].valueEncrypted));
|
|
227
|
+
expect(persisted.tokens).toEqual({ access_token: 'new', refresh_token: 'rt-new' });
|
|
228
|
+
expect(persisted.pendingScopes).toBe('mcp.read');
|
|
229
|
+
|
|
230
|
+
// Only a token rotation is merge-able: a row that meanwhile became another
|
|
231
|
+
// provider's sign-in (or a static value) aborts, since the consent URL was
|
|
232
|
+
// built for the provider we read first.
|
|
233
|
+
for (const changed of [
|
|
234
|
+
{ ...rotated, oauthMeta: { ...PUBLIC_META, pkce: false, clientId: 'someone-else' } },
|
|
235
|
+
{ ...rotated, kind: 'static' },
|
|
236
|
+
]) {
|
|
237
|
+
const race = makeFakeDb([[stale], [], [changed]]);
|
|
238
|
+
await expect(
|
|
239
|
+
new DbSecretsVaultService(race.db, ENC_KEY).beginOAuth('user-1', 'secret-1', 'https://bevel.example.com/cb', 's'),
|
|
240
|
+
).rejects.toBeInstanceOf(SecretOAuthError);
|
|
241
|
+
expect(race.captured.set).toHaveLength(1); // nothing written onto the changed row
|
|
242
|
+
}
|
|
243
|
+
});
|
|
154
244
|
});
|
|
155
245
|
|
|
156
246
|
describe('DbSecretsVaultService — dead-grant detection on refresh', () => {
|
|
@@ -210,6 +300,20 @@ describe('DbSecretsVaultService — dead-grant detection on refresh', () => {
|
|
|
210
300
|
expect(onMutation).toHaveBeenCalledWith('user-1');
|
|
211
301
|
});
|
|
212
302
|
|
|
303
|
+
it('a refresh keeps the recorded grant when `scope` is absent, and takes an echoed one — empty included', async () => {
|
|
304
|
+
// Same rule as the code exchange: only an ABSENT field means "unchanged".
|
|
305
|
+
const refreshWith = async (body: Record<string, unknown>) => {
|
|
306
|
+
const { db, captured } = makeFakeDb([[userRow()], undefined]);
|
|
307
|
+
const svc = new DbSecretsVaultService(db, ENC_KEY, undefined, userScope);
|
|
308
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })));
|
|
309
|
+
await svc.resolve('user-1', KEY);
|
|
310
|
+
return JSON.parse(crypto.decrypt(captured.set[0].valueEncrypted)).tokens.scope;
|
|
311
|
+
};
|
|
312
|
+
expect(await refreshWith({ access_token: 'a', expires_in: 3600 })).toBe('mcp.read');
|
|
313
|
+
expect(await refreshWith({ access_token: 'a', expires_in: 3600, scope: 'mcp.read mcp.write' })).toBe('mcp.read mcp.write');
|
|
314
|
+
expect(await refreshWith({ access_token: 'a', expires_in: 3600, scope: '' })).toBe('');
|
|
315
|
+
});
|
|
316
|
+
|
|
213
317
|
it('putSharedOAuthProvider notifies mutation listeners with null (shared → everyone)', async () => {
|
|
214
318
|
const { db } = makeFakeDb([undefined]); // provider upsert
|
|
215
319
|
const svc = new DbSecretsVaultService(db, ENC_KEY);
|
|
@@ -147,10 +147,62 @@ describe('McpOAuthDiscoveryService', () => {
|
|
|
147
147
|
});
|
|
148
148
|
const res = await makeService(vault, fetchFn).statusFor('notion', MCP_URL);
|
|
149
149
|
expect(res.status).toBe('unsupported');
|
|
150
|
+
// The reason is the admin's to-do list: it names the redirect URI to
|
|
151
|
+
// register and where the client id goes, because that is the next step.
|
|
152
|
+
expect(res).toMatchObject({ reason: expect.stringContaining(REDIRECT_URI) });
|
|
153
|
+
expect(res).toMatchObject({ reason: expect.stringContaining('client id') });
|
|
150
154
|
expect(vault.putSharedOAuthProvider).not.toHaveBeenCalled();
|
|
151
155
|
warn.mockRestore();
|
|
152
156
|
});
|
|
153
157
|
|
|
158
|
+
it('completes an owner-declared client from the server metadata — no registration, nothing persisted', async () => {
|
|
159
|
+
const vault = makeVault();
|
|
160
|
+
const { fetchFn, calls } = makeFetch({
|
|
161
|
+
[MCP_URL]: () => json({}, 401),
|
|
162
|
+
[PRM_URL]: () => json(PRM),
|
|
163
|
+
[AS_META_URL]: () => json({ ...AS_META, registration_endpoint: undefined }),
|
|
164
|
+
});
|
|
165
|
+
const svc = makeService(vault, fetchFn);
|
|
166
|
+
const res = await svc.providerForDeclaredClient('hubspot', MCP_URL, 'owner-app-1');
|
|
167
|
+
expect(res).toEqual({
|
|
168
|
+
status: 'oauth',
|
|
169
|
+
provider: {
|
|
170
|
+
authorizationUrl: 'https://auth.example.com/authorize',
|
|
171
|
+
tokenUrl: 'https://auth.example.com/token',
|
|
172
|
+
clientId: 'owner-app-1',
|
|
173
|
+
scopes: ['mcp.read'],
|
|
174
|
+
pkce: true,
|
|
175
|
+
resource: MCP_URL,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
expect(calls).not.toContain('https://auth.example.com/register');
|
|
179
|
+
expect(vault.putSharedOAuthProvider).not.toHaveBeenCalled();
|
|
180
|
+
// The metadata walk is per server, so a second declared client on the
|
|
181
|
+
// same URL costs no network — and gets ITS client id, not the first one's.
|
|
182
|
+
const before = calls.length;
|
|
183
|
+
const again = await svc.providerForDeclaredClient('hubspot_eu', MCP_URL, 'owner-app-2');
|
|
184
|
+
expect(again).toMatchObject({ status: 'oauth', provider: { clientId: 'owner-app-2' } });
|
|
185
|
+
expect(calls.length).toBe(before);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('refuses to complete a declared client when the server is open or publishes no metadata', async () => {
|
|
189
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
190
|
+
const open = makeService(makeVault(), makeFetch({ [MCP_URL]: () => json({ ok: true }) }).fetchFn);
|
|
191
|
+
expect(await open.providerForDeclaredClient('x', MCP_URL, 'c')).toMatchObject({
|
|
192
|
+
status: 'unsupported',
|
|
193
|
+
reason: expect.stringContaining('authorizationUrl'),
|
|
194
|
+
});
|
|
195
|
+
const noMeta = makeService(
|
|
196
|
+
makeVault(),
|
|
197
|
+
makeFetch({ [MCP_URL]: () => json({}, 401), [PRM_URL]: () => json(PRM) }).fetchFn,
|
|
198
|
+
);
|
|
199
|
+
expect(await noMeta.providerForDeclaredClient('x', MCP_URL, 'c')).toMatchObject({
|
|
200
|
+
status: 'unsupported',
|
|
201
|
+
reason: expect.stringContaining('no authorization-server metadata'),
|
|
202
|
+
});
|
|
203
|
+
warn.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
|
|
154
206
|
it('short-circuits on an already-persisted registration — never re-registers a client', async () => {
|
|
155
207
|
const existing: OAuthProviderConfig = {
|
|
156
208
|
authorizationUrl: 'https://auth.example.com/authorize',
|
|
@@ -25,6 +25,7 @@ const toolManualService = {
|
|
|
25
25
|
name: 'weather',
|
|
26
26
|
path: TOOL_PATH,
|
|
27
27
|
type: 'mcp' as const,
|
|
28
|
+
setup: { kind: 'oauth-manual' as const, reason: 'no authorization-server metadata at https://weather.example' },
|
|
28
29
|
variables: [
|
|
29
30
|
{ name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
|
|
30
31
|
{
|
|
@@ -37,6 +38,19 @@ const toolManualService = {
|
|
|
37
38
|
clientId: 'client-1',
|
|
38
39
|
},
|
|
39
40
|
},
|
|
41
|
+
{
|
|
42
|
+
name: 'LEGACY',
|
|
43
|
+
scope: 'user' as const,
|
|
44
|
+
oauth: {
|
|
45
|
+
authorizationUrl: 'https://auth.example.com/authorize',
|
|
46
|
+
tokenUrl: 'https://auth.example.com/token',
|
|
47
|
+
clientId: 'client-2',
|
|
48
|
+
pkce: false,
|
|
49
|
+
resource: 'https://weather.example/mcp',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
// Declared by client id alone, and discovery could NOT fill the endpoints in.
|
|
53
|
+
{ name: 'BYO', scope: 'user' as const, oauth: { clientId: 'owner-app' } },
|
|
40
54
|
],
|
|
41
55
|
},
|
|
42
56
|
],
|
|
@@ -125,11 +139,44 @@ describe('tool owner gate — shared config requires WRITE on the `.tool` file',
|
|
|
125
139
|
expect.objectContaining({
|
|
126
140
|
key: 'weather_SIGNIN',
|
|
127
141
|
clientSecret: 'cs-123',
|
|
128
|
-
|
|
142
|
+
// PKCE rides along by default — the MCP spec requires it and a
|
|
143
|
+
// provider without it ignores the parameters. No `resource` declared.
|
|
144
|
+
provider: expect.objectContaining({ clientId: 'client-1', pkce: true, resource: undefined }),
|
|
129
145
|
}),
|
|
130
146
|
);
|
|
131
147
|
});
|
|
132
148
|
|
|
149
|
+
it('pins the declaration\'s PKCE opt-out and resource indicator with the secret', async () => {
|
|
150
|
+
const base = await baseUrlAs(WRITER);
|
|
151
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/LEGACY/oauth/admin`, {
|
|
152
|
+
method: 'PUT',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ clientSecret: 'cs-456' }),
|
|
155
|
+
});
|
|
156
|
+
expect(res.status).toBe(201);
|
|
157
|
+
expect(putSharedOAuthClientSecret).toHaveBeenCalledWith(
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
key: 'weather_LEGACY',
|
|
160
|
+
provider: expect.objectContaining({ clientId: 'client-2', pkce: false, resource: 'https://weather.example/mcp' }),
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('refuses the secret while the sign-in endpoints are still unknown, naming why (422)', async () => {
|
|
166
|
+
const base = await baseUrlAs(WRITER);
|
|
167
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/BYO/oauth/admin`, {
|
|
168
|
+
method: 'PUT',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify({ clientSecret: 'cs-789' }),
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(422);
|
|
173
|
+
const body = (await res.json()) as { error: string };
|
|
174
|
+
expect(body.error).toContain('no authorization-server metadata at https://weather.example');
|
|
175
|
+
// Pinning a provider with no endpoints would make every later sign-in
|
|
176
|
+
// fail with a far vaguer error than this one.
|
|
177
|
+
expect(putSharedOAuthClientSecret).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
133
180
|
it('a non-writer cannot set the client secret (403)', async () => {
|
|
134
181
|
const base = await baseUrlAs(READER);
|
|
135
182
|
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/admin`, {
|
|
@@ -34,10 +34,13 @@ interface OAuthTokenSet {
|
|
|
34
34
|
expires_at?: number;
|
|
35
35
|
token_type?: string;
|
|
36
36
|
/**
|
|
37
|
-
* Space-delimited scopes the provider actually granted
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Space-delimited scopes the provider actually granted. The durable record of
|
|
38
|
+
* what this token can do, so a later check can tell whether it still covers a
|
|
39
|
+
* tool whose required scopes have grown. Taken from the token response's
|
|
40
|
+
* `scope` when echoed; when the provider stays silent, RFC 6749 §5.1 says the
|
|
41
|
+
* grant is identical to the request, so the scopes asked for at `begin*` time
|
|
42
|
+
* are recorded instead. Absent only on tokens minted before this was captured
|
|
43
|
+
* (treated as "covers nothing").
|
|
41
44
|
*/
|
|
42
45
|
scope?: string;
|
|
43
46
|
}
|
|
@@ -51,6 +54,13 @@ interface OAuthBlob {
|
|
|
51
54
|
* it's a one-time secret binding the pending consent to this server.
|
|
52
55
|
*/
|
|
53
56
|
pendingVerifier?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The space-delimited `scope` the pending consent asked for — what the
|
|
59
|
+
* token is deemed granted when the provider's token response echoes none
|
|
60
|
+
* (RFC 6749 §5.1: `scope` is optional when identical to the request). One-time
|
|
61
|
+
* like the verifier: consumed by the exchange it was minted for.
|
|
62
|
+
*/
|
|
63
|
+
pendingScopes?: string;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -281,16 +291,39 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
281
291
|
// PKCE (S256): same as the tool path — mint the verifier, stash it on the
|
|
282
292
|
// row (so `completeOAuth` echoes it at the token exchange), and put only
|
|
283
293
|
// the derived challenge in the consent URL. A provider registered pkce
|
|
284
|
-
// would otherwise fail the exchange from this standalone flow.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
+
// would otherwise fail the exchange from this standalone flow. The
|
|
295
|
+
// requested scopes ride along for the same exchange (see `pendingScopes`).
|
|
296
|
+
const pendingVerifier = meta.pkce ? randomBytes(32).toString('base64url') : undefined;
|
|
297
|
+
const pendingScopes = meta.scopes && meta.scopes.length ? meta.scopes.join(' ') : undefined;
|
|
298
|
+
if (pendingVerifier || pendingScopes) {
|
|
299
|
+
// Read-modify-write on the blob, guarded on the ciphertext we read: a
|
|
300
|
+
// refresh running concurrently (the row is a live credential) may have
|
|
301
|
+
// persisted ROTATED tokens in between, and writing our copy over them
|
|
302
|
+
// would leave the row holding a dead refresh token if this consent is
|
|
303
|
+
// then abandoned. On a miss (0 rows), re-read and merge onto the fresh
|
|
304
|
+
// blob; bounded so a row that keeps changing fails loudly, not forever.
|
|
305
|
+
let current = row;
|
|
306
|
+
for (let attempt = 0; ; attempt++) {
|
|
307
|
+
const blob = this.readBlob(current.valueEncrypted);
|
|
308
|
+
const next: OAuthBlob = { ...blob, pendingVerifier, pendingScopes };
|
|
309
|
+
const written = await this.db
|
|
310
|
+
.update(secrets)
|
|
311
|
+
.set({ valueEncrypted: this.crypto().encrypt(JSON.stringify(next)), updatedAt: new Date() })
|
|
312
|
+
.where(
|
|
313
|
+
and(eq(secrets.id, id), eq(secrets.userId, userId), eq(secrets.valueEncrypted, current.valueEncrypted)),
|
|
314
|
+
)
|
|
315
|
+
.returning({ id: secrets.id });
|
|
316
|
+
if (!Array.isArray(written) || written.length > 0) break;
|
|
317
|
+
if (attempt >= 2) throw new SecretOAuthError('The sign-in changed while starting — try again');
|
|
318
|
+
current = await this.requireRow(userId, id);
|
|
319
|
+
// Only a token rotation is merge-able. A row that is no longer this
|
|
320
|
+
// OAuth secret — re-registered against another provider, or replaced by
|
|
321
|
+
// a static value — would have the consent URL built above pointing at
|
|
322
|
+
// one provider and the pending fields stashed for another.
|
|
323
|
+
if (current.kind !== 'oauth' || JSON.stringify(current.oauthMeta) !== JSON.stringify(row.oauthMeta)) {
|
|
324
|
+
throw new SecretOAuthError('The sign-in changed while starting — try again');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
294
327
|
}
|
|
295
328
|
|
|
296
329
|
const url = new URL(meta.authorizationUrl);
|
|
@@ -340,7 +373,14 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
340
373
|
if (meta.resource) body.set('resource', meta.resource);
|
|
341
374
|
|
|
342
375
|
const tokens = await this.tokenRequest(meta.tokenUrl, body);
|
|
343
|
-
//
|
|
376
|
+
// A silent token response granted what was asked (RFC 6749 §5.1) — record
|
|
377
|
+
// the request as the grant, or every declared scope would read as missing
|
|
378
|
+
// and the sign-in would be flagged "again" forever. An echoed `scope` is
|
|
379
|
+
// the provider's own word and wins — narrower grants included, and so does
|
|
380
|
+
// an explicit empty one: only an ABSENT field means "as requested".
|
|
381
|
+
if (tokens.scope === undefined && blob.pendingScopes) tokens.scope = blob.pendingScopes;
|
|
382
|
+
// The verifier and the requested scopes are one-time — never carried past
|
|
383
|
+
// the exchange they were minted for.
|
|
344
384
|
const next: OAuthBlob = { clientSecret: blob.clientSecret, tokens };
|
|
345
385
|
await this.db
|
|
346
386
|
.update(secrets)
|
|
@@ -433,11 +473,22 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
433
473
|
// only the derived challenge in the consent URL. `completeOAuth` echoes the
|
|
434
474
|
// verifier at the token exchange and drops it.
|
|
435
475
|
const pendingVerifier = meta.pkce ? randomBytes(32).toString('base64url') : undefined;
|
|
476
|
+
// The scopes this consent asks for: the caller may override from the live
|
|
477
|
+
// tool file (`input.scopes`) so an owner adding a permission takes effect
|
|
478
|
+
// without re-setting the secret. Remembered on the row so a token response
|
|
479
|
+
// that echoes no `scope` is read as granting exactly this (RFC 6749 §5.1).
|
|
480
|
+
const requestedScopes = input.scopes && input.scopes.length ? input.scopes : meta.scopes;
|
|
481
|
+
const pendingScopes = requestedScopes && requestedScopes.length ? requestedScopes.join(' ') : undefined;
|
|
436
482
|
|
|
437
483
|
// Provision (or reset) the caller's own oauth row for this key from the shared
|
|
438
484
|
// provider meta + secret, preserving any existing tokens. Keyed `<manual>_<VAR>`
|
|
439
485
|
// so `resolve` (scope 'user') returns the token once sign-in completes.
|
|
440
|
-
const blob: OAuthBlob = {
|
|
486
|
+
const blob: OAuthBlob = {
|
|
487
|
+
clientSecret: sharedBlob.clientSecret,
|
|
488
|
+
tokens: existingTokens,
|
|
489
|
+
pendingVerifier,
|
|
490
|
+
pendingScopes,
|
|
491
|
+
};
|
|
441
492
|
const valueEncrypted = this.crypto().encrypt(JSON.stringify(blob));
|
|
442
493
|
const [row] = await this.db
|
|
443
494
|
.insert(secrets)
|
|
@@ -449,10 +500,8 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
449
500
|
.returning();
|
|
450
501
|
|
|
451
502
|
// Build the consent URL exactly as beginOAuth does, from the stored meta —
|
|
452
|
-
// EXCEPT the requested scopes
|
|
453
|
-
//
|
|
454
|
-
// re-setting the secret. Client id, addresses, and secret stay owner-pinned.
|
|
455
|
-
const requestedScopes = input.scopes && input.scopes.length ? input.scopes : meta.scopes;
|
|
503
|
+
// EXCEPT the requested scopes (above). Client id, addresses, and secret
|
|
504
|
+
// stay owner-pinned.
|
|
456
505
|
const url = new URL(meta.authorizationUrl);
|
|
457
506
|
url.searchParams.set('response_type', 'code');
|
|
458
507
|
url.searchParams.set('client_id', meta.clientId);
|
|
@@ -572,8 +621,10 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
572
621
|
// Some providers omit the refresh_token on refresh — keep the old one.
|
|
573
622
|
if (!refreshed.refresh_token) refreshed.refresh_token = tokens.refresh_token;
|
|
574
623
|
// Likewise, a refresh response often omits `scope` — keep the granted scopes
|
|
575
|
-
// recorded at sign-in so coverage checks don't regress to "unknown".
|
|
576
|
-
|
|
624
|
+
// recorded at sign-in so coverage checks don't regress to "unknown". Same
|
|
625
|
+
// rule as the exchange: only an ABSENT field means "unchanged"; an echoed
|
|
626
|
+
// value, empty included, is the provider's word.
|
|
627
|
+
if (refreshed.scope === undefined) refreshed.scope = tokens.scope;
|
|
577
628
|
const next: OAuthBlob = { clientSecret: blob.clientSecret, tokens: refreshed };
|
|
578
629
|
|
|
579
630
|
// Optimistic concurrency: only persist if the stored ciphertext is unchanged,
|