@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.
Files changed (135) 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.service.d.ts +36 -14
  25. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.js +145 -43
  27. package/dist/modules/access/access-control.service.js.map +1 -1
  28. package/dist/modules/auth/index.d.ts +15 -0
  29. package/dist/modules/auth/index.d.ts.map +1 -0
  30. package/dist/modules/auth/index.js +20 -0
  31. package/dist/modules/auth/index.js.map +1 -0
  32. package/dist/modules/embed/embed.interface.d.ts +22 -0
  33. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  34. package/dist/modules/embed/embed.service.d.ts +28 -14
  35. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.js +38 -17
  37. package/dist/modules/embed/embed.service.js.map +1 -1
  38. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  39. package/dist/modules/embed/embed.tools.js +10 -2
  40. package/dist/modules/embed/embed.tools.js.map +1 -1
  41. package/dist/modules/kb-fs/index.d.ts +7 -0
  42. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  43. package/dist/modules/kb-fs/index.js +2 -0
  44. package/dist/modules/kb-fs/index.js.map +1 -0
  45. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  46. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  47. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  48. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  49. package/dist/modules/skills/skills.tools.d.ts +8 -1
  50. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.js +115 -11
  52. package/dist/modules/skills/skills.tools.js.map +1 -1
  53. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  54. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  55. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  56. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  57. package/dist/modules/tool-helpers/index.d.ts +1 -0
  58. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/index.js +1 -0
  60. package/dist/modules/tool-helpers/index.js.map +1 -1
  61. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  62. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.js +70 -18
  64. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  65. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  66. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  67. package/dist/modules/workflow/git/git.service.js +24 -0
  68. package/dist/modules/workflow/git/git.service.js.map +1 -1
  69. package/dist/modules/workflow/index.d.ts +8 -0
  70. package/dist/modules/workflow/index.d.ts.map +1 -0
  71. package/dist/modules/workflow/index.js +8 -0
  72. package/dist/modules/workflow/index.js.map +1 -0
  73. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  74. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  75. package/dist/modules/workflow/pending-commits.service.js +4 -24
  76. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  77. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  78. package/dist/modules/workflow/workflow.routes.js +20 -1
  79. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  80. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  81. package/dist/modules/workflow/workflow.service.js +5 -3
  82. package/dist/modules/workflow/workflow.service.js.map +1 -1
  83. package/dist/modules/workspace/index.d.ts +10 -0
  84. package/dist/modules/workspace/index.d.ts.map +1 -0
  85. package/dist/modules/workspace/index.js +10 -0
  86. package/dist/modules/workspace/index.js.map +1 -0
  87. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  88. package/dist/modules/workspace/workspace.tools.js +106 -20
  89. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  90. package/dist/shared/workspace-id.d.ts +17 -0
  91. package/dist/shared/workspace-id.d.ts.map +1 -1
  92. package/dist/shared/workspace-id.js +24 -0
  93. package/dist/shared/workspace-id.js.map +1 -1
  94. package/mcp-app/page.html +142 -8
  95. package/package.json +3 -3
  96. package/src/core/__tests__/compose-embed.test.ts +65 -0
  97. package/src/core/__tests__/lifecycle.test.ts +29 -26
  98. package/src/core/catalog-revision.ts +4 -2
  99. package/src/core/compose-embed.ts +50 -0
  100. package/src/core/core-ports.ts +10 -0
  101. package/src/core/create-core-server.ts +7 -1
  102. package/src/core/create-core-services.ts +27 -21
  103. package/src/core/lifecycle.ts +24 -12
  104. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  105. package/src/modules/access/access-control.service.ts +162 -46
  106. package/src/modules/auth/index.ts +29 -0
  107. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  108. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  109. package/src/modules/embed/embed.interface.ts +24 -0
  110. package/src/modules/embed/embed.service.ts +44 -19
  111. package/src/modules/embed/embed.tools.ts +10 -2
  112. package/src/modules/kb-fs/index.ts +6 -0
  113. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  114. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  115. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  116. package/src/modules/skills/skills.contract.ts +4 -0
  117. package/src/modules/skills/skills.tools.ts +120 -10
  118. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  119. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  120. package/src/modules/tool-helpers/index.ts +5 -0
  121. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  122. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  123. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  124. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  125. package/src/modules/workflow/file-lock.service.ts +79 -18
  126. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  127. package/src/modules/workflow/git/git.service.ts +23 -0
  128. package/src/modules/workflow/index.ts +7 -0
  129. package/src/modules/workflow/pending-commits.service.ts +4 -23
  130. package/src/modules/workflow/workflow.routes.ts +24 -1
  131. package/src/modules/workflow/workflow.service.ts +3 -2
  132. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  133. package/src/modules/workspace/index.ts +14 -0
  134. package/src/modules/workspace/workspace.tools.ts +120 -20
  135. 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 — the blob's text, or null when the spec is missing/ambiguous at the
73
- * ref or resolves to a non-blob (e.g. a directory). Output protocol per
74
- * request: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`
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 catFileBatch(
88
+ async function catFileBatchOutcomes(
78
89
  runner: IGitRunner,
79
90
  repoDir: string,
80
91
  specs: string[],
81
- ): Promise<(string | null)[]> {
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: (string | null)[] = [];
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(null);
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
- // Non-blob (a directory path resolves to a tree) → null, but the
111
- // payload still has to be skipped to stay aligned.
112
- results.push(parts[1] === 'blob' ? out.subarray(off, off + size).toString('utf-8') : null);
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: an `ls-tree -r` plus a
1388
- * `git show` per `access.md`, ~270ms each on a real knowledge base. Six
1389
- * builds per click was the lag behind the approve checkmark.
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, so a long-lived server holds the last few tips rather than
1392
- * one model per commit it ever gated against. A build that found no usable
1393
- * roles.yaml is deliberately NOT cached, and a build that saw any git read
1394
- * fail never becomes a model at all: it throws AccessUnreadableError and the
1395
- * gate fails closed for that call. Both are what a transient failure looks
1396
- * like, and pinning either to a commit would turn one hiccup into a wrong
1397
- * verdict, denying or granting, until the base branch moved. Concurrent
1398
- * misses on the same commit share one build via `atRefInFlight`.
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 = 64;
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
- * `'error'` when git failed: a model built without its access files is
2605
- * not the model, and the caller must not cache it as one.
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 line of stdout.split('\n')) {
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 outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2742
- if (outcome.kind === 'error') {
2743
- // The operational signal: one line per failed read, naming the commit
2744
- // and the path, so a run of these is visible in the logs.
2745
- logger(`access@${tag}`).warn(`git read of ${relativePath} failed; refusing to decide from a partial tree`);
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
- return outcome.kind === 'text' ? outcome.text : null;
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
- const rolesYaml = await read('roles.yaml');
2752
- if (rolesYaml === null) return 'no-roles';
2753
- const rolesParsed = parseRolesYaml(rolesYaml);
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
- * One hour (Razvan, 2026-10-09), down from the two the embed used before.
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 a token that lives one hour', async () => {
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,