@bevel-software/platform-shared 0.15.1 → 0.19.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 (73) hide show
  1. package/dist/auth/types.d.ts +12 -0
  2. package/dist/auth/types.d.ts.map +1 -1
  3. package/dist/git/pr.types.d.ts +75 -11
  4. package/dist/git/pr.types.d.ts.map +1 -1
  5. package/dist/git/types.d.ts +28 -1
  6. package/dist/git/types.d.ts.map +1 -1
  7. package/dist/index.d.ts +6 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/workflow/events.d.ts +32 -4
  12. package/dist/workflow/events.d.ts.map +1 -1
  13. package/dist/workflow/events.js.map +1 -1
  14. package/dist/workflow/interface.d.ts +103 -4
  15. package/dist/workflow/interface.d.ts.map +1 -1
  16. package/dist/workflow/types.d.ts +49 -0
  17. package/dist/workflow/types.d.ts.map +1 -1
  18. package/dist/workspace/access-verbs.d.ts +83 -0
  19. package/dist/workspace/access-verbs.d.ts.map +1 -0
  20. package/dist/workspace/access-verbs.js +110 -0
  21. package/dist/workspace/access-verbs.js.map +1 -0
  22. package/dist/workspace/agent-preamble.d.ts +33 -0
  23. package/dist/workspace/agent-preamble.d.ts.map +1 -0
  24. package/dist/workspace/agent-preamble.js +45 -0
  25. package/dist/workspace/agent-preamble.js.map +1 -0
  26. package/dist/workspace/entry-exists.d.ts +27 -0
  27. package/dist/workspace/entry-exists.d.ts.map +1 -0
  28. package/dist/workspace/entry-exists.js +33 -0
  29. package/dist/workspace/entry-exists.js.map +1 -0
  30. package/dist/workspace/filename.d.ts +15 -0
  31. package/dist/workspace/filename.d.ts.map +1 -1
  32. package/dist/workspace/filename.js +30 -0
  33. package/dist/workspace/filename.js.map +1 -1
  34. package/dist/workspace/frontmatter-carriers.d.ts +44 -0
  35. package/dist/workspace/frontmatter-carriers.d.ts.map +1 -0
  36. package/dist/workspace/frontmatter-carriers.js +52 -0
  37. package/dist/workspace/frontmatter-carriers.js.map +1 -0
  38. package/dist/workspace/frontmatter.d.ts +23 -4
  39. package/dist/workspace/frontmatter.d.ts.map +1 -1
  40. package/dist/workspace/frontmatter.js +59 -9
  41. package/dist/workspace/frontmatter.js.map +1 -1
  42. package/dist/workspace/kb-layout.d.ts +226 -19
  43. package/dist/workspace/kb-layout.d.ts.map +1 -1
  44. package/dist/workspace/kb-layout.js +353 -23
  45. package/dist/workspace/kb-layout.js.map +1 -1
  46. package/dist/workspace/placeholder.d.ts +21 -0
  47. package/dist/workspace/placeholder.d.ts.map +1 -0
  48. package/dist/workspace/placeholder.js +28 -0
  49. package/dist/workspace/placeholder.js.map +1 -0
  50. package/dist/workspace/platform-files.d.ts +105 -0
  51. package/dist/workspace/platform-files.d.ts.map +1 -0
  52. package/dist/workspace/platform-files.js +147 -0
  53. package/dist/workspace/platform-files.js.map +1 -0
  54. package/dist/workspace/types.d.ts +8 -0
  55. package/dist/workspace/types.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/auth/types.ts +12 -0
  58. package/src/git/pr.types.ts +77 -11
  59. package/src/git/types.ts +35 -1
  60. package/src/index.ts +6 -0
  61. package/src/workflow/events.ts +33 -3
  62. package/src/workflow/interface.ts +127 -4
  63. package/src/workflow/types.ts +43 -0
  64. package/src/workspace/access-verbs.ts +124 -0
  65. package/src/workspace/agent-preamble.ts +47 -0
  66. package/src/workspace/entry-exists.ts +36 -0
  67. package/src/workspace/filename.ts +31 -0
  68. package/src/workspace/frontmatter-carriers.ts +57 -0
  69. package/src/workspace/frontmatter.ts +57 -8
  70. package/src/workspace/kb-layout.ts +388 -25
  71. package/src/workspace/placeholder.ts +29 -0
  72. package/src/workspace/platform-files.ts +188 -0
  73. package/src/workspace/types.ts +8 -0
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { AuthUser } from '../auth/types.js';
21
+ import type { ChangeRequestApplyFailureKind } from '../git/pr.types.js';
21
22
  import type {
22
23
  AcquireLockResult,
23
24
  Branch,
@@ -33,6 +34,9 @@ import type {
33
34
  ChangedFile,
34
35
  FileApproval,
35
36
  FileLock,
37
+ FolderChangeRequest,
38
+ FolderChangeRequestRemoval,
39
+ MergeBranchOutcome,
36
40
  MergeChangeRequestOutcome,
37
41
  OpenChangeRequestInput,
38
42
  PostChangeRequestCommentInput,
@@ -222,9 +226,38 @@ export interface IWorkflowService {
222
226
  path: string,
223
227
  sha: string,
224
228
  ): Promise<{ baseline: string | null; current: string | null }>;
229
+ /**
230
+ * One file as it stood at a change request's fork point (`sha`, which must
231
+ * lie on the target branch's history) — the "before" side of the request
232
+ * dialog's diff. `null` when the path did not exist there. Access is the
233
+ * caller's to check.
234
+ */
235
+ fileAtForkPoint(
236
+ workspaceId: string,
237
+ baseBranch: string,
238
+ sha: string,
239
+ path: string,
240
+ ): Promise<string | null>;
241
+ /**
242
+ * A change request's current fork point: the merge base of the freshly
243
+ * fetched target and source branches, or `null` when they share no history.
244
+ */
245
+ changeRequestForkPoint(
246
+ workspaceId: string,
247
+ baseBranch: string,
248
+ headBranch: string,
249
+ ): Promise<string | null>;
225
250
 
226
251
  // ── File locks (new — currently NotImplementedWorkflowError) ──────────────
227
252
 
253
+ // Every lock method below coordinates on ONE canonical identity per file:
254
+ // `x/a.md`, `./x/a.md` and `x//a.md` name the same lock, so a lock acquired
255
+ // under any of them is contended, heartbeated, checkpointed, released and
256
+ // read under any other. A path that escapes the workspace, or that only
257
+ // becomes relative by being laundered, is refused with the same status the
258
+ // file verbs answer for that input: 400 for an unusable path, 403 for one
259
+ // that resolves outside the workspace.
260
+
228
261
  /**
229
262
  * Try to acquire an edit lock on `(branch, path)` for the caller. Returns
230
263
  * `{ acquired: true, lock }` on success; `{ acquired: false, lock }` if
@@ -246,13 +279,37 @@ export interface IWorkflowService {
246
279
  * `releaseLockUntouched`) — `releaseLock` rejects rather than ever enqueue
247
280
  * a commit for a hold that was never allowed to write. Internal callers
248
281
  * only; never plumbed from a route.
282
+ *
283
+ * Every non-coordination acquire also passes the READ-BEFORE-WRITE gate, on
284
+ * every branch: the caller must be able to read the path (for a new path,
285
+ * where it lands), or the path must start a new folder directly under one
286
+ * of the three roots (knowledge, skills, plugins). Nothing is created,
287
+ * changed or removed where its author cannot see it, whatever write rules
288
+ * say; a refusal is an `AccessDeniedError` whose `access.unreadable` names
289
+ * the place. A write that passed only as a platform-file restore is not
290
+ * asked — that rescue exists for a destination whose rules deny the admin
291
+ * making it.
292
+ *
293
+ * `opts.platformRestore` CLAIMS that this acquire is the destination side of
294
+ * an admin putting a misplaced platform file back (`access.md`, `roles.yaml`,
295
+ * `.bevelignore`, the agent guide), and names the move's `source` — the path the
296
+ * file is coming FROM, in the same spelling as `path`.
297
+ *
298
+ * It is a claim, not an authorisation. The implementation re-asks both
299
+ * halves of it: that source→path is a restore at all (a misplaced copy
300
+ * going back under its own name, never the root's own copy coming out —
301
+ * `isPlatformRestoreShape`), and that the access module
302
+ * (`canRestorePlatformFile`) lets this caller land this exact path. Only
303
+ * both yeses let the acquire past the write gate, so a caller that omits or
304
+ * fakes the source gains nothing: the gate never takes the route's word for
305
+ * which move this is.
249
306
  */
250
307
  acquireLock(
251
308
  workspaceId: string,
252
309
  branch: string,
253
310
  path: string,
254
311
  user: AuthUser,
255
- opts?: { coordination?: boolean },
312
+ opts?: { coordination?: boolean; platformRestore?: { source: string } },
256
313
  ): Promise<AcquireLockResult>;
257
314
  /** Heartbeat to keep an acquired lock alive past its current TTL. */
258
315
  heartbeatLock(workspaceId: string, branch: string, path: string, user: AuthUser): Promise<FileLock>;
@@ -394,9 +451,12 @@ export interface IWorkflowService {
394
451
  ): Promise<ChangeRequestDetail>;
395
452
 
396
453
  /**
397
- * Re-run `targetBranch → sourceBranch` merge on an existing change request.
398
- * Used when the target has advanced since the change request was opened.
399
- * Throws `NotImplementedWorkflowError` until the backing merge path lands.
454
+ * Re-run `targetBranch → sourceBranch` merge on an existing change request
455
+ * and push it — the request dialog's Update, offered when the target has
456
+ * advanced since the request was opened. Only the request's author or
457
+ * someone who may apply it (`viewerCanUpdate`) may run it (403 otherwise).
458
+ * A conflicting merge is aborted, leaving the branch exactly as it was, and
459
+ * surfaces as `ChangeRequestConflictsError` (409).
400
460
  */
401
461
  updateFromTarget(workspaceId: string, user: AuthUser, number: number): Promise<ChangeRequestDetail>;
402
462
 
@@ -454,6 +514,22 @@ export interface IWorkflowService {
454
514
  */
455
515
  closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean>;
456
516
 
517
+ /**
518
+ * The open change requests proposing files under a KB-repo-relative
519
+ * folder, each with whether the caller may take those files out of it
520
+ * (their own request, they are an admin, or they may write every file it
521
+ * proposes under the folder).
522
+ */
523
+ changeRequestsUnderFolder(folder: string, user: AuthUser): Promise<FolderChangeRequest[]>;
524
+
525
+ /**
526
+ * Take every file under the folder out of every open change request
527
+ * proposing one; a request left empty is withdrawn. All or nothing: refused
528
+ * (403), with nothing touched, when the caller may not act on even one of
529
+ * them.
530
+ */
531
+ removeFolderFromChangeRequests(folder: string, user: AuthUser): Promise<FolderChangeRequestRemoval[]>;
532
+
457
533
  /**
458
534
  * Close every open change request either of whose branches no longer exists
459
535
  * — source or target, since a proposal needs both ends. Such a request
@@ -510,4 +586,51 @@ export interface IWorkflowService {
510
586
  workspaceId: string,
511
587
  opts?: { bypass?: boolean },
512
588
  ): Promise<MergeChangeRequestOutcome>;
589
+
590
+ /** Start an apply attempt on a request; the token scopes `recordApplyFailure`. */
591
+ beginApplyAttempt(number: number): number;
592
+
593
+ /** End an attempt `beginApplyAttempt` started, whatever its outcome. */
594
+ endApplyAttempt(number: number, attempt: number): void;
595
+
596
+ /**
597
+ * Persist why an apply did not land on the (still open) request, and
598
+ * announce `change-request-apply-failed` to every session so the author and
599
+ * other viewers re-read it — not only the user who clicked. Resolves false,
600
+ * recording and announcing nothing, when `attempt` is no longer the latest
601
+ * or the request is no longer open.
602
+ */
603
+ recordApplyFailure(
604
+ number: number,
605
+ failure: { reason: string; kind: ChangeRequestApplyFailureKind; at?: Date },
606
+ user: AuthUser,
607
+ attempt: number,
608
+ ): Promise<boolean>;
609
+
610
+ /**
611
+ * Merge `sourceBranch` into `targetBranch` directly, authored as `user`,
612
+ * and publish the target. The agent path for merging branches — it never
613
+ * lands a change request:
614
+ *
615
+ * - refused (`OpenChangeRequestBlocksMergeError`, naming the request) when
616
+ * a change request from `sourceBranch` into `targetBranch` is open; a
617
+ * person merges that one in the app. The reverse direction — the target
618
+ * into the source, the sync that keeps a draft current — is allowed.
619
+ * - refused (`WorkflowDomainError`, `kind: 'protected-merge-target'`,
620
+ * status 403, listing the denied paths) when `targetBranch` is protected
621
+ * and `user` could not commit every file the merge changes directly to
622
+ * it. Decided against the target commit the merge is built on, not a
623
+ * workspace `HEAD` that may be behind it.
624
+ * - refused (`WorkflowDomainError`, `kind: 'protected-merge-changes-roles'`,
625
+ * status 403) when `targetBranch` is protected and the merge would
626
+ * change its `roles.yaml` — whoever the caller is. Roles never change
627
+ * through a merge; a change request's merge restores the target's copy
628
+ * first, and this path refuses instead. Roles are changed in the app.
629
+ * - refused (`WorkflowDomainError`, `kind: 'merge-target-busy'`, status
630
+ * 409) when `targetBranch`'s workspace still holds unshared edits: the
631
+ * merge resets it to the published tip, which would discard them.
632
+ *
633
+ * Conflicts write nothing and come back as `conflicts-need-resolution`.
634
+ */
635
+ mergeBranch(user: AuthUser, sourceBranch: string, targetBranch: string): Promise<MergeBranchOutcome>;
513
636
  }
@@ -133,6 +133,40 @@ export type CancelChangeRequestResult = CancelPrResult;
133
133
  export type ChangeRequestComment = PrReviewComment;
134
134
  export type PostChangeRequestCommentInput = PostPrCommentInput;
135
135
 
136
+ /**
137
+ * One open change request proposing files under a folder, as a folder delete
138
+ * lists it. `mayRemove`: the caller may take those files out of it (their own,
139
+ * they are an admin, or they may write every file it proposes under the
140
+ * folder); a refusal always says why.
141
+ */
142
+ export type FolderChangeRequest = {
143
+ number: number;
144
+ title: string;
145
+ authorName: string | null;
146
+ /** The caller authored it. */
147
+ mine: boolean;
148
+ /** KB-repo-relative paths it proposes under the folder. */
149
+ paths: string[];
150
+ } & ({ mayRemove: true } | { mayRemove: false; reason: string });
151
+
152
+ /** What removing a folder's files did to one change request. */
153
+ export interface FolderChangeRequestRemoval {
154
+ number: number;
155
+ removedPaths: string[];
156
+ /** The request proposed nothing else, so it was withdrawn. */
157
+ withdrawn: boolean;
158
+ /**
159
+ * Files under the folder it still proposes: added while the removal ran,
160
+ * so never judged, and left alone.
161
+ */
162
+ stillProposed: string[];
163
+ /**
164
+ * It proposes nothing now but was kept open, because a save to its branch
165
+ * was still landing — withdrawing would have deleted that save.
166
+ */
167
+ keptForSaves: boolean;
168
+ }
169
+
136
170
  /**
137
171
  * Input for opening a new change request. The workflow auto-merges
138
172
  * `targetBranch` into `sourceBranch` as part of opening, so callers don't
@@ -164,6 +198,15 @@ export type MergeChangeRequestOutcome =
164
198
  | { kind: 'merged'; result: MergeChangeRequestResult }
165
199
  | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] };
166
200
 
201
+ /**
202
+ * What a direct branch-to-branch merge (`mergeBranch`) did: landed as `sha`
203
+ * on the target (the target's own tip when it already contained the source),
204
+ * or stopped on conflicts, nothing written.
205
+ */
206
+ export type MergeBranchOutcome =
207
+ | { kind: 'merged'; sha: string }
208
+ | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] };
209
+
167
210
  /**
168
211
  * What a remote sync did to one branch's clone. One entry per branch in the
169
212
  * `POST /api/sync` response, so a pipeline can read exactly which branch
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The access verbs and the ONE dependency graph between them.
3
+ *
4
+ * Shared so that everything that folds verbs — the resolver in core-backend,
5
+ * the eligible lists it serves, and the share dialog in core-frontend that
6
+ * renders and edits them — reads the same table. A second fold written out by
7
+ * hand somewhere else is how the sheet comes to show "Owner" for someone the
8
+ * resolver refuses, or writes a `read:` line under an `owner:` it just granted.
9
+ *
10
+ * `read` controls who may VIEW a path. It is default-deny: a path with no
11
+ * effective grant of any verb is not readable. `write` is editing (and, with
12
+ * it, approval rights), `download` is saving a copy, `owner` is the contact
13
+ * point for the node and the right to manage its access.
14
+ */
15
+
16
+ /**
17
+ * Verbs an `access.md` frontmatter can grant, each a list of principals
18
+ * optionally prefixed with `deny `. `AccessFile.entries` in the backend is
19
+ * statically keyed on this union; keep `Verb` and `KNOWN_VERBS` in lockstep.
20
+ */
21
+ export const KNOWN_VERBS = ['read', 'write', 'download', 'owner'] as const;
22
+ export type Verb = (typeof KNOWN_VERBS)[number];
23
+
24
+ /**
25
+ * THE DEPENDENCY GRAPH — what holding each verb presupposes, declared once.
26
+ * `owner` presupposes `write` and `download`; `write` and `download` each
27
+ * presuppose `read`; `read` presupposes nothing. Everything about how verbs
28
+ * fold into one another is DERIVED from this table, in both directions:
29
+ *
30
+ * - a GRANT confers, downwards, every verb the granted one presupposes
31
+ * (`sourceVerbsFor`, `effectiveVerbs`): an owner may edit, save and open;
32
+ * an editor may open; someone trusted with a copy may open it;
33
+ * - a DENIAL strips, upwards, every verb that presupposes the denied one
34
+ * (`requiredVerbsFor`): nobody owns what they may not edit, and nobody
35
+ * edits or saves what they may not open.
36
+ *
37
+ * Neither converse is implied: a `deny write` says nothing about `read`, and a
38
+ * `read` grant confers no `write`. `write` and `download` are independent of
39
+ * each other. Add a verb, or change what one presupposes, HERE and nowhere
40
+ * else; every list and fold below follows.
41
+ */
42
+ export const VERB_REQUIRES: Readonly<Record<Verb, readonly Verb[]>> = {
43
+ read: [],
44
+ write: ['read'],
45
+ download: ['read'],
46
+ owner: ['write', 'download'],
47
+ };
48
+
49
+ /** `verb` and everything it presupposes, transitively. */
50
+ function presupposedBy(verb: Verb): ReadonlySet<Verb> {
51
+ const out = new Set<Verb>();
52
+ const visit = (v: Verb) => {
53
+ if (out.has(v)) return;
54
+ out.add(v);
55
+ for (const dep of VERB_REQUIRES[v]) visit(dep);
56
+ };
57
+ visit(verb);
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Verbs whose GRANT confers `verb`, target verb first: `verb` itself, then
63
+ * every verb that presupposes it, in `KNOWN_VERBS` order.
64
+ */
65
+ export function sourceVerbsFor(verb: Verb): Verb[] {
66
+ return [verb, ...KNOWN_VERBS.filter((v) => v !== verb && presupposedBy(v).has(verb))];
67
+ }
68
+
69
+ /**
70
+ * Verbs whose DENIAL strips `verb`, target verb first: `verb` itself, then
71
+ * every verb it presupposes, in `KNOWN_VERBS` order.
72
+ */
73
+ export function requiredVerbsFor(verb: Verb): Verb[] {
74
+ const needs = presupposedBy(verb);
75
+ return [verb, ...KNOWN_VERBS.filter((v) => v !== verb && needs.has(v))];
76
+ }
77
+
78
+ /**
79
+ * The verbs in the order a change to a principal's set has to be APPLIED:
80
+ * broadest first, so every verb comes before the verbs it presupposes (ties in
81
+ * `KNOWN_VERBS` order). Lowering must start at the top — denying `write` while
82
+ * an `owner:` grant still stands in the same file is refused as ineffective,
83
+ * since owner confers write; stripping owner first removes what was conferring
84
+ * it. Raising reads the same order for the opposite reason: granting `owner`
85
+ * first satisfies the rest in one line.
86
+ */
87
+ export const VERBS_BROADEST_FIRST: readonly Verb[] = [...KNOWN_VERBS].sort(
88
+ (a, b) =>
89
+ presupposedBy(b).size - presupposedBy(a).size || KNOWN_VERBS.indexOf(a) - KNOWN_VERBS.indexOf(b),
90
+ );
91
+
92
+ /** One flag per verb: what a principal holds, or what a form has ticked. */
93
+ export type VerbSet = Record<Verb, boolean>;
94
+ /** A partial set — flags left out read as not held. */
95
+ export type VerbFlags = Partial<Record<Verb, boolean>>;
96
+
97
+ /**
98
+ * The grant fold, as a whole set: what a principal EFFECTIVELY holds given the
99
+ * verbs they are granted. Each verb is held when it, or any verb that
100
+ * presupposes it, is in `held`. Idempotent on an already-folded set.
101
+ */
102
+ export function effectiveVerbs(held: VerbFlags): VerbSet {
103
+ const out = {} as VerbSet;
104
+ for (const verb of KNOWN_VERBS) out[verb] = sourceVerbsFor(verb).some((w) => held[w] === true);
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * True when some OTHER verb in `held` confers `verb` — it is implied, not
110
+ * chosen, which is what a form renders as checked-and-disabled.
111
+ */
112
+ export function conferredByOthers(held: VerbFlags, verb: Verb): boolean {
113
+ return sourceVerbsFor(verb).some((w) => w !== verb && held[w] === true);
114
+ }
115
+
116
+ /**
117
+ * The fewest grant lines that produce `held`: every held verb that no other
118
+ * held verb confers, broadest first. `{ owner }` is one line; `{ write, read,
119
+ * download }` is `write` and `download` (read rides on either); `{ read,
120
+ * download }` is `download` alone. A flag left out is not granted.
121
+ */
122
+ export function minimalGrantVerbs(held: VerbFlags): Verb[] {
123
+ return VERBS_BROADEST_FIRST.filter((verb) => held[verb] === true && !conferredByOthers(held, verb));
124
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The rules about the deployment preamble's TEXT that both sides need to
3
+ * agree on: the file it lives in, the caps it is cut at, and what counts as a
4
+ * private comment inside it.
5
+ *
6
+ * These lived in the backend composer and were mirrored by hand in the
7
+ * frontend editor, whose own comment said so. The two are not free to differ:
8
+ * the editor decides what an admin is shown and what it writes back, the
9
+ * composer decides what agents receive, and a drift between them shows the
10
+ * admin one description while agents get another.
11
+ *
12
+ * Pure: no IO, no clock, no platform assumptions.
13
+ */
14
+
15
+ /** The repository-root file an admin edits. */
16
+ export const PREAMBLE_FILE = 'mcp-description.md';
17
+
18
+ /** UTF-16 units of preamble sent on the handshake before the marker replaces the rest. */
19
+ export const PREAMBLE_CAP = 6_000;
20
+
21
+ /** UTF-16 units of the whole tool prefix (fixed line included). */
22
+ export const TOOL_PREFIX_CAP = 300;
23
+
24
+ /**
25
+ * Remove every `<!-- … -->` block.
26
+ *
27
+ * A `<!--` that is never closed takes the rest of the text with it and is
28
+ * reported, so a caller can warn: that is the fail-closed half of the rule,
29
+ * and it is why an admin's private note cannot leak through the likeliest
30
+ * editing slip. The editor strips the same blocks to decide what to show, and
31
+ * closes an unterminated one when it writes back.
32
+ */
33
+ export function stripHtmlComments(text: string): { text: string; unterminated: boolean } {
34
+ let out = '';
35
+ let from = 0;
36
+ for (;;) {
37
+ const open = text.indexOf('<!--', from);
38
+ if (open === -1) {
39
+ out += text.slice(from);
40
+ return { text: out, unterminated: false };
41
+ }
42
+ out += text.slice(from, open);
43
+ const close = text.indexOf('-->', open + 4);
44
+ if (close === -1) return { text: out, unterminated: true };
45
+ from = close + 3;
46
+ }
47
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * THE sentence a rename, a move or a copy answers with when something is
3
+ * already at the destination.
4
+ *
5
+ * A rename is not a way to replace content: renaming a `.docx` onto an
6
+ * existing `.md` used to hand the markdown file the Word bytes and break the
7
+ * page. Every surface — the sidebar rename box, a drag onto a folder, the
8
+ * agent's `move_file` and `copy_file` — refuses with this one sentence, so a
9
+ * user who meets it in the sidebar and an agent that meets it in a tool
10
+ * result are told the same thing.
11
+ *
12
+ * Kept here, beside `filename.ts`, for the same reason that file is: the
13
+ * backend throws it and the frontend renders it, and neither should spell it
14
+ * its own way.
15
+ */
16
+
17
+ /** What is already sitting at the destination. */
18
+ export type ExistingEntryKind = 'file' | 'folder';
19
+
20
+ /**
21
+ * `A file named <name> already exists in <folder>.` — `name` and `folder` are
22
+ * read off `destinationPath`, which may be workspace-relative or
23
+ * repo-relative; only its last two segments are used, so the reader sees the
24
+ * name they typed and the folder they were aiming at rather than a full path.
25
+ * A destination directly at the top of the tree has no folder segment to
26
+ * name, and says `the top level` instead.
27
+ */
28
+ export function entryExistsMessage(kind: ExistingEntryKind, destinationPath: string): string {
29
+ const segments = destinationPath
30
+ .replace(/\\/g, '/')
31
+ .split('/')
32
+ .filter((segment) => segment.length > 0 && segment !== '.');
33
+ const name = segments[segments.length - 1] ?? destinationPath;
34
+ const folder = segments.length > 1 ? segments[segments.length - 2] : 'the top level';
35
+ return `A ${kind} named ${name} already exists in ${folder}.`;
36
+ }
@@ -96,6 +96,37 @@ export function validateRelativePath(relativePath: string): string | null {
96
96
  return null;
97
97
  }
98
98
 
99
+ /**
100
+ * ONE identity per file, so everything that coordinates on a path agrees about
101
+ * what it is coordinating on: an in-process queue, a database lock row, and
102
+ * the bytes on disk. {@link validateRelativePath} accepts a leading `./` and
103
+ * repeated slashes as spellings of the same path, and two callers spelling one
104
+ * file differently would otherwise take two different locks and write over
105
+ * each other. `.` and `..` segments are refused outright by the validator, so
106
+ * there is nothing to resolve here beyond the separators.
107
+ *
108
+ * Case is deliberately left alone. The deployment target is Linux, where
109
+ * `Foo.md` and `foo.md` are two different files; folding case to suit a
110
+ * case-insensitive development machine would merge two real files in
111
+ * production, which is a worse failure than the race it would close.
112
+ */
113
+ export function canonicalRelativePath(relativePath: string): string {
114
+ // Never LAUNDER a path. Dropping empty segments would turn the absolute
115
+ // `/etc/passwd` into the perfectly ordinary `etc/passwd`, and an absolute
116
+ // path is exactly what `path.resolve` lets win over the workspace directory
117
+ // — which is why the workspace-boundary check refuses it today. A path this
118
+ // cannot canonicalise is returned UNCHANGED, so every gate downstream sees
119
+ // what the caller actually sent and goes on refusing it.
120
+ if (relativePath.startsWith('/') || validateRelativePath(relativePath) !== null) {
121
+ return relativePath;
122
+ }
123
+ return relativePath
124
+ .replace(/^\.\//, '')
125
+ .split('/')
126
+ .filter((segment) => segment.length > 0)
127
+ .join('/');
128
+ }
129
+
99
130
  /**
100
131
  * Throwing wrapper for the backend service layer — keeps call sites a single
101
132
  * line and produces a clear `Error` the route handler can surface as a 400.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which files can carry their OWN frontmatter — and therefore their own
3
+ * per-file access rules.
4
+ *
5
+ * A file-level grant, revoke or restriction splices a `---` YAML block into
6
+ * the file itself. That is right for a Markdown note and destructive for
7
+ * anything else: spliced into a PDF, a presentation or an image it corrupts
8
+ * the bytes (the preview goes blank), and the grant never even resolves.
9
+ * Such a file takes its folder's rules instead.
10
+ *
11
+ * THE ONE predicate for that question: the access routes refuse a file-level
12
+ * mutation on a file it rejects, and the Manage access dialog shows the folder
13
+ * pointer for the same files.
14
+ *
15
+ * The carriers are exactly the files the access resolver reads its own
16
+ * frontmatter from — the backend's core access-frontmatter extension set:
17
+ * Markdown notes (`.md`) and `.tool` definitions (whole-document YAML whose
18
+ * access verbs sit beside the definition). Both are served by the file-reader
19
+ * registry's text-editable text reader. The registry test pins both facts, so
20
+ * this list cannot drift from the resolver or the registry silently. (Neither
21
+ * can live here: the registry's document readers carry backend-only extraction
22
+ * dependencies, and overlays register further extensions at boot — the backend
23
+ * passes its full registered set as `extensions`.)
24
+ *
25
+ * Matching is case-SENSITIVE, as the resolver's is: `Note.MD` carries no
26
+ * enforced rule, so a grant written there would never resolve. An extensionless
27
+ * file is NOT a carrier either: its content may be anything, and a path alone
28
+ * cannot tell a note from a binary.
29
+ */
30
+ export const FRONTMATTER_CARRIER_EXTENSIONS: readonly string[] = ['.md', '.tool'];
31
+
32
+ /**
33
+ * True when the file at `path` can carry its own frontmatter (and so its own
34
+ * access rules). `extensions` defaults to the core set; the backend passes the
35
+ * resolver's registered set so an overlay's kinds count too.
36
+ */
37
+ export function canCarryFrontmatter(
38
+ path: string,
39
+ extensions: readonly string[] = FRONTMATTER_CARRIER_EXTENSIONS,
40
+ ): boolean {
41
+ const name = path.slice(path.lastIndexOf('/') + 1);
42
+ // The resolver's own rule is a plain suffix match, so a file named exactly
43
+ // `.md` is read for its frontmatter there — and must be a carrier here too,
44
+ // or a rule the resolver honours could not be managed.
45
+ return extensions.some((ext) => name.endsWith(ext));
46
+ }
47
+
48
+ /** The `kind` a refused file-level access mutation answers with. */
49
+ export const FOLDER_GOVERNS_ACCESS_KIND = 'folder-governs-access';
50
+
51
+ /**
52
+ * The one sentence both the 422 and the dialog say, naming the folder whose
53
+ * rules govern the file.
54
+ */
55
+ export function folderGovernsAccessMessage(folder: string): string {
56
+ return `This file's access comes from its folder. Manage access on ${folder} instead.`;
57
+ }
@@ -1,14 +1,60 @@
1
+ /**
2
+ * THE fence rule: a line is a frontmatter fence when, whitespace aside, it is
3
+ * exactly `---`. Forgiving on purpose — an opening fence with a trailing space
4
+ * or an indented fence is still a fence.
5
+ *
6
+ * Every reader in the platform asks this one function. The access model's
7
+ * line scan always judged fences this way while the splitter below demanded
8
+ * `---` at column 0, so a file written with a near-miss fence had access
9
+ * rules that applied while the catalog, the tool manuals and the frontmatter
10
+ * panel saw no frontmatter at all. One rule, asked everywhere, is what keeps
11
+ * a file from meaning two things.
12
+ */
13
+ export function isFrontmatterFence(line: string | undefined): boolean {
14
+ return line?.trim() === '---';
15
+ }
16
+
1
17
  /**
2
18
  * The ONE `---` frontmatter splitter, shared by backend and frontend so no file
3
- * type grows its own regex. Splits a leading `---`-fenced YAML block from the
4
- * body; null when the text doesn't open with a fence. Parsing the YAML inside is
5
- * the caller's concern (the access-control resolver deliberately keeps its own
6
- * hardened line-based reader — see `modules/access/access-splice.ts`).
19
+ * type grows its own reader. Splits a leading fenced YAML block from the body;
20
+ * null when the first line is not a fence or no later line closes it. Fences
21
+ * are judged by {@link isFrontmatterFence}. Parsing the YAML inside is the
22
+ * caller's concern (the access model keeps its own line scan, on the same
23
+ * fence rule, because a splice must put bytes back exactly as it found them).
24
+ *
25
+ * `frontmatter` is the raw text between the fence lines and `body` the raw
26
+ * text after the closing fence's line break, both byte for byte — line
27
+ * endings included — so a caller that rebuilds the file changes nothing it
28
+ * did not mean to.
7
29
  */
8
30
  export function extractFrontmatter(text: string): { frontmatter: string; body: string } | null {
9
- const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
10
- if (!m) return null;
11
- return { frontmatter: m[1], body: m[2] };
31
+ // Walk the lines by offset rather than splitting, so both halves can be
32
+ // sliced out of the original text with their own line endings intact.
33
+ let start = 0;
34
+ let lineIndex = 0;
35
+ let openEnd = -1; // offset just past the opening fence's line break
36
+ while (start <= text.length) {
37
+ const nl = text.indexOf('\n', start);
38
+ const end = nl === -1 ? text.length : nl;
39
+ const line = text.slice(start, end).replace(/\r$/, '');
40
+ const next = nl === -1 ? text.length + 1 : nl + 1;
41
+ if (lineIndex === 0) {
42
+ // A lone `---` with nothing after it opens nothing.
43
+ if (!isFrontmatterFence(line) || nl === -1) return null;
44
+ openEnd = next;
45
+ } else if (isFrontmatterFence(line)) {
46
+ // The frontmatter ends before the line break that precedes this fence.
47
+ const fmEnd = start - (start >= 2 && text[start - 2] === '\r' ? 2 : 1);
48
+ return {
49
+ frontmatter: fmEnd > openEnd ? text.slice(openEnd, fmEnd) : '',
50
+ body: next > text.length ? '' : text.slice(next),
51
+ };
52
+ }
53
+ if (nl === -1) return null; // never closed
54
+ start = next;
55
+ lineIndex += 1;
56
+ }
57
+ return null;
12
58
  }
13
59
 
14
60
  /**
@@ -37,7 +83,10 @@ export function setFrontmatterField(text: string, key: string, value: string): s
37
83
  // No frontmatter — prepend a fresh block, keeping the original body intact.
38
84
  return `---${eol}${line}${eol}---${eol}${text}`;
39
85
  }
40
- const fmLines = fm.frontmatter.split(/\r?\n/);
86
+ // An empty block has no lines, not one empty line: splitting '' would give
87
+ // [''], and the inserted key would be followed by a blank line before the
88
+ // closing fence.
89
+ const fmLines = fm.frontmatter === '' ? [] : fm.frontmatter.split(/\r?\n/);
41
90
  const idx = fmLines.findIndex((l) => keyRe.test(l));
42
91
  if (idx >= 0) fmLines[idx] = line;
43
92
  else fmLines.unshift(line);