@bevel-software/platform-core-backend 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
GrantSources,
|
|
14
14
|
ResolvedPrincipal,
|
|
15
15
|
} from './access-control.interface.js';
|
|
16
|
-
import { AccessConfigError } from '../access-model/access-errors.js';
|
|
16
|
+
import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
|
|
17
17
|
import {
|
|
18
18
|
GROUPS_YAML,
|
|
19
19
|
SYNCED_GROUPS_YAML,
|
|
@@ -193,6 +193,14 @@ export async function loadActiveGroups(
|
|
|
193
193
|
try {
|
|
194
194
|
syncedText = await read(SYNCED_GROUPS_YAML);
|
|
195
195
|
} catch (err) {
|
|
196
|
+
// An at-ref `read` throws this when git itself failed — the file's
|
|
197
|
+
// existence is UNKNOWN, not absent and not broken. That must propagate:
|
|
198
|
+
// swallowing it into a broken-groups marker builds a model with no groups
|
|
199
|
+
// in it and caches that model for the commit, so every group-based grant
|
|
200
|
+
// and denial at that commit is wrong on the strength of a flaky
|
|
201
|
+
// subprocess. roles.yaml and access.md already fail the build closed on
|
|
202
|
+
// the same error; groups cannot be the one input that does not.
|
|
203
|
+
if (err instanceof AccessUnreadableError) throw err;
|
|
196
204
|
if (isAbsenceError(err)) {
|
|
197
205
|
syncedText = null;
|
|
198
206
|
} else {
|
|
@@ -211,6 +219,7 @@ export async function loadActiveGroups(
|
|
|
211
219
|
try {
|
|
212
220
|
text = await read(GROUPS_YAML);
|
|
213
221
|
} catch (err) {
|
|
222
|
+
if (err instanceof AccessUnreadableError) throw err; // see above
|
|
214
223
|
if (isAbsenceError(err)) text = null;
|
|
215
224
|
else return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
|
|
216
225
|
}
|
|
@@ -764,6 +773,19 @@ function ineligibleNamedEmailsResolved(
|
|
|
764
773
|
// Service
|
|
765
774
|
// ---------------------------------------------------------------------------
|
|
766
775
|
|
|
776
|
+
/**
|
|
777
|
+
* An access model read at a git ref. `resolvedRef` is the COMMIT the model
|
|
778
|
+
* was built from (not the ref name that led there), so the per-file
|
|
779
|
+
* own-entries reads that follow a gate resolve against exactly that tree.
|
|
780
|
+
*/
|
|
781
|
+
type AtRefModel = { model: AccessModel; resolvedRef: string };
|
|
782
|
+
/**
|
|
783
|
+
* One build's outcome: a model, no roles.yaml at the commit, or one that
|
|
784
|
+
* doesn't parse. A git read failure is none of these: it throws
|
|
785
|
+
* AccessUnreadableError out of the build instead.
|
|
786
|
+
*/
|
|
787
|
+
type AtRefBuild = AtRefModel | 'no-roles' | 'malformed';
|
|
788
|
+
|
|
767
789
|
export class AccessControlService implements IAccessControl {
|
|
768
790
|
/**
|
|
769
791
|
* Per-workspace cache: `model` is the resolved access tree and `loadedAt`
|
|
@@ -792,6 +814,31 @@ export class AccessControlService implements IAccessControl {
|
|
|
792
814
|
>();
|
|
793
815
|
private static readonly OWN_ENTRIES_TTL_MS = 5 * 60_000;
|
|
794
816
|
|
|
817
|
+
/**
|
|
818
|
+
* At-ref models keyed by workspace + the COMMIT the ref resolved to. The
|
|
819
|
+
* tree at a commit is immutable, so an entry can never go stale: a push
|
|
820
|
+
* that moves `origin/<base>` resolves to a new commit and simply misses.
|
|
821
|
+
* No TTL, and `invalidate()` leaves it alone, for the same reason.
|
|
822
|
+
*
|
|
823
|
+
* This is what makes the change-request gates affordable. One detail read
|
|
824
|
+
* plus one approval click runs six at-ref gates, and before this cache
|
|
825
|
+
* every one of them rebuilt the model from git: an `ls-tree -r` plus a
|
|
826
|
+
* `git show` per `access.md`, ~270ms each on a real knowledge base. Six
|
|
827
|
+
* builds per click was the lag behind the approve checkmark.
|
|
828
|
+
*
|
|
829
|
+
* Bounded FIFO, so a long-lived server holds the last few tips rather than
|
|
830
|
+
* one model per commit it ever gated against. A build that found no usable
|
|
831
|
+
* roles.yaml is deliberately NOT cached, and a build that saw any git read
|
|
832
|
+
* fail never becomes a model at all: it throws AccessUnreadableError and the
|
|
833
|
+
* gate fails closed for that call. Both are what a transient failure looks
|
|
834
|
+
* like, and pinning either to a commit would turn one hiccup into a wrong
|
|
835
|
+
* verdict, denying or granting, until the base branch moved. Concurrent
|
|
836
|
+
* misses on the same commit share one build via `atRefInFlight`.
|
|
837
|
+
*/
|
|
838
|
+
private readonly atRefCache = new Map<string, AtRefModel>();
|
|
839
|
+
private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
|
|
840
|
+
private static readonly AT_REF_CACHE_MAX = 64;
|
|
841
|
+
|
|
795
842
|
/**
|
|
796
843
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
797
844
|
* Held on the service rather than read per-model because it comes from the
|
|
@@ -817,7 +864,9 @@ export class AccessControlService implements IAccessControl {
|
|
|
817
864
|
|
|
818
865
|
/**
|
|
819
866
|
* Drop a workspace's cached model + frontmatter memo. Call after operations
|
|
820
|
-
* that mutate the working tree — commit, push, pull, branch switch.
|
|
867
|
+
* that mutate the working tree — commit, push, pull, branch switch. The
|
|
868
|
+
* at-ref cache is untouched on purpose: its entries are keyed by commit,
|
|
869
|
+
* and nothing that happens in a working tree changes what a commit holds.
|
|
821
870
|
*/
|
|
822
871
|
invalidate(workspaceId: string): void {
|
|
823
872
|
this.cache.delete(workspaceId);
|
|
@@ -1515,18 +1564,46 @@ export class AccessControlService implements IAccessControl {
|
|
|
1515
1564
|
/**
|
|
1516
1565
|
* Read a file's content at a specific ref via `git show <ref>:<path>`.
|
|
1517
1566
|
* Returns null when the file is absent on that ref (or the ref doesn't
|
|
1518
|
-
* resolve)
|
|
1567
|
+
* resolve) and also on any other git failure: the single-path gates that
|
|
1568
|
+
* call this have always treated the two alike. The at-ref model build
|
|
1569
|
+
* needs them told apart, so it reads through `showAtRefOutcome` instead.
|
|
1519
1570
|
*/
|
|
1520
1571
|
private async showAtRef(repoDir: string, ref: string, relativePath: string): Promise<string | null> {
|
|
1572
|
+
const outcome = await this.showAtRefOutcome(repoDir, ref, relativePath);
|
|
1573
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* `git show <ref>:<path>`, telling "not there" apart from "git failed".
|
|
1578
|
+
* Absence is what git reports as `path '<p>' does not exist in '<ref>'`
|
|
1579
|
+
* (or `exists on disk, but not in`), plus an unresolvable ref; anything
|
|
1580
|
+
* else (an IO error, a corrupt object, a dying subprocess) is `error`.
|
|
1581
|
+
* `LC_ALL=C` pins the messages this classifies to English.
|
|
1582
|
+
*/
|
|
1583
|
+
private async showAtRefOutcome(
|
|
1584
|
+
repoDir: string,
|
|
1585
|
+
ref: string,
|
|
1586
|
+
relativePath: string,
|
|
1587
|
+
): Promise<{ kind: 'text'; text: string } | { kind: 'absent' } | { kind: 'error' }> {
|
|
1521
1588
|
try {
|
|
1522
1589
|
const { stdout } = await execFileAsync(
|
|
1523
1590
|
'git',
|
|
1524
1591
|
['-C', repoDir, 'show', `${ref}:${relativePath}`],
|
|
1525
|
-
{
|
|
1592
|
+
{
|
|
1593
|
+
encoding: 'utf-8',
|
|
1594
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1595
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
1596
|
+
},
|
|
1526
1597
|
);
|
|
1527
|
-
return stdout;
|
|
1528
|
-
} catch {
|
|
1529
|
-
|
|
1598
|
+
return { kind: 'text', text: stdout };
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
const stderr =
|
|
1601
|
+
(err as { stderr?: string }).stderr ?? (err instanceof Error ? err.message : String(err));
|
|
1602
|
+
return /does not exist in|exists on disk, but not in|invalid object name|unknown revision|bad revision/.test(
|
|
1603
|
+
stderr,
|
|
1604
|
+
)
|
|
1605
|
+
? { kind: 'absent' }
|
|
1606
|
+
: { kind: 'error' };
|
|
1530
1607
|
}
|
|
1531
1608
|
}
|
|
1532
1609
|
|
|
@@ -1534,8 +1611,10 @@ export class AccessControlService implements IAccessControl {
|
|
|
1534
1611
|
* List every `access.md` path that exists at any depth as of `ref`.
|
|
1535
1612
|
* The access tree is structure-agnostic: any `access.md` participates
|
|
1536
1613
|
* regardless of where it sits. Uses `git ls-tree -r --name-only`.
|
|
1614
|
+
* `'error'` when git failed: a model built without its access files is
|
|
1615
|
+
* not the model, and the caller must not cache it as one.
|
|
1537
1616
|
*/
|
|
1538
|
-
private async listAccessFilesAtRef(repoDir: string, ref: string): Promise<string[]> {
|
|
1617
|
+
private async listAccessFilesAtRef(repoDir: string, ref: string): Promise<string[] | 'error'> {
|
|
1539
1618
|
try {
|
|
1540
1619
|
const { stdout } = await execFileAsync(
|
|
1541
1620
|
'git',
|
|
@@ -1550,7 +1629,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
1550
1629
|
}
|
|
1551
1630
|
return out;
|
|
1552
1631
|
} catch {
|
|
1553
|
-
return
|
|
1632
|
+
return 'error';
|
|
1554
1633
|
}
|
|
1555
1634
|
}
|
|
1556
1635
|
|
|
@@ -1569,11 +1648,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
1569
1648
|
* the gate becomes useful. NEVER fall back to the working tree here: a
|
|
1570
1649
|
* working-tree fallback lets anyone with edit access to access.md grant
|
|
1571
1650
|
* themselves approval rights.
|
|
1651
|
+
*
|
|
1652
|
+
* Cached per commit (see `atRefCache`): the ref is resolved to the commit
|
|
1653
|
+
* it points at first, and that commit is both the cache key and the ref
|
|
1654
|
+
* every read is pinned to. `resolvedRef` in the result is therefore a
|
|
1655
|
+
* commit SHA, so the own-entries read callers do afterwards can never
|
|
1656
|
+
* straddle a push that lands between the two.
|
|
1572
1657
|
*/
|
|
1573
|
-
private async loadModelAtRef(
|
|
1574
|
-
workspaceId: string,
|
|
1575
|
-
ref: string,
|
|
1576
|
-
): Promise<{ model: AccessModel; resolvedRef: string } | null> {
|
|
1658
|
+
private async loadModelAtRef(workspaceId: string, ref: string): Promise<AtRefModel | null> {
|
|
1577
1659
|
const repoDir = await this.repoDir(workspaceId);
|
|
1578
1660
|
|
|
1579
1661
|
// Refresh remote-tracking refs so a PR branch we've never personally
|
|
@@ -1581,47 +1663,126 @@ export class AccessControlService implements IAccessControl {
|
|
|
1581
1663
|
// block the lookup if the ref already exists locally.
|
|
1582
1664
|
await this.workspaceService.ensureRemotesFetched(workspaceId).catch(() => undefined);
|
|
1583
1665
|
|
|
1584
|
-
|
|
1585
|
-
|
|
1666
|
+
// Same candidate order as before the cache existed: the first candidate
|
|
1667
|
+
// that carries a roles.yaml wins, so a local branch without one still
|
|
1668
|
+
// defers to `origin/<branch>`.
|
|
1586
1669
|
for (const candidate of this.refCandidates(ref)) {
|
|
1587
|
-
const
|
|
1588
|
-
if (
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1670
|
+
const commit = await this.revParseCommit(repoDir, candidate);
|
|
1671
|
+
if (!commit) continue;
|
|
1672
|
+
const key = `${workspaceId}\0${commit}`;
|
|
1673
|
+
const hit = this.atRefCache.get(key);
|
|
1674
|
+
if (hit) return hit;
|
|
1675
|
+
// Registered BEFORE the first await on the build path, so concurrent
|
|
1676
|
+
// misses on one commit share a single build instead of all racing past
|
|
1677
|
+
// an empty in-flight check together.
|
|
1678
|
+
let pending = this.atRefInFlight.get(key);
|
|
1679
|
+
if (!pending) {
|
|
1680
|
+
pending = this.buildModelAtCommit(repoDir, commit, candidate)
|
|
1681
|
+
.then((built) => {
|
|
1682
|
+
if (typeof built !== 'string') this.rememberAtRef(key, built);
|
|
1683
|
+
return built;
|
|
1684
|
+
})
|
|
1685
|
+
.finally(() => {
|
|
1686
|
+
if (this.atRefInFlight.get(key) === pending) this.atRefInFlight.delete(key);
|
|
1687
|
+
});
|
|
1688
|
+
this.atRefInFlight.set(key, pending);
|
|
1592
1689
|
}
|
|
1690
|
+
const built = await pending;
|
|
1691
|
+
if (built === 'no-roles') continue;
|
|
1692
|
+
if (built === 'malformed') return null;
|
|
1693
|
+
return built;
|
|
1694
|
+
}
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** The commit `ref` points at in this clone, or null when it doesn't resolve. */
|
|
1699
|
+
private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
|
|
1700
|
+
if (!ref || ref.startsWith('-')) return null;
|
|
1701
|
+
try {
|
|
1702
|
+
const { stdout } = await execFileAsync(
|
|
1703
|
+
'git',
|
|
1704
|
+
['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
|
1705
|
+
{ encoding: 'utf-8' },
|
|
1706
|
+
);
|
|
1707
|
+
const sha = stdout.trim();
|
|
1708
|
+
return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
|
|
1709
|
+
} catch {
|
|
1710
|
+
return null;
|
|
1593
1711
|
}
|
|
1594
|
-
|
|
1712
|
+
}
|
|
1595
1713
|
|
|
1714
|
+
private rememberAtRef(key: string, value: AtRefModel): void {
|
|
1715
|
+
this.atRefCache.set(key, value);
|
|
1716
|
+
if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
1717
|
+
const oldest = this.atRefCache.keys().next().value;
|
|
1718
|
+
if (oldest !== undefined) this.atRefCache.delete(oldest);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* The uncached build behind `loadModelAtRef`: the whole access tree read
|
|
1724
|
+
* at `commit`. `label` is the candidate ref the commit came from, kept for
|
|
1725
|
+
* log lines only; every git read here is pinned to the commit. `'no-roles'`
|
|
1726
|
+
* means the commit carries no roles.yaml (the caller tries its next
|
|
1727
|
+
* candidate); `'malformed'` means it does but it doesn't parse (the caller
|
|
1728
|
+
* answers null, as it always has).
|
|
1729
|
+
*/
|
|
1730
|
+
private async buildModelAtCommit(
|
|
1731
|
+
repoDir: string,
|
|
1732
|
+
commit: string,
|
|
1733
|
+
label: string,
|
|
1734
|
+
): Promise<AtRefBuild> {
|
|
1735
|
+
// Every read below tells "absent" apart from "git failed". A failure
|
|
1736
|
+
// refuses the whole build: a model missing one access.md could grant what
|
|
1737
|
+
// that file denies, so no verdict is answered from it, nothing is cached,
|
|
1738
|
+
// and the caller fails closed (AccessUnreadableError, a 503) until a
|
|
1739
|
+
// retry reads the tree in full.
|
|
1740
|
+
const tag = `${label}@${commit.slice(0, 7)}`;
|
|
1741
|
+
const read = async (relativePath: string): Promise<string | null> => {
|
|
1742
|
+
const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
|
|
1743
|
+
if (outcome.kind === 'error') {
|
|
1744
|
+
// The operational signal: one line per failed read, naming the commit
|
|
1745
|
+
// and the path, so a run of these is visible in the logs.
|
|
1746
|
+
console.warn(`[access@${tag}] git read of ${relativePath} failed; refusing to decide from a partial tree`);
|
|
1747
|
+
throw new AccessUnreadableError(label, relativePath);
|
|
1748
|
+
}
|
|
1749
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
const rolesYaml = await read('roles.yaml');
|
|
1753
|
+
if (rolesYaml === null) return 'no-roles';
|
|
1596
1754
|
const rolesParsed = parseRolesYaml(rolesYaml);
|
|
1597
|
-
if (!rolesParsed.ok) return
|
|
1755
|
+
if (!rolesParsed.ok) return 'malformed';
|
|
1598
1756
|
|
|
1599
|
-
// Same group loading as the working-tree model, read AT THE
|
|
1757
|
+
// Same group loading as the working-tree model, read AT THE COMMIT — the
|
|
1600
1758
|
// whole point of file-materialized groups is that the merge/push gates
|
|
1601
|
-
// can evaluate them at the commit they gate.
|
|
1602
|
-
//
|
|
1603
|
-
//
|
|
1604
|
-
//
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
);
|
|
1759
|
+
// can evaluate them at the commit they gate. `read` throws
|
|
1760
|
+
// `AccessUnreadableError` on a git failure and `loadActiveGroups` lets
|
|
1761
|
+
// that through, so a flaky subprocess fails this build closed exactly as
|
|
1762
|
+
// it does for roles.yaml and access.md; the broken-groups marker below
|
|
1763
|
+
// fires only on a file that was read and would not parse.
|
|
1764
|
+
const activeGroups = await loadActiveGroups(read);
|
|
1608
1765
|
if (!activeGroups.health.ok) {
|
|
1609
1766
|
console.error(
|
|
1610
|
-
`[access@${
|
|
1767
|
+
`[access@${tag}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
|
|
1611
1768
|
);
|
|
1612
1769
|
}
|
|
1613
|
-
for (const w of activeGroups.warnings) console.warn(`[access@${
|
|
1770
|
+
for (const w of activeGroups.warnings) console.warn(`[access@${tag}] ${w}`);
|
|
1614
1771
|
const mergeWarnings = mergeGroupsIntoRoles(
|
|
1615
1772
|
rolesParsed.index,
|
|
1616
1773
|
activeGroups.groups,
|
|
1617
1774
|
activeGroups.sourceFile,
|
|
1618
1775
|
);
|
|
1619
|
-
for (const w of mergeWarnings) console.warn(`[access@${
|
|
1776
|
+
for (const w of mergeWarnings) console.warn(`[access@${tag}] ${w}`);
|
|
1620
1777
|
|
|
1621
1778
|
const accessFiles = new Map<string, AccessFile>();
|
|
1622
|
-
const
|
|
1623
|
-
|
|
1624
|
-
|
|
1779
|
+
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
1780
|
+
if (listed === 'error') {
|
|
1781
|
+
console.warn(`[access@${tag}] listing access.md files failed; refusing to decide from a partial tree`);
|
|
1782
|
+
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
1783
|
+
}
|
|
1784
|
+
for (const p of listed) {
|
|
1785
|
+
const text = await read(p);
|
|
1625
1786
|
if (text === null) continue;
|
|
1626
1787
|
const parsed = parseAccessFile(text, p);
|
|
1627
1788
|
if (!parsed.ok) continue;
|
|
@@ -1652,7 +1813,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
1652
1813
|
// land — the lockout moved one step later, not removed.
|
|
1653
1814
|
deploymentOwners: this.deploymentOwners,
|
|
1654
1815
|
},
|
|
1655
|
-
resolvedRef,
|
|
1816
|
+
resolvedRef: commit,
|
|
1656
1817
|
};
|
|
1657
1818
|
}
|
|
1658
1819
|
}
|
|
@@ -74,7 +74,7 @@ export function createSyncedGroupsCommitter(deps: {
|
|
|
74
74
|
// roles admin's atomic writes use.
|
|
75
75
|
const fsys = new LockingFilesystem(
|
|
76
76
|
{ basePath, contained: true },
|
|
77
|
-
{ workflow: workflowService, workspaceId, branch, user: bot },
|
|
77
|
+
{ workflow: workflowService, workspaceId, branch, user: bot, kbDirName },
|
|
78
78
|
);
|
|
79
79
|
try {
|
|
80
80
|
await fsys.writeFiles(
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
parseYamlSubset,
|
|
4
|
+
hasAccessFrontmatterExtension,
|
|
5
|
+
registerAccessFrontmatterExtensions,
|
|
6
|
+
accessFrontmatterExtensionList,
|
|
7
|
+
parseOwnAccessEntries,
|
|
8
|
+
} from '../access-grammar.js';
|
|
3
9
|
import { parseGroupsFile } from '../group-files.js';
|
|
4
10
|
|
|
5
11
|
describe('parseYamlSubset — inline empty collections', () => {
|
|
@@ -29,3 +35,97 @@ describe('parseGroupsFile — empty group sources', () => {
|
|
|
29
35
|
if (res.ok) expect(res.groups.size).toBe(0);
|
|
30
36
|
});
|
|
31
37
|
});
|
|
38
|
+
|
|
39
|
+
describe('the access-frontmatter extension set', () => {
|
|
40
|
+
// Registration is process-global and deliberately has no way to undo it, so
|
|
41
|
+
// these tests never assert that a REAL extension is absent — that would
|
|
42
|
+
// depend on no earlier test (in any order, in any file) having registered it.
|
|
43
|
+
// They use extensions nothing else will ever register instead, which makes
|
|
44
|
+
// every assertion here true regardless of what ran first.
|
|
45
|
+
it('covers nodes and tool manuals out of the box', () => {
|
|
46
|
+
// `.tool` is whole-document YAML with its verbs as ordinary keys inside;
|
|
47
|
+
// `access.md` is covered by `.md`.
|
|
48
|
+
expect(hasAccessFrontmatterExtension('Data/E/Knowledge/T.md')).toBe(true);
|
|
49
|
+
expect(hasAccessFrontmatterExtension('Plugins/E/tools/github.tool')).toBe(true);
|
|
50
|
+
expect(hasAccessFrontmatterExtension('Plugins/E/access.md')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('is case-sensitive on the path, as it always was', () => {
|
|
54
|
+
// Which files are governed must not change because the set became
|
|
55
|
+
// registrable. An uppercase extension was never a node and still is not.
|
|
56
|
+
expect(hasAccessFrontmatterExtension('Data/E/Knowledge/T.MD')).toBe(false);
|
|
57
|
+
expect(hasAccessFrontmatterExtension('Plugins/E/tools/github.TOOL')).toBe(false);
|
|
58
|
+
// Registration itself normalizes, so a mixed-case REGISTRATION governs the
|
|
59
|
+
// lowercase files it meant.
|
|
60
|
+
registerAccessFrontmatterExtensions(['.CaseKind']);
|
|
61
|
+
expect(hasAccessFrontmatterExtension('Overlay/x.casekind')).toBe(true);
|
|
62
|
+
expect(hasAccessFrontmatterExtension('Overlay/x.CaseKind')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('covers nothing an overlay has not registered', () => {
|
|
66
|
+
// The grammar is core's; the file kinds are not necessarily.
|
|
67
|
+
expect(hasAccessFrontmatterExtension('Some/File.neverregistered')).toBe(false);
|
|
68
|
+
expect(hasAccessFrontmatterExtension('notes.txt')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('covers a file kind once an overlay registers it', () => {
|
|
72
|
+
expect(hasAccessFrontmatterExtension('Overlay/thing.testkind')).toBe(false);
|
|
73
|
+
registerAccessFrontmatterExtensions(['.testkind']);
|
|
74
|
+
expect(hasAccessFrontmatterExtension('Overlay/thing.testkind')).toBe(true);
|
|
75
|
+
// A near-miss must still miss: a backup is not a live grant.
|
|
76
|
+
expect(hasAccessFrontmatterExtension('Overlay/thing.testkind.bak')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is additive and idempotent', () => {
|
|
80
|
+
// Removing an extension would silently drop grants already being enforced,
|
|
81
|
+
// so there is no way to remove one.
|
|
82
|
+
registerAccessFrontmatterExtensions(['.testidem']);
|
|
83
|
+
const after = accessFrontmatterExtensionList().length;
|
|
84
|
+
registerAccessFrontmatterExtensions(['.testidem', '.TESTIDEM']);
|
|
85
|
+
expect(accessFrontmatterExtensionList()).toHaveLength(after);
|
|
86
|
+
expect(accessFrontmatterExtensionList()).toContain('.md');
|
|
87
|
+
expect(accessFrontmatterExtensionList()).toContain('.tool');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws on a malformed extension rather than skipping it', () => {
|
|
91
|
+
// A typo would otherwise leave capability-granting files ungoverned, at
|
|
92
|
+
// boot, with nothing to distinguish it from a successful registration.
|
|
93
|
+
expect(() => registerAccessFrontmatterExtensions(['pipeline'])).toThrow(/malformed/);
|
|
94
|
+
expect(() => registerAccessFrontmatterExtensions(['.two.dots'])).toThrow(/malformed/);
|
|
95
|
+
expect(() => registerAccessFrontmatterExtensions([''])).toThrow(/malformed/);
|
|
96
|
+
expect(hasAccessFrontmatterExtension('x.pipeline_typo_guard')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('applies nothing from a list that contains a malformed entry', () => {
|
|
100
|
+
// Validate all, then apply: a list half-applied when it throws leaves later
|
|
101
|
+
// scans governing a set nobody asked for.
|
|
102
|
+
expect(() => registerAccessFrontmatterExtensions(['.validfirst', 'broken'])).toThrow(/malformed/);
|
|
103
|
+
expect(hasAccessFrontmatterExtension('a.validfirst')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reads verbs out of a registered file kind, ignoring its other keys', () => {
|
|
107
|
+
// The point of per-file access on these: they are configuration rather than
|
|
108
|
+
// graph nodes, but they are exactly the files whose edits grant capability.
|
|
109
|
+
// The verbs are ordinary keys beside the rest of the definition, exactly as
|
|
110
|
+
// a `.tool` carries them beside `id:` and `tools:`.
|
|
111
|
+
const pipeline = [
|
|
112
|
+
'---',
|
|
113
|
+
'name: Coding Delivery',
|
|
114
|
+
'owner: Razvan <razvan@bevel.software>',
|
|
115
|
+
'read:',
|
|
116
|
+
' - coding-agent <coding-agent@bevel.software>',
|
|
117
|
+
'do:',
|
|
118
|
+
' - name: Coding',
|
|
119
|
+
'---',
|
|
120
|
+
].join('\n');
|
|
121
|
+
const entries = parseOwnAccessEntries(pipeline);
|
|
122
|
+
expect(entries).not.toBeNull();
|
|
123
|
+
expect(entries!.owner).toEqual([
|
|
124
|
+
{ kind: 'user', email: 'razvan@bevel.software', displayName: 'Razvan', deny: false },
|
|
125
|
+
]);
|
|
126
|
+
expect(entries!.read).toEqual([
|
|
127
|
+
{ kind: 'user', email: 'coding-agent@bevel.software', displayName: 'coding-agent', deny: false },
|
|
128
|
+
]);
|
|
129
|
+
expect(entries!.write).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -40,6 +40,25 @@ export class AccessDeniedError extends WorkflowDomainError {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when the access tree at a git ref could not be READ (a git
|
|
45
|
+
* subprocess failed on the way), as opposed to being absent or malformed.
|
|
46
|
+
* Nothing was decided: a verdict from a partially read tree could grant what
|
|
47
|
+
* a lost `access.md` would have denied, so the operation fails closed with a
|
|
48
|
+
* 503 and the caller retries. Distinct from AccessConfigError (the config is
|
|
49
|
+
* there and wrong) and AccessDeniedError (a real permission decision).
|
|
50
|
+
*/
|
|
51
|
+
export class AccessUnreadableError extends WorkflowDomainError {
|
|
52
|
+
constructor(ref: string, relativePath: string) {
|
|
53
|
+
super(
|
|
54
|
+
`Access rules at ${ref} could not be read (git failed on ${relativePath}); nothing was decided. Try again.`,
|
|
55
|
+
503,
|
|
56
|
+
{ ref, path: relativePath },
|
|
57
|
+
);
|
|
58
|
+
this.name = 'AccessUnreadableError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
/**
|
|
44
63
|
* Thrown when the access-control config (roles.yaml or access.md) is missing
|
|
45
64
|
* or malformed at runtime — distinct from AccessDeniedError because the cause
|
|
@@ -138,12 +138,76 @@ export function isAccessMdPath(p: string): boolean {
|
|
|
138
138
|
* the access-declarations scan and the shared `KbReferenceScanner` (which
|
|
139
139
|
* must scan/rewrite the same set, or a rename strands a live `.tool`
|
|
140
140
|
* frontmatter grant). `access.md` is covered by `.md`.
|
|
141
|
+
*
|
|
142
|
+
* `.tool` is whole-document YAML: the file IS one `---` fenced block, and its
|
|
143
|
+
* access verbs sit in it as ordinary keys beside the rest of the definition
|
|
144
|
+
* (`parseOwnAccessEntries` ignores every key that is not a verb). It is
|
|
145
|
+
* configuration rather than a graph node — it lives outside the typed
|
|
146
|
+
* ontologies — but it is exactly the kind of file whose edits grant
|
|
147
|
+
* capability, so it needs the same per-file governance as a node.
|
|
148
|
+
*
|
|
149
|
+
* This is the CORE set. An overlay that ships its own whole-document
|
|
150
|
+
* configuration files adds their extensions through
|
|
151
|
+
* {@link registerAccessFrontmatterExtensions} — the grammar is core's, the
|
|
152
|
+
* file kinds are not necessarily.
|
|
141
153
|
*/
|
|
142
|
-
|
|
154
|
+
const CORE_ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
|
|
155
|
+
|
|
156
|
+
const accessFrontmatterExtensions = new Set<string>(CORE_ACCESS_FRONTMATTER_EXTENSIONS);
|
|
143
157
|
|
|
144
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Extend the set of files whose own frontmatter carries access verbs.
|
|
160
|
+
*
|
|
161
|
+
* For overlays whose configuration files follow the same whole-document
|
|
162
|
+
* convention and, like a `.tool`, grant capability by being edited. Call once
|
|
163
|
+
* at boot, BEFORE any access resolution or reference scan — the declarations
|
|
164
|
+
* scan and the reference scanner must agree on the set, and a scanner that
|
|
165
|
+
* learned a new extension after a scan would leave a live grant unrewritten
|
|
166
|
+
* on the next rename.
|
|
167
|
+
*
|
|
168
|
+
* Idempotent, and additive only: an extension cannot be removed, because
|
|
169
|
+
* removing one would silently drop grants that are already enforced.
|
|
170
|
+
*/
|
|
171
|
+
export function registerAccessFrontmatterExtensions(extensions: readonly string[]): void {
|
|
172
|
+
// Validate EVERYTHING first, then apply. These registrations decide which
|
|
173
|
+
// files' edits carry enforced access grants, so a typo (`pipeline` for
|
|
174
|
+
// `.pipeline`) must THROW rather than be skipped — a silent skip makes a
|
|
175
|
+
// failed registration indistinguishable from a successful one, at boot,
|
|
176
|
+
// where nobody is looking. And it must throw before the registry changes:
|
|
177
|
+
// a list that is half-applied when it throws leaves later scans governing a
|
|
178
|
+
// set nobody asked for.
|
|
179
|
+
const normalized = extensions.map((ext) => {
|
|
180
|
+
const n = ext.trim().toLowerCase();
|
|
181
|
+
if (!/^\.[a-z0-9]+$/.test(n)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`access frontmatter extension "${ext}" is malformed — expected a leading dot, e.g. ".pipeline"`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return n;
|
|
187
|
+
});
|
|
188
|
+
for (const n of normalized) accessFrontmatterExtensions.add(n);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Every extension currently in the set, core's plus any an overlay registered. */
|
|
192
|
+
export function accessFrontmatterExtensionList(): string[] {
|
|
193
|
+
return [...accessFrontmatterExtensions];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* True when `p` is a file the resolver reads access frontmatter from.
|
|
198
|
+
*
|
|
199
|
+
* Case-SENSITIVE on the path, as it always was: `doc.MD` is not a node and
|
|
200
|
+
* carries no enforced grant. Registered extensions are normalized to lowercase
|
|
201
|
+
* so `.Pipeline` and `.pipeline` register the same thing, but which FILES are
|
|
202
|
+
* governed must not change because the set became registrable — widening it to
|
|
203
|
+
* `X.MD` silently in a release would be an access-model change nobody asked
|
|
204
|
+
* for, made in a refactor.
|
|
205
|
+
*/
|
|
145
206
|
export function hasAccessFrontmatterExtension(p: string): boolean {
|
|
146
|
-
|
|
207
|
+
for (const ext of accessFrontmatterExtensions) {
|
|
208
|
+
if (p.endsWith(ext)) return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
147
211
|
}
|
|
148
212
|
/**
|
|
149
213
|
* The PRINCIPAL index — canonical name → member emails. Despite the name it
|