@bevel-software/platform-core-backend 0.28.0 → 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.interface.d.ts +5 -2
- package/dist/modules/access/access-control.interface.d.ts.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 +208 -85
- 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/kb-template/access.md +2 -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.admin-root-floor.test.ts +23 -3
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
- package/src/modules/access/access-control.interface.ts +5 -2
- package/src/modules/access/access-control.service.ts +236 -95
- 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
package/src/core/lifecycle.ts
CHANGED
|
@@ -425,13 +425,18 @@ export interface CoreStopDeps {
|
|
|
425
425
|
/** The lease loop from {@link holdCommitWorkerLease}: its stop finishes the in-flight commit and releases the lease. */
|
|
426
426
|
commitWorker: Pick<LeaseLoopHandle, 'stop'>;
|
|
427
427
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
428
|
+
* The plugin join-request jobs, whose sweep must be wound down before the
|
|
429
|
+
* pool goes. The timer is stopped first so nothing new is scheduled, then
|
|
430
|
+
* the jobs already running are awaited (within the budget) so the pool is
|
|
431
|
+
* not ended under a clone or a push that is still writing to it. Optional:
|
|
432
|
+
* a caller that has not built them yet passes nothing.
|
|
433
|
+
*
|
|
434
|
+
* Named as `CoreServices` names it, because the graph IS what is passed
|
|
435
|
+
* here (`stopCore(graph.core)`): under another name the slot stayed empty,
|
|
436
|
+
* the sweep outlived every stopped graph, and a multi-tenant host gained
|
|
437
|
+
* one more sweeper querying an ended pool, every minute, per eviction.
|
|
433
438
|
*/
|
|
434
|
-
|
|
439
|
+
pluginJoinRequestJobs?: { stopSweeping(): void; drain(): Promise<void> };
|
|
435
440
|
/**
|
|
436
441
|
* The agent upload store, whose own sweep deletes staged bytes nobody
|
|
437
442
|
* applied. Stopped with this graph because the next graph for the same
|
|
@@ -532,7 +537,7 @@ async function releaseCore(deps: CoreStopDeps, remaining: () => number, log: (m:
|
|
|
532
537
|
// server is closed (or the tenant is no longer routed to), so this is the
|
|
533
538
|
// earliest point the interval is dead weight. Synchronous and unfailing
|
|
534
539
|
// — it just clears an interval — so it needs no budget of its own.
|
|
535
|
-
deps.
|
|
540
|
+
deps.pluginJoinRequestJobs?.stopSweeping();
|
|
536
541
|
// And the upload store's sweep, for the same reason and in the same breath:
|
|
537
542
|
// synchronous, and it only clears an interval and tells a sweep already
|
|
538
543
|
// running to stop deleting.
|
|
@@ -548,11 +553,18 @@ async function releaseCore(deps: CoreStopDeps, remaining: () => number, log: (m:
|
|
|
548
553
|
// Clearing the interval stops future ticks; it does nothing to a job a
|
|
549
554
|
// tick already started, which holds a claim it heartbeats through the
|
|
550
555
|
// pool and is mid-clone or mid-push. Awaited here, in the budget: a job
|
|
551
|
-
// that finishes releases or marks its row
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
+
// that finishes releases or marks its row. One that cannot in time is
|
|
557
|
+
// NOT cancelled — the bounded wait only stops waiting — and what becomes
|
|
558
|
+
// of it depends on who is stopping: a process shutting down takes it
|
|
559
|
+
// along; a tenant host evicting one graph ends the pool under it, which
|
|
560
|
+
// cancels nothing already running — a clone or push in flight finishes
|
|
561
|
+
// on its own — but its next heartbeat or database write fails, so from
|
|
562
|
+
// there the job records nothing and stops. Either way its claim goes
|
|
563
|
+
// stale and the next sweep — the next boot's, or a live graph's — picks
|
|
564
|
+
// the row up and does the whole thing again, which is the recovery every
|
|
565
|
+
// crash already gets (and why each step is written to be redone).
|
|
566
|
+
if (deps.pluginJoinRequestJobs) {
|
|
567
|
+
await bounded(deps.pluginJoinRequestJobs.drain(), remaining(), 'finishing the join-request jobs', log);
|
|
556
568
|
}
|
|
557
569
|
// The sweep in flight when the interval was cleared: awaited so the next
|
|
558
570
|
// graph for this tenant cannot start staging bytes while a store it has
|
|
@@ -133,11 +133,31 @@ describe('Admin write floor at the repository root', () => {
|
|
|
133
133
|
expect(await service().canWrite(WS, ADMIN, 'README.md')).toBe(true);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
it('is write
|
|
137
|
-
await write(repo, 'access.md', '---\n---\nwrite:\n - deny Admin\n');
|
|
136
|
+
it('is write and the read it implies: download still answers to the access tree', async () => {
|
|
137
|
+
await write(repo, 'access.md', '---\n---\nwrite:\n - deny Admin\nread:\n - deny Admin\n');
|
|
138
138
|
const svc = service();
|
|
139
|
-
|
|
139
|
+
for (const who of [ADMIN, OWNER]) {
|
|
140
|
+
expect(await svc.canRead(WS, who, 'README.md')).toBe(true);
|
|
141
|
+
expect(await svc.canRead(WS, who, 'Knowledge/Foo.md')).toBe(true);
|
|
142
|
+
expect(await svc.canDownload(WS, who, 'README.md')).toBe(false);
|
|
143
|
+
}
|
|
140
144
|
expect(await svc.canWrite(WS, ENGINEER, 'README.md')).toBe(false);
|
|
145
|
+
expect(await svc.canRead(WS, ENGINEER, 'README.md')).toBe(false);
|
|
146
|
+
// Who may read, for the banner: the floor's holders, and nobody else.
|
|
147
|
+
expect((await svc.eligibleReaders(WS, 'Knowledge/Foo.md')).roles).toEqual(['Admin']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('the deployment owner, absent from roles.yaml, reads where the Admin role reads and not where it is excluded', async () => {
|
|
151
|
+
await write(repo, 'access.md', '---\n---\nread:\n - Engineer\n');
|
|
152
|
+
await write(repo, 'HR/access.md', '---\n---\nread:\n - Engineer\n - deny Admin\n');
|
|
153
|
+
await write(repo, 'Ops/access.md', '---\n---\nread:\n - deny everyone\n - Admin\n');
|
|
154
|
+
const svc = service();
|
|
155
|
+
for (const who of [ADMIN, OWNER]) {
|
|
156
|
+
expect(await svc.canRead(WS, who, 'Knowledge/Foo.md')).toBe(true);
|
|
157
|
+
expect(await svc.canRead(WS, who, 'HR/Salaries.md')).toBe(false);
|
|
158
|
+
expect(await svc.canRead(WS, who, 'Ops/Runbook.md')).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
expect(await svc.canRead(WS, ENGINEER, 'Ops/Runbook.md')).toBe(false);
|
|
141
161
|
});
|
|
142
162
|
|
|
143
163
|
describe('a subfolder that excludes Admin', () => {
|
|
@@ -18,11 +18,16 @@ import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
|
18
18
|
* the process boundary, which is the cost this cache exists to remove.
|
|
19
19
|
* `injected.failNext` makes ONE matching spawn fail the way a transient git
|
|
20
20
|
* error does, so the cache's behaviour under a failed read can be pinned
|
|
21
|
-
* without a flaky fixture.
|
|
21
|
+
* without a flaky fixture. `injected.rewriteNext` lets ONE matching spawn
|
|
22
|
+
* run for real and rewrites what it printed before the service reads it,
|
|
23
|
+
* for replies a healthy git cannot be made to give.
|
|
22
24
|
*/
|
|
23
25
|
const { spawnLog, injected } = vi.hoisted(() => ({
|
|
24
26
|
spawnLog: [] as string[][],
|
|
25
|
-
injected: {
|
|
27
|
+
injected: {
|
|
28
|
+
failNext: null as null | ((argv: string[]) => boolean),
|
|
29
|
+
rewriteNext: null as null | ((argv: string[]) => null | ((stdout: Buffer, stdin: string) => Buffer)),
|
|
30
|
+
},
|
|
26
31
|
}));
|
|
27
32
|
|
|
28
33
|
vi.mock('node:child_process', async (importOriginal) => {
|
|
@@ -64,6 +69,41 @@ vi.mock('node:child_process', async (importOriginal) => {
|
|
|
64
69
|
});
|
|
65
70
|
return child as unknown as ReturnType<typeof actual.spawn>;
|
|
66
71
|
};
|
|
72
|
+
type Child = ReturnType<typeof actual.spawn>;
|
|
73
|
+
const realSpawn = actual.spawn as unknown as (...a: unknown[]) => Child;
|
|
74
|
+
/**
|
|
75
|
+
* The real child, with what it was fed on stdin recorded and its stdout
|
|
76
|
+
* handed to `fn` whole, once it has exited, before the service sees it.
|
|
77
|
+
*/
|
|
78
|
+
const rewriting = (file: string, args: string[], options: unknown, fn: (stdout: Buffer, stdin: string) => Buffer) => {
|
|
79
|
+
const real = realSpawn(file, args, options);
|
|
80
|
+
const fed: Buffer[] = [];
|
|
81
|
+
const out: Buffer[] = [];
|
|
82
|
+
const child = Object.assign(new EventEmitter(), {
|
|
83
|
+
pid: real.pid,
|
|
84
|
+
exitCode: null as number | null,
|
|
85
|
+
signalCode: null,
|
|
86
|
+
stdin: Object.assign(new EventEmitter(), {
|
|
87
|
+
write: (chunk: string | Buffer) => {
|
|
88
|
+
fed.push(Buffer.from(chunk));
|
|
89
|
+
return real.stdin!.write(chunk);
|
|
90
|
+
},
|
|
91
|
+
end: () => real.stdin!.end(),
|
|
92
|
+
}),
|
|
93
|
+
stdout: new PassThrough(),
|
|
94
|
+
stderr: new PassThrough(),
|
|
95
|
+
kill: (signal?: NodeJS.Signals | number) => real.kill(signal),
|
|
96
|
+
});
|
|
97
|
+
real.stdout!.on('data', (chunk: Buffer) => out.push(chunk));
|
|
98
|
+
real.stderr!.pipe(child.stderr);
|
|
99
|
+
real.on('error', (err) => child.emit('error', err));
|
|
100
|
+
real.on('close', (code, signal) => {
|
|
101
|
+
child.stdout.end(fn(Buffer.concat(out), Buffer.concat(fed).toString('utf-8')));
|
|
102
|
+
child.exitCode = code;
|
|
103
|
+
child.emit('close', code, signal);
|
|
104
|
+
});
|
|
105
|
+
return child as unknown as Child;
|
|
106
|
+
};
|
|
67
107
|
const spawn = ((file: string, args: string[], options: unknown) => {
|
|
68
108
|
if (file === 'git') {
|
|
69
109
|
spawnLog.push(args ?? []);
|
|
@@ -71,8 +111,13 @@ vi.mock('node:child_process', async (importOriginal) => {
|
|
|
71
111
|
injected.failNext = null;
|
|
72
112
|
return failing();
|
|
73
113
|
}
|
|
114
|
+
const rewrite = injected.rewriteNext?.(args);
|
|
115
|
+
if (rewrite) {
|
|
116
|
+
injected.rewriteNext = null;
|
|
117
|
+
return rewriting(file, args, options, rewrite);
|
|
118
|
+
}
|
|
74
119
|
}
|
|
75
|
-
return (
|
|
120
|
+
return realSpawn(file, args, options);
|
|
76
121
|
}) as unknown as typeof actual.spawn;
|
|
77
122
|
return { ...actual, execFile, spawn };
|
|
78
123
|
});
|
|
@@ -84,20 +129,58 @@ function lsTreeBuilds(): number {
|
|
|
84
129
|
return spawnLog.filter((argv) => argv.includes('ls-tree')).length;
|
|
85
130
|
}
|
|
86
131
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
/** Batched reads of a commit's access tree: one per build. */
|
|
133
|
+
function catFileBatches(): number {
|
|
134
|
+
return spawnLog.filter((argv) => argv.includes('cat-file')).length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Single-file reads of the MODEL's files at a ref (roles, groups, any
|
|
139
|
+
* access.md, any plugin manifest): none, since the build reads them in one
|
|
140
|
+
* batch. The one `show` a single-path gate makes afterwards, for the asked
|
|
141
|
+
* file's own frontmatter, is not the model's and is not counted.
|
|
142
|
+
*/
|
|
143
|
+
function fileShows(): number {
|
|
144
|
+
return spawnLog.filter(
|
|
145
|
+
(argv) =>
|
|
146
|
+
argv.includes('show') &&
|
|
147
|
+
argv.some((a) => /:(roles\.yaml|groups\.yaml|synced-groups\.yaml|(.*\/)?access\.md|.*\/plugin\.json)$/.test(a)),
|
|
148
|
+
).length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Rewrite ONE record of a `git cat-file --batch` reply — the one answering
|
|
153
|
+
* the stdin spec `which` matches — as `<spec> missing`, or as the same
|
|
154
|
+
* object reported with type `tree`. The reply is index-aligned with the
|
|
155
|
+
* specs fed on stdin, each record `<oid> <type> <size>\n<size bytes>\n` or
|
|
156
|
+
* `<spec> missing\n`, so the records are walked by the specs.
|
|
157
|
+
*/
|
|
158
|
+
function rewriteBatchRecord(which: RegExp, as: 'missing' | 'tree') {
|
|
159
|
+
return (stdout: Buffer, stdin: string): Buffer => {
|
|
160
|
+
const parts: Buffer[] = [];
|
|
161
|
+
let off = 0;
|
|
162
|
+
for (const spec of stdin.split('\n').filter(Boolean)) {
|
|
163
|
+
const nl = stdout.indexOf(0x0a, off);
|
|
164
|
+
const header = stdout.subarray(off, nl).toString('utf-8');
|
|
165
|
+
const end = header.endsWith(' missing') ? nl + 1 : nl + 1 + Number(header.split(' ')[2]) + 1;
|
|
166
|
+
if (!which.test(spec)) parts.push(stdout.subarray(off, end));
|
|
167
|
+
else if (as === 'missing') parts.push(Buffer.from(`${spec} missing\n`));
|
|
168
|
+
else parts.push(Buffer.from(header.replace(' blob ', ' tree ')), stdout.subarray(nl, end));
|
|
169
|
+
off = end;
|
|
170
|
+
}
|
|
171
|
+
return Buffer.concat(parts);
|
|
172
|
+
};
|
|
90
173
|
}
|
|
91
174
|
|
|
92
175
|
/**
|
|
93
176
|
* The at-ref access model — `canWriteAtRef` / `canWriteBatchAtRef` /
|
|
94
177
|
* `eligibleWritersForPathsAtRef`, the gates every change-request read and
|
|
95
178
|
* every approval click run — is a pure function of the tree at the commit
|
|
96
|
-
* the ref resolves to. Building it costs one `ls-tree` plus
|
|
97
|
-
*
|
|
98
|
-
* gate call that lands on that commit,
|
|
99
|
-
* still seen on the very next call, and a
|
|
100
|
-
* is never pinned.
|
|
179
|
+
* the ref resolves to. Building it costs two git calls, one `ls-tree` plus
|
|
180
|
+
* one `cat-file --batch` for every file the model reads, so it must be built
|
|
181
|
+
* ONCE per commit and shared by every gate call that lands on that commit,
|
|
182
|
+
* while a push that moves the ref is still seen on the very next call, and a
|
|
183
|
+
* build that saw a git read fail is never pinned.
|
|
101
184
|
*
|
|
102
185
|
* Every test seeds its own bare origin, workspace clone, and second clone
|
|
103
186
|
* (`other`, standing in for another user's push), so each one holds alone.
|
|
@@ -110,6 +193,8 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
110
193
|
const workspaceId = 'ws-atref-cache';
|
|
111
194
|
const admin = 'razvan@bevel.software';
|
|
112
195
|
const DOC = 'Team/Doc.md';
|
|
196
|
+
const QUOTED_DIR = 'Équipe';
|
|
197
|
+
const QUOTED_DOC = `${QUOTED_DIR}/Doc.md`;
|
|
113
198
|
|
|
114
199
|
async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
115
200
|
const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf-8' });
|
|
@@ -137,6 +222,14 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
137
222
|
await fs.writeFile(path.join(repo, 'access.md'), '---\nwrite:\n - Admin\n---\n');
|
|
138
223
|
await fs.writeFile(path.join(repo, 'Team/access.md'), '---\nwrite:\n - Admin\n---\n');
|
|
139
224
|
await fs.writeFile(path.join(repo, DOC), '# doc\n');
|
|
225
|
+
// A folder git would C-quote in its default listing, with an access.md
|
|
226
|
+
// that DENIES the admin: read as it is, or the denial is missed.
|
|
227
|
+
await fs.mkdir(path.join(repo, QUOTED_DIR), { recursive: true });
|
|
228
|
+
await fs.writeFile(path.join(repo, QUOTED_DIR, 'access.md'), '---\nwrite:\n - deny Admin\n---\n');
|
|
229
|
+
await fs.writeFile(path.join(repo, QUOTED_DOC), '# doc\n');
|
|
230
|
+
// A plugin, whose manifest the model reads too.
|
|
231
|
+
await fs.mkdir(path.join(repo, 'Plugins/Demo'), { recursive: true });
|
|
232
|
+
await fs.writeFile(path.join(repo, 'Plugins/Demo/plugin.json'), '{ "name": "Demo" }\n');
|
|
140
233
|
await execFileAsync('git', ['init', '-b', 'main', repo]);
|
|
141
234
|
await git(repo, 'config', 'user.email', 'test@example.com');
|
|
142
235
|
await git(repo, 'config', 'user.name', 'Test');
|
|
@@ -160,10 +253,12 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
160
253
|
svc = new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs());
|
|
161
254
|
spawnLog.length = 0;
|
|
162
255
|
injected.failNext = null;
|
|
256
|
+
injected.rewriteNext = null;
|
|
163
257
|
});
|
|
164
258
|
|
|
165
259
|
afterEach(async () => {
|
|
166
260
|
injected.failNext = null;
|
|
261
|
+
injected.rewriteNext = null;
|
|
167
262
|
// Spies are restored here, not at the end of the test that made them:
|
|
168
263
|
// a failing assertion would otherwise leave a console.warn mock in place
|
|
169
264
|
// for every test after it (this config sets no `restoreMocks`).
|
|
@@ -233,10 +328,13 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
233
328
|
const map = await svc.eligibleWritersForPathsAtRef(workspaceId, 'feature', [DOC]);
|
|
234
329
|
expect(map).not.toBeNull();
|
|
235
330
|
expect(map!.get(DOC)!.roles).toContain('Admin');
|
|
236
|
-
|
|
237
|
-
//
|
|
331
|
+
// Two commits were read: the local tip, found to carry no roles.yaml,
|
|
332
|
+
// then origin/feature's.
|
|
333
|
+
expect(lsTreeBuilds()).toBe(2);
|
|
334
|
+
// ...and both answers are remembered: the fallthrough result under
|
|
335
|
+
// origin/feature's commit, the local tip as a commit with no roles.
|
|
238
336
|
expect(await svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).toBe(true);
|
|
239
|
-
expect(lsTreeBuilds()).toBe(
|
|
337
|
+
expect(lsTreeBuilds()).toBe(2);
|
|
240
338
|
});
|
|
241
339
|
|
|
242
340
|
it('refuses to decide from a build that lost a git read, and never caches it', async () => {
|
|
@@ -246,18 +344,19 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
246
344
|
fs.writeFile(path.join(other, 'Team/access.md'), '---\nwrite:\n - deny Admin\n---\n'),
|
|
247
345
|
);
|
|
248
346
|
|
|
249
|
-
// Lose
|
|
250
|
-
// gate must not answer from
|
|
251
|
-
// lost file denies): it fails closed with an
|
|
252
|
-
|
|
347
|
+
// Lose the batched read of the tree, once, to a simulated transient
|
|
348
|
+
// failure. The gate must not answer from a partial tree (which would
|
|
349
|
+
// grant what the lost file denies): it fails closed with an
|
|
350
|
+
// AccessUnreadableError...
|
|
351
|
+
injected.failNext = (argv) => argv.includes('cat-file');
|
|
253
352
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
254
353
|
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
255
354
|
AccessUnreadableError,
|
|
256
355
|
);
|
|
257
356
|
expect(injected.failNext).toBeNull();
|
|
258
357
|
expect(lsTreeBuilds()).toBe(1);
|
|
259
|
-
// ...and says so in the logs
|
|
260
|
-
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/
|
|
358
|
+
// ...and says so in the logs.
|
|
359
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/access tree failed; refusing to decide/));
|
|
261
360
|
|
|
262
361
|
// Nothing was pinned: the next call rebuilds, answers correctly, and
|
|
263
362
|
// that healthy build is the one that gets cached.
|
|
@@ -267,7 +366,7 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
267
366
|
expect(lsTreeBuilds()).toBe(2);
|
|
268
367
|
});
|
|
269
368
|
|
|
270
|
-
it('
|
|
369
|
+
it('remembers a commit with no roles.yaml, and keeps answering null for it without another read', async () => {
|
|
271
370
|
await git(other, 'checkout', '-qb', 'bare');
|
|
272
371
|
await git(other, 'rm', '-q', 'roles.yaml');
|
|
273
372
|
await git(other, 'commit', '-qm', 'no roles here');
|
|
@@ -276,9 +375,188 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
276
375
|
spawnLog.length = 0;
|
|
277
376
|
expect(await svc.canWriteAtRef(workspaceId, 'origin/bare', admin, DOC)).toBeNull();
|
|
278
377
|
expect(await svc.canWriteAtRef(workspaceId, 'origin/bare', admin, DOC)).toBeNull();
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
expect(
|
|
282
|
-
expect(
|
|
378
|
+
// Git said "missing" for roles.yaml at that commit once, which is as
|
|
379
|
+
// final as the commit; the second call read nothing.
|
|
380
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
381
|
+
expect(catFileBatches()).toBe(1);
|
|
382
|
+
expect(fileShows()).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('reads the whole access tree at a commit in two git calls, never one per file', async () => {
|
|
386
|
+
// The seed holds roles.yaml and two access.md files; a read per file was
|
|
387
|
+
// what the build used to cost, and what a server forking a large heap
|
|
388
|
+
// cannot afford.
|
|
389
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
390
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
391
|
+
expect(catFileBatches()).toBe(1);
|
|
392
|
+
expect(fileShows()).toBe(0);
|
|
393
|
+
// Both access files were read through the batch: Team's denial is seen.
|
|
394
|
+
await pushFromOther('deny Admin in Team', () =>
|
|
395
|
+
fs.writeFile(path.join(other, 'Team/access.md'), '---\nwrite:\n - deny Admin\n---\n'),
|
|
396
|
+
);
|
|
397
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(false);
|
|
398
|
+
expect(lsTreeBuilds()).toBe(2);
|
|
399
|
+
expect(catFileBatches()).toBe(2);
|
|
400
|
+
expect(fileShows()).toBe(0);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('honours an access.md under a folder git would quote in its listing', async () => {
|
|
404
|
+
// `git ls-tree` prints "\303\211quipe/access.md" unless told `-z`; read
|
|
405
|
+
// under that spelling nothing comes back, and a denial would be missed.
|
|
406
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, QUOTED_DOC)).toBe(false);
|
|
407
|
+
expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [QUOTED_DOC, DOC])).toEqual(
|
|
408
|
+
new Map([
|
|
409
|
+
[QUOTED_DOC, false],
|
|
410
|
+
[DOC, true],
|
|
411
|
+
]),
|
|
412
|
+
);
|
|
413
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
414
|
+
expect(fileShows()).toBe(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('answers null for a ref whose roles.yaml is a directory, and never falls through to origin for it', async () => {
|
|
418
|
+
// `feature` on origin grants the admin; the LOCAL `feature` replaced
|
|
419
|
+
// roles.yaml with a directory of that name. That is malformed config, as
|
|
420
|
+
// a roles file that does not parse is: the gate answers null for the
|
|
421
|
+
// ref, rather than reading the next candidate and granting from it.
|
|
422
|
+
await git(repo, 'checkout', '-q', '-b', 'feature');
|
|
423
|
+
await git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
|
424
|
+
await git(repo, 'rm', '-q', 'roles.yaml');
|
|
425
|
+
await fs.mkdir(path.join(repo, 'roles.yaml'));
|
|
426
|
+
await fs.writeFile(path.join(repo, 'roles.yaml/README.md'), 'not roles\n');
|
|
427
|
+
await git(repo, 'add', '-A');
|
|
428
|
+
await git(repo, 'commit', '-qm', 'roles.yaml is a folder now');
|
|
429
|
+
|
|
430
|
+
spawnLog.length = 0;
|
|
431
|
+
expect(await svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).toBeNull();
|
|
432
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('refuses to decide, twice, for a ref whose roles.yaml blob is lost, and never falls through to a granting origin', async () => {
|
|
436
|
+
// `feature` on origin carries a roles.yaml that grants the admin. The
|
|
437
|
+
// LOCAL `feature` is one commit ahead, with a roles.yaml of its own
|
|
438
|
+
// whose blob is then lost from the object store (real git: the loose
|
|
439
|
+
// object file removed). The tree still names roles.yaml, so the batch's
|
|
440
|
+
// "missing" is an unreadable file, not an absent one: the build must
|
|
441
|
+
// fail closed on both attempts, remember nothing for the commit, and
|
|
442
|
+
// never read the next candidate, origin/feature, which would grant.
|
|
443
|
+
await git(repo, 'checkout', '-q', '-b', 'feature');
|
|
444
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - razvan@bevel.software\n# on origin\n');
|
|
445
|
+
await git(repo, 'commit', '-qam', 'roles on origin/feature');
|
|
446
|
+
await git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
|
447
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - razvan@bevel.software\n# local only\n');
|
|
448
|
+
await git(repo, 'commit', '-qam', 'roles on the local feature tip');
|
|
449
|
+
const lost = await git(repo, 'rev-parse', 'HEAD:roles.yaml');
|
|
450
|
+
await fs.rm(path.join(repo, '.git/objects', lost.slice(0, 2), lost.slice(2)));
|
|
451
|
+
|
|
452
|
+
spawnLog.length = 0;
|
|
453
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
454
|
+
for (const attempt of [1, 2]) {
|
|
455
|
+
await expect(svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).rejects.toBeInstanceOf(
|
|
456
|
+
AccessUnreadableError,
|
|
457
|
+
);
|
|
458
|
+
// One listing per attempt: the local tip, and never origin/feature.
|
|
459
|
+
expect(lsTreeBuilds()).toBe(attempt);
|
|
460
|
+
expect(catFileBatches()).toBe(attempt);
|
|
461
|
+
}
|
|
462
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/roles\.yaml is in the tree but its blob could not be read/));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it.each(['synced-groups.yaml', 'groups.yaml'])(
|
|
466
|
+
'refuses to decide, twice, for a ref whose %s blob is lost, and never reads it as absent',
|
|
467
|
+
async (file) => {
|
|
468
|
+
// The groups source the model reads at origin/main — the synced file,
|
|
469
|
+
// which supersedes a manual one still naming a retired team, or the
|
|
470
|
+
// manual file alone — has its blob lost from the workspace's object
|
|
471
|
+
// store (real git: the loose object file removed after the fetch).
|
|
472
|
+
// The tree still names it, so the batch's "missing" is an unreadable
|
|
473
|
+
// file, not an absent one: the build fails closed on both attempts and
|
|
474
|
+
// remembers nothing. Reading the synced file as absent would revive
|
|
475
|
+
// the manual groups; reading the manual one as absent would drop what
|
|
476
|
+
// it grants.
|
|
477
|
+
await pushFromOther(`${file} on main`, async () => {
|
|
478
|
+
if (file === 'synced-groups.yaml') {
|
|
479
|
+
await fs.writeFile(path.join(other, 'synced-groups.yaml'), 'groups:\n Platform:\n - zoe@x.io\n');
|
|
480
|
+
}
|
|
481
|
+
await fs.writeFile(path.join(other, 'groups.yaml'), 'groups:\n Retired Team:\n - ghost@x.io\n');
|
|
482
|
+
});
|
|
483
|
+
await git(repo, 'fetch', '-q', 'origin');
|
|
484
|
+
const lost = await git(repo, 'rev-parse', `origin/main:${file}`);
|
|
485
|
+
await fs.rm(path.join(repo, '.git/objects', lost.slice(0, 2), lost.slice(2)));
|
|
486
|
+
|
|
487
|
+
spawnLog.length = 0;
|
|
488
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
489
|
+
for (const attempt of [1, 2]) {
|
|
490
|
+
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
491
|
+
AccessUnreadableError,
|
|
492
|
+
);
|
|
493
|
+
expect(lsTreeBuilds()).toBe(attempt);
|
|
494
|
+
expect(catFileBatches()).toBe(attempt);
|
|
495
|
+
}
|
|
496
|
+
expect(warn).toHaveBeenCalledWith(
|
|
497
|
+
expect.stringMatching(new RegExp(`${file.replaceAll('.', '\\.')} is in the tree but its blob could not be read`)),
|
|
498
|
+
);
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
it('refuses to decide when a groups source is a directory rather than a file', async () => {
|
|
503
|
+
await pushFromOther('groups.yaml as a folder', async () => {
|
|
504
|
+
await fs.mkdir(path.join(other, 'groups.yaml'));
|
|
505
|
+
await fs.writeFile(path.join(other, 'groups.yaml/README.md'), 'not groups\n');
|
|
506
|
+
});
|
|
507
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
508
|
+
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
509
|
+
AccessUnreadableError,
|
|
510
|
+
);
|
|
511
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/groups\.yaml is a tree, not a file; refusing to decide/));
|
|
283
512
|
});
|
|
513
|
+
|
|
514
|
+
it.each([
|
|
515
|
+
['access.md', 'missing', /:Team\/access\.md$/, /Team\/access\.md is in the tree but its blob could not be read/],
|
|
516
|
+
['access.md', 'a tree', /:Team\/access\.md$/, /Team\/access\.md is a tree, not a file/],
|
|
517
|
+
['plugin manifest', 'missing', /:Plugins\/Demo\/plugin\.json$/, /Plugins\/Demo\/plugin\.json is in the tree but its blob could not be read/],
|
|
518
|
+
['plugin manifest', 'a tree', /:Plugins\/Demo\/plugin\.json$/, /Plugins\/Demo\/plugin\.json is a tree, not a file/],
|
|
519
|
+
])('refuses to decide when a listed %s comes back %s from the batch, and never caches it', async (_what, how, spec, warned) => {
|
|
520
|
+
// A healthy git cannot be made to answer this: the listing names blobs
|
|
521
|
+
// only, and a commit's objects do not change between two reads of it.
|
|
522
|
+
// The batch's reply is rewritten at the process boundary instead, so the
|
|
523
|
+
// build's refusal of a listed path it could not read is pinned all the
|
|
524
|
+
// same: a model missing that file could grant what the file denies.
|
|
525
|
+
injected.rewriteNext = (argv) =>
|
|
526
|
+
argv.includes('cat-file') ? rewriteBatchRecord(spec, how === 'missing' ? 'missing' : 'tree') : null;
|
|
527
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
528
|
+
const batchesBefore = catFileBatches();
|
|
529
|
+
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
530
|
+
AccessUnreadableError,
|
|
531
|
+
);
|
|
532
|
+
expect(injected.rewriteNext).toBeNull();
|
|
533
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(warned));
|
|
534
|
+
// ...and nothing was pinned: the next call, with git answering in full,
|
|
535
|
+
// builds again and answers.
|
|
536
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
537
|
+
expect(catFileBatches()).toBe(batchesBefore + 2);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('holds the models of far more commits than one listing resolves', async () => {
|
|
541
|
+
// A listing resolves access at every open change request's two refs: a
|
|
542
|
+
// knowledge base with a hundred open requests spans a hundred commits.
|
|
543
|
+
// Under the old bound of 64 that evicted in a cycle, so every listing
|
|
544
|
+
// rebuilt every model. Each commit here is a distinct tip; after seventy
|
|
545
|
+
// of them, past that old bound, the first is still held.
|
|
546
|
+
const shas: string[] = [];
|
|
547
|
+
for (let i = 0; i < 70; i++) {
|
|
548
|
+
await fs.writeFile(path.join(other, 'Team/Doc.md'), `# doc ${i}\n`);
|
|
549
|
+
await git(other, 'add', '-A');
|
|
550
|
+
await git(other, 'commit', '-qm', `edit ${i}`);
|
|
551
|
+
shas.push(await git(other, 'rev-parse', 'HEAD'));
|
|
552
|
+
}
|
|
553
|
+
await git(other, 'push', '-q', 'origin', 'HEAD');
|
|
554
|
+
await git(repo, 'fetch', '-q', 'origin');
|
|
555
|
+
spawnLog.length = 0;
|
|
556
|
+
for (const sha of shas) expect(await svc.canWriteAtRef(workspaceId, sha, admin, DOC)).toBe(true);
|
|
557
|
+
expect(lsTreeBuilds()).toBe(70);
|
|
558
|
+
expect(await svc.canWriteAtRef(workspaceId, shas[0]!, admin, DOC)).toBe(true);
|
|
559
|
+
expect(await svc.canWriteAtRef(workspaceId, shas[35]!, admin, DOC)).toBe(true);
|
|
560
|
+
expect(lsTreeBuilds()).toBe(70);
|
|
561
|
+
}, 120_000);
|
|
284
562
|
});
|
|
@@ -102,7 +102,9 @@ describe('download implies read', () => {
|
|
|
102
102
|
'access.md': '---\n---\ndownload:\n - Product Manager\n',
|
|
103
103
|
});
|
|
104
104
|
expect(await svc.canRead(workspaceId, 'pm@x.io', 'Knowledge/Deal.md')).toBe(true);
|
|
105
|
-
|
|
105
|
+
// Nobody else: the fold adds no grant that was not written. (An admin
|
|
106
|
+
// reads here too, through the root floor, not through this fold.)
|
|
107
|
+
expect(await svc.canRead(workspaceId, 'mallory@x.io', 'Knowledge/Deal.md')).toBe(false);
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it('for a GROUP grant', async () => {
|