@bevel-software/platform-core-backend 0.13.5 → 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/THIRD-PARTY-NOTICES.md +639 -433
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +6 -3
- 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/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.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 +7 -2
- 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/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- 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
|
@@ -271,6 +271,8 @@ export class GitService implements IGitService {
|
|
|
271
271
|
// worst case for branch-switching UX).
|
|
272
272
|
private readonly fetchLocks = new Map<string, Promise<void>>();
|
|
273
273
|
private readonly lastImplicitFetchAt = new Map<string, number>();
|
|
274
|
+
/** Whether the last implicit fetch per workspace SUCCEEDED — see `fetchOriginIfStale`. */
|
|
275
|
+
private readonly lastImplicitFetchOk = new Map<string, boolean>();
|
|
274
276
|
private static readonly IMPLICIT_FETCH_TTL_MS = 5_000;
|
|
275
277
|
// Cap on concurrent `aheadBehindForBranch` calls inside `listBranches`. Each
|
|
276
278
|
// call serializes up to 3 `git rev-list` spawns, so 8 workers ≈ ≤24
|
|
@@ -315,6 +317,11 @@ export class GitService implements IGitService {
|
|
|
315
317
|
*/
|
|
316
318
|
noteWorkspaceFetched(workspaceId: string): void {
|
|
317
319
|
this.lastImplicitFetchAt.set(workspaceId, Date.now());
|
|
320
|
+
// A fresh clone IS a successful fetch. Without this, a workspace
|
|
321
|
+
// recreated after some earlier failed fetch inherits the stale `false`
|
|
322
|
+
// under the same id, and a strict listBranches inside the TTL window
|
|
323
|
+
// refuses a list that is in fact provably fresh.
|
|
324
|
+
this.lastImplicitFetchOk.set(workspaceId, true);
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
/**
|
|
@@ -399,7 +406,7 @@ export class GitService implements IGitService {
|
|
|
399
406
|
|
|
400
407
|
async listBranches(
|
|
401
408
|
workspaceId: string,
|
|
402
|
-
opts: { freshFetch?: boolean } = {},
|
|
409
|
+
opts: { freshFetch?: boolean; strictFetch?: boolean } = {},
|
|
403
410
|
): Promise<BranchInfo[]> {
|
|
404
411
|
const cwd = await this.repoDir(workspaceId);
|
|
405
412
|
// Run the fetch BEFORE entering the mutex so a slow origin can't block
|
|
@@ -409,7 +416,18 @@ export class GitService implements IGitService {
|
|
|
409
416
|
// bypasses the TTL — used for user-initiated refreshes (opening the branch
|
|
410
417
|
// selector) so a draft another workspace just deleted is pruned right away
|
|
411
418
|
// instead of lingering up to one TTL window.
|
|
412
|
-
await this.fetchOriginIfStale(cwd, workspaceId, opts.freshFetch);
|
|
419
|
+
const fresh = await this.fetchOriginIfStale(cwd, workspaceId, opts.freshFetch);
|
|
420
|
+
// `strictFetch` is for callers that USE the list to prove absence — the
|
|
421
|
+
// deleted-branch sweep closes change requests on it. The degrade-to-stale
|
|
422
|
+
// behaviour below is right for a UI listing (stale branches beat a 500),
|
|
423
|
+
// and exactly wrong for an absence proof: a clone that last fetched
|
|
424
|
+
// before a branch was created reports it missing. Such a caller gets a
|
|
425
|
+
// thrown error instead of a list it must not trust.
|
|
426
|
+
if (opts.strictFetch && !fresh) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
'the fetch from origin failed, so the branch list cannot prove absence — refusing to serve a stale list to a strict caller',
|
|
429
|
+
);
|
|
430
|
+
}
|
|
413
431
|
return this.mutex.run(workspaceId, async () => {
|
|
414
432
|
// Union local heads with origin's remote-tracking refs: a fresh per-user
|
|
415
433
|
// clone only has one local head (HEAD's default), so without this we'd hide
|
|
@@ -509,7 +527,10 @@ export class GitService implements IGitService {
|
|
|
509
527
|
* onto a single in-flight fetch instead of each waiting their turn behind
|
|
510
528
|
* the network round-trip. Failures (offline, bad auth, no remote) are
|
|
511
529
|
* logged and swallowed so the branch list still serves stale local data
|
|
512
|
-
* instead of returning 500
|
|
530
|
+
* instead of returning 500 — the RETURN VALUE says whether the refs are
|
|
531
|
+
* provably fresh (`true`: this call fetched, or shared a fetch, that
|
|
532
|
+
* succeeded; also `true` on a TTL skip, whose window a recent success
|
|
533
|
+
* opened). Callers that need proof (strict listing) read it.
|
|
513
534
|
*
|
|
514
535
|
* Safe to keep outside the mutex: fetch only writes remote-tracking refs
|
|
515
536
|
* and the object store, neither of which checkout/status/commit touch.
|
|
@@ -518,9 +539,11 @@ export class GitService implements IGitService {
|
|
|
518
539
|
cwd: string,
|
|
519
540
|
workspaceId: string,
|
|
520
541
|
force = false,
|
|
521
|
-
): Promise<
|
|
542
|
+
): Promise<boolean> {
|
|
522
543
|
const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
|
|
523
|
-
if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS)
|
|
544
|
+
if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS) {
|
|
545
|
+
return this.lastImplicitFetchOk.get(workspaceId) ?? true;
|
|
546
|
+
}
|
|
524
547
|
|
|
525
548
|
const inFlight = this.fetchLocks.get(workspaceId);
|
|
526
549
|
if (inFlight) {
|
|
@@ -529,7 +552,7 @@ export class GitService implements IGitService {
|
|
|
529
552
|
// the same promise also means subsequent listBranches calls see the
|
|
530
553
|
// newly fetched refs.
|
|
531
554
|
await inFlight;
|
|
532
|
-
return;
|
|
555
|
+
return this.lastImplicitFetchOk.get(workspaceId) ?? false;
|
|
533
556
|
}
|
|
534
557
|
|
|
535
558
|
const runFetch = async (): Promise<void> => {
|
|
@@ -546,6 +569,7 @@ export class GitService implements IGitService {
|
|
|
546
569
|
...SAFE_IMPLICIT_FETCH_ARGS,
|
|
547
570
|
]);
|
|
548
571
|
this.lastImplicitFetchAt.set(workspaceId, Date.now());
|
|
572
|
+
this.lastImplicitFetchOk.set(workspaceId, true);
|
|
549
573
|
} catch (err) {
|
|
550
574
|
const msg = err instanceof Error ? err.message : String(err);
|
|
551
575
|
console.warn(`[git] implicit fetch failed for workspace ${workspaceId}: ${msg}`);
|
|
@@ -553,12 +577,14 @@ export class GitService implements IGitService {
|
|
|
553
577
|
// every subsequent listBranches retry the (still-failing) fetch and
|
|
554
578
|
// pile up 10s timeouts. Stale local refs are better than spinning.
|
|
555
579
|
this.lastImplicitFetchAt.set(workspaceId, Date.now());
|
|
580
|
+
this.lastImplicitFetchOk.set(workspaceId, false);
|
|
556
581
|
}
|
|
557
582
|
};
|
|
558
583
|
const p = runFetch();
|
|
559
584
|
this.fetchLocks.set(workspaceId, p);
|
|
560
585
|
try {
|
|
561
586
|
await p;
|
|
587
|
+
return this.lastImplicitFetchOk.get(workspaceId) ?? false;
|
|
562
588
|
} finally {
|
|
563
589
|
// Drop the lock once the fetch settles so the next caller past the TTL
|
|
564
590
|
// can spawn a fresh one. Compare-and-delete in case a future race
|
|
@@ -1781,17 +1807,46 @@ export class GitService implements IGitService {
|
|
|
1781
1807
|
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1782
1808
|
return this.mutex.run(workspaceId, async () => {
|
|
1783
1809
|
const cwd = await this.repoDir(workspaceId);
|
|
1810
|
+
// Same per-file rule as the diff endpoints: `git log -- <dir>` lists
|
|
1811
|
+
// every child's commits, and even metadata (subjects, authors, when)
|
|
1812
|
+
// is history the per-file gate never authorized for the children. A
|
|
1813
|
+
// HEAD check alone is not enough — a directory deleted before HEAD is
|
|
1814
|
+
// absent NOW and still traversed by the log — so the proof comes from
|
|
1815
|
+
// what the log itself TOUCHED: `--name-only` lists the paths behind
|
|
1816
|
+
// each listed commit, and any name that is not exactly the requested
|
|
1817
|
+
// path means the pathspec matched a tree somewhere in the range.
|
|
1818
|
+
await this.assertNotTreeAtRef(cwd, 'HEAD', repoRelativePath, relativePath);
|
|
1784
1819
|
const { stdout } = await this.git(cwd, [
|
|
1785
1820
|
'log',
|
|
1786
1821
|
`--max-count=${max}`,
|
|
1787
|
-
'--
|
|
1822
|
+
'--name-only',
|
|
1823
|
+
'--pretty=format:%x01%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
1788
1824
|
'--',
|
|
1789
1825
|
repoRelativePath,
|
|
1790
1826
|
]);
|
|
1827
|
+
for (const chunk of stdout.split('\u0001')) {
|
|
1828
|
+
const lines = chunk.split('\n');
|
|
1829
|
+
for (const nameLine of lines.slice(1)) {
|
|
1830
|
+
// No trim: a tracked name CAN begin or end with whitespace, and
|
|
1831
|
+
// trimming it here would reject that valid file as a directory.
|
|
1832
|
+
// Only the genuinely empty separator lines are skipped.
|
|
1833
|
+
const touched = nameLine.endsWith('\r') ? nameLine.slice(0, -1) : nameLine;
|
|
1834
|
+
if (touched && touched !== repoRelativePath) {
|
|
1835
|
+
throw new WorkflowValidationError(
|
|
1836
|
+
`"${relativePath}" is a directory at that point in history — history is served per file`,
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1791
1841
|
return stdout
|
|
1842
|
+
.split('\u0001')
|
|
1843
|
+
.join('')
|
|
1792
1844
|
.split('\n')
|
|
1793
1845
|
.map((line) => line.trim())
|
|
1794
1846
|
.filter(Boolean)
|
|
1847
|
+
// `--name-only` interleaves the touched path under each commit line;
|
|
1848
|
+
// only the field lines carry the NUL separators.
|
|
1849
|
+
.filter((line) => line.includes('\x00'))
|
|
1795
1850
|
.map((line): CommitAttribution => {
|
|
1796
1851
|
const [sha, authorName, authorEmail, subject, committedAt] = line.split('\x00');
|
|
1797
1852
|
return {
|
|
@@ -1817,6 +1872,11 @@ export class GitService implements IGitService {
|
|
|
1817
1872
|
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1818
1873
|
return this.mutex.run(workspaceId, async () => {
|
|
1819
1874
|
const cwd = await this.repoDir(workspaceId);
|
|
1875
|
+
// BOTH sides: at a commit that DELETES a directory the path is absent
|
|
1876
|
+
// at `sha` but a tree at `sha^` — and the diff of that deletion is
|
|
1877
|
+
// every child's content, exactly what the per-file gate never checked.
|
|
1878
|
+
await this.assertNotTreeAtRef(cwd, sha, repoRelativePath, relativePath);
|
|
1879
|
+
await this.assertNotTreeAtRef(cwd, `${sha}^`, repoRelativePath, relativePath);
|
|
1820
1880
|
try {
|
|
1821
1881
|
const { stdout } = await this.git(cwd, [
|
|
1822
1882
|
'show',
|
|
@@ -1862,6 +1922,9 @@ export class GitService implements IGitService {
|
|
|
1862
1922
|
}
|
|
1863
1923
|
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1864
1924
|
return this.mutex.run(workspaceId, async () => {
|
|
1925
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1926
|
+
await this.assertNotTreeAtRef(cwd, sha, repoRelativePath, relativePath);
|
|
1927
|
+
await this.assertNotTreeAtRef(cwd, `${sha}^`, repoRelativePath, relativePath);
|
|
1865
1928
|
const current = await this.readFileAtRef(workspaceId, sha, repoRelativePath);
|
|
1866
1929
|
let baseline: string | null;
|
|
1867
1930
|
try {
|
|
@@ -1898,6 +1961,8 @@ export class GitService implements IGitService {
|
|
|
1898
1961
|
const cwd = await this.repoDir(workspaceId);
|
|
1899
1962
|
const fromRef = await this.resolveBranchRef(cwd, fromBranch);
|
|
1900
1963
|
const toRef = await this.resolveBranchRef(cwd, toBranch);
|
|
1964
|
+
await this.assertNotTreeAtRef(cwd, fromRef, repoRelativePath, relativePath);
|
|
1965
|
+
await this.assertNotTreeAtRef(cwd, toRef, repoRelativePath, relativePath);
|
|
1901
1966
|
// When one side is the currently-checked-out branch, diff against the
|
|
1902
1967
|
// working tree instead of the branch's HEAD commit. Otherwise saves
|
|
1903
1968
|
// that haven't been committed yet (which is the normal state in the
|
|
@@ -2394,6 +2459,49 @@ export class GitService implements IGitService {
|
|
|
2394
2459
|
* `showAtRef`) — the caller distinguishes "absent" from "present but empty".
|
|
2395
2460
|
* Read-only: `git show <ref>:<path>` never mutates the working tree.
|
|
2396
2461
|
*/
|
|
2462
|
+
/**
|
|
2463
|
+
* Refuse a history pathspec that names a TREE at `ref`. The history
|
|
2464
|
+
* surfaces are per-file, and their read gate upstream checked exactly one
|
|
2465
|
+
* path — but `git show/diff -- <dir>` walks every child, and a directory
|
|
2466
|
+
* whose folder chain grants read can hold children whose own frontmatter
|
|
2467
|
+
* denies it. `git show <ref>:<dir>` even prints the tree listing. Absent
|
|
2468
|
+
* at the ref is fine (absent is not a tree — a deleted file's history is
|
|
2469
|
+
* still its own), and an unresolvable ref is left for the actual read to
|
|
2470
|
+
* report in its own words.
|
|
2471
|
+
*/
|
|
2472
|
+
private async assertNotTreeAtRef(
|
|
2473
|
+
cwd: string,
|
|
2474
|
+
ref: string,
|
|
2475
|
+
repoRelativePath: string,
|
|
2476
|
+
displayPath: string,
|
|
2477
|
+
): Promise<void> {
|
|
2478
|
+
let kind: string;
|
|
2479
|
+
try {
|
|
2480
|
+
const { stdout } = await this.git(cwd, ['cat-file', '-t', `${ref}:${repoRelativePath}`]);
|
|
2481
|
+
kind = stdout.trim();
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
// ONLY proven absence passes: the path missing at the ref, or the ref
|
|
2484
|
+
// itself unresolvable (a root commit's `^`), which the actual read
|
|
2485
|
+
// reports in its own words. Anything else — a locked repo, a corrupt
|
|
2486
|
+
// object, git failing to run — must not be read as "not a tree": a
|
|
2487
|
+
// guard that fails open on its own errors is not a guard.
|
|
2488
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2489
|
+
if (
|
|
2490
|
+
/not a valid object name|invalid object name|does not exist in|unknown revision|bad revision|exists on disk, but not in/i.test(
|
|
2491
|
+
msg,
|
|
2492
|
+
)
|
|
2493
|
+
) {
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
throw err;
|
|
2497
|
+
}
|
|
2498
|
+
if (kind === 'tree') {
|
|
2499
|
+
throw new WorkflowValidationError(
|
|
2500
|
+
`"${displayPath}" is a directory at that point in history — history is served per file`,
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2397
2505
|
async readFileAtRef(
|
|
2398
2506
|
workspaceId: string,
|
|
2399
2507
|
ref: string,
|
|
@@ -26,6 +26,8 @@ import type {
|
|
|
26
26
|
PostChangeRequestCommentInput,
|
|
27
27
|
} from '@bevel-software/platform-shared';
|
|
28
28
|
import type { AuthService } from '../auth/auth.service.js';
|
|
29
|
+
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
30
|
+
import { canReadWorkspacePath, toKbRelative } from '../access-model/kb-read-filter.js';
|
|
29
31
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
30
32
|
import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
31
33
|
import type { WorkflowEventBus } from './event-bus.js';
|
|
@@ -60,6 +62,8 @@ export function createWorkflowRoutes(
|
|
|
60
62
|
workspaceService: WorkspaceService,
|
|
61
63
|
authService: AuthService,
|
|
62
64
|
events: WorkflowEventBus,
|
|
65
|
+
accessControl: IAccessControl,
|
|
66
|
+
kbDirName: string,
|
|
63
67
|
): express.Router {
|
|
64
68
|
const router = express.Router({ mergeParams: true });
|
|
65
69
|
|
|
@@ -93,6 +97,62 @@ export function createWorkflowRoutes(
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Gate a history read on the same `read:` verb the content routes enforce.
|
|
102
|
+
* The history endpoints below serve a file's past — its commit list, its
|
|
103
|
+
* diffs, its full content at a commit — and a file's past IS its content,
|
|
104
|
+
* with a time axis. They used to check only "is authenticated", so any
|
|
105
|
+
* logged-in user could pull a read-denied file's history by path, straight
|
|
106
|
+
* past the default-deny read model that hides the file everywhere else.
|
|
107
|
+
* Same rules as the content read (`workspace.routes` /
|
|
108
|
+
* `diff.routes.canReadPath`): the FULL `canRead` (per-node frontmatter
|
|
109
|
+
* honoured), non-KB paths ungated (they carry no `read:` rules), and both
|
|
110
|
+
* denial and any error fail closed with the content route's 403.
|
|
111
|
+
*/
|
|
112
|
+
async function requireReadPermission(
|
|
113
|
+
req: express.Request,
|
|
114
|
+
res: express.Response,
|
|
115
|
+
workspaceId: string,
|
|
116
|
+
relativePath: string,
|
|
117
|
+
): Promise<boolean> {
|
|
118
|
+
const user = await requireUser(req, res);
|
|
119
|
+
if (!user) return false;
|
|
120
|
+
// No ungated path form here, unlike the content routes: the git layer
|
|
121
|
+
// accepts BOTH the workspace-relative form (`knowledge-base/GTM/x.md`)
|
|
122
|
+
// and the repo-relative one (`GTM/x.md` — it strips the prefix when
|
|
123
|
+
// present, `stripRepoPrefix`), so treating an unprefixed path as
|
|
124
|
+
// "non-KB, no rules" would let the same repository object through
|
|
125
|
+
// without its gate. History exists only for tracked files and every
|
|
126
|
+
// tracked file lives in the repository — a path without the prefix is
|
|
127
|
+
// refused, not exempted. (Workspace scratch files outside the repo are
|
|
128
|
+
// untracked; their "history" was always an empty list.)
|
|
129
|
+
if (toKbRelative(relativePath, kbDirName) === null) {
|
|
130
|
+
res.status(400).json({
|
|
131
|
+
error: `History is served for repository files — pass the workspace-relative path (starting "${kbDirName}/").`,
|
|
132
|
+
});
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
let allowed: boolean;
|
|
136
|
+
try {
|
|
137
|
+
allowed = await canReadWorkspacePath(
|
|
138
|
+
(w, e, p) => accessControl.canRead(w, e, p),
|
|
139
|
+
workspaceId,
|
|
140
|
+
user.email,
|
|
141
|
+
kbDirName,
|
|
142
|
+
relativePath,
|
|
143
|
+
);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const { status, body } = toHttpError(err);
|
|
146
|
+
res.status(status).json(body);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (!allowed) {
|
|
150
|
+
res.status(403).json({ error: `You don't have permission to read "${relativePath}".` });
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
96
156
|
// ── Branches ──────────────────────────────────────────────────────────────
|
|
97
157
|
|
|
98
158
|
router.get('/workspace/:id/workflow/branches', async (req, res) => {
|
|
@@ -250,12 +310,12 @@ export function createWorkflowRoutes(
|
|
|
250
310
|
});
|
|
251
311
|
|
|
252
312
|
router.get('/workspace/:id/workflow/changes', async (req, res) => {
|
|
253
|
-
if (!(await requireUser(req, res))) return;
|
|
254
313
|
const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
|
|
255
314
|
if (!pathParam) {
|
|
256
315
|
res.status(400).json({ error: 'path is required' });
|
|
257
316
|
return;
|
|
258
317
|
}
|
|
318
|
+
if (!(await requireReadPermission(req, res, req.params.id, pathParam))) return;
|
|
259
319
|
const rawLimit = Number.parseInt(String(req.query.limit ?? '20'), 10);
|
|
260
320
|
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 100)) : 20;
|
|
261
321
|
try {
|
|
@@ -267,7 +327,6 @@ export function createWorkflowRoutes(
|
|
|
267
327
|
});
|
|
268
328
|
|
|
269
329
|
router.get('/workspace/:id/workflow/compare-file', async (req, res) => {
|
|
270
|
-
if (!(await requireUser(req, res))) return;
|
|
271
330
|
const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
|
|
272
331
|
const fromBranch = typeof req.query.from === 'string' ? req.query.from : '';
|
|
273
332
|
const toBranch = typeof req.query.to === 'string' ? req.query.to : '';
|
|
@@ -275,6 +334,47 @@ export function createWorkflowRoutes(
|
|
|
275
334
|
res.status(400).json({ error: 'path, from, and to are required' });
|
|
276
335
|
return;
|
|
277
336
|
}
|
|
337
|
+
if (!(await requireReadPermission(req, res, req.params.id, pathParam))) return;
|
|
338
|
+
// The other history endpoints serve commits of the URL workspace's own
|
|
339
|
+
// branch, so its working-tree verdict covers what they return. This one
|
|
340
|
+
// serves content of TWO caller-named branches — the verdict must come
|
|
341
|
+
// from the refs actually being served, or a caller could pick a
|
|
342
|
+
// workspace whose tree grants what the compared branches' trees deny.
|
|
343
|
+
//
|
|
344
|
+
// The comparison prefers a LOCAL ref (and the working tree for the
|
|
345
|
+
// checked-out branch) over `origin/<branch>`, so both candidate refs are
|
|
346
|
+
// authorized: every ref that RESOLVES must grant the read, and a branch
|
|
347
|
+
// none of whose refs resolve is denied — never serve a ref that cannot
|
|
348
|
+
// be authorized. (The working-tree side is additionally covered by the
|
|
349
|
+
// workspace `canRead` above.)
|
|
350
|
+
{
|
|
351
|
+
const user = await requireUser(req, res);
|
|
352
|
+
if (!user) return;
|
|
353
|
+
const repoRelative = toKbRelative(pathParam, kbDirName)!;
|
|
354
|
+
for (const branch of [fromBranch, toBranch]) {
|
|
355
|
+
const verdicts: (boolean | null)[] = [];
|
|
356
|
+
for (const ref of [`origin/${branch}`, branch]) {
|
|
357
|
+
try {
|
|
358
|
+
verdicts.push(await accessControl.canReadAtRef(req.params.id, ref, user.email, repoRelative));
|
|
359
|
+
} catch (err) {
|
|
360
|
+
// An access-model FAILURE is not an unresolvable ref: mapped to
|
|
361
|
+
// null it would let the other candidate's grant serve a ref
|
|
362
|
+
// whose authorization errored. Fail closed — but keep the
|
|
363
|
+
// error's own status: an unreadable access tree is the
|
|
364
|
+
// documented 503 a client retries, not a permanent 403 denial.
|
|
365
|
+
const { status, body } = toHttpError(err);
|
|
366
|
+
res.status(status).json(body);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const denied = verdicts.some((v) => v === false);
|
|
371
|
+
const unresolvable = verdicts.every((v) => v === null);
|
|
372
|
+
if (denied || unresolvable) {
|
|
373
|
+
res.status(403).json({ error: `You don't have permission to read "${pathParam}" on "${branch}".` });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
278
378
|
try {
|
|
279
379
|
const diff = await workflow.compareFile(req.params.id, pathParam, fromBranch, toBranch);
|
|
280
380
|
res.json({ diff });
|
|
@@ -285,13 +385,13 @@ export function createWorkflowRoutes(
|
|
|
285
385
|
});
|
|
286
386
|
|
|
287
387
|
router.get('/workspace/:id/workflow/show-file', async (req, res) => {
|
|
288
|
-
if (!(await requireUser(req, res))) return;
|
|
289
388
|
const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
|
|
290
389
|
const sha = typeof req.query.sha === 'string' ? req.query.sha : '';
|
|
291
390
|
if (!pathParam || !sha) {
|
|
292
391
|
res.status(400).json({ error: 'path and sha are required' });
|
|
293
392
|
return;
|
|
294
393
|
}
|
|
394
|
+
if (!(await requireReadPermission(req, res, req.params.id, pathParam))) return;
|
|
295
395
|
try {
|
|
296
396
|
const diff = await workflow.showFileAtChange(req.params.id, pathParam, sha);
|
|
297
397
|
res.json({ diff });
|
|
@@ -302,13 +402,13 @@ export function createWorkflowRoutes(
|
|
|
302
402
|
});
|
|
303
403
|
|
|
304
404
|
router.get('/workspace/:id/workflow/file-at-change', async (req, res) => {
|
|
305
|
-
if (!(await requireUser(req, res))) return;
|
|
306
405
|
const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
|
|
307
406
|
const sha = typeof req.query.sha === 'string' ? req.query.sha : '';
|
|
308
407
|
if (!pathParam || !sha) {
|
|
309
408
|
res.status(400).json({ error: 'path and sha are required' });
|
|
310
409
|
return;
|
|
311
410
|
}
|
|
411
|
+
if (!(await requireReadPermission(req, res, req.params.id, pathParam))) return;
|
|
312
412
|
try {
|
|
313
413
|
const { baseline, current } = await workflow.fileAtChange(req.params.id, pathParam, sha);
|
|
314
414
|
res.json({ baseline, current });
|