@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.
Files changed (111) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +2 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +9 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +12 -5
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  12. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  13. package/dist/modules/access-model/kb-read-filter.js +15 -0
  14. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  15. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  16. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  17. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  18. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  19. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  20. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  21. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  22. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  23. package/dist/modules/connection-probe/index.d.ts +3 -0
  24. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  25. package/dist/modules/connection-probe/index.js +3 -0
  26. package/dist/modules/connection-probe/index.js.map +1 -0
  27. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  28. package/dist/modules/diff/diff.routes.js +3 -5
  29. package/dist/modules/diff/diff.routes.js.map +1 -1
  30. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  31. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  32. package/dist/modules/kb-fs/mutex.js +48 -5
  33. package/dist/modules/kb-fs/mutex.js.map +1 -1
  34. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  35. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  36. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  37. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  38. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  39. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  40. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  41. package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
  42. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  43. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  44. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  45. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  46. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  47. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  48. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
  49. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  50. package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
  51. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  52. package/dist/modules/workflow/git/git.service.d.ts +18 -1
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +98 -6
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  57. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  58. package/dist/modules/workflow/workflow.routes.js +98 -9
  59. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  60. package/dist/modules/workflow/workflow.service.d.ts +81 -8
  61. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  62. package/dist/modules/workflow/workflow.service.js +220 -43
  63. package/dist/modules/workflow/workflow.service.js.map +1 -1
  64. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  65. package/dist/modules/workspace/workspace.routes.js +2 -5
  66. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  67. package/dist/modules/workspace/workspace.service.d.ts +5 -1
  68. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  69. package/dist/modules/workspace/workspace.service.js +21 -4
  70. package/dist/modules/workspace/workspace.service.js.map +1 -1
  71. package/dist/shared/token-crypto.d.ts +17 -2
  72. package/dist/shared/token-crypto.d.ts.map +1 -1
  73. package/dist/shared/token-crypto.js +25 -8
  74. package/dist/shared/token-crypto.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/__tests__/core-config.admin.test.ts +21 -0
  77. package/src/core/create-core-server.ts +3 -0
  78. package/src/core/create-core-services.ts +11 -0
  79. package/src/core-config.ts +14 -7
  80. package/src/modules/access-model/kb-read-filter.ts +22 -0
  81. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  82. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  83. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  84. package/src/modules/connection-probe/index.ts +2 -0
  85. package/src/modules/diff/diff.routes.ts +9 -5
  86. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  87. package/src/modules/kb-fs/mutex.ts +50 -5
  88. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  89. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  90. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  91. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  92. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  93. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
  94. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  95. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  96. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
  97. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  98. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  99. package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
  100. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  101. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  102. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  103. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
  104. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  105. package/src/modules/workflow/git/git.service.ts +115 -7
  106. package/src/modules/workflow/workflow.routes.ts +104 -4
  107. package/src/modules/workflow/workflow.service.ts +245 -55
  108. package/src/modules/workspace/workspace.routes.ts +8 -4
  109. package/src/modules/workspace/workspace.service.ts +21 -3
  110. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  111. 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.db
300
- .select({ number: changeRequests.number })
301
- .from(changeRequests)
302
- .where(
303
- and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')),
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
- `Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`,
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 source branch's lifecycle lock. `retireMergedSourceBranch`
1288
- // takes the same lock around its own check-then-delete, so a merge
1289
- // cleanup can never race into the multi-second gap between this
1290
- // method's "no open request" answer and its row landing, and delete the
1291
- // branch out from under a request being opened.
1292
- return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
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
- const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
1705
- await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
1706
- const isAdmin = await this.accessControl.canWriteAtRef(
1707
- ws.id,
1708
- `origin/${summary.base}`,
1709
- user.email,
1710
- 'roles.yaml',
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 source branch no longer exists.
1912
+ * Close every open change request either of whose branches no longer exists.
1784
1913
  *
1785
- * A change request is a proposal to merge a branch. When that branch is
1786
- * gone — deleted after a manual merge, pruned as abandoned, or left behind
1787
- * by a feature that has itself been removed — the request cannot be read,
1788
- * reviewed, applied or declined. It is not a pending decision, it is a
1789
- * tombstone, and it costs more than tidiness: `listOpenPrs` resolves each
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 the branch is missing from THAT
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({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
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
- if (live.has(cr.sourceBranch)) continue;
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}: source branch "${cr.sourceBranch}" no longer exists`,
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
- const detail = await this.prs.getPrDetail(number, {
1896
- fresh: true,
1897
- workspaceId,
1898
- viewerEmail: user.email,
1899
- // Only `files[].path` is read below; a patch per file would be one
1900
- // git subprocess each, generated for nothing, on every reject by a
1901
- // non-author.
1902
- patches: false,
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
- const stillOpen = await this.db
2090
- .select({ number: changeRequests.number })
2091
- .from(changeRequests)
2092
- .where(
2093
- and(
2094
- eq(changeRequests.sourceBranch, sourceBranch),
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 accessControl.canRead(workspaceId, user.email, repoRelative);
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) return 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
- return promise;
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
- /** @param rawKey 32-byte key as hex (64 chars) or base64. */
15
- constructor(rawKey: string) {
16
- const key = decodeKey(rawKey);
17
- if (key.length !== 32) {
18
- throw new Error(
19
- `SHAREPOINT_TOKEN_ENC_KEY must decode to 32 bytes (got ${key.length}). ` +
20
- 'Generate one with: `node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"`.',
21
- );
22
- }
23
- this.key = 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
+ }