@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.
Files changed (143) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/core/catalog-revision.d.ts.map +1 -1
  4. package/dist/core/catalog-revision.js +4 -2
  5. package/dist/core/catalog-revision.js.map +1 -1
  6. package/dist/core/compose-embed.d.ts +35 -0
  7. package/dist/core/compose-embed.d.ts.map +1 -0
  8. package/dist/core/compose-embed.js +14 -0
  9. package/dist/core/compose-embed.js.map +1 -0
  10. package/dist/core/core-ports.d.ts +10 -0
  11. package/dist/core/core-ports.d.ts.map +1 -1
  12. package/dist/core/core-ports.js.map +1 -1
  13. package/dist/core/create-core-server.d.ts.map +1 -1
  14. package/dist/core/create-core-server.js +7 -1
  15. package/dist/core/create-core-server.js.map +1 -1
  16. package/dist/core/create-core-services.d.ts +1 -1
  17. package/dist/core/create-core-services.d.ts.map +1 -1
  18. package/dist/core/create-core-services.js +25 -14
  19. package/dist/core/create-core-services.js.map +1 -1
  20. package/dist/core/lifecycle.d.ts +11 -6
  21. package/dist/core/lifecycle.d.ts.map +1 -1
  22. package/dist/core/lifecycle.js +13 -6
  23. package/dist/core/lifecycle.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +5 -2
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +36 -14
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +208 -85
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/auth/index.d.ts +15 -0
  31. package/dist/modules/auth/index.d.ts.map +1 -0
  32. package/dist/modules/auth/index.js +20 -0
  33. package/dist/modules/auth/index.js.map +1 -0
  34. package/dist/modules/embed/embed.interface.d.ts +22 -0
  35. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.d.ts +28 -14
  37. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  38. package/dist/modules/embed/embed.service.js +38 -17
  39. package/dist/modules/embed/embed.service.js.map +1 -1
  40. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  41. package/dist/modules/embed/embed.tools.js +10 -2
  42. package/dist/modules/embed/embed.tools.js.map +1 -1
  43. package/dist/modules/kb-fs/index.d.ts +7 -0
  44. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  45. package/dist/modules/kb-fs/index.js +2 -0
  46. package/dist/modules/kb-fs/index.js.map +1 -0
  47. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  48. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  49. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  50. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.d.ts +8 -1
  52. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  53. package/dist/modules/skills/skills.tools.js +115 -11
  54. package/dist/modules/skills/skills.tools.js.map +1 -1
  55. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  56. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  57. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  58. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  59. package/dist/modules/tool-helpers/index.d.ts +1 -0
  60. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  61. package/dist/modules/tool-helpers/index.js +1 -0
  62. package/dist/modules/tool-helpers/index.js.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  64. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  65. package/dist/modules/workflow/file-lock.service.js +70 -18
  66. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  67. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  68. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  69. package/dist/modules/workflow/git/git.service.js +24 -0
  70. package/dist/modules/workflow/git/git.service.js.map +1 -1
  71. package/dist/modules/workflow/index.d.ts +8 -0
  72. package/dist/modules/workflow/index.d.ts.map +1 -0
  73. package/dist/modules/workflow/index.js +8 -0
  74. package/dist/modules/workflow/index.js.map +1 -0
  75. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  76. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  77. package/dist/modules/workflow/pending-commits.service.js +4 -24
  78. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  79. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  80. package/dist/modules/workflow/workflow.routes.js +20 -1
  81. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  82. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  83. package/dist/modules/workflow/workflow.service.js +5 -3
  84. package/dist/modules/workflow/workflow.service.js.map +1 -1
  85. package/dist/modules/workspace/index.d.ts +10 -0
  86. package/dist/modules/workspace/index.d.ts.map +1 -0
  87. package/dist/modules/workspace/index.js +10 -0
  88. package/dist/modules/workspace/index.js.map +1 -0
  89. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  90. package/dist/modules/workspace/workspace.tools.js +106 -20
  91. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  92. package/dist/shared/workspace-id.d.ts +17 -0
  93. package/dist/shared/workspace-id.d.ts.map +1 -1
  94. package/dist/shared/workspace-id.js +24 -0
  95. package/dist/shared/workspace-id.js.map +1 -1
  96. package/kb-template/access.md +2 -1
  97. package/mcp-app/page.html +142 -8
  98. package/package.json +3 -3
  99. package/src/core/__tests__/compose-embed.test.ts +65 -0
  100. package/src/core/__tests__/lifecycle.test.ts +29 -26
  101. package/src/core/catalog-revision.ts +4 -2
  102. package/src/core/compose-embed.ts +50 -0
  103. package/src/core/core-ports.ts +10 -0
  104. package/src/core/create-core-server.ts +7 -1
  105. package/src/core/create-core-services.ts +27 -21
  106. package/src/core/lifecycle.ts +24 -12
  107. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
  108. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  109. package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
  110. package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
  111. package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
  112. package/src/modules/access/access-control.interface.ts +5 -2
  113. package/src/modules/access/access-control.service.ts +236 -95
  114. package/src/modules/auth/index.ts +29 -0
  115. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  116. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  117. package/src/modules/embed/embed.interface.ts +24 -0
  118. package/src/modules/embed/embed.service.ts +44 -19
  119. package/src/modules/embed/embed.tools.ts +10 -2
  120. package/src/modules/kb-fs/index.ts +6 -0
  121. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  122. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  123. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  124. package/src/modules/skills/skills.contract.ts +4 -0
  125. package/src/modules/skills/skills.tools.ts +120 -10
  126. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  127. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  128. package/src/modules/tool-helpers/index.ts +5 -0
  129. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  130. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  131. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  132. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  133. package/src/modules/workflow/file-lock.service.ts +79 -18
  134. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  135. package/src/modules/workflow/git/git.service.ts +23 -0
  136. package/src/modules/workflow/index.ts +7 -0
  137. package/src/modules/workflow/pending-commits.service.ts +4 -23
  138. package/src/modules/workflow/workflow.routes.ts +24 -1
  139. package/src/modules/workflow/workflow.service.ts +3 -2
  140. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  141. package/src/modules/workspace/index.ts +14 -0
  142. package/src/modules/workspace/workspace.tools.ts +120 -20
  143. package/src/shared/workspace-id.ts +24 -0
