@bevel-software/platform-core-backend 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +32 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +13 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +51 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +26 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +11 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +93 -4
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +27 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +187 -13
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +3 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +17 -4
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +34 -7
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +148 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +17 -1
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +28 -2
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +39 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +55 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +10 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js.map +1 -1
- package/package.json +3 -3
- package/src/core/create-core-server.ts +37 -1
- package/src/core/create-core-services.ts +65 -8
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
- package/src/modules/auth/auth.service.ts +28 -1
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
- package/src/modules/settings/deployment-settings.service.ts +109 -3
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +214 -10
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +25 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +170 -52
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
- package/src/modules/workspace/workspace.service.ts +17 -1
- package/src/modules/workspace/workspace.tools.ts +35 -0
- package/src/shared/domain-errors.ts +58 -0
- package/src/shared/git.contract.ts +10 -0
|
@@ -31,8 +31,26 @@ import type { KbContext } from '../../shared/kb-context.js';
|
|
|
31
31
|
import { failureOf, type GitFailure } from '../../shared/git-failure.js';
|
|
32
32
|
import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
|
|
33
33
|
import { listRootFolders, pickListingBranch } from './git-root-folders.js';
|
|
34
|
+
import { MANAGED_DEFAULT_BRANCH } from './managed-repository.js';
|
|
35
|
+
import { GIT_MODES, type GitHubAppRepository, type RepositorySource } from './repository-source.js';
|
|
34
36
|
import '../auth/auth.middleware.js'; // Express Request augmentation
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* What setup needs of the ways a deployment can be given its repository:
|
|
40
|
+
* the source every reader of the repository asks, and the one thing a mode
|
|
41
|
+
* has to have DONE before a save that chooses it may go through.
|
|
42
|
+
*/
|
|
43
|
+
export interface RepositorySetup {
|
|
44
|
+
source: RepositorySource;
|
|
45
|
+
/** Create the repository the deployment keeps for itself, if it is not there. Never touches one that is. */
|
|
46
|
+
ensureManaged(initialBranch: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* The connection to GitHub through a GitHub App. Absent, that way is not
|
|
49
|
+
* offered, and a save that names it is refused.
|
|
50
|
+
*/
|
|
51
|
+
githubApp?: Pick<GitHubAppRepository, 'url' | 'answered' | 'prepare' | 'token' | 'permits'>;
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/** Which name a host expects beside the token when none is configured. */
|
|
37
55
|
const DEFAULT_GIT_USERNAME = 'x-access-token';
|
|
38
56
|
|
|
@@ -138,8 +156,14 @@ export function createSetupRoutes(
|
|
|
138
156
|
checkOidc: (config: OidcConfiguration) => Promise<OidcCheck> = checkOidcConfiguration,
|
|
139
157
|
/** The issuer-only half, for a test with no application id or secret yet. */
|
|
140
158
|
checkIssuer: (issuerUrl: string) => Promise<IssuerCheck> = (url) => checkOidcIssuer(url),
|
|
159
|
+
/**
|
|
160
|
+
* The ways a deployment can be given its repository. Absent, there is the
|
|
161
|
+
* one there always was: an address and a token, read from the settings.
|
|
162
|
+
*/
|
|
163
|
+
repository?: RepositorySetup,
|
|
141
164
|
): express.Router {
|
|
142
165
|
const router = express.Router();
|
|
166
|
+
const source = repository?.source;
|
|
143
167
|
|
|
144
168
|
/**
|
|
145
169
|
* Why the last setup-time run of the KB startup phase FAILED, or null. While
|
|
@@ -184,7 +208,30 @@ export function createSetupRoutes(
|
|
|
184
208
|
};
|
|
185
209
|
/** The app-gate answer: settings complete AND the KB phase settled clean, whoever ran it. */
|
|
186
210
|
const kbReady = () =>
|
|
187
|
-
isComplete(settings, kb) && kbInit === null && kbInitInFlight === null && bootFailure() === null;
|
|
211
|
+
isComplete(settings, kb, source) && kbInit === null && kbInitInFlight === null && bootFailure() === null;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Which ways of having a repository this deployment offers and which one
|
|
215
|
+
* it is on, for the setup screen. `mode` is the one IN EFFECT, inferred
|
|
216
|
+
* for a deployment that never chose; `chosen` is the one the settings
|
|
217
|
+
* say, which differs from it between a move and the restart the move
|
|
218
|
+
* owes. The screen opens on what was chosen, says a restart is pending
|
|
219
|
+
* when the two differ, and offers the first of `modes` to a deployment
|
|
220
|
+
* that has none. `pinned` names the variable that chose for the
|
|
221
|
+
* deployment, when one did. Nothing at all from a mount without the
|
|
222
|
+
* choice, which the screen reads as the one way there was.
|
|
223
|
+
*/
|
|
224
|
+
const repositoryStatus = () =>
|
|
225
|
+
source
|
|
226
|
+
? {
|
|
227
|
+
repository: {
|
|
228
|
+
mode: source.mode(),
|
|
229
|
+
chosen: source.chosen(),
|
|
230
|
+
...(settings.sourceOf('gitMode') === 'env' ? { pinned: 'GIT_MODE' } : {}),
|
|
231
|
+
modes: GIT_MODES.filter((mode) => mode !== 'github-app' || repository?.githubApp !== undefined),
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: {};
|
|
188
235
|
|
|
189
236
|
const requireAdmin: express.RequestHandler = async (req, res, next) => {
|
|
190
237
|
if (!(await adminAccess.isAdmin(req.userEmail))) {
|
|
@@ -219,9 +266,10 @@ export function createSetupRoutes(
|
|
|
219
266
|
}
|
|
220
267
|
res.json({
|
|
221
268
|
complete: kbReady(),
|
|
222
|
-
awaitingRestart: awaitingRestart(settings, kb),
|
|
269
|
+
awaitingRestart: awaitingRestart(settings, kb, source),
|
|
223
270
|
isAdmin: true,
|
|
224
271
|
settings: settings.describe(),
|
|
272
|
+
...repositoryStatus(),
|
|
225
273
|
oidcVerification: await settings.oidcVerification(),
|
|
226
274
|
...(kbInit ? { kbInit } : bootFailure() !== null ? { kbInit: bootFailureKind() } : {}),
|
|
227
275
|
...(sync ? { sync: { url: sync.url, last: sync.lastSync() } } : {}),
|
|
@@ -265,7 +313,13 @@ export function createSetupRoutes(
|
|
|
265
313
|
// save that flips it false→true — whichever field arrives last — is the
|
|
266
314
|
// one that must run the KB startup phase, regardless of which save
|
|
267
315
|
// configured the branch model.
|
|
268
|
-
const wasComplete = isComplete(settings, kb);
|
|
316
|
+
const wasComplete = isComplete(settings, kb, source);
|
|
317
|
+
// A different question, asked of the GATE: whether anyone is being
|
|
318
|
+
// served. Settings that are answered are not a deployment that works
|
|
319
|
+
// — one whose first clone failed has answered everything and serves
|
|
320
|
+
// nobody.
|
|
321
|
+
const wasServing = kbReady();
|
|
322
|
+
nameBranchesOfManagedRepository(entries);
|
|
269
323
|
if (!(await connectionHoldsFor(entries, wasComplete, res))) return;
|
|
270
324
|
const oidc = await signInHoldsFor(entries, res);
|
|
271
325
|
if (!oidc) return;
|
|
@@ -277,7 +331,35 @@ export function createSetupRoutes(
|
|
|
277
331
|
if (oidc.record) {
|
|
278
332
|
await settings.recordOidcVerification(oidc.record.state, oidc.record.credentials);
|
|
279
333
|
}
|
|
280
|
-
const
|
|
334
|
+
const saved = await settings.save(entries, req.userId ?? null);
|
|
335
|
+
/**
|
|
336
|
+
* A deployment that was not serving has nothing on the mode it had:
|
|
337
|
+
* the mode chosen takes effect now, and the startup phase below is
|
|
338
|
+
* what a restart would have run. One that WAS serving stays on the
|
|
339
|
+
* mode it is on, working copies and credential alike, until it is
|
|
340
|
+
* started again.
|
|
341
|
+
*
|
|
342
|
+
* SERVING, not answered. The pin protects what is running on the
|
|
343
|
+
* mode in effect, and behind a shut gate nothing is: a first run
|
|
344
|
+
* whose initialisation failed, a boot that found its repository
|
|
345
|
+
* unreachable. Pinned there, the way out of a repository that does
|
|
346
|
+
* not work would be closed — the admin chooses another, the retry
|
|
347
|
+
* below runs against the one that just failed, and the screen asks
|
|
348
|
+
* for a restart that changes nothing.
|
|
349
|
+
*/
|
|
350
|
+
if (source && !wasServing) source.takeEffect();
|
|
351
|
+
/**
|
|
352
|
+
* The restart the mode owes is read off the two modes themselves, not
|
|
353
|
+
* off what this save wrote: it is owed for as long as the mode chosen
|
|
354
|
+
* is not the one in effect, whichever save chose it, and no longer
|
|
355
|
+
* once a save chooses the mode in effect back.
|
|
356
|
+
*/
|
|
357
|
+
const modePending = source !== undefined && source.chosen() !== source.mode();
|
|
358
|
+
const restartKeys = [
|
|
359
|
+
...saved.restartKeys.filter((key) => key !== 'gitMode'),
|
|
360
|
+
...(modePending ? ['gitMode'] : []),
|
|
361
|
+
];
|
|
362
|
+
const restartRequired = restartKeys.length > 0;
|
|
281
363
|
/** Whether this save put the stored folder names into the running process. */
|
|
282
364
|
let layoutApplied = false;
|
|
283
365
|
/** Whether this save put the stored branch model into the running process. */
|
|
@@ -320,7 +402,7 @@ export function createSetupRoutes(
|
|
|
320
402
|
* on a re-save of a complete, healthy setup: with the gate open,
|
|
321
403
|
* sessions may be live and that is no longer a quiet moment.
|
|
322
404
|
*/
|
|
323
|
-
if ((!wasComplete || kbInit !== null || bootFailure() !== null) && isComplete(settings, kb)) {
|
|
405
|
+
if ((!wasComplete || kbInit !== null || bootFailure() !== null) && isComplete(settings, kb, source)) {
|
|
324
406
|
/**
|
|
325
407
|
* The folder names, applied BEFORE the phase for the same reason as
|
|
326
408
|
* the branch model above: they are otherwise applied once at boot, so
|
|
@@ -360,6 +442,7 @@ export function createSetupRoutes(
|
|
|
360
442
|
const raw = initErr instanceof Error ? initErr.message : String(initErr);
|
|
361
443
|
const msg = redactSecret(raw, [
|
|
362
444
|
settings.resolve('gitToken'),
|
|
445
|
+
source?.credentials.token() ?? '',
|
|
363
446
|
...urlQuerySecrets(settings.resolve('kbRepoUrl')),
|
|
364
447
|
]);
|
|
365
448
|
log.error(`KB initialization failed after setup completed: ${msg}`);
|
|
@@ -386,8 +469,9 @@ export function createSetupRoutes(
|
|
|
386
469
|
)
|
|
387
470
|
: restartRequired,
|
|
388
471
|
complete: kbReady(),
|
|
389
|
-
awaitingRestart: awaitingRestart(settings, kb),
|
|
472
|
+
awaitingRestart: awaitingRestart(settings, kb, source),
|
|
390
473
|
settings: settings.describe(),
|
|
474
|
+
...repositoryStatus(),
|
|
391
475
|
oidcVerification: await settings.oidcVerification(),
|
|
392
476
|
});
|
|
393
477
|
} catch (err) {
|
|
@@ -402,6 +486,101 @@ export function createSetupRoutes(
|
|
|
402
486
|
}
|
|
403
487
|
}
|
|
404
488
|
|
|
489
|
+
/**
|
|
490
|
+
* A repository the deployment keeps for itself is new, and empty, and has
|
|
491
|
+
* no branches for anyone to look up: the branch model is ours to name. It
|
|
492
|
+
* is named only when nobody has — an admin's own answer, or one the
|
|
493
|
+
* environment supplies, stands. Every other mode has a repository that
|
|
494
|
+
* already exists, whose branches are asked for, never guessed.
|
|
495
|
+
*/
|
|
496
|
+
function nameBranchesOfManagedRepository(entries: Record<string, string>): void {
|
|
497
|
+
if (!source) return;
|
|
498
|
+
const after = settings.resolveAfter(entries);
|
|
499
|
+
if (source.mode(after) !== 'managed') return;
|
|
500
|
+
if (after('defaultBranch') || after('protectedBranches')) return;
|
|
501
|
+
entries.defaultBranch = MANAGED_DEFAULT_BRANCH;
|
|
502
|
+
entries.protectedBranches = MANAGED_DEFAULT_BRANCH;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The same rule for a repository reached through a GitHub App: a saved
|
|
507
|
+
* connection is one GitHub has accepted for reading and writing, asked
|
|
508
|
+
* with the token git will present, which is the installation's.
|
|
509
|
+
*
|
|
510
|
+
* That token is also what keeps the repository's NAME honest. It is the
|
|
511
|
+
* one part of this connection an admin types, and a name the installation
|
|
512
|
+
* does not reach is refused here by GitHub, not by a list of ours.
|
|
513
|
+
*
|
|
514
|
+
* The repository has just said what it calls its trunk, so a deployment
|
|
515
|
+
* with no branch model is given that one: the same answer the setup
|
|
516
|
+
* screen fills in for a repository reached by its address.
|
|
517
|
+
*/
|
|
518
|
+
async function githubConnectionHoldsFor(
|
|
519
|
+
setup: RepositorySetup,
|
|
520
|
+
entries: Record<string, string>,
|
|
521
|
+
after: (key: string) => string,
|
|
522
|
+
wasComplete: boolean,
|
|
523
|
+
res: express.Response,
|
|
524
|
+
): Promise<boolean> {
|
|
525
|
+
const refuse = (problem: string, field = 'githubRepository') => {
|
|
526
|
+
res.status(400).json({ error: problem, problems: { [field]: problem } });
|
|
527
|
+
return false;
|
|
528
|
+
};
|
|
529
|
+
const app = setup.githubApp;
|
|
530
|
+
if (!app) return refuse('This deployment cannot connect to GitHub through a GitHub App.', 'gitMode');
|
|
531
|
+
if (!app.answered(after)) {
|
|
532
|
+
return refuse(
|
|
533
|
+
after('githubRepository')
|
|
534
|
+
? 'Connect GitHub before choosing a repository.'
|
|
535
|
+
: 'Choose the repository the knowledge base lives in.',
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const url = app.url(after);
|
|
539
|
+
const unanswered =
|
|
540
|
+
validateBranchModel({ defaultBranch: after('defaultBranch'), protectedBranches: after('protectedBranches') }) !== null;
|
|
541
|
+
// Against what is CHOSEN now, not what is in effect: a repository that
|
|
542
|
+
// was proven by the save that chose it is not proven again by the next.
|
|
543
|
+
const stored = (key: string) => settings.resolve(key);
|
|
544
|
+
const changes = setup.source.chosen() !== 'github-app' || url !== setup.source.url(stored);
|
|
545
|
+
if (!changes && (wasComplete || unanswered)) return true;
|
|
546
|
+
// BEFORE GitHub is asked anything with the installation token: that
|
|
547
|
+
// token reaches every repository the installation covers, and would
|
|
548
|
+
// answer for one the person connecting it could never have written to.
|
|
549
|
+
if (!app.permits(after('githubRepository'), after)) {
|
|
550
|
+
log.warn('a repository was named that the person who connected GitHub could not push to');
|
|
551
|
+
return refuse(
|
|
552
|
+
'Choose a repository your own GitHub account can write to. If this one should be, press “Refresh the list” so it is brought up to date.',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
await app.prepare({ asked: true });
|
|
557
|
+
} catch (err) {
|
|
558
|
+
log.error('GitHub gave no token for the installation:', { detail: err instanceof Error ? err.message : String(err) });
|
|
559
|
+
}
|
|
560
|
+
const token = app.token();
|
|
561
|
+
if (!token) {
|
|
562
|
+
return refuse('GitHub gave this deployment no access. The app may have been uninstalled: connect it again.', 'gitMode');
|
|
563
|
+
}
|
|
564
|
+
const check = await checkConnection({ url, token, username: DEFAULT_GIT_USERNAME });
|
|
565
|
+
if (check.outcome !== 'connected') {
|
|
566
|
+
return refuse(
|
|
567
|
+
check.outcome === 'read-only'
|
|
568
|
+
? 'The GitHub App can read that repository but not write to it. Grant it write access to the repository’s contents.'
|
|
569
|
+
: check.reason === 'unreachable'
|
|
570
|
+
? 'GitHub could not be reached. Try again shortly.'
|
|
571
|
+
: 'The GitHub App cannot reach that repository. Add the repository to the app’s installation on GitHub.',
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (!after('defaultBranch') && !after('protectedBranches')) {
|
|
575
|
+
const trunk = check.defaultBranch || check.branches[0] || (check.empty ? MANAGED_DEFAULT_BRANCH : '');
|
|
576
|
+
if (trunk) {
|
|
577
|
+
entries.defaultBranch = trunk;
|
|
578
|
+
entries.protectedBranches = trunk;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
|
|
405
584
|
/**
|
|
406
585
|
* A SAVED CONNECTION IS ONE THE HOST HAS ACCEPTED FOR READING AND WRITING.
|
|
407
586
|
*
|
|
@@ -427,6 +606,25 @@ export function createSetupRoutes(
|
|
|
427
606
|
res: express.Response,
|
|
428
607
|
): Promise<boolean> {
|
|
429
608
|
const after = settings.resolveAfter(entries);
|
|
609
|
+
if (repository && repository.source.mode(after) === 'managed') {
|
|
610
|
+
// Nothing to prove to a host: the repository is the deployment's own.
|
|
611
|
+
// What has to hold is that it EXISTS before the save that points
|
|
612
|
+
// everything at it, so a disk that cannot take it refuses the choice
|
|
613
|
+
// instead of failing the first clone.
|
|
614
|
+
try {
|
|
615
|
+
await repository.ensureManaged(after('defaultBranch') || MANAGED_DEFAULT_BRANCH);
|
|
616
|
+
return true;
|
|
617
|
+
} catch (err) {
|
|
618
|
+
log.error('the managed repository could not be created:', { err });
|
|
619
|
+
const problem =
|
|
620
|
+
'The repository could not be created on this deployment’s storage. Check that its backups folder is writable, or connect a repository of your own.';
|
|
621
|
+
res.status(400).json({ error: problem, problems: { gitMode: problem } });
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (repository && repository.source.mode(after) === 'github-app') {
|
|
626
|
+
return githubConnectionHoldsFor(repository, entries, after, wasComplete, res);
|
|
627
|
+
}
|
|
430
628
|
const now: RepositoryConnection = {
|
|
431
629
|
url: settings.resolve('kbRepoUrl'),
|
|
432
630
|
token: settings.resolve('gitToken'),
|
|
@@ -763,8 +961,12 @@ export function createSetupRoutes(
|
|
|
763
961
|
* deployment signs in perfectly well without it, and gating on it would lock
|
|
764
962
|
* an admin out of the screen where they would set it up.
|
|
765
963
|
*/
|
|
766
|
-
export function settingsAnswered(
|
|
767
|
-
|
|
964
|
+
export function settingsAnswered(
|
|
965
|
+
settings: DeploymentSettingsService,
|
|
966
|
+
/** What answers for the repository. Absent: an address and a token, the one way there was. */
|
|
967
|
+
source?: Pick<RepositorySource, 'answered'>,
|
|
968
|
+
): boolean {
|
|
969
|
+
const kb = source ? source.answered() : Boolean(settings.resolve('kbRepoUrl') && settings.resolve('gitToken'));
|
|
768
970
|
const branches =
|
|
769
971
|
validateBranchModel({
|
|
770
972
|
defaultBranch: settings.resolve('defaultBranch'),
|
|
@@ -791,14 +993,16 @@ export function settingsAnswered(settings: DeploymentSettingsService): boolean {
|
|
|
791
993
|
export function isComplete(
|
|
792
994
|
settings: DeploymentSettingsService,
|
|
793
995
|
kb: Pick<KbContext, 'isBranchModelConfigured'>,
|
|
996
|
+
source?: Pick<RepositorySource, 'answered'>,
|
|
794
997
|
): boolean {
|
|
795
|
-
return settingsAnswered(settings) && kb.isBranchModelConfigured();
|
|
998
|
+
return settingsAnswered(settings, source) && kb.isBranchModelConfigured();
|
|
796
999
|
}
|
|
797
1000
|
|
|
798
1001
|
/** Answered, but not yet in effect: everything is stored, the process is stale. */
|
|
799
1002
|
export function awaitingRestart(
|
|
800
1003
|
settings: DeploymentSettingsService,
|
|
801
1004
|
kb: Pick<KbContext, 'isBranchModelConfigured'>,
|
|
1005
|
+
source?: Pick<RepositorySource, 'answered'>,
|
|
802
1006
|
): boolean {
|
|
803
|
-
return settingsAnswered(settings) && !kb.isBranchModelConfigured();
|
|
1007
|
+
return settingsAnswered(settings, source) && !kb.isBranchModelConfigured();
|
|
804
1008
|
}
|
|
@@ -27,15 +27,19 @@ const FOLDER = 'Skills/Eng/deploy';
|
|
|
27
27
|
|
|
28
28
|
async function makeHarness(listBranches: IWorkflowService['listBranches']) {
|
|
29
29
|
const workflow = {
|
|
30
|
-
|
|
30
|
+
listChangeRequests: vi.fn(async () => []),
|
|
31
31
|
listBranches: vi.fn(listBranches),
|
|
32
32
|
createBranch: vi.fn(async () => ({ name: 'x', isDefault: false, isProtected: false })),
|
|
33
|
+
deleteBranch: vi.fn(async () => undefined),
|
|
33
34
|
commitChanges: vi.fn(async () => null),
|
|
34
35
|
openChangeRequest: vi.fn(async () => ({ number: 42 })),
|
|
36
|
+
getChangeRequestDetail: vi.fn(async () => ({ body: '' })),
|
|
35
37
|
} as unknown as IWorkflowService;
|
|
36
38
|
const workspaceService = {
|
|
37
39
|
getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: workspaceIdForBranch(branch) })),
|
|
38
40
|
readFile: vi.fn(async () => '---\n---\nwrite:\n - Eve <eve@x.io>\n'),
|
|
41
|
+
readFileAtRef: vi.fn(async () => null),
|
|
42
|
+
ensureRemotesFetched: vi.fn(async () => undefined),
|
|
39
43
|
writeFile: vi.fn(async () => undefined),
|
|
40
44
|
} as unknown as WorkspaceService;
|
|
41
45
|
const accessControl = { canWrite: vi.fn(async () => false) } as unknown as IAccessControl;
|
|
@@ -3,16 +3,12 @@ import { logger } from '../../shared/logging.js';
|
|
|
3
3
|
|
|
4
4
|
const log = logger('skills');
|
|
5
5
|
import '../auth/auth.middleware.js'; // Express Request.userId / userEmail augmentation
|
|
6
|
-
import {
|
|
7
|
-
joinBranchFor,
|
|
8
|
-
type AuthUser,
|
|
9
|
-
type IWorkflowService,
|
|
10
|
-
} from '@bevel-software/platform-shared';
|
|
6
|
+
import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
|
|
11
7
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
12
|
-
import {
|
|
8
|
+
import { folderTarget } from '../access/access-requests.contract.js';
|
|
9
|
+
import { AccessRequestsService } from '../access/access-requests.service.js';
|
|
13
10
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
14
11
|
import { domainErrorBody } from '../../shared/http-errors.js';
|
|
15
|
-
import { isAbsence } from '../../shared/fs.contract.js';
|
|
16
12
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
17
13
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
18
14
|
import type { JoinRequestsService } from '../plugins/join-requests.service.js';
|
|
@@ -28,13 +24,11 @@ import type { ISkillService } from './skills.contract.js';
|
|
|
28
24
|
*
|
|
29
25
|
* Why it exists: linking a skill into a plugin needs write on the skill's
|
|
30
26
|
* rules (the link grants the plugin's principal there), and a plugin manager
|
|
31
|
-
* often has no say over a skill some other scope owns. The request is
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* path rather than a plugin name — the branch-name convention takes any
|
|
37
|
-
* exact string).
|
|
27
|
+
* often has no say over a skill some other scope owns. The request is opened
|
|
28
|
+
* by the shared `AccessRequestsService` — the same one the Manage access
|
|
29
|
+
* dialog's Request access uses — keyed on the skill FOLDER path, so this
|
|
30
|
+
* button and a Can edit request on that same folder are ONE request, shown in
|
|
31
|
+
* the skill page's banner and in the folder's dialog alike.
|
|
38
32
|
*
|
|
39
33
|
* Fail-closed like the skill catalog: a skill the caller cannot read answers
|
|
40
34
|
* as unknown, and the editors' listing answers `[]` to anyone else.
|
|
@@ -49,10 +43,18 @@ export function createSkillAccessRequestRoutes(deps: {
|
|
|
49
43
|
resolveUser: (req: express.Request) => Promise<AuthUser | null>;
|
|
50
44
|
}): express.Router {
|
|
51
45
|
const { skillService, accessControl, workflow, workspaceService, joinRequests, kb, resolveUser } = deps;
|
|
52
|
-
const { kbDirName } = kb;
|
|
53
46
|
const router = express.Router();
|
|
54
47
|
const wsId = () => kb.defaultWorkspaceId();
|
|
55
48
|
const rulesOf = (folder: string) => `${folder}/access.md`;
|
|
49
|
+
// The same opener the Manage access dialog uses, pointed at a skill's
|
|
50
|
+
// folder. Keyed on that folder's PATH, which is what makes this button and
|
|
51
|
+
// a Can edit request on the same folder one request rather than two.
|
|
52
|
+
const requests = new AccessRequestsService({
|
|
53
|
+
workflow,
|
|
54
|
+
workspaceService,
|
|
55
|
+
lifecycle: joinRequests,
|
|
56
|
+
kb,
|
|
57
|
+
});
|
|
56
58
|
|
|
57
59
|
/** The skill the caller may read, by name — or null after answering 404. */
|
|
58
60
|
async function readableSkill(
|
|
@@ -82,65 +84,13 @@ export function createSkillAccessRequestRoutes(deps: {
|
|
|
82
84
|
res.status(409).json({ error: 'You can already edit this skill', kind: 'already-writable' });
|
|
83
85
|
return;
|
|
84
86
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(cr) => cr.state === 'open' && cr.branch === branch,
|
|
91
|
-
);
|
|
92
|
-
if (existing) {
|
|
93
|
-
res.json({ ok: true, number: existing.number });
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
// The branch may already exist (a request listed as closed, a retry
|
|
97
|
-
// after a failed open). Existence is PROBED, before and — if creation
|
|
98
|
-
// fails — after: the race shows up as "already exists" locally or as a
|
|
99
|
-
// rejected push when origin got there first, and a message is not a
|
|
100
|
-
// contract. A branch that is there is proceeded against; anything else
|
|
101
|
-
// is a real failure, and a proposal on a branch that was not made would
|
|
102
|
-
// be worse than the error. STRICT: the list proves absence, and a
|
|
103
|
-
// listing that could not fetch proves nothing — it throws instead of
|
|
104
|
-
// serving refs from before the branch was made (or deleted).
|
|
105
|
-
const branchExists = async () =>
|
|
106
|
-
(await workflow.listBranches(wsId(), { freshFetch: true, strictFetch: true })).some(
|
|
107
|
-
(b) => b.name === branch,
|
|
108
|
-
);
|
|
109
|
-
if (!(await branchExists())) {
|
|
110
|
-
try {
|
|
111
|
-
await workflow.createBranch(wsId(), branch, kb.defaultBranch);
|
|
112
|
-
} catch (err) {
|
|
113
|
-
if (!(await branchExists())) throw err;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
const ws = await workspaceService.getOrCreateForBranch(branch);
|
|
117
|
-
const accessPath = `${kbDirName}/${rulesOf(folder)}`;
|
|
118
|
-
// Only a PROVEN absence reads as "no rules yet": any other failure must
|
|
119
|
-
// not be turned into an empty file that a splice then overwrites.
|
|
120
|
-
const current = await workspaceService.readFile(ws.id, accessPath).catch((err: unknown) => {
|
|
121
|
-
if (!isAbsence(err)) throw err;
|
|
122
|
-
return '';
|
|
123
|
-
});
|
|
124
|
-
const spliced = spliceGrant(
|
|
125
|
-
current,
|
|
126
|
-
'write',
|
|
127
|
-
{ kind: 'user', email: user.email, displayName: user.name },
|
|
128
|
-
{ target: 'folder' },
|
|
129
|
-
);
|
|
130
|
-
if (spliced.changed) {
|
|
131
|
-
await workspaceService.writeFile(ws.id, accessPath, spliced.text);
|
|
132
|
-
await workflow.commitChanges(ws.id, user, `Request write access to ${name}`);
|
|
133
|
-
}
|
|
134
|
-
const detail = await workflow.openChangeRequest(ws.id, user, {
|
|
135
|
-
sourceBranch: branch,
|
|
136
|
-
targetBranch: kb.defaultBranch,
|
|
137
|
-
title: `Access request: ${name}`,
|
|
138
|
-
description:
|
|
139
|
-
`${user.name} asked to edit the skill ${name}. An editor of the skill accepts by ` +
|
|
140
|
-
`granting the access this branch proposes; the request closes itself once ` +
|
|
141
|
-
`every proposal has landed.`,
|
|
87
|
+
const { number } = await requests.open({
|
|
88
|
+
user,
|
|
89
|
+
target: folderTarget(folder),
|
|
90
|
+
itemName: name,
|
|
91
|
+
level: 'write',
|
|
142
92
|
});
|
|
143
|
-
res.json({ ok: true, number
|
|
93
|
+
res.json({ ok: true, number });
|
|
144
94
|
} catch (err) {
|
|
145
95
|
if (err instanceof WorkflowDomainError) {
|
|
146
96
|
res.status(err.status).json(domainErrorBody(err));
|
|
@@ -171,7 +121,7 @@ export function createSkillAccessRequestRoutes(deps: {
|
|
|
171
121
|
const ctx = await requireEditor(req, res, () => res.json({ requests: [] }));
|
|
172
122
|
if (!ctx) return;
|
|
173
123
|
const crs = await workflow.listChangeRequests();
|
|
174
|
-
res.json({ requests: await joinRequests.list(ctx.folder, ctx.folder, crs, ctx.user) });
|
|
124
|
+
res.json({ requests: await joinRequests.list(ctx.folder, folderTarget(ctx.folder), crs, ctx.user) });
|
|
175
125
|
} catch (err) {
|
|
176
126
|
log.error('failed to list access requests:', { err });
|
|
177
127
|
res.status(500).json({ error: 'Failed to list access requests' });
|
|
@@ -192,7 +142,7 @@ export function createSkillAccessRequestRoutes(deps: {
|
|
|
192
142
|
res.status(404).json({ error: 'Not found' });
|
|
193
143
|
return;
|
|
194
144
|
}
|
|
195
|
-
res.json({ closed: await joinRequests.reconcile(ctx.folder, ctx.folder, cr, ctx.user) });
|
|
145
|
+
res.json({ closed: await joinRequests.reconcile(ctx.folder, folderTarget(ctx.folder), cr, ctx.user) });
|
|
196
146
|
} catch (err) {
|
|
197
147
|
log.error('failed to reconcile an access request:', { err });
|
|
198
148
|
res.status(500).json({ error: 'Failed to reconcile the request' });
|
|
@@ -12,6 +12,7 @@ import type { ICreatorAccess } from '../access-model/creator.js';
|
|
|
12
12
|
import type { GroupsIndex } from '../access-model/group-files.js';
|
|
13
13
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
14
14
|
import { isAbsence } from '../../shared/fs.contract.js';
|
|
15
|
+
import { assertBranchProvided } from '../../shared/domain-errors.js';
|
|
15
16
|
import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
16
17
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
17
18
|
import type { AuthService } from '../auth/auth.service.js';
|
|
@@ -77,6 +78,20 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
77
78
|
const { loadActiveGroups } = deps;
|
|
78
79
|
const fsCache = new Map<string, LocalFilesystem>();
|
|
79
80
|
const getFilesystem = async (branch: string): Promise<LocalFilesystem> => {
|
|
81
|
+
// THE choke point: every knowledge-base tool resolves its workspace
|
|
82
|
+
// through here, and a `call_tool_chain` call reaches the very same route
|
|
83
|
+
// over loopback — so refusing a branch-less call here refuses it on both
|
|
84
|
+
// paths at once, with no per-tool guard to keep in step.
|
|
85
|
+
//
|
|
86
|
+
// Before ANY workspace work, because both things downstream do with this
|
|
87
|
+
// value are wrong when it is absent: `getOrCreateForUser` defaults a
|
|
88
|
+
// missing branch to the deployment's default (protected) branch, so an
|
|
89
|
+
// omitted argument would silently act on `main`; and a present-but-absent
|
|
90
|
+
// value like the string "undefined" goes to `workspaceIdForBranch`, which
|
|
91
|
+
// makes it a workspace directory of that name and then tries to clone a
|
|
92
|
+
// branch nobody ever pushed. Failing closed here means no workspace is
|
|
93
|
+
// created and no clone is attempted.
|
|
94
|
+
assertBranchProvided(branch);
|
|
80
95
|
const cached = fsCache.get(branch);
|
|
81
96
|
if (cached) return cached;
|
|
82
97
|
const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
|
|
@@ -582,7 +582,7 @@ describe('WorkflowService — a refusal clears when a change makes it obsolete',
|
|
|
582
582
|
const { svc } = service(
|
|
583
583
|
store.db,
|
|
584
584
|
(e) => emitted.push(e as { kind: string }),
|
|
585
|
-
{},
|
|
585
|
+
{ carryApprovalsForward: vi.fn(async () => 0) },
|
|
586
586
|
{
|
|
587
587
|
git: {
|
|
588
588
|
pull: vi.fn(async () => ({ treeChanged: false })),
|
|
@@ -591,6 +591,14 @@ describe('WorkflowService — a refusal clears when a change makes it obsolete',
|
|
|
591
591
|
return { kind: 'merged', alreadyUpToDate: false };
|
|
592
592
|
}),
|
|
593
593
|
push: vi.fn(async () => undefined),
|
|
594
|
+
hasUnpushedCommits: vi.fn(async () => true),
|
|
595
|
+
pathsChangedBetween: vi.fn(async () => []),
|
|
596
|
+
// The head this test is named for: one sha before the merge,
|
|
597
|
+
// another after it.
|
|
598
|
+
resolvePrShas: vi
|
|
599
|
+
.fn()
|
|
600
|
+
.mockResolvedValueOnce({ baseSha: 'b'.repeat(40), headSha: 'a'.repeat(40) })
|
|
601
|
+
.mockResolvedValue({ baseSha: 'b'.repeat(40), headSha: 'd'.repeat(40) }),
|
|
594
602
|
} as unknown as Partial<GitService>,
|
|
595
603
|
prs: {
|
|
596
604
|
getPrDetail: vi.fn(async () => ({ state: 'open', branch: 'bo/suggestions', base: 'main', viewerCanUpdate: true })),
|