@bevel-software/platform-shared 0.14.0 → 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 +75 -2
  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 +39 -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 +137 -5
  15. package/dist/workflow/interface.d.ts.map +1 -1
  16. package/dist/workflow/types.d.ts +93 -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 +37 -3
  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 +365 -19
  43. package/dist/workspace/kb-layout.d.ts.map +1 -1
  44. package/dist/workspace/kb-layout.js +619 -20
  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 +81 -3
  60. package/src/index.ts +6 -0
  61. package/src/workflow/events.ts +40 -3
  62. package/src/workflow/interface.ts +162 -6
  63. package/src/workflow/types.ts +70 -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 +38 -3
  68. package/src/workspace/frontmatter-carriers.ts +57 -0
  69. package/src/workspace/frontmatter.ts +57 -8
  70. package/src/workspace/kb-layout.ts +660 -24
  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
@@ -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
+ }
@@ -23,9 +23,13 @@ const WINDOWS_RESERVED_NAMES = new Set([
23
23
 
24
24
  /** Characters Windows forbids in any filename component. `/` is the path
25
25
  * separator on Unix, included for the same reason. `\` is also a path
26
- * separator on Windows. Control chars (0x00–0x1F) are rejected separately. */
26
+ * separator on Windows. Control characters are rejected with them: NUL and
27
+ * 0x01–0x1F because Windows refuses them, and DEL (0x7F) — which every
28
+ * filesystem would write — as a matter of hygiene: a name carrying a
29
+ * character nobody can see or type is not a name people can share, and the
30
+ * knowledge-base root names are validated by this same rule. */
27
31
  // eslint-disable-next-line no-control-regex
28
- const FORBIDDEN_CHARS = /[<>:"/\\|?*\x00-\x1F]/;
32
+ const FORBIDDEN_CHARS = /[<>:"/\\|?*\x00-\x1F\x7F]/;
29
33
 
30
34
  /** Per-component byte limit: NTFS = 255 UTF-16 units, ext4 = 255 bytes,
31
35
  * APFS = 255 UTF-8 bytes. Use UTF-8 bytes — the strictest of the three. */
@@ -45,7 +49,7 @@ export function validateFilename(name: string): string | null {
45
49
  if (name === '.' || name === '..') return 'Name cannot be "." or ".."';
46
50
 
47
51
  if (FORBIDDEN_CHARS.test(name)) {
48
- return 'Name cannot contain any of these characters: < > : " / \\ | ? *';
52
+ return 'Name cannot contain control characters or any of: < > : " / \\ | ? *';
49
53
  }
50
54
 
51
55
  // Windows trims trailing dots and spaces silently — a name ending in either
@@ -92,6 +96,37 @@ export function validateRelativePath(relativePath: string): string | null {
92
96
  return null;
93
97
  }
94
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
+
95
130
  /**
96
131
  * Throwing wrapper for the backend service layer — keeps call sites a single
97
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);