@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
@@ -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;
@@ -130,15 +160,16 @@ interface AccessModel {
130
160
  * Canonical emails that count as Admin whatever `roles.yaml` says — the
131
161
  * deployment owner (`ADMIN_EMAIL`). Empty when none is configured.
132
162
  *
133
- * This exists for the two hardcoded `write` rescues below and the root write
134
- * floor, and only those. It is NOT a general grant: it never enters scope
135
- * resolution, so it gives no read, no download, and no write beyond
136
- * `roles.yaml`, `access.md`, and what the root floor reaches (see
137
- * `hasPermissionResolved`). The deployment owner is the person who can already change
138
- * `ADMIN_EMAIL` itself, so admitting them to the rescue path concedes
139
- * nothing they could not already take — while withholding it turns a
140
- * `roles.yaml` that has lost its last Admin into a knowledge base nobody
141
- * can repair through the app.
163
+ * An owner IS an Admin, for every verb: resolution gives them the Admin
164
+ * role's own keys (`principalKeysOf`), so they read, download, write and
165
+ * own exactly where a member of the role does and are excluded exactly
166
+ * where the role is, and they hold the two hardcoded `write` rescues and
167
+ * the root floor as the role does. The owner is the person who can already
168
+ * change `ADMIN_EMAIL` itself, so none of this concedes anything they could
169
+ * not already take — while withholding it turns a `roles.yaml` that has
170
+ * lost its last Admin into a knowledge base nobody can repair through the
171
+ * app, and an owner the file does not name into someone the write floor
172
+ * admits everywhere and the read gate admits nowhere.
142
173
  */
143
174
  deploymentOwners: ReadonlySet<string>;
144
175
  }
