@bevel-software/platform-core-backend 0.12.0 → 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 (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  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/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. 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
+ });
@@ -1,6 +1,13 @@
1
1
  /**
2
- * The git configuration a per-branch workspace clone must have for its branch
3
- * to track exactly ONE upstream ref.
2
+ * The git configuration a per-branch workspace clone must carry: the tracking
3
+ * config that pins its branch to exactly ONE upstream ref, and the credential
4
+ * helper its remote operations authenticate with.
5
+ *
6
+ * Both are stamped at clone time AND re-stamped when an existing clone is
7
+ * adopted from disk, so a clone created by an older build — or by a path that
8
+ * only authenticated its own invocation — is repaired rather than left broken.
9
+ *
10
+ * On the tracking half:
4
11
  *
5
12
  * Why this exists: a clone's config is append-only from git's point of view,
6
13
  * and several things write to it:
@@ -69,3 +76,91 @@ export function cloneTrackingConfigArgs(branch: string): string[][] {
69
76
  ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`],
70
77
  ];
71
78
  }
79
+
80
+ /**
81
+ * The inline credential helper a workspace clone authenticates its remote
82
+ * operations with, or null when the deployment has no git token configured.
83
+ *
84
+ * The snippet reads `$GITHUB_TOKEN` at CALL time, so the secret itself never
85
+ * enters argv, the clone's `.git/config`, or any error message quoting either.
86
+ * `GITHUB_TOKEN` is the normalised name — `CoreConfig` collapses `GIT_TOKEN` /
87
+ * `GH_TOKEN` onto it at boot, and `DeploymentSettingsService.syncGitTokenEnv`
88
+ * publishes a token supplied through the setup screen to the same place — so
89
+ * this one name covers every way a token can arrive.
90
+ *
91
+ * The username is provider-specific (GitHub `x-access-token`, GitLab `oauth2`,
92
+ * Bitbucket `x-token-auth`); the token is always the Basic-auth password,
93
+ * which every major host accepts.
94
+ */
95
+ export function credentialHelperValue(gitUsername: string): string | null {
96
+ if (!process.env.GITHUB_TOKEN) return null;
97
+ // Interpolated into a shell snippet that git will execute. `CoreConfig` and
98
+ // `DeploymentSettingsService` both reject a username outside this charset
99
+ // before it can get this far, so reaching the throw means an unvalidated
100
+ // fourth path appeared — fail closed rather than emit an injectable helper.
101
+ if (!/^[A-Za-z0-9._-]+$/.test(gitUsername)) {
102
+ throw new Error(`git username must match [A-Za-z0-9._-]+; got "${gitUsername}"`);
103
+ }
104
+ // printf '%s' rather than echo: POSIX leaves echo's handling of backslash
105
+ // escapes implementation-defined (dash interprets \c as "stop output"), so
106
+ // a token containing a backslash sequence could be silently truncated on
107
+ // the way to git. printf %s passes the bytes through verbatim. The literal
108
+ // `password=$GITHUB_TOKEN` stays in the snippet, which is what
109
+ // APP_HELPER_VALUE_PATTERN keys on — echo-era helpers stamped by earlier
110
+ // builds still match it and get replaced on the next stamp.
111
+ return `!f() { printf '%s\\n' "username=${gitUsername}" "password=$GITHUB_TOKEN"; }; f`;
112
+ }
113
+
114
+ /**
115
+ * `git clone` arguments that PERSIST the credential helper into the repository
116
+ * being created. Position is load-bearing: these belong AFTER the `clone`
117
+ * subcommand, where `--config` means "write this into the new repo's config".
118
+ * The same pair placed BEFORE it is git's per-invocation `-c`, which
119
+ * authenticates that one clone and leaves the resulting repo with no helper at
120
+ * all — so every later `git push` from that clone prompts for a username,
121
+ * finds no tty, and dies. That was a real outage: a self-hosted deployment
122
+ * cloned fine, committed fine, and silently pushed nothing for two days.
123
+ *
124
+ * Empty when no token is configured, matching the un-authenticated clone the
125
+ * rest of the layer already supports.
126
+ */
127
+ export function cloneCredentialArgs(gitUsername: string): string[] {
128
+ const helper = credentialHelperValue(gitUsername);
129
+ return helper ? ['--config', `credential.helper=${helper}`] : [];
130
+ }
131
+
132
+ /**
133
+ * `git` argument lists that bring a clone's credential helper into line with
134
+ * the deployment's current config — the repair path for clones on disk (see
135
+ * `cloneCredentialArgs`), applied wherever `cloneTrackingConfigArgs` is:
136
+ * `--replace-all` when a token is configured (idempotent re-stamp, stale
137
+ * values collapse), `--unset-all` when the deployment lost its token (a
138
+ * leftover helper answering with an empty password must not shadow whatever
139
+ * auth the operator fell back to; unset of a missing value exits non-zero
140
+ * and callers tolerate it — see `stampCredentialHelper`). Both operations
141
+ * are scoped by the value pattern below so only app-owned helpers are
142
+ * touched. Returns the arguments rather than running them, for the same
143
+ * reason `cloneTrackingConfigArgs` does.
144
+ */
145
+ /**
146
+ * Value pattern (a POSIX ERE, as `git config`'s optional value-pattern
147
+ * argument expects) that matches ONLY helpers this application stamped —
148
+ * every one of ours reads `password=$GITHUB_TOKEN`, and no operator-authored
149
+ * helper has a reason to contain that literal. Scoping both the replace and
150
+ * the unset to it means an operator's own clone-local helper (`store`,
151
+ * `cache`, something custom) is never collapsed away or deleted by our
152
+ * stamping: git chains all configured helpers in order, so ours and theirs
153
+ * coexist.
154
+ */
155
+ const APP_HELPER_VALUE_PATTERN = 'password=\\$GITHUB_TOKEN';
156
+
157
+ export function cloneCredentialConfigArgs(gitUsername: string): string[][] {
158
+ const helper = credentialHelperValue(gitUsername);
159
+ // `--replace-all key value pattern` replaces every line MATCHING the
160
+ // pattern with the one value (adding it when none match) and leaves
161
+ // non-matching values — operator helpers — untouched. Same scoping on the
162
+ // unset: only app-stamped values are removed when the token goes away.
163
+ return helper
164
+ ? [['config', '--replace-all', 'credential.helper', helper, APP_HELPER_VALUE_PATTERN]]
165
+ : [['config', '--unset-all', 'credential.helper', APP_HELPER_VALUE_PATTERN]];
166
+ }
@@ -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`, {