@bevel-software/platform-core-backend 0.13.6 → 0.14.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 +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +3 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -42,7 +42,7 @@ import type {
|
|
|
42
42
|
PostChangeRequestCommentInput,
|
|
43
43
|
} from '@bevel-software/platform-shared';
|
|
44
44
|
import { isProtectedBranch, DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
45
|
-
import { and, eq } from 'drizzle-orm';
|
|
45
|
+
import { and, eq, or } from 'drizzle-orm';
|
|
46
46
|
import type { Database } from '../database/connection.js';
|
|
47
47
|
import { changeRequests } from '../database/schema.js';
|
|
48
48
|
import type { GitService } from './git/git.service.js';
|
|
@@ -282,6 +282,85 @@ export class WorkflowService implements IWorkflowService {
|
|
|
282
282
|
*/
|
|
283
283
|
private readonly branchLifecycle = new WorkspaceMutex();
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Hold the lifecycle lock of BOTH branches a change request names, so a
|
|
287
|
+
* check-then-insert spanning them excludes either one being deleted.
|
|
288
|
+
*
|
|
289
|
+
* ATOMICALLY, via `runAll` — not `run(a, () => run(b, …))`. Taking the keys
|
|
290
|
+
* one at a time leaves a gap between them in which a delete of the second
|
|
291
|
+
* branch can start, pass its "is any open request using this?" check
|
|
292
|
+
* (the row does not exist yet) and finish, so the request lands pointing at
|
|
293
|
+
* a branch that was removed while it was being opened. That is the same
|
|
294
|
+
* failure this PR is about, reintroduced one level down — and it would have
|
|
295
|
+
* been a REGRESSION on the source branch, which the previous single-key
|
|
296
|
+
* lock held for the whole sequence.
|
|
297
|
+
*
|
|
298
|
+
* Reserving the set in one step also removes the deadlock question that
|
|
299
|
+
* sequential acquisition raises: two requests may legally be open in
|
|
300
|
+
* opposite directions (`A -> B` and `B -> A`), and nothing is ever held
|
|
301
|
+
* while waiting for the other key.
|
|
302
|
+
*
|
|
303
|
+
* A PROTECTED target's key is NOT reserved. No path in the system can
|
|
304
|
+
* delete a protected branch (`git.deleteBranch` throws
|
|
305
|
+
* `ProtectedBranchError` before any lock, `retireMergedSourceBranch`
|
|
306
|
+
* returns early on one), so the key would exclude nothing. It would only
|
|
307
|
+
* cost: nearly every request targets the protected default branch, so one
|
|
308
|
+
* shared key there would serialize every open on the hottest path for the
|
|
309
|
+
* duration of a fetch + merge + push + diff. The source needs no such
|
|
310
|
+
* carve-out; `openChangeRequest` refuses protected sources before locking.
|
|
311
|
+
*/
|
|
312
|
+
private runOnBranchPair<T>(
|
|
313
|
+
source: string,
|
|
314
|
+
target: string,
|
|
315
|
+
fn: () => Promise<T>,
|
|
316
|
+
): Promise<T> {
|
|
317
|
+
const keys = [`branch:${source}`];
|
|
318
|
+
if (!isProtectedBranch(target)) keys.push(`branch:${target}`);
|
|
319
|
+
return this.branchLifecycle.runAll(keys, fn);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The open change requests that deleting `branch` would strand — every one
|
|
324
|
+
* that NAMES it, at EITHER end.
|
|
325
|
+
*
|
|
326
|
+
* Both ends, not just the source. A change request is a proposal to move
|
|
327
|
+
* commits FROM one branch INTO another and it needs both to exist: the
|
|
328
|
+
* detail read resolves the target BEFORE the source, so a request whose
|
|
329
|
+
* target has been deleted cannot be read, declined, or deleted either. It
|
|
330
|
+
* is also the worse half to lose — a stranded source is closed by the boot
|
|
331
|
+
* sweep, while a stranded target used to survive every restart, because
|
|
332
|
+
* every guard here asked only "is this branch anybody's SOURCE?".
|
|
333
|
+
*
|
|
334
|
+
* That question is what let a merge retire a branch a second, still-open
|
|
335
|
+
* request was pointing INTO: the pair `X -> B` (merged, B retired) and
|
|
336
|
+
* `Y -> X` (open, now unusable forever).
|
|
337
|
+
*
|
|
338
|
+
* Returns the first such request's number and which end of it names the
|
|
339
|
+
* branch, or null when the branch is free. The end matters to the caller's
|
|
340
|
+
* error message: "withdraw your request" is only actionable when the asker
|
|
341
|
+
* owns the request, and a request proposing INTO the branch is someone
|
|
342
|
+
* else's.
|
|
343
|
+
*/
|
|
344
|
+
private async openChangeRequestOn(
|
|
345
|
+
branch: string,
|
|
346
|
+
): Promise<{ number: number; end: 'source' | 'target' } | null> {
|
|
347
|
+
const [row] = await this.db
|
|
348
|
+
.select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
|
|
349
|
+
.from(changeRequests)
|
|
350
|
+
.where(
|
|
351
|
+
and(
|
|
352
|
+
or(
|
|
353
|
+
eq(changeRequests.sourceBranch, branch),
|
|
354
|
+
eq(changeRequests.targetBranch, branch),
|
|
355
|
+
),
|
|
356
|
+
eq(changeRequests.state, 'open'),
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
.limit(1);
|
|
360
|
+
if (!row) return null;
|
|
361
|
+
return { number: row.number, end: row.sourceBranch === branch ? 'source' : 'target' };
|
|
362
|
+
}
|
|
363
|
+
|
|
285
364
|
async deleteBranch(
|
|
286
365
|
workspaceId: string,
|
|
287
366
|
name: string,
|
|
@@ -296,16 +375,19 @@ export class WorkflowService implements IWorkflowService {
|
|
|
296
375
|
// its own open-request check under this same lock, and the legacy
|
|
297
376
|
// `onlyIfNoRemote` prune only fires when origin no longer has the ref.
|
|
298
377
|
if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
|
|
299
|
-
const open = await this.
|
|
300
|
-
|
|
301
|
-
.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
.limit(1);
|
|
306
|
-
if (open.length > 0) {
|
|
378
|
+
const open = await this.openChangeRequestOn(name);
|
|
379
|
+
if (open !== null) {
|
|
380
|
+
// Two different situations, two different actors. A request FROM
|
|
381
|
+
// this branch is the deleter's own to withdraw; a request INTO it
|
|
382
|
+
// belongs to someone else, and telling the branch owner to
|
|
383
|
+
// "withdraw" it points them at a verb they cannot use.
|
|
307
384
|
throw new WorkflowValidationError(
|
|
308
|
-
|
|
385
|
+
open.end === 'source'
|
|
386
|
+
? `Branch "${name}" has an open change request (#${open.number}). ` +
|
|
387
|
+
`Withdraw or decline it before deleting the branch.`
|
|
388
|
+
: `Open change request #${open.number} proposes changes into "${name}". ` +
|
|
389
|
+
`Its author can withdraw it, or an admin can decline it; ` +
|
|
390
|
+
`after that the branch can be deleted.`,
|
|
309
391
|
);
|
|
310
392
|
}
|
|
311
393
|
}
|
|
@@ -1284,12 +1366,17 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1284
1366
|
}
|
|
1285
1367
|
|
|
1286
1368
|
// The whole open sequence — uniqueness check through row insert — runs
|
|
1287
|
-
// under the
|
|
1288
|
-
//
|
|
1289
|
-
// cleanup can never race into the
|
|
1290
|
-
// method's "no open request" answer and its
|
|
1291
|
-
// branch out from under a request being opened.
|
|
1292
|
-
|
|
1369
|
+
// under the lifecycle lock of BOTH branches. `retireMergedSourceBranch`
|
|
1370
|
+
// and `deleteBranch` take the lock of the branch they are removing around
|
|
1371
|
+
// their own check-then-delete, so a cleanup can never race into the
|
|
1372
|
+
// multi-second gap between this method's "no open request" answer and its
|
|
1373
|
+
// row landing, and delete a branch out from under a request being opened.
|
|
1374
|
+
//
|
|
1375
|
+
// BOTH, because that gap is symmetric: a request is unusable if EITHER of
|
|
1376
|
+
// its branches is retired inside the window, and holding only the source
|
|
1377
|
+
// left a `Y -> X` request open to X being deleted under it — the very
|
|
1378
|
+
// failure the widened guards exist to prevent.
|
|
1379
|
+
return this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
|
|
1293
1380
|
// Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
|
|
1294
1381
|
// parallel is still allowed). `listOpenPrs` is cached for 30s so we
|
|
1295
1382
|
// force-fresh to catch a CR that opened just now.
|
|
@@ -1701,14 +1788,56 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1701
1788
|
throw new WorkflowDomainError('This change request has already been applied.', 422);
|
|
1702
1789
|
}
|
|
1703
1790
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1791
|
+
// Which `roles.yaml` authorizes this? The base branch's, exactly as every
|
|
1792
|
+
// other admin check here. When the base branch itself is gone, this
|
|
1793
|
+
// bootstrap (or the at-ref check below) fails; that failure is NOT proven
|
|
1794
|
+
// absence, so it never re-keys authorization to some other branch. It
|
|
1795
|
+
// kicks the deleted-branch sweep instead: the one mechanism that proves a
|
|
1796
|
+
// branch is missing (fresh fetch, then a set lookup) and closes every
|
|
1797
|
+
// request stranded by it, this one included. This call still fails; by
|
|
1798
|
+
// the next attempt the sweep has either closed the row (nothing left to
|
|
1799
|
+
// delete) or shown the base alive, meaning the failure was transient and
|
|
1800
|
+
// a retry can succeed.
|
|
1801
|
+
let ws: { id: string };
|
|
1802
|
+
let isAdmin: boolean | null;
|
|
1803
|
+
try {
|
|
1804
|
+
ws = await this.workspaceService.getOrCreateForBranch(summary.base);
|
|
1805
|
+
// STRICT: the admin check below reads roles.yaml at `origin/<base>`,
|
|
1806
|
+
// and on a swallowed fetch failure that ref is whatever the clone last
|
|
1807
|
+
// saw — a user whose rights were since revoked would still pass
|
|
1808
|
+
// against the stale tree. Authorization never runs against refs of
|
|
1809
|
+
// unknown age.
|
|
1810
|
+
try {
|
|
1811
|
+
await this.workspaceService.ensureRemotesFetched(ws.id, { strict: true });
|
|
1812
|
+
} catch {
|
|
1813
|
+
throw new WorkflowDomainError(
|
|
1814
|
+
`Could not verify the base branch "${summary.base}" — origin was unreachable. Retry in a moment.`,
|
|
1815
|
+
409,
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
isAdmin = await this.accessControl.canWriteAtRef(
|
|
1819
|
+
ws.id,
|
|
1820
|
+
`origin/${summary.base}`,
|
|
1821
|
+
user.email,
|
|
1822
|
+
'roles.yaml',
|
|
1823
|
+
);
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
this.kickDeletedBranchSweep();
|
|
1826
|
+
throw err;
|
|
1827
|
+
}
|
|
1828
|
+
// null is "the ref did not resolve", not "denied" — and saying "only an
|
|
1829
|
+
// admin can do this" to an admin whose base branch is merely unfetchable
|
|
1830
|
+
// sent people chasing their role instead of the branch. Kick the sweep
|
|
1831
|
+
// (it proves absence itself, with a strict fetch, and closes the
|
|
1832
|
+
// stranded rows) and fail with the honest condition; by the next
|
|
1833
|
+
// attempt the sweep has either closed the row or shown the base alive.
|
|
1834
|
+
if (isAdmin === null) {
|
|
1835
|
+
this.kickDeletedBranchSweep();
|
|
1836
|
+
throw new WorkflowDomainError(
|
|
1837
|
+
`The base branch "${summary.base}" could not be resolved — it may have been deleted, or origin was unreachable. A background check is running; retry in a moment.`,
|
|
1838
|
+
409,
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1712
1841
|
if (isAdmin !== true) {
|
|
1713
1842
|
throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
|
|
1714
1843
|
}
|
|
@@ -1780,17 +1909,23 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1780
1909
|
}
|
|
1781
1910
|
|
|
1782
1911
|
/**
|
|
1783
|
-
* Close every open change request whose
|
|
1912
|
+
* Close every open change request either of whose branches no longer exists.
|
|
1784
1913
|
*
|
|
1785
|
-
* A change request is a proposal to merge
|
|
1786
|
-
* gone — deleted after a manual merge, pruned as
|
|
1787
|
-
* by a feature that has itself been removed — the
|
|
1788
|
-
* reviewed, applied or declined. It is not a pending
|
|
1789
|
-
* tombstone, and it costs more than tidiness: `listOpenPrs`
|
|
1790
|
-
* open request's changed paths, so every one of these throws
|
|
1914
|
+
* A change request is a proposal to merge one branch into another, so it
|
|
1915
|
+
* needs BOTH. When either is gone — deleted after a manual merge, pruned as
|
|
1916
|
+
* abandoned, or left behind by a feature that has itself been removed — the
|
|
1917
|
+
* request cannot be read, reviewed, applied or declined. It is not a pending
|
|
1918
|
+
* decision, it is a tombstone, and it costs more than tidiness: `listOpenPrs`
|
|
1919
|
+
* resolves each open request's changed paths, so every one of these throws
|
|
1791
1920
|
* `unknown branch` on every poll, filling the log and slowing the list it
|
|
1792
1921
|
* appears in.
|
|
1793
1922
|
*
|
|
1923
|
+
* The TARGET half is the one that used to be missed, and it was the worse
|
|
1924
|
+
* half: a missing source could only ever arrive by a path that also closed
|
|
1925
|
+
* the request, while a request whose target had been retired under it was
|
|
1926
|
+
* unreadable, undeclinable, undeletable — and invisible here, so it survived
|
|
1927
|
+
* every restart.
|
|
1928
|
+
*
|
|
1794
1929
|
* CLOSED, NOT DELETED. The row is evidence — who proposed what, when, and
|
|
1795
1930
|
* that it was never applied — and it is the only remaining trace once the
|
|
1796
1931
|
* branch is gone. Closing takes it out of every open list (which is the
|
|
@@ -1799,7 +1934,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1799
1934
|
* operation in the workflow that destroys history without a human asking.
|
|
1800
1935
|
*
|
|
1801
1936
|
* ONE fetch, then a set lookup. The freshness matters more than the cost:
|
|
1802
|
-
* `changedPathsForPr` fails when
|
|
1937
|
+
* `changedPathsForPr` fails when a branch is missing from THAT
|
|
1803
1938
|
* workspace's refs, which is not the same thing as missing from origin —
|
|
1804
1939
|
* a clone that never fetched a branch someone else created reports exactly
|
|
1805
1940
|
* the same error. Closing on that signal would eat live requests. So the
|
|
@@ -1815,7 +1950,11 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1815
1950
|
*/
|
|
1816
1951
|
async closeChangeRequestsWithDeletedBranches(): Promise<number> {
|
|
1817
1952
|
const open = await this.db
|
|
1818
|
-
.select({
|
|
1953
|
+
.select({
|
|
1954
|
+
number: changeRequests.number,
|
|
1955
|
+
sourceBranch: changeRequests.sourceBranch,
|
|
1956
|
+
targetBranch: changeRequests.targetBranch,
|
|
1957
|
+
})
|
|
1819
1958
|
.from(changeRequests)
|
|
1820
1959
|
.where(eq(changeRequests.state, 'open'));
|
|
1821
1960
|
if (open.length === 0) return 0;
|
|
@@ -1824,6 +1963,11 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1824
1963
|
try {
|
|
1825
1964
|
const branches = await this.git.listBranches(workspaceIdForBranch(DEFAULT_BRANCH), {
|
|
1826
1965
|
freshFetch: true,
|
|
1966
|
+
// The list is used to PROVE absence, and the listing's normal
|
|
1967
|
+
// degrade-to-stale behaviour would report every branch created since
|
|
1968
|
+
// the last good fetch as missing — closing live requests. Strict:
|
|
1969
|
+
// a failed fetch throws into the catch below, which closes nothing.
|
|
1970
|
+
strictFetch: true,
|
|
1827
1971
|
});
|
|
1828
1972
|
live = new Set(branches.map((b) => b.name));
|
|
1829
1973
|
} catch (err) {
|
|
@@ -1839,7 +1983,15 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1839
1983
|
|
|
1840
1984
|
let closed = 0;
|
|
1841
1985
|
for (const cr of open) {
|
|
1842
|
-
|
|
1986
|
+
// Either end being gone makes the request unusable — the detail read
|
|
1987
|
+
// resolves the target first, so a missing target fails it just as hard
|
|
1988
|
+
// as a missing source, and no other path ever closes those.
|
|
1989
|
+
const missing = !live.has(cr.sourceBranch)
|
|
1990
|
+
? cr.sourceBranch
|
|
1991
|
+
: !live.has(cr.targetBranch)
|
|
1992
|
+
? cr.targetBranch
|
|
1993
|
+
: null;
|
|
1994
|
+
if (!missing) continue;
|
|
1843
1995
|
const now = new Date();
|
|
1844
1996
|
// Guarded on `state = 'open'`, so a merge or withdraw racing this call
|
|
1845
1997
|
// wins and this becomes a no-op.
|
|
@@ -1852,13 +2004,45 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1852
2004
|
this.prs.invalidateDetailCache(cr.number);
|
|
1853
2005
|
this.events?.emit({ kind: 'change-request-rejected', number: cr.number });
|
|
1854
2006
|
console.log(
|
|
1855
|
-
`[cr] closed change request #${cr.number}:
|
|
2007
|
+
`[cr] closed change request #${cr.number}: branch "${missing}" no longer exists`,
|
|
1856
2008
|
);
|
|
1857
2009
|
closed++;
|
|
1858
2010
|
}
|
|
1859
2011
|
return closed;
|
|
1860
2012
|
}
|
|
1861
2013
|
|
|
2014
|
+
/**
|
|
2015
|
+
* The sweep above, on demand. Boot runs it once (`create-core-server`),
|
|
2016
|
+
* which clears requests stranded BEFORE the process started; a branch that
|
|
2017
|
+
* goes missing mid-uptime would otherwise keep its requests broken until
|
|
2018
|
+
* the next restart. Verbs that hit a failure which MIGHT mean a missing
|
|
2019
|
+
* branch kick it here.
|
|
2020
|
+
*
|
|
2021
|
+
* Detached, because the kicking verb is already surfacing its own error
|
|
2022
|
+
* and must not hold its response on a fetch. Coalesced, because the kick
|
|
2023
|
+
* sites are user actions that cluster when something is stranded (retry,
|
|
2024
|
+
* decline, delete) and one sweep answers all of them. The sweep proves
|
|
2025
|
+
* absence itself and fails safe by closing nothing, so a spurious kick
|
|
2026
|
+
* costs one fetch and never closes a live request.
|
|
2027
|
+
*/
|
|
2028
|
+
private sweepKick: Promise<void> | null = null;
|
|
2029
|
+
private kickDeletedBranchSweep(): void {
|
|
2030
|
+
if (this.sweepKick) return;
|
|
2031
|
+
this.sweepKick = this.closeChangeRequestsWithDeletedBranches()
|
|
2032
|
+
.then((n) => {
|
|
2033
|
+
if (n > 0) {
|
|
2034
|
+
console.log(
|
|
2035
|
+
`[cr] on-demand sweep closed ${n} stranded change request${n === 1 ? '' : 's'}`,
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
})
|
|
2039
|
+
.catch((err) => console.warn('[cr] on-demand deleted-branch sweep failed:', err))
|
|
2040
|
+
.finally(() => {
|
|
2041
|
+
this.sweepKick = null;
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
|
|
1862
2046
|
/**
|
|
1863
2047
|
* Reject (close-without-merging) a change request. Per PLAN.md spec, the
|
|
1864
2048
|
* authorized set is:
|
|
@@ -1892,15 +2076,26 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1892
2076
|
// The path list comes from the PR detail — fetch fresh so the rule
|
|
1893
2077
|
// applies against the current head (a stale list would let a user
|
|
1894
2078
|
// reject a CR whose files they no longer all own).
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
2079
|
+
let detail;
|
|
2080
|
+
try {
|
|
2081
|
+
detail = await this.prs.getPrDetail(number, {
|
|
2082
|
+
fresh: true,
|
|
2083
|
+
workspaceId,
|
|
2084
|
+
viewerEmail: user.email,
|
|
2085
|
+
// Only `files[].path` is read below; a patch per file would be one
|
|
2086
|
+
// git subprocess each, generated for nothing, on every reject by a
|
|
2087
|
+
// non-author.
|
|
2088
|
+
patches: false,
|
|
2089
|
+
});
|
|
2090
|
+
} catch (err) {
|
|
2091
|
+
// The detail read resolves both branch tips (`resolvePrShas`), so a
|
|
2092
|
+
// request one of whose branches is gone fails HERE, on the verb that
|
|
2093
|
+
// exists to get rid of it. Kick the sweep (which proves the absence
|
|
2094
|
+
// and closes the stranded rows) and surface the failure; see
|
|
2095
|
+
// `kickDeletedBranchSweep`.
|
|
2096
|
+
this.kickDeletedBranchSweep();
|
|
2097
|
+
throw err;
|
|
2098
|
+
}
|
|
1904
2099
|
const paths = detail?.files.map((f) => f.path) ?? [];
|
|
1905
2100
|
if (paths.length > 0) {
|
|
1906
2101
|
const writeMap = await this.accessControl.canWriteBatchAtRef(
|
|
@@ -2086,17 +2281,12 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2086
2281
|
// until retirement finishes (and fails loudly on the missing branch,
|
|
2087
2282
|
// retryable, instead of losing the branch mid-open).
|
|
2088
2283
|
await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
eq(changeRequests.state, 'open'),
|
|
2096
|
-
),
|
|
2097
|
-
)
|
|
2098
|
-
.limit(1);
|
|
2099
|
-
if (stillOpen.length > 0) return;
|
|
2284
|
+
// EITHER end (see `openChangeRequestOn`). The request being merged is
|
|
2285
|
+
// already `merged` by now, so it never matches itself; anything left
|
|
2286
|
+
// is a live request that still needs this branch — including one
|
|
2287
|
+
// proposing INTO it, which the source-only question used to miss.
|
|
2288
|
+
const stillOpen = await this.openChangeRequestOn(sourceBranch);
|
|
2289
|
+
if (stillOpen !== null) return;
|
|
2100
2290
|
|
|
2101
2291
|
const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
2102
2292
|
// The remote-tracking ref must be current, or deleteBranch's
|
|
@@ -18,7 +18,7 @@ import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
|
18
18
|
import type { WorkspaceService } from './workspace.service.js';
|
|
19
19
|
import type { AuthService } from '../auth/auth.service.js';
|
|
20
20
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
21
|
-
import { resolveReadableMap, toKbRelative } from '../access-model/kb-read-filter.js';
|
|
21
|
+
import { canReadWorkspacePath, resolveReadableMap, toKbRelative } from '../access-model/kb-read-filter.js';
|
|
22
22
|
import type { ICreatorAccess } from '../access-model/creator.js';
|
|
23
23
|
import { isRolesYamlPath, assertRolesYamlParsable } from '../access-model/roles-yaml-guard.js';
|
|
24
24
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
@@ -426,11 +426,15 @@ export function createWorkspaceRoutes(
|
|
|
426
426
|
): Promise<boolean> {
|
|
427
427
|
const user = await requireUser(req, res);
|
|
428
428
|
if (!user) return false;
|
|
429
|
-
const repoRelative = toKbRelative(relativePath, kbDirName);
|
|
430
|
-
if (repoRelative === null) return true; // non-KB path — no read rules apply
|
|
431
429
|
let allowed: boolean;
|
|
432
430
|
try {
|
|
433
|
-
allowed = await
|
|
431
|
+
allowed = await canReadWorkspacePath(
|
|
432
|
+
(w, e, p) => accessControl.canRead(w, e, p),
|
|
433
|
+
workspaceId,
|
|
434
|
+
user.email,
|
|
435
|
+
kbDirName,
|
|
436
|
+
relativePath,
|
|
437
|
+
);
|
|
434
438
|
} catch (err) {
|
|
435
439
|
sendError(res, err);
|
|
436
440
|
return false;
|
|
@@ -114,6 +114,8 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
114
114
|
|
|
115
115
|
/** Per-workspace-dir `git fetch origin` timestamp + in-flight tracker. */
|
|
116
116
|
private readonly lastFetchAt = new Map<string, number>();
|
|
117
|
+
/** Whether the last fetch per repo SUCCEEDED — read by strict callers of `ensureRemotesFetched`. */
|
|
118
|
+
private readonly lastFetchOk = new Map<string, boolean>();
|
|
117
119
|
private readonly inFlightFetches = new Map<string, Promise<void>>();
|
|
118
120
|
|
|
119
121
|
/**
|
|
@@ -808,15 +810,26 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
808
810
|
* cached for `FETCH_CACHE_TTL_MS`; concurrent callers share the same
|
|
809
811
|
* in-flight fetch to avoid fetch storms when e.g. the CR list poll fans out.
|
|
810
812
|
*/
|
|
811
|
-
async ensureRemotesFetched(workspaceId: string): Promise<void> {
|
|
813
|
+
async ensureRemotesFetched(workspaceId: string, opts: { strict?: boolean } = {}): Promise<void> {
|
|
812
814
|
const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
|
|
813
815
|
const repoDir = path.join(workspaceDir, this.kbDirName);
|
|
814
816
|
const now = Date.now();
|
|
817
|
+
// The TTL is stamped only by a SUCCESSFUL fetch, so a fresh window is
|
|
818
|
+
// itself the proof a strict caller wants.
|
|
815
819
|
const last = this.lastFetchAt.get(repoDir) ?? 0;
|
|
816
820
|
if (now - last < FETCH_CACHE_TTL_MS) return;
|
|
817
821
|
|
|
818
822
|
const inFlight = this.inFlightFetches.get(repoDir);
|
|
819
|
-
if (inFlight)
|
|
823
|
+
if (inFlight) {
|
|
824
|
+
await inFlight;
|
|
825
|
+
// `strict` callers use the refs to AUTHORIZE or to prove absence; the
|
|
826
|
+
// swallow-and-degrade below is right for the polling paths and wrong
|
|
827
|
+
// for them — a failed fetch must surface, never pass as freshness.
|
|
828
|
+
if (opts.strict && !this.lastFetchOk.get(repoDir)) {
|
|
829
|
+
throw new Error('git fetch origin failed — remote refs could not be refreshed');
|
|
830
|
+
}
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
820
833
|
|
|
821
834
|
// This driver runs outside the git layer's per-workspace mutex, so it may
|
|
822
835
|
// only run the safe implicit-fetch shape — see `SAFE_IMPLICIT_FETCH_ARGS`
|
|
@@ -828,8 +841,10 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
828
841
|
)
|
|
829
842
|
.then(() => {
|
|
830
843
|
this.lastFetchAt.set(repoDir, Date.now());
|
|
844
|
+
this.lastFetchOk.set(repoDir, true);
|
|
831
845
|
})
|
|
832
846
|
.catch((err) => {
|
|
847
|
+
this.lastFetchOk.set(repoDir, false);
|
|
833
848
|
console.warn('[workspace] git fetch origin failed:', redactError(err));
|
|
834
849
|
})
|
|
835
850
|
.finally(() => {
|
|
@@ -838,7 +853,10 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
838
853
|
}
|
|
839
854
|
});
|
|
840
855
|
this.inFlightFetches.set(repoDir, promise);
|
|
841
|
-
|
|
856
|
+
await promise;
|
|
857
|
+
if (opts.strict && !this.lastFetchOk.get(repoDir)) {
|
|
858
|
+
throw new Error('git fetch origin failed — remote refs could not be refreshed');
|
|
859
|
+
}
|
|
842
860
|
}
|
|
843
861
|
|
|
844
862
|
private assertValidGitRef(ref: string): void {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TokenCrypto } from '../token-crypto.js';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The contract an operator meets at boot: a wrong-length key must name the
|
|
7
|
+
* variable THEY set. The error used to hardcode `SHAREPOINT_TOKEN_ENC_KEY` —
|
|
8
|
+
* the primitive's birthplace — and a core deployment failing on its vault key
|
|
9
|
+
* was told to fix a SharePoint variable it never configured.
|
|
10
|
+
*/
|
|
11
|
+
describe('TokenCrypto key validation', () => {
|
|
12
|
+
// 30 bytes — the classic wrong key: 40 base64 chars instead of 44.
|
|
13
|
+
const SHORT = randomBytes(30).toString('base64');
|
|
14
|
+
|
|
15
|
+
it('names SECRETS_ENC_KEY by default — the variable every core consumer reads', () => {
|
|
16
|
+
expect(() => new TokenCrypto(SHORT)).toThrowError(
|
|
17
|
+
/^SECRETS_ENC_KEY must decode to 32 bytes \(got 30\)/,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('names the variable an overlay passes for its own key', () => {
|
|
22
|
+
expect(() => new TokenCrypto(SHORT, 'SHAREPOINT_TOKEN_ENC_KEY')).toThrowError(
|
|
23
|
+
/^SHAREPOINT_TOKEN_ENC_KEY must decode to 32 bytes/,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('accepts a 32-byte key as base64 and as hex, and round-trips', () => {
|
|
28
|
+
const raw = randomBytes(32);
|
|
29
|
+
for (const encoded of [raw.toString('base64'), raw.toString('hex')]) {
|
|
30
|
+
const crypto = new TokenCrypto(encoded);
|
|
31
|
+
expect(crypto.decrypt(crypto.encrypt('s3cret'))).toBe('s3cret');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -11,16 +11,16 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
|
11
11
|
export class TokenCrypto {
|
|
12
12
|
private readonly key: Buffer;
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this.key =
|
|
14
|
+
/**
|
|
15
|
+
* @param rawKey 32-byte key as hex (64 chars) or base64.
|
|
16
|
+
* @param envVarName the environment variable the caller read `rawKey` from,
|
|
17
|
+
* so a bad key is reported against the variable the operator actually has
|
|
18
|
+
* to fix. Defaults to core's own `SECRETS_ENC_KEY` — every core consumer
|
|
19
|
+
* reads that; an overlay bringing its own key (the SharePoint token
|
|
20
|
+
* cache, connector configs) passes its own name.
|
|
21
|
+
*/
|
|
22
|
+
constructor(rawKey: string, envVarName = 'SECRETS_ENC_KEY') {
|
|
23
|
+
this.key = assertKeyDecodesTo32Bytes(rawKey, envVarName);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
encrypt(plaintext: string): string {
|
|
@@ -52,3 +52,21 @@ function decodeKey(raw: string): Buffer {
|
|
|
52
52
|
if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, 'hex');
|
|
53
53
|
return Buffer.from(raw, 'base64');
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decode a key and refuse anything that is not exactly 32 bytes, blaming
|
|
58
|
+
* `envVarName`. Exported so boot-time config can validate the key THE MOMENT
|
|
59
|
+
* it knows which environment variable supplied it (`SECRETS_ENC_KEY` or a
|
|
60
|
+
* legacy fallback) — every later `new TokenCrypto(key)` with the default name
|
|
61
|
+
* is then safe, because a bad key never gets that far.
|
|
62
|
+
*/
|
|
63
|
+
export function assertKeyDecodesTo32Bytes(rawKey: string, envVarName: string): Buffer {
|
|
64
|
+
const key = decodeKey(rawKey);
|
|
65
|
+
if (key.length !== 32) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${envVarName} must decode to 32 bytes (got ${key.length}). ` +
|
|
68
|
+
'Generate one with: `node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"`.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return key;
|
|
72
|
+
}
|