@@ -478,19 +509,38 @@ function resolveAtPath(
478
509
  /**
479
510
  * Every principal key a caller holds: the tokens their email is a member of,
480
511
  * plus the PUBLIC keys every caller holds regardless (a plugin principal
481
- * whose plugin grants `everyone`). Undefined when there is nothing at all,
512
+ * whose plugin grants `everyone`), plus — for a deployment owner — the Admin
513
+ * role's own keys, whether or not `roles.yaml` names them: the owner IS an
514
+ * Admin (see `AccessModel.deploymentOwners`), so every rule that binds the
515
+ * role binds them, for every verb. Undefined when there is nothing at all,
482
516
  * matching the plain `byEmail.get` the tier-2 check used to read.
483
517
  */
484
518
  function principalKeysOf(model: AccessModel, email: string): Set<string> | undefined {
485
519
  const own = model.roles.byEmail.get(email);
486
520
  const pub = model.roles.publicKeys;
487
- if (!pub?.size) return own;
488
- return new Set([...(own ?? []), ...pub]);
521
+ const asOwner = model.deploymentOwners.has(email) && !own?.has(ADMIN_ROLE_KEY);
522
+ if (!pub?.size && !asOwner) return own;
523
+ return new Set([
524
+ ...(own ?? []),
525
+ ...(pub ?? []),
526
+ ...(asOwner ? principalKeysOfToken(model, ADMIN_ROLE_KEY) : []),
527
+ ]);
489
528
  }
490
529
 
491
530
  /** The explicit key of the Admin ROLE — never the bare token, which a same-named group may own. */
492
531
  const ADMIN_ROLE_KEY = `${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`;
493
532
 
533
+ /**
534
+ * The verbs the Admin floor at the repository root confers: write, and the
535
+ * read that write implies (`VERB_REQUIRES`). An admin who may change
536
+ * everything under the root may see it; otherwise the read-before-write gate
537
+ * refuses them the very change the floor grants. Download is not implied by
538
+ * write and stays with the access tree.
539
+ */
540
+ function holdsAdminFloor(verb: Verb): boolean {
541
+ return verb === 'write' || verb === 'read';
542
+ }
543
+
494
544
  /**
495
545
  * Add to `keys` what holding them confers on top: every plugin principal
496
546
  * expanded from one of the keys, and the public keys every caller holds.
@@ -540,9 +590,10 @@ const NO_KEYS: ReadonlySet<string> = new Set();
540
590
  /**
541
591
  * The tier-2 half of `hasPermissionResolved` on its own: a verdict for a set
542
592
  * of principal KEYS with no person behind them — no direct-email tier, no
543
- * admin rescue, no machine-owned rule — only the Admin write floor, when the
544
- * keys include the Admin role. Closest scope first; within a scope a
545
- * grant to any key wins over a denial to another, exactly as for a person.
593
+ * admin rescue, no machine-owned rule — only the Admin floor (write, and the
594
+ * read it implies), when the keys include the Admin role. Closest scope
595
+ * first; within a scope a grant to any key wins over a denial to another,
596
+ * exactly as for a person.
546
597
  */
547
598
  function hasPermissionForKeys(
548
599
  model: AccessModel,
@@ -551,7 +602,7 @@ function hasPermissionForKeys(
551
602
  relativePath: string,
552
603
  fileOwn?: OwnEntries | null,
553
604
  ): boolean {
554
- const adminFloor = verb === 'write' && keys.has(ADMIN_ROLE_KEY);
605
+ const adminFloor = holdsAdminFloor(verb) && keys.has(ADMIN_ROLE_KEY);
555
606
  for (const scope of resolveScopes(model, verb, relativePath, fileOwn)) {
556
607
  if (adminFloor && isAdminFloorScope(scope, relativePath)) return true;
557
608
  let grant = false;
@@ -620,17 +671,20 @@ function isAdminEmail(model: AccessModel, email: string): boolean {
620
671
  * owner is whoever can already set `ADMIN_EMAIL`, so this concedes no
621
672
  * authority they did not have; it only gives it a door.
622
673
  *
623
- * One floor for `write`, applied during the walk: an admin (same definition)
624
- * always holds `write` at the repository root. When the walk reaches the root
625
- * `access.md` (or the own frontmatter of a file directly in the root) — or runs
626
- * out of scopes without a verdict — an admin is granted,
627
- * whatever the root file says, so no root rule can lock the deployment out of
628
- * its own tree. A NEARER scope still decides first: a subfolder that denies
629
- * Admin (or denies `everyone` without naming Admin) excludes admins there like
630
- * anyone else. The root file's own entries are not rewritten — the floor is a
631
- * verdict, not a grant line — so `grantSources` stays file-backed.
674
+ * One floor for `write` and the `read` it implies (`holdsAdminFloor`), applied
675
+ * during the walk: an admin (same definition) always holds both at the
676
+ * repository root. When the walk reaches the root `access.md` (or the own
677
+ * frontmatter of a file directly in the root) — or runs out of scopes without
678
+ * a verdict — an admin is granted, whatever the root file says, so no root
679
+ * rule can lock the deployment out of its own tree, and nobody who may change
680
+ * everything under the root is refused the sight of it. A NEARER scope still
681
+ * decides first: a subfolder that denies Admin (or denies `everyone` without
682
+ * naming Admin) excludes admins there like anyone else. The root file's own
683
+ * entries are not rewritten — the floor is a verdict, not a grant line — so
684
+ * `grantSources` stays file-backed.
632
685
  *
633
- * No special-cases for `read`/`download` — they fall through to scope resolution.
686
+ * No special-case for `download` (or `owner`) — they fall through to scope
687
+ * resolution; write implies read, not download.
634
688
  */
635
689
  function hasPermissionResolved(
636
690
  model: AccessModel,
@@ -676,14 +730,12 @@ function decidePermissionResolved(
676
730
  }
677
731
  }
678
732
 
679
- const adminFloor = verb === 'write' && isAdminEmail(model, email);
680
- // For write, the deployment owner IS Admin — the floor admits them, so the
681
- // Admin rules along the way bind them too: a subfolder denying Admin excludes
682
- // the owner exactly as it excludes the role's members.
683
- let userRoles = principalKeysOf(model, email);
684
- if (adminFloor && !userRoles?.has(ADMIN_ROLE_KEY)) {
685
- userRoles = new Set([...(userRoles ?? []), ...principalKeysOfToken(model, ADMIN_ROLE_KEY)]);
686
- }
733
+ const adminFloor = holdsAdminFloor(verb) && isAdminEmail(model, email);
734
+ // The deployment owner IS Admin: `principalKeysOf` gives them the role's
735
+ // keys, so the Admin rules along the way bind them too — a subfolder
736
+ // denying Admin excludes the owner exactly as it excludes the role's
737
+ // members, and one granting Admin admits them.
738
+ const userRoles = principalKeysOf(model, email);
687
739
  const scopes = resolveScopes(model, verb, relativePath, fileOwn);
688
740
 
689
741
  for (const scope of scopes) {
@@ -1090,12 +1142,11 @@ function canEveryoneReadResolved(
1090
1142
  // Every *named* principal must also still read. A collapsed deny may have
1091
1143
  // been shadowed by a closer-scope `everyone` grant, so re-resolve each
1092
1144
  // candidate through the closeness-first gate rather than trusting the
1093
- // collapsed deny state. (Role members live in roles.yaml; inline-named users
1094
- // come from `byEmail`.)
1095
- const candidates = new Set<string>();
1096
- for (const email of model.roles.byEmail.keys()) candidates.add(email);
1097
- for (const email of byEmail.keys()) candidates.add(email);
1098
- for (const email of candidates) {
1145
+ // collapsed deny state. The candidates are the same set the eligible-email
1146
+ // lists enumerate — roles.yaml members, inline-named users, and the
1147
+ // deployment owner, whom a `deny Admin` carves out like any member of the
1148
+ // role whether or not roles.yaml names them.
1149
+ for (const email of namedCandidateEmails(model, byEmail)) {
1099
1150
  if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn)) return false;
1100
1151
  }
1101
1152
  return true;
@@ -1106,11 +1157,14 @@ function canEveryoneReadResolved(
1106
1157
  *
1107
1158
  * Read is default-deny: a path is readable only when resolution grants the
1108
1159
  * caller `read` directly, via one of their roles, via the built-in `everyone`
1109
- * role, or via `owner:` (owners implicitly read).
1160
+ * role, or via `write:`/`owner:` (both imply read).
1110
1161
  *
1111
- * No admin rescue (mirrors `download`): admins read a node only if an
1112
- * `access.md` lists them — directly, via a role, through `everyone`, or as an
1113
- * owner.
1162
+ * No admin rescue on root files (that is write's), but the Admin floor
1163
+ * holds: an admin — a member of the role or the deployment owner — reads
1164
+ * wherever no nearer scope excludes Admin, whether or not the root names it,
1165
+ * because the floor gives them write there and write implies read. A
1166
+ * subfolder that denies Admin, or everyone, takes the read away as it does
1167
+ * for any principal.
1114
1168
  */
1115
1169
  function canReadResolved(
1116
1170
  model: AccessModel,
@@ -1183,9 +1237,10 @@ function eligibleHoldersResolved(
1183
1237
  const record = model.roles.byCanonical.get(canonical);
1184
1238
  addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
1185
1239
  }
1186
- // The Admin write floor (see `hasPermissionResolved`): Admin holds write here
1187
- // whenever no nearer scope excludes it, whether or not any file names it.
1188
- if (verb === 'write' && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
1240
+ // The Admin floor (see `hasPermissionResolved`): Admin holds write, and the
1241
+ // read it implies, here whenever no nearer scope excludes it, whether or not
1242
+ // any file names it.
1243
+ if (holdsAdminFloor(verb) && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
1189
1244
  const adminRole = model.roles.byCanonical.get(ADMIN_ROLE_KEY)!;
1190
1245
  if (allowedFor(principalKeysOfToken(model, ADMIN_ROLE_KEY))) addPrincipal(adminRole.displayName, 'role');
1191
1246
  }
@@ -1232,8 +1287,8 @@ function eligibleHoldersResolved(
1232
1287
 
1233
1288
  /**
1234
1289
  * Every individual the model names: `roles.yaml` members, emails named inline
1235
- * at this path's scope, and the deployment owner (Admin for the write floor
1236
- * even when `roles.yaml` does not list them).
1290
+ * at this path's scope, and the deployment owner (bound by every rule that
1291
+ * names Admin even when `roles.yaml` does not list them).
1237
1292
  */
1238
1293
  function namedCandidateEmails(model: AccessModel, byEmail: CollapsedScope['byEmail']): Set<string> {
1239
1294
  const candidates = new Set<string>(model.deploymentOwners);
@@ -1359,22 +1414,31 @@ export class AccessControlService implements IAccessControl {
1359
1414
  *
1360
1415
  * This is what makes the change-request gates affordable. One detail read
1361
1416
  * plus one approval click runs six at-ref gates, and before this cache
1362
- * every one of them rebuilt the model from git: an `ls-tree -r` plus a
1363
- * `git show` per `access.md`, ~270ms each on a real knowledge base. Six
1364
- * 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.
1365
1421
  *
1366
- * Bounded FIFO, so a long-lived server holds the last few tips rather than
1367
- * one model per commit it ever gated against. A build that found no usable
1368
- * roles.yaml is deliberately NOT cached, and a build that saw any git read
1369
- * fail never becomes a model at all: it throws AccessUnreadableError and the
1370
- * gate fails closed for that call. Both are what a transient failure looks
1371
- * like, and pinning either to a commit would turn one hiccup into a wrong
1372
- * verdict, denying or granting, until the base branch moved. Concurrent
1373
- * 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`.
1374
1437
  */
1375
1438
  private readonly atRefCache = new Map<string, AtRefModel>();
1439
+ private readonly atRefNoRoles = new Set<string>();
1376
1440
  private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
1377
- private static readonly AT_REF_CACHE_MAX = 64;
1441
+ private static readonly AT_REF_CACHE_MAX = 512;
1378
1442
 
1379
1443
  /**
1380
1444
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
@@ -2575,28 +2639,35 @@ export class AccessControlService implements IAccessControl {
2575
2639
  /**
2576
2640
  * List every `access.md` path that exists at any depth as of `ref`.
2577
2641
  * The access tree is structure-agnostic: any `access.md` participates
2578
- * regardless of where it sits. Uses `git ls-tree -r --name-only`.
2579
- * `'error'` when git failed: a model built without its access files is
2580
- * 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.
2581
2648
  */
2582
2649
  private async listAccessFilesAtRef(
2583
2650
  repoDir: string,
2584
2651
  ref: string,
2585
- ): Promise<{ accessFiles: string[]; pluginDirs: string[] } | 'error'> {
2652
+ ): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string> } | 'error'> {
2586
2653
  try {
2587
- 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]);
2588
2655
  const out: string[] = [];
2589
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>();
2590
2661
  const { pluginsDir } = this.kb.layout;
2591
- for (const line of stdout.split('\n')) {
2592
- const p = line.trim();
2662
+ for (const p of stdout.split('\0')) {
2593
2663
  if (!p) continue;
2594
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);
2595
2666
  else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
2596
2667
  pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
2597
2668
  }
2598
2669
  }
2599
- return { accessFiles: out, pluginDirs };
2670
+ return { accessFiles: out, pluginDirs, named };
2600
2671
  } catch {
2601
2672
  return 'error';
2602
2673
  }
@@ -2641,6 +2712,9 @@ export class AccessControlService implements IAccessControl {
2641
2712
  const key = `${workspaceId}\0${commit}`;
2642
2713
  const hit = this.atRefCache.get(key);
2643
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;
2644
2718
  // Registered BEFORE the first await on the build path, so concurrent
2645
2719
  // misses on one commit share a single build instead of all racing past
2646
2720
  // an empty in-flight check together.
@@ -2649,6 +2723,7 @@ export class AccessControlService implements IAccessControl {
2649
2723
  pending = this.buildModelAtCommit(repoDir, commit, candidate)
2650
2724
  .then((built) => {
2651
2725
  if (typeof built !== 'string') this.rememberAtRef(key, built);
2726
+ else if (built === 'no-roles') this.rememberNoRolesAtRef(key);
2652
2727
  return built;
2653
2728
  })
2654
2729
  .finally(() => {
@@ -2693,6 +2768,14 @@ export class AccessControlService implements IAccessControl {
2693
2768
  }
2694
2769
  }
2695
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
+
2696
2779
  /**
2697
2780
  * The uncached build behind `loadModelAtRef`: the whole access tree read
2698
2781
  * at `commit`. `label` is the candidate ref the commit came from, kept for
@@ -2700,6 +2783,15 @@ export class AccessControlService implements IAccessControl {
2700
2783
  * means the commit carries no roles.yaml (the caller tries its next
2701
2784
  * candidate); `'malformed'` means it does but it doesn't parse (the caller
2702
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.
2703
2795
  */
2704
2796
  private async buildModelAtCommit(
2705
2797
  repoDir: string,
@@ -2712,20 +2804,74 @@ export class AccessControlService implements IAccessControl {
2712
2804
  // and the caller fails closed (AccessUnreadableError, a 503) until a
2713
2805
  // retry reads the tree in full.
2714
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);
2715
2847
  const read = async (relativePath: string): Promise<string | null> => {
2716
- const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2717
- if (outcome.kind === 'error') {
2718
- // The operational signal: one line per failed read, naming the commit
2719
- // and the path, so a run of these is visible in the logs.
2720
- logger(`access@${tag}`).warn(`git read of ${relativePath} failed; refusing to decide from a partial tree`);
2721
- 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;
2722
2853
  }
2723
- 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');
2724
2858
  };
2725
2859
 
2726
- const rolesYaml = await read('roles.yaml');
2727
- if (rolesYaml === null) return 'no-roles';
2728
- 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);
2729
2875
  if (!rolesParsed.ok) return 'malformed';
2730
2876
 
2731
2877
  // Same group loading as the working-tree model, read AT THE COMMIT — the
@@ -2750,11 +2896,6 @@ export class AccessControlService implements IAccessControl {
2750
2896
  for (const w of mergeWarnings) logger(`access@${tag}`).warn(w);
2751
2897
 
2752
2898
  const accessFiles = new Map<string, AccessFile>();
2753
- const listed = await this.listAccessFilesAtRef(repoDir, commit);
2754
- if (listed === 'error') {
2755
- logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2756
- throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2757
- }
2758
2899
  for (const p of listed.accessFiles) {
2759
2900
  const text = await read(p);
2760
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';