@@ -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
- * Background work to wind down before the pool goes — the join-request
429
- * sweep and its jobs, today. The timer is stopped first so nothing new is
430
- * scheduled, then the jobs already running are awaited (within the budget)
431
- * so the pool is not ended under a clone or a push that is still writing
432
- * to it. Optional: a caller that has not built them yet passes nothing.
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
- backgroundJobs?: { stopSweeping(): void; drain(): Promise<void> };
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.backgroundJobs?.stopSweeping();
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; one that cannot in time is
552
- // killed with the process and its claim goes stale for the next sweep
553
- // — which is the recovery every crash already gets.
554
- if (deps.backgroundJobs) {
555
- await bounded(deps.backgroundJobs.drain(), remaining(), 'finishing the background jobs', log);
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 only: read and download still answer to the access tree', async () => {
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
- expect(await svc.canDownload(WS, ADMIN, 'README.md')).toBe(false);
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: { failNext: null as null | ((argv: string[]) => boolean) },
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 (actual.spawn as unknown as (...a: unknown[]) => unknown)(file, args, options);
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
- function rolesReads(): number {
88
- return spawnLog.filter((argv) => argv.includes('show') && argv.some((a) => a.endsWith(':roles.yaml')))
89
- .length;
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 one `git show`
97
- * per `access.md`, so it must be built ONCE per commit and shared by every
98
- * gate call that lands on that commit, while a push that moves the ref is
99
- * still seen on the very next call, and a build that saw a git read fail
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
- expect(lsTreeBuilds()).toBe(1);
237
- // ...and the fallthrough result is cached under origin/feature's commit.
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(1);
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 exactly that read, once, to a simulated transient failure. The
250
- // gate must not answer from the partial tree (which would grant what the
251
- // lost file denies): it fails closed with an AccessUnreadableError...
252
- injected.failNext = (argv) => argv.includes('show') && argv.some((a) => a.endsWith(':Team/access.md'));
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, naming the read it lost.
260
- expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Team\/access\.md failed; refusing to decide/));
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('does not cache a commit with no roles.yaml, and keeps answering null for it', async () => {
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
- // Each call went back to git for roles.yaml (nothing was remembered),
280
- // and neither ever got as far as walking the tree.
281
- expect(rolesReads()).toBe(2);
282
- expect(lsTreeBuilds()).toBe(0);
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
- expect(await svc.canRead(workspaceId, 'admin@x.io', 'Knowledge/Deal.md')).toBe(false);
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 () => {