@bevel-software/platform-core-backend 0.12.1 → 0.13.1

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 (158) hide show
  1. package/THIRD-PARTY-NOTICES.md +5 -3
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  37. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  39. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  40. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  41. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  42. package/dist/modules/kb-fs/repo-path.js +54 -0
  43. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  44. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  45. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  46. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  47. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  48. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  50. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  52. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  53. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  55. package/dist/modules/tool-helpers/tool-context.js +1 -0
  56. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  57. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  58. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  59. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  60. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  62. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  63. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  64. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  66. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  68. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  70. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  71. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  72. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  73. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  74. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  75. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +70 -5
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  85. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  87. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  89. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  90. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  91. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  92. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  95. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.js +12 -0
  97. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  98. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  99. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.service.js +4 -0
  101. package/dist/modules/workflow/workflow.service.js.map +1 -1
  102. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  103. package/dist/modules/workspace/workspace.tools.js +31 -15
  104. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  105. package/kb-template/AGENTS.md +52 -8
  106. package/package.json +4 -3
  107. package/src/core/create-core-server.ts +13 -1
  108. package/src/core/create-core-services.ts +1 -0
  109. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  110. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  111. package/src/modules/access/access-control.service.ts +198 -37
  112. package/src/modules/access/admin-locked-commit.ts +1 -0
  113. package/src/modules/access/synced-groups-committer.ts +1 -1
  114. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  115. package/src/modules/access-model/access-errors.ts +19 -0
  116. package/src/modules/access-model/access-grammar.ts +67 -3
  117. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  118. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  119. package/src/modules/declared-variables/index.ts +1 -0
  120. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  121. package/src/modules/diff/diff.routes.ts +3 -2
  122. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  123. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  124. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  125. package/src/modules/kb-fs/repo-path.ts +56 -0
  126. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  127. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  128. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  129. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  130. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  131. package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
  132. package/src/modules/tool-helpers/tool-context.ts +1 -0
  133. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  134. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  135. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  136. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  137. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  138. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  139. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  140. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  141. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  142. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  143. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  144. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  145. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  147. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  148. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  149. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  150. package/src/modules/workflow/git/git.service.ts +73 -6
  151. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  152. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  153. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  154. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  155. package/src/modules/workflow/workflow.routes.ts +12 -0
  156. package/src/modules/workflow/workflow.service.ts +5 -1
  157. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  158. 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
- provider: expect.objectContaining({ clientId: 'client-1' }),
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 (echoed on the token
38
- * response). The durable record of what this token can do, so a later check can
39
- * tell whether it still covers a tool whose required scopes have grown. Absent
40
- * on tokens minted before this was captured (treated as "covers nothing").
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
- let pendingVerifier: string | undefined;
286
- if (meta.pkce) {
287
- pendingVerifier = randomBytes(32).toString('base64url');
288
- const blob = this.readBlob(row.valueEncrypted);
289
- const next: OAuthBlob = { ...blob, pendingVerifier };
290
- await this.db
291
- .update(secrets)
292
- .set({ valueEncrypted: this.crypto().encrypt(JSON.stringify(next)), updatedAt: new Date() })
293
- .where(and(eq(secrets.id, id), eq(secrets.userId, userId)));
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
- // The verifier is one-time — never carried past the exchange it was minted for.
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 = { clientSecret: sharedBlob.clientSecret, tokens: existingTokens, pendingVerifier };
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, which the caller may override from the live tool
453
- // file (`input.scopes`) so an owner adding a permission takes effect without
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
- if (!refreshed.scope) refreshed.scope = tokens.scope;
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,