@bevel-software/platform-core-backend 0.28.1 → 0.29.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/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +7 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -14
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +11 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +13 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +36 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +145 -43
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +7 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +2 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +13 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +24 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +10 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +106 -20
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +29 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +7 -1
- package/src/core/create-core-services.ts +27 -21
- package/src/core/lifecycle.ts +24 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
- package/src/modules/access/access-control.service.ts +162 -46
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/index.ts +6 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/git.service.ts +23 -0
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
- package/src/modules/workspace/index.ts +14 -0
- package/src/modules/workspace/workspace.tools.ts +120 -20
- package/src/shared/workspace-id.ts +24 -0
|
@@ -66,19 +66,30 @@ import {
|
|
|
66
66
|
DIRECTORY_SYNC_BOT_NAME,
|
|
67
67
|
} from './directory-sync-bot.js';
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* What one spec of a batched read came back as: a blob's text, nothing at
|
|
71
|
+
* that path, or an object that is not a file (a directory, say). The last
|
|
72
|
+
* two are told apart on purpose: for the model at a commit, a path that is
|
|
73
|
+
* there but is not a file is not "absent", it is config the model cannot be
|
|
74
|
+
* read from.
|
|
75
|
+
*/
|
|
76
|
+
type CatFileOutcome =
|
|
77
|
+
| { kind: 'blob'; text: string }
|
|
78
|
+
| { kind: 'missing' }
|
|
79
|
+
| { kind: 'non-blob'; type: string };
|
|
80
|
+
|
|
69
81
|
/**
|
|
70
82
|
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
71
83
|
* `specs` are `<ref>:<path>` lines; the result array is index-aligned with
|
|
72
|
-
* them
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* (the spec may itself contain spaces, hence the endsWith checks).
|
|
84
|
+
* them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
|
|
85
|
+
* or `<spec> missing\n` (the spec may itself contain spaces, hence the
|
|
86
|
+
* endsWith checks).
|
|
76
87
|
*/
|
|
77
|
-
async function
|
|
88
|
+
async function catFileBatchOutcomes(
|
|
78
89
|
runner: IGitRunner,
|
|
79
90
|
repoDir: string,
|
|
80
91
|
specs: string[],
|
|
81
|
-
): Promise<
|
|
92
|
+
): Promise<CatFileOutcome[]> {
|
|
82
93
|
// Bytes, not text: every `<size>` in the header counts bytes, and the walk
|
|
83
94
|
// below has to step by that count through content that is not all ASCII.
|
|
84
95
|
// One spec per LINE is the protocol, and this walk depends on getting back
|
|
@@ -91,7 +102,7 @@ async function catFileBatch(
|
|
|
91
102
|
input: `${specs.join('\n')}\n`,
|
|
92
103
|
encoding: 'buffer',
|
|
93
104
|
});
|
|
94
|
-
const results:
|
|
105
|
+
const results: CatFileOutcome[] = [];
|
|
95
106
|
let off = 0;
|
|
96
107
|
for (let i = 0; i < specs.length; i++) {
|
|
97
108
|
const nl = out.indexOf(0x0a, off);
|
|
@@ -99,7 +110,7 @@ async function catFileBatch(
|
|
|
99
110
|
const header = out.subarray(off, nl).toString('utf-8');
|
|
100
111
|
off = nl + 1;
|
|
101
112
|
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
|
|
102
|
-
results.push(
|
|
113
|
+
results.push({ kind: 'missing' });
|
|
103
114
|
continue;
|
|
104
115
|
}
|
|
105
116
|
const parts = header.split(' ');
|
|
@@ -107,14 +118,33 @@ async function catFileBatch(
|
|
|
107
118
|
if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
|
|
108
119
|
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
109
120
|
}
|
|
110
|
-
//
|
|
111
|
-
// payload still has to be skipped to stay aligned.
|
|
112
|
-
results.push(
|
|
121
|
+
// A non-blob (a directory path resolves to a tree) is reported as such,
|
|
122
|
+
// and its payload still has to be skipped to stay aligned.
|
|
123
|
+
results.push(
|
|
124
|
+
parts[1] === 'blob'
|
|
125
|
+
? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
|
|
126
|
+
: { kind: 'non-blob', type: parts[1]! },
|
|
127
|
+
);
|
|
113
128
|
off += size + 1; // payload + trailing LF
|
|
114
129
|
}
|
|
115
130
|
return results;
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/**
|
|
134
|
+
* {@link catFileBatchOutcomes} for callers that want text or nothing: the
|
|
135
|
+
* blob's text, or null when the spec is missing at the ref or resolves to a
|
|
136
|
+
* non-blob — the own-entries reads, where a directory where a file was
|
|
137
|
+
* named simply carries no entries.
|
|
138
|
+
*/
|
|
139
|
+
async function catFileBatch(
|
|
140
|
+
runner: IGitRunner,
|
|
141
|
+
repoDir: string,
|
|
142
|
+
specs: string[],
|
|
143
|
+
): Promise<(string | null)[]> {
|
|
144
|
+
const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
|
|
145
|
+
return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
|
|
119
149
|
interface AccessModel {
|
|
120
150
|
roles: RolesIndex;
|
|
@@ -1384,22 +1414,31 @@ export class AccessControlService implements IAccessControl {
|
|
|
1384
1414
|
*
|
|
1385
1415
|
* This is what makes the change-request gates affordable. One detail read
|
|
1386
1416
|
* plus one approval click runs six at-ref gates, and before this cache
|
|
1387
|
-
* every one of them rebuilt the model from git
|
|
1388
|
-
* `
|
|
1389
|
-
*
|
|
1417
|
+
* every one of them rebuilt the model from git. A build is two git calls
|
|
1418
|
+
* (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
|
|
1419
|
+
* file the model reads), and each call forks the server process, which
|
|
1420
|
+
* costs tens of milliseconds on a server holding a large heap.
|
|
1390
1421
|
*
|
|
1391
|
-
* Bounded FIFO
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1422
|
+
* Bounded FIFO. The bound is sized for a listing that resolves access at
|
|
1423
|
+
* every open change request's two refs: one commit per open request plus
|
|
1424
|
+
* the targets, and a knowledge base with a hundred open requests was
|
|
1425
|
+
* evicting in a cycle under the old bound of 64, rebuilding every model on
|
|
1426
|
+
* every listing — thousands of forks, minutes of the main thread, and
|
|
1427
|
+
* every other request waiting behind them. A commit's tree never changes,
|
|
1428
|
+
* so a model is good for as long as it is held; the bound only caps memory.
|
|
1429
|
+
*
|
|
1430
|
+
* A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
|
|
1431
|
+
* read answers "missing" for it from git itself, which is as final as the
|
|
1432
|
+
* commit. What is never cached is a build that saw a git read FAIL: it
|
|
1433
|
+
* throws AccessUnreadableError and the gate fails closed for that call, and
|
|
1434
|
+
* pinning that to a commit would turn one hiccup into a wrong verdict until
|
|
1435
|
+
* the branch moved. Concurrent misses on the same commit share one build
|
|
1436
|
+
* via `atRefInFlight`.
|
|
1399
1437
|
*/
|
|
1400
1438
|
private readonly atRefCache = new Map<string, AtRefModel>();
|
|
1439
|
+
private readonly atRefNoRoles = new Set<string>();
|
|
1401
1440
|
private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
|
|
1402
|
-
private static readonly AT_REF_CACHE_MAX =
|
|
1441
|
+
private static readonly AT_REF_CACHE_MAX = 512;
|
|
1403
1442
|
|
|
1404
1443
|
/**
|
|
1405
1444
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
@@ -2600,28 +2639,35 @@ export class AccessControlService implements IAccessControl {
|
|
|
2600
2639
|
/**
|
|
2601
2640
|
* List every `access.md` path that exists at any depth as of `ref`.
|
|
2602
2641
|
* The access tree is structure-agnostic: any `access.md` participates
|
|
2603
|
-
* regardless of where it sits. Uses `git ls-tree -r --name-only
|
|
2604
|
-
*
|
|
2605
|
-
*
|
|
2642
|
+
* regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
|
|
2643
|
+
* puts a NUL between paths and prints them as they are, where the default
|
|
2644
|
+
* output C-quotes a path with a non-ASCII or special character, which would
|
|
2645
|
+
* then name nothing when read back. `'error'` when git failed: a model
|
|
2646
|
+
* built without its access files is not the model, and the caller must not
|
|
2647
|
+
* cache it as one.
|
|
2606
2648
|
*/
|
|
2607
2649
|
private async listAccessFilesAtRef(
|
|
2608
2650
|
repoDir: string,
|
|
2609
2651
|
ref: string,
|
|
2610
|
-
): Promise<{ accessFiles: string[]; pluginDirs: string[] } | 'error'> {
|
|
2652
|
+
): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string> } | 'error'> {
|
|
2611
2653
|
try {
|
|
2612
|
-
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
|
|
2654
|
+
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
|
|
2613
2655
|
const out: string[] = [];
|
|
2614
2656
|
const pluginDirs: string[] = [];
|
|
2657
|
+
// The fixed-name sources the tree carries (roles.yaml, the groups
|
|
2658
|
+
// files): a read of one of these that comes back empty is "absent"
|
|
2659
|
+
// only when the tree does not name it.
|
|
2660
|
+
const named = new Set<string>();
|
|
2615
2661
|
const { pluginsDir } = this.kb.layout;
|
|
2616
|
-
for (const
|
|
2617
|
-
const p = line.trim();
|
|
2662
|
+
for (const p of stdout.split('\0')) {
|
|
2618
2663
|
if (!p) continue;
|
|
2619
2664
|
if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
|
|
2665
|
+
else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
|
|
2620
2666
|
else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
|
|
2621
2667
|
pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
|
|
2622
2668
|
}
|
|
2623
2669
|
}
|
|
2624
|
-
return { accessFiles: out, pluginDirs };
|
|
2670
|
+
return { accessFiles: out, pluginDirs, named };
|
|
2625
2671
|
} catch {
|
|
2626
2672
|
return 'error';
|
|
2627
2673
|
}
|
|
@@ -2666,6 +2712,9 @@ export class AccessControlService implements IAccessControl {
|
|
|
2666
2712
|
const key = `${workspaceId}\0${commit}`;
|
|
2667
2713
|
const hit = this.atRefCache.get(key);
|
|
2668
2714
|
if (hit) return hit;
|
|
2715
|
+
// A commit known to carry no roles.yaml: the next candidate, without a
|
|
2716
|
+
// build (see `atRefNoRoles`).
|
|
2717
|
+
if (this.atRefNoRoles.has(key)) continue;
|
|
2669
2718
|
// Registered BEFORE the first await on the build path, so concurrent
|
|
2670
2719
|
// misses on one commit share a single build instead of all racing past
|
|
2671
2720
|
// an empty in-flight check together.
|
|
@@ -2674,6 +2723,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2674
2723
|
pending = this.buildModelAtCommit(repoDir, commit, candidate)
|
|
2675
2724
|
.then((built) => {
|
|
2676
2725
|
if (typeof built !== 'string') this.rememberAtRef(key, built);
|
|
2726
|
+
else if (built === 'no-roles') this.rememberNoRolesAtRef(key);
|
|
2677
2727
|
return built;
|
|
2678
2728
|
})
|
|
2679
2729
|
.finally(() => {
|
|
@@ -2718,6 +2768,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
2718
2768
|
}
|
|
2719
2769
|
}
|
|
2720
2770
|
|
|
2771
|
+
private rememberNoRolesAtRef(key: string): void {
|
|
2772
|
+
this.atRefNoRoles.add(key);
|
|
2773
|
+
if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2774
|
+
const oldest = this.atRefNoRoles.values().next().value;
|
|
2775
|
+
if (oldest !== undefined) this.atRefNoRoles.delete(oldest);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2721
2779
|
/**
|
|
2722
2780
|
* The uncached build behind `loadModelAtRef`: the whole access tree read
|
|
2723
2781
|
* at `commit`. `label` is the candidate ref the commit came from, kept for
|
|
@@ -2725,6 +2783,15 @@ export class AccessControlService implements IAccessControl {
|
|
|
2725
2783
|
* means the commit carries no roles.yaml (the caller tries its next
|
|
2726
2784
|
* candidate); `'malformed'` means it does but it doesn't parse (the caller
|
|
2727
2785
|
* answers null, as it always has).
|
|
2786
|
+
*
|
|
2787
|
+
* TWO git calls, whatever the tree holds: one `ls-tree` to learn which
|
|
2788
|
+
* files the model is made of, then one `cat-file --batch` that reads all of
|
|
2789
|
+
* them — roles.yaml, the groups sources, every access.md and every plugin
|
|
2790
|
+
* manifest. It used to be one `git show` per file, and each git call forks
|
|
2791
|
+
* the server process, which on a server holding a large heap costs tens of
|
|
2792
|
+
* milliseconds of the main thread per call: a knowledge base with a few
|
|
2793
|
+
* dozen access files paid a second or more per commit, times every ref a
|
|
2794
|
+
* listing resolves.
|
|
2728
2795
|
*/
|
|
2729
2796
|
private async buildModelAtCommit(
|
|
2730
2797
|
repoDir: string,
|
|
@@ -2737,20 +2804,74 @@ export class AccessControlService implements IAccessControl {
|
|
|
2737
2804
|
// and the caller fails closed (AccessUnreadableError, a 503) until a
|
|
2738
2805
|
// retry reads the tree in full.
|
|
2739
2806
|
const tag = `${label}@${commit.slice(0, 7)}`;
|
|
2807
|
+
|
|
2808
|
+
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2809
|
+
if (listed === 'error') {
|
|
2810
|
+
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2811
|
+
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2812
|
+
}
|
|
2813
|
+
const wanted = [
|
|
2814
|
+
'roles.yaml',
|
|
2815
|
+
SYNCED_GROUPS_YAML,
|
|
2816
|
+
GROUPS_YAML,
|
|
2817
|
+
...listed.accessFiles,
|
|
2818
|
+
...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
|
|
2819
|
+
];
|
|
2820
|
+
let outcomes: CatFileOutcome[];
|
|
2821
|
+
try {
|
|
2822
|
+
outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
|
|
2823
|
+
} catch (err) {
|
|
2824
|
+
// The operational signal: one line per failed build, naming the commit,
|
|
2825
|
+
// so a run of these is visible in the logs.
|
|
2826
|
+
logger(`access@${tag}`).warn(
|
|
2827
|
+
`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`,
|
|
2828
|
+
);
|
|
2829
|
+
throw new AccessUnreadableError(label, 'access tree (cat-file)');
|
|
2830
|
+
}
|
|
2831
|
+
const fetched = new Map<string, CatFileOutcome>();
|
|
2832
|
+
wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
|
|
2833
|
+
const refuse = (relativePath: string, why: string): never => {
|
|
2834
|
+
logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
|
|
2835
|
+
throw new AccessUnreadableError(label, relativePath);
|
|
2836
|
+
};
|
|
2837
|
+
// What the build reads. "Missing" from the batch is "absent" only for a
|
|
2838
|
+
// groups source the tree does not name. A path the tree names — an
|
|
2839
|
+
// access.md, a plugin manifest, or a groups file the listing carried —
|
|
2840
|
+
// that comes back missing has a blob git could not read (a lost object),
|
|
2841
|
+
// and one that is not a file is a tree the model cannot be read from:
|
|
2842
|
+
// either way the build fails closed rather than drop what that file
|
|
2843
|
+
// would deny. A path the build did not prefetch (none today) is read on
|
|
2844
|
+
// its own, told apart from a git failure as before.
|
|
2845
|
+
const inTree = (relativePath: string) =>
|
|
2846
|
+
listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
|
|
2740
2847
|
const read = async (relativePath: string): Promise<string | null> => {
|
|
2741
|
-
const
|
|
2742
|
-
if (
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
throw new AccessUnreadableError(label, relativePath);
|
|
2848
|
+
const got = fetched.get(relativePath);
|
|
2849
|
+
if (got === undefined) {
|
|
2850
|
+
const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
|
|
2851
|
+
if (outcome.kind === 'error') refuse(relativePath, 'could not be read');
|
|
2852
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
2747
2853
|
}
|
|
2748
|
-
|
|
2854
|
+
if (got.kind === 'blob') return got.text;
|
|
2855
|
+
if (got.kind === 'non-blob') return refuse(relativePath, `is a ${got.type}, not a file`);
|
|
2856
|
+
if (!inTree(relativePath)) return null;
|
|
2857
|
+
return refuse(relativePath, 'is in the tree but its blob could not be read');
|
|
2749
2858
|
};
|
|
2750
2859
|
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2860
|
+
// roles.yaml decides the build's outcome, so its states are read
|
|
2861
|
+
// directly. Absent from the tree is "no roles here" (the caller tries
|
|
2862
|
+
// its next candidate ref). In the tree but unreadable refuses the build:
|
|
2863
|
+
// answering "no roles" for it would be remembered for the commit and
|
|
2864
|
+
// would send a short ref through to origin, which may grant. A directory
|
|
2865
|
+
// of that name is as malformed as a file that does not parse (the caller
|
|
2866
|
+
// answers null, and never falls through to a ref that might grant what
|
|
2867
|
+
// this one does not).
|
|
2868
|
+
const roles = fetched.get('roles.yaml') ?? { kind: 'missing' as const };
|
|
2869
|
+
if (roles.kind === 'missing') {
|
|
2870
|
+
if (listed.named.has('roles.yaml')) refuse('roles.yaml', 'is in the tree but its blob could not be read');
|
|
2871
|
+
return 'no-roles';
|
|
2872
|
+
}
|
|
2873
|
+
if (roles.kind === 'non-blob') return 'malformed';
|
|
2874
|
+
const rolesParsed = parseRolesYaml(roles.text);
|
|
2754
2875
|
if (!rolesParsed.ok) return 'malformed';
|
|
2755
2876
|
|
|
2756
2877
|
// Same group loading as the working-tree model, read AT THE COMMIT — the
|
|
@@ -2775,11 +2896,6 @@ export class AccessControlService implements IAccessControl {
|
|
|
2775
2896
|
for (const w of mergeWarnings) logger(`access@${tag}`).warn(w);
|
|
2776
2897
|
|
|
2777
2898
|
const accessFiles = new Map<string, AccessFile>();
|
|
2778
|
-
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2779
|
-
if (listed === 'error') {
|
|
2780
|
-
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2781
|
-
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2782
|
-
}
|
|
2783
2899
|
for (const p of listed.accessFiles) {
|
|
2784
2900
|
const text = await read(p);
|
|
2785
2901
|
if (text === null) continue;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry other modules reach auth through: its admission contract, and
|
|
3
|
+
* the two things a module checking a credential or refusing a switched-off
|
|
4
|
+
* account needs to agree with auth on. The composition root wires the rest.
|
|
5
|
+
*
|
|
6
|
+
* The embed reaches auth here. Modules written before this entry existed
|
|
7
|
+
* still import auth's files directly (`auth.service.ts`, the middleware's
|
|
8
|
+
* request augmentation, `account-admission.ts`); each moves here as it is
|
|
9
|
+
* touched, and what it needs is added here then — this file holds what is
|
|
10
|
+
* offered, not everything auth has.
|
|
11
|
+
*/
|
|
12
|
+
export {
|
|
13
|
+
ACCOUNT_DEACTIVATED_MESSAGE,
|
|
14
|
+
AccountAdmissionRefusedError,
|
|
15
|
+
AccountChangeRefusedError,
|
|
16
|
+
AccountDeactivatedError,
|
|
17
|
+
AuthBackendError,
|
|
18
|
+
admitEveryone,
|
|
19
|
+
type AccountAdmissionVerdict,
|
|
20
|
+
type AccountProvisionReason,
|
|
21
|
+
type IAccountAdmission,
|
|
22
|
+
} from './account-admission.js';
|
|
23
|
+
// A constant-time comparison for a secret arriving on a request, so a
|
|
24
|
+
// shared-secret check elsewhere cannot leak the secret's length or prefix by
|
|
25
|
+
// timing — one implementation, auth's.
|
|
26
|
+
export { timingSafeStringEqual } from './password-hash.js';
|
|
27
|
+
// The Express `Request` augmentation (`req.userId`, …): a module whose routes
|
|
28
|
+
// read it imports this entry, never the middleware file.
|
|
29
|
+
import './auth.middleware.js';
|
|
@@ -168,15 +168,51 @@ describe('EmbedService: minting', () => {
|
|
|
168
168
|
/**
|
|
169
169
|
* The token rides in the tool result and so in the chat transcript;
|
|
170
170
|
* whoever holds it acts on that one file as its user until it expires.
|
|
171
|
-
*
|
|
171
|
+
* Five minutes (Razvan, 2026-10-09): the chat view renews it through its
|
|
172
|
+
* host when a call is refused, so a short life costs the reader nothing.
|
|
172
173
|
*/
|
|
173
|
-
it('mints
|
|
174
|
+
it('mints an open_page token that lives five minutes', async () => {
|
|
174
175
|
const { service } = build();
|
|
175
176
|
const { token } = await service.mintForUser({ userId: USER.id, reference: REPO });
|
|
176
177
|
const { iat, exp } = claimsOf(token) as { iat: number; exp: number };
|
|
178
|
+
expect(exp - iat).toBe(5 * 60);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** The connector frames `/embed` and has no host to renew through: its hour stands. */
|
|
182
|
+
it('mints a connector token that keeps its one-hour lifetime', async () => {
|
|
183
|
+
const { service } = build();
|
|
184
|
+
const { token } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
|
|
185
|
+
const { iat, exp } = claimsOf(token) as { iat: number; exp: number };
|
|
177
186
|
expect(exp - iat).toBe(60 * 60);
|
|
178
187
|
});
|
|
179
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Both kinds are read by the same verification: each loads while it lives
|
|
191
|
+
* and is refused as a token once it has run out — the user's after five
|
|
192
|
+
* minutes, the connector's only after the hour.
|
|
193
|
+
*/
|
|
194
|
+
it('reads both kinds while they live and refuses each once its own lifetime is over', async () => {
|
|
195
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
196
|
+
try {
|
|
197
|
+
vi.setSystemTime(new Date('2026-10-09T10:00:00Z'));
|
|
198
|
+
const { service } = build();
|
|
199
|
+
const user = await service.mintForUser({ userId: USER.id, reference: REPO });
|
|
200
|
+
const connector = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
|
|
201
|
+
|
|
202
|
+
vi.setSystemTime(new Date('2026-10-09T10:04:50Z'));
|
|
203
|
+
await expect(service.loadFile(user.token)).resolves.toMatchObject({ repoRelative: REPO });
|
|
204
|
+
|
|
205
|
+
vi.setSystemTime(new Date('2026-10-09T10:05:01Z'));
|
|
206
|
+
await expect(service.loadFile(user.token)).rejects.toThrow(EmbedTokenError);
|
|
207
|
+
await expect(service.loadFile(connector.token)).resolves.toMatchObject({ repoRelative: REPO });
|
|
208
|
+
|
|
209
|
+
vi.setSystemTime(new Date('2026-10-09T11:00:01Z'));
|
|
210
|
+
await expect(service.loadFile(connector.token)).rejects.toThrow(EmbedTokenError);
|
|
211
|
+
} finally {
|
|
212
|
+
vi.useRealTimers();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
180
216
|
it('mints for an outside account — the connector path, unchanged', async () => {
|
|
181
217
|
const { service } = build();
|
|
182
218
|
const { token } = await service.mintToken({
|
|
@@ -189,7 +225,9 @@ describe('EmbedService: minting', () => {
|
|
|
189
225
|
|
|
190
226
|
it('keeps the token PSEUDONYMOUS — no address, no name', async () => {
|
|
191
227
|
const { service } = build();
|
|
192
|
-
const { token } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
|
|
228
|
+
const { token, embedUrl } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
|
|
229
|
+
// The connector's host sizes its frame to the content: its view is told so.
|
|
230
|
+
expect(embedUrl).toBe(`https://hexis.example/embed?token=${encodeURIComponent(token)}&sizing=content`);
|
|
193
231
|
expect(JSON.stringify(claimsOf(token))).not.toContain(USER.email);
|
|
194
232
|
expect(JSON.stringify(claimsOf(token))).not.toContain(USER.name);
|
|
195
233
|
});
|
|
@@ -711,6 +749,41 @@ describe('EmbedService: a switched-off account', () => {
|
|
|
711
749
|
});
|
|
712
750
|
});
|
|
713
751
|
|
|
752
|
+
/**
|
|
753
|
+
* A distribution's own token-authed route beside the embed (a graph for a
|
|
754
|
+
* dashboard) asks who is looking before it serves anything, and must judge
|
|
755
|
+
* the viewer exactly as the embed does.
|
|
756
|
+
*/
|
|
757
|
+
describe('EmbedService: who is looking through a token', () => {
|
|
758
|
+
it('answers the linked user, their verdicts on the file, and the file', async () => {
|
|
759
|
+
const { service } = build({ canWrite: false });
|
|
760
|
+
const { token } = await service.mintForUser({ userId: USER.id, reference: `${REPO}#intro` });
|
|
761
|
+
expect(await service.viewerOf(token)).toEqual({
|
|
762
|
+
linked: true,
|
|
763
|
+
user: expect.objectContaining({ id: USER.id, email: USER.email }),
|
|
764
|
+
canRead: true,
|
|
765
|
+
canWrite: false,
|
|
766
|
+
repoRelative: REPO,
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('answers nobody for an unlinked outside account, and refuses a switched-off one as the embed does', async () => {
|
|
771
|
+
const unlinked = build({ linkedUserId: null });
|
|
772
|
+
const outside = await unlinked.service.mintToken({ accountId: 'acc-1', reference: REPO });
|
|
773
|
+
expect(await unlinked.service.viewerOf(outside.token)).toEqual({
|
|
774
|
+
linked: false,
|
|
775
|
+
user: null,
|
|
776
|
+
canRead: false,
|
|
777
|
+
canWrite: false,
|
|
778
|
+
repoRelative: REPO,
|
|
779
|
+
});
|
|
780
|
+
const off = build({ active: false });
|
|
781
|
+
const { token } = await off.service.mintForUser({ userId: USER.id, reference: REPO });
|
|
782
|
+
await expect(off.service.viewerOf(token)).rejects.toThrow(ACCOUNT_DEACTIVATED_MESSAGE);
|
|
783
|
+
await expect(off.service.viewerOf('not-a-token')).rejects.toThrow(EmbedTokenError);
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
714
787
|
describe('EmbedService: the roles.yaml write gate', () => {
|
|
715
788
|
const ROLES = `${KB}/roles.yaml`;
|
|
716
789
|
const VALID = 'roles:\n Admin:\n - a@x.eu\n';
|
|
@@ -2,6 +2,7 @@ import type { Server } from 'node:http';
|
|
|
2
2
|
import type { AddressInfo } from 'node:net';
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { checkFor } from '@bevel-software/platform-mcp-core';
|
|
5
6
|
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
6
7
|
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
7
8
|
import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
@@ -122,6 +123,21 @@ describe('open_page: the listing', () => {
|
|
|
122
123
|
expect(internal).not.toContain(OPEN_PAGE_TOOL);
|
|
123
124
|
});
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The chat view renews its token by having its host call this tool with
|
|
128
|
+
* `{ body: { path, heading } }` (see `mcp-app/page.html`). The listing's
|
|
129
|
+
* own check decides what `/api/mcp` accepts: that shape passes it, and the
|
|
130
|
+
* flat one the view once sent is refused, so no token ever came back.
|
|
131
|
+
*/
|
|
132
|
+
it("accepts the chat view's renewal arguments under body, and refuses them flat", async () => {
|
|
133
|
+
const { registry } = await serve();
|
|
134
|
+
const def = (await registry.listExternal()).find((t) => t.name === OPEN_PAGE_TOOL)!;
|
|
135
|
+
const compiled = checkFor(def.inputs);
|
|
136
|
+
if (!compiled.checkable) throw new Error(`open_page's schema is not checkable: ${compiled.reason}`);
|
|
137
|
+
expect(compiled.check({ body: { path: 'Data/Thing.md', heading: 'what-it-is' } })).toEqual([]);
|
|
138
|
+
expect(compiled.check({ path: 'Data/Thing.md', heading: 'what-it-is' })).not.toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
125
141
|
it('takes a path and an optional heading, and no branch at all', async () => {
|
|
126
142
|
const { registry } = await serve();
|
|
127
143
|
const def = (await registry.listExternal()).find((t) => t.name === OPEN_PAGE_TOOL)!;
|
|
@@ -197,6 +213,19 @@ describe('open_page: the answer', () => {
|
|
|
197
213
|
expect(body.appUrl).toContain('#what-it-is');
|
|
198
214
|
});
|
|
199
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The view renews its token by calling this tool again with the arguments
|
|
218
|
+
* it was opened with, and the result is all it is told — so the heading
|
|
219
|
+
* comes back beside the path, and is absent when none was named.
|
|
220
|
+
*/
|
|
221
|
+
it('echoes the heading beside the path, for the view to call again with', async () => {
|
|
222
|
+
const { call } = await serve();
|
|
223
|
+
const { body } = await call({ path: 'Data/Thing.md', heading: 'what-it-is' });
|
|
224
|
+
expect(body).toMatchObject({ path: 'Data/Thing.md', heading: 'what-it-is' });
|
|
225
|
+
const plain = await call({ path: 'Data/Thing.md' });
|
|
226
|
+
expect(plain.body).not.toHaveProperty('heading');
|
|
227
|
+
});
|
|
228
|
+
|
|
200
229
|
it('accepts a path with or without the knowledge-base prefix, as read_file documents', async () => {
|
|
201
230
|
const { call, mints } = await serve();
|
|
202
231
|
await call({ path: 'Data/Thing.md' });
|
|
@@ -103,6 +103,19 @@ export interface EmbedLinkedAccount {
|
|
|
103
103
|
*/
|
|
104
104
|
export type EmbedNodeIdResolver = (nodeId: string) => Promise<string | null>;
|
|
105
105
|
|
|
106
|
+
/** The viewer behind a token, as {@link IEmbedService.viewerOf} answers it. */
|
|
107
|
+
export interface EmbedViewer {
|
|
108
|
+
/** Whether the token's identity resolved to a user at all. */
|
|
109
|
+
linked: boolean;
|
|
110
|
+
/** The linked user; null when unlinked. */
|
|
111
|
+
user: AuthUser | null;
|
|
112
|
+
/** Read access on the token's file — always false when unlinked. */
|
|
113
|
+
canRead: boolean;
|
|
114
|
+
canWrite: boolean;
|
|
115
|
+
/** The token's file, repository-relative. */
|
|
116
|
+
repoRelative: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
/**
|
|
107
120
|
* What the embed service needs of the workspace: the clone of a branch, and
|
|
108
121
|
* four verbs on one file of it. A port rather than the workspace service
|
|
@@ -165,6 +178,17 @@ export interface IEmbedService {
|
|
|
165
178
|
/** Load the file the token is scoped to, with the viewer's read/write status. */
|
|
166
179
|
loadFile(token: string): Promise<EmbedFileView>;
|
|
167
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Who is looking through `token`, and at what: the resolved viewer (the
|
|
183
|
+
* linked user, or none), their read and write verdicts on the token's
|
|
184
|
+
* file, and the file. The one answer a distribution's own token-authed
|
|
185
|
+
* route beside this surface (a graph for a dashboard, say) asks before it
|
|
186
|
+
* serves anything, so it judges the viewer exactly as the embed does — the
|
|
187
|
+
* same token check, the same account link, the same switched-off refusal.
|
|
188
|
+
* Throws as `loadFile` throws for a token that is invalid or expired.
|
|
189
|
+
*/
|
|
190
|
+
viewerOf(token: string): Promise<EmbedViewer>;
|
|
191
|
+
|
|
168
192
|
/**
|
|
169
193
|
* The raw bytes of the embedded file, or of one asset `path` beside it (an
|
|
170
194
|
* image a markdown page shows — a file no reader edits as text, or an SVG,
|