beatrina 0.8.6 → 0.9.43

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 (112) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +156 -26
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +271 -35
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
@@ -0,0 +1,104 @@
1
+ # session-documents.R — which documents a session has open, held by the kernel.
2
+ #
3
+ # Why the kernel, and not the page's storage: every session of one build is
4
+ # served by the SAME notebook file, and the page remembers its open documents
5
+ # per file (lib/open-documents.js). So two sessions share one record, and the
6
+ # later one overwrites the earlier — reopening a session from the menu helper
7
+ # brought back its R (40 objects, 1.9 GB) in front of an empty notebook
8
+ # (owner, 2026-09-14: "it has the objects but not the file").
9
+ #
10
+ # The session is the thing that has an identity here, so the session keeps the
11
+ # list. The page reports it (`page-documents`); a page that attaches to a
12
+ # running session with nobody else on it asks for it back (`session-documents`)
13
+ # and reopens each file. Only paths, names and formats travel — never text:
14
+ # unsaved edits stay in the page that made them, and a document that was never
15
+ # saved is listed by name so the page can SAY it cannot come back.
16
+ #
17
+ # Pure functions; spike/serve.R holds the state. Pinned by
18
+ # spike/test-session-documents.R.
19
+
20
+ SESSION_DOCUMENTS_MAX <- 64L
21
+ SESSION_DOCUMENT_FORMATS <- c("qmd", "Rmd", "md", "carmd")
22
+
23
+ #' Validate a page's report into a clean list of documents.
24
+ #'
25
+ #' Anything malformed is dropped row by row, never the whole report: one odd
26
+ #' row must not cost the session the record of the others.
27
+ #'
28
+ #' @param documents What jsonlite made of the frame's `documents` field
29
+ #' (simplifyVector = TRUE: a data.frame, a list, or NULL).
30
+ #' @return A list of `list(path, name, format, active)`; `path` is "" for a
31
+ #' document that has no file yet. At most SESSION_DOCUMENTS_MAX rows.
32
+ session_documents_clean <- function(documents) {
33
+ if (is.null(documents) || !length(documents)) return(list())
34
+ rows <- if (is.data.frame(documents)) {
35
+ lapply(seq_len(nrow(documents)), \(i) as.list(documents[i, , drop = FALSE]))
36
+ } else if (is.list(documents)) {
37
+ documents
38
+ } else {
39
+ return(list())
40
+ }
41
+ one <- function(row) {
42
+ if (!is.list(row)) return(NULL)
43
+ text_of <- function(value, cap) {
44
+ if (!is.character(value) || length(value) != 1L || is.na(value)) return(NULL)
45
+ value <- gsub("[[:cntrl:]]", " ", value)
46
+ if (nchar(value) > cap) return(NULL)
47
+ value
48
+ }
49
+ path <- text_of(row$path %||% "", 4096L)
50
+ name <- text_of(row$name %||% "", 200L)
51
+ format <- text_of(row$format %||% "", 16L)
52
+ if (is.null(path) || is.null(name) || is.null(format)) return(NULL)
53
+ # A path is reopened through the file ops, which resolve it themselves;
54
+ # here it need only be absolute, so a relative string cannot be read
55
+ # against whatever the worker's cwd is by then.
56
+ if (nzchar(path) && !grepl("^(/|[A-Za-z]:[/\\\\])", path)) return(NULL)
57
+ if (!nzchar(path) && !nzchar(trimws(name))) return(NULL)
58
+ if (!format %in% SESSION_DOCUMENT_FORMATS) format <- "qmd"
59
+ list(path = path, name = trimws(name), format = format,
60
+ active = isTRUE(row$active))
61
+ }
62
+ cleaned <- Filter(Negate(is.null), lapply(rows, one))
63
+ # The same file listed twice is one document.
64
+ paths <- vapply(cleaned, \(row) row$path, character(1))
65
+ keep <- !nzchar(paths) | !duplicated(paths)
66
+ head(cleaned[keep], SESSION_DOCUMENTS_MAX)
67
+ }
68
+
69
+ #' The name a session goes by: its document, not its generated notebook name.
70
+ #'
71
+ #' Flattened for the runtime record, which helper-sessions.sh reads with a
72
+ #' bounded sed — a quote or a backslash would cut every field after it.
73
+ #'
74
+ #' @param documents A list from session_documents_clean().
75
+ #' @return "" when there are none; else the on-screen document's name (the
76
+ #' first one when none is marked), then " + N more" for the rest.
77
+ session_documents_label <- function(documents) {
78
+ if (!length(documents)) return("")
79
+ active <- Filter(\(row) isTRUE(row$active), documents)
80
+ lead <- if (length(active)) active[[1L]] else documents[[1L]]
81
+ name <- if (nzchar(lead$path)) basename(lead$path) else lead$name
82
+ if (!nzchar(lead$path)) name <- sprintf("%s (unsaved)", name)
83
+ rest <- length(documents) - 1L
84
+ label <- if (rest > 0L) sprintf("%s + %d more", name, rest) else name
85
+ trimws(substr(gsub("[\"\\\\]", "'", gsub("[[:cntrl:]]", " ", label)), 1L, 120L))
86
+ }
87
+
88
+ #' Serialise for a successor supervisor's environment (a session handoff).
89
+ #' @param documents A list from session_documents_clean().
90
+ #' @return A JSON string; "" when there are none.
91
+ session_documents_encode <- function(documents) {
92
+ if (!length(documents)) return("")
93
+ as.character(jsonlite::toJSON(documents, auto_unbox = TRUE))
94
+ }
95
+
96
+ #' Read back what session_documents_encode() wrote; junk yields an empty list.
97
+ #' @param text A JSON string (possibly "").
98
+ #' @return A list from session_documents_clean().
99
+ session_documents_decode <- function(text) {
100
+ if (!is.character(text) || length(text) != 1L || !nzchar(text)) return(list())
101
+ parsed <- tryCatch(jsonlite::fromJSON(text, simplifyVector = TRUE),
102
+ error = function(e) NULL)
103
+ session_documents_clean(parsed)
104
+ }
@@ -0,0 +1,55 @@
1
+ // ai-authority.mjs — the supervisor's half of the chat authority contract.
2
+ //
3
+ // The page's half is `lib/ai-authority.js`, and the two lists are ONE list in
4
+ // two files for the reason every other pair in this project is (THEME_TOKENS /
5
+ // EDITOR_TOKENS, LATEX_TEMPLATE_KEYS / TEMPLATE_KEYS): the kernel ships `host/`
6
+ // and `spike/` without `lib/`, and no host module imports across that line.
7
+ // `test/ai-chat-authority.test.mjs` reads both files and fails if they drift.
8
+ //
9
+ // WHY THE HOST IS WHERE THIS IS DECIDED. A CLI turn is a process THIS process
10
+ // spawns. The tool list handed to that process is the only gate with no
11
+ // page-side bypass: there is nothing in the browser to work around, because
12
+ // the spawn was never made with those tools in it. The page's copy exists to
13
+ // ASK for a level and to describe the lane honestly; the host's copy is what
14
+ // happens.
15
+ //
16
+ // The level arriving on the wire is a REQUEST, and `normalizeAuthority` fails
17
+ // closed — absent or unrecognised is `read`. An older page that never heard of
18
+ // the field asks for the reading tools and gets them; silence never grants the
19
+ // most (fix.md P1.5: "switching providers never silently increases authority"
20
+ // covers switching anything else, too).
21
+
22
+ export const READ_TOOLS = Object.freeze([
23
+ "beatrina_status", "notebook_read", "chunk_read", "file_list", "file_read",
24
+ ]);
25
+
26
+ export const WRITE_TOOLS = Object.freeze([
27
+ "chunk_insert", "chunk_update", "chunk_run",
28
+ ]);
29
+
30
+ export const AUTHOR_TOOLS = Object.freeze([...READ_TOOLS, ...WRITE_TOOLS]);
31
+
32
+ export const AUTHORITY_LEVELS = Object.freeze(["text", "read", "author"]);
33
+
34
+ /** Read a level off the wire, failing closed. */
35
+ export function normalizeAuthority(value) {
36
+ const asked = String(value || "").toLowerCase();
37
+ return AUTHORITY_LEVELS.includes(asked) ? asked : "read";
38
+ }
39
+
40
+ /** The tools a level may use. `text` gets none. */
41
+ export function toolsFor(authority) {
42
+ if (authority === "author") return AUTHOR_TOOLS.slice();
43
+ if (authority === "read") return READ_TOOLS.slice();
44
+ return [];
45
+ }
46
+
47
+ /** The `--allowedTools` value for a spawned Claude Code turn at this level. */
48
+ export function allowedToolsArg(authority) {
49
+ return toolsFor(authority).map((name) => `mcp__beatrina__${name}`).join(",");
50
+ }
51
+
52
+ export default {
53
+ READ_TOOLS, WRITE_TOOLS, AUTHOR_TOOLS, AUTHORITY_LEVELS,
54
+ normalizeAuthority, toolsFor, allowedToolsArg,
55
+ };
@@ -26,15 +26,16 @@
26
26
  //
27
27
  // ── where a policy comes from ──────────────────────────────────────────────
28
28
  //
29
- // CARMAR_AI_POLICY=/etc/carmar/ai-policy.json a file the user cannot write
30
- // CARMAR_AI_PROVIDERS=anthropic,lmstudio the quick form
31
- // CARMAR_AI_LOCAL_ONLY=1 only on-machine providers
29
+ // BEATRINA_AI_POLICY=/etc/beatrina/ai-policy.json a file the user cannot write
30
+ // BEATRINA_AI_PROVIDERS=anthropic,lmstudio the quick form
31
+ // BEATRINA_AI_LOCAL_ONLY=1 only on-machine providers
32
32
  //
33
33
  // The file wins where both are set, because a file is the form an administrator
34
34
  // can own while an environment variable is inherited from whoever started the
35
35
  // process — on a shared host, that may be the user.
36
36
 
37
37
  import fs from "node:fs";
38
+ import { envReader } from "./env-names.mjs";
38
39
 
39
40
  /**
40
41
  * The providers whose text never leaves the machine.
@@ -54,7 +55,7 @@ export const KEY_PROVIDERS = Object.freeze(["openai", "anthropic", "google", "gr
54
55
  export const CLI_PROVIDERS = Object.freeze(["claudecode", "codex"]);
55
56
  export const ALL_PROVIDERS = Object.freeze([...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS]);
56
57
 
57
- const sysEnv = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
58
+ const sysEnv = envReader(process.env); // dual-read: BEATRINA_AI_POLICY or its older spelling (host/env-names.mjs)
58
59
  const asList = (v) => (v == null ? [] : (Array.isArray(v) ? v : [v]));
59
60
 
60
61
  /**
@@ -101,6 +102,12 @@ export function normaliseBaseUrls(raw) {
101
102
  return { values, errors };
102
103
  }
103
104
 
105
+ // Every key this file reads. A policy carrying anything else is a policy
106
+ // whose author meant something the supervisor will not do — `provider` for
107
+ // `providers` was accepted in silence and read as "permit all fifteen"
108
+ // (fix.md P1.4). An unknown key is a refusal, with the key named.
109
+ export const POLICY_KEYS = Object.freeze(["providers", "local_only", "note", "models", "base_urls"]);
110
+
104
111
  const policySplit = (value) => String(value || "").split(/[,\s]+/).map((s) => s.trim()).filter((s) => s.length);
105
112
  // Control characters are 0x00-0x1f and 0x7f — R's [[:cntrl:]], spelled out.
106
113
  // eslint-disable-next-line no-control-regex
@@ -115,12 +122,13 @@ const stripControl = (s) => String(s).replace(/[\u0000-\u001f\u007f]/g, " ");
115
122
  * named nothing / named something valid / named only junk — and conflating the
116
123
  * last two is the fail-open bug.
117
124
  */
118
- export function carmarAiPolicy(env = sysEnv) {
119
- const file = String(env("CARMAR_AI_POLICY", "")).trim();
125
+ export function beatrinaAiPolicy(env = sysEnv) {
126
+ const file = String(env("BEATRINA_AI_POLICY", "")).trim();
120
127
  let named = [];
121
128
  let models = {};
122
129
  let baseUrls = {};
123
- let localOnly = env("CARMAR_AI_LOCAL_ONLY", "") === "1";
130
+ let localOnly = env("BEATRINA_AI_LOCAL_ONLY", "") === "1";
131
+ let namedKeyPresent = false;
124
132
  let note = "";
125
133
  let source = "";
126
134
  let errors = [];
@@ -128,13 +136,25 @@ export function carmarAiPolicy(env = sysEnv) {
128
136
  if (file.length) {
129
137
  source = file;
130
138
  if (!fs.existsSync(file)) {
131
- errors.push(`CARMAR_AI_POLICY names a file that does not exist: ${file}`);
139
+ errors.push(`BEATRINA_AI_POLICY names a file that does not exist: ${file}`);
132
140
  } else {
133
141
  let doc = null;
134
142
  let parseError = "";
135
143
  try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { parseError = e.message; }
136
- if (parseError) errors.push(`CARMAR_AI_POLICY is not valid JSON: ${parseError}`);
137
- else {
144
+ if (parseError) errors.push(`BEATRINA_AI_POLICY is not valid JSON: ${parseError}`);
145
+ else if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
146
+ errors.push("BEATRINA_AI_POLICY must be a JSON object of policy keys.");
147
+ } else {
148
+ const unknownKeys = Object.keys(doc).filter((k) => !POLICY_KEYS.includes(k));
149
+ if (unknownKeys.length) {
150
+ errors.push(`AI policy: ${unknownKeys.map((k) => `'${k}'`).join(", ")} `
151
+ + `${unknownKeys.length === 1 ? "is not a policy key" : "are not policy keys"}. `
152
+ + `A policy may name: ${POLICY_KEYS.join(", ")}. A key nobody reads permits everything, which is never what the file meant.`);
153
+ }
154
+ // "the key is present" and "the key names something" are different
155
+ // facts: `providers: []` used to read as "said nothing", which the
156
+ // default answers with every provider.
157
+ namedKeyPresent = Object.prototype.hasOwnProperty.call(doc, "providers");
138
158
  named = asList(doc && doc.providers).flat().map((v) => String(v));
139
159
  if (doc && doc.local_only === true) localOnly = true;
140
160
  note = String(asList(doc && doc.note).flat()[0] ?? "").trim();
@@ -148,20 +168,34 @@ export function carmarAiPolicy(env = sysEnv) {
148
168
  errors = errors.concat(endpoints.errors);
149
169
  }
150
170
  }
151
- } else if (String(env("CARMAR_AI_PROVIDERS", "")).length) {
171
+ } else if (String(env("BEATRINA_AI_PROVIDERS", "")).length) {
152
172
  source = "env";
153
- named = policySplit(env("CARMAR_AI_PROVIDERS", ""));
173
+ named = policySplit(env("BEATRINA_AI_PROVIDERS", ""));
154
174
  } else if (localOnly) {
155
175
  source = "env";
156
176
  }
157
177
 
158
178
  const unknown = named.filter((n) => !ALL_PROVIDERS.includes(n));
159
- const asked = named.length > 0; // did the policy NAME anything at all?
179
+ // ASKED means the policy spoke about providers at all — including an empty
180
+ // list, which speaks and names nothing. Only silence inherits the default.
181
+ const asked = named.length > 0 || namedKeyPresent;
160
182
  named = named.filter((n) => ALL_PROVIDERS.includes(n));
161
183
 
162
184
  let providers = named.length ? named : (asked ? [] : [...ALL_PROVIDERS]);
163
185
  if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
164
186
 
187
+ // local_only is a promise about where the words GO, so an endpoint that
188
+ // leaves the machine contradicts it — and an on-machine provider pointed at
189
+ // a hosted gateway is exactly how that happens without looking wrong.
190
+ if (localOnly) {
191
+ Object.entries(baseUrls).forEach(([provider, url]) => {
192
+ if (!LOOPBACK_RE.test(url)) {
193
+ errors.push(`AI policy: local_only is set, but base_urls.${provider} is ${url}, which leaves this machine. `
194
+ + "Point it at loopback, or drop local_only.");
195
+ }
196
+ });
197
+ }
198
+
165
199
  let set = source.length > 0 && errors.length === 0;
166
200
  if (set && !providers.length) {
167
201
  errors.push(`The AI policy permits no provider at all${
@@ -193,18 +227,6 @@ export function carmarAiPolicy(env = sysEnv) {
193
227
  export const aiProviderAllowed = (provider, policy) =>
194
228
  policy.set !== true || (typeof provider === "string" && provider.length > 0 && policy.providers.includes(provider));
195
229
 
196
- /**
197
- * May this model be used with this provider? TRUE when no models are pinned for
198
- * that provider — pinning is opt-in per provider. DISCLOSURE ONLY: a model id
199
- * is a field in a request body the page builds and sends itself, so there is no
200
- * door here to close.
201
- */
202
- export const aiModelAllowed = (provider, model, policy) => {
203
- if (policy.set !== true) return true;
204
- const allowed = policy.models[provider || ""];
205
- if (!allowed || !allowed.length) return true;
206
- return typeof model === "string" && model.length > 0 && allowed.includes(model);
207
- };
208
230
 
209
231
  /**
210
232
  * The sentence the notebook shows when something is refused. Names the
@@ -213,6 +235,6 @@ export const aiModelAllowed = (provider, model, policy) => {
213
235
  * message.
214
236
  */
215
237
  export const aiPolicyReason = (policy) => {
216
- const base = `This CarmaR is configured to allow only: ${policy.providers.join(", ")}.`;
238
+ const base = `This Beatrina is configured to allow only: ${policy.providers.join(", ")}.`;
217
239
  return policy.note.length ? `${base} ${policy.note}` : base;
218
240
  };
@@ -0,0 +1,194 @@
1
+ // bundle.mjs — `.btrn`, the Beatrina document that travels (docs/results-plan.md R2).
2
+ //
3
+ // A standard ZIP (host/zip.mjs) holding the document, its results and the files
4
+ // it needs — "transfer the whole document like carmnote" (owner, 2026-09-19): // beatrina-names: keep
5
+ //
6
+ // manifest.json format, version, app, created, title, what is inside
7
+ // (every entry: size + SHA-256), and the data: its mode
8
+ // and every file's path, size and SHA-256
9
+ // <name>.qmd the document, plain text (its own .Rmd/.md if it was one)
10
+ // results/results.json the beatrina-results/1 record (lib/document-results.js)
11
+ // results/figures/* the record's figures, stored (PNG is compressed already)
12
+ // data/<relative path> every file the document needs, deflated, at the path it
13
+ // uses: data, bibliography, citation style, images, included
14
+ // files, project settings (lib/bundle.js documentFiles)
15
+ //
16
+ // NO HISTORY, by the owner's decision (2026-09-19): Undo history holds every
17
+ // keystroke, deleted text included — a document sent to someone must not carry
18
+ // what its author removed. A CarmNote carries none either; the author's own
19
+ // history stays in their session's journal.
20
+ //
21
+ // DATA ≤ 100 MB (compressed, in total) travels inside. Past that it goes BESIDE
22
+ // the bundle, in `<name>.btrn-data/` at the same relative paths, and the manifest
23
+ // keeps each file's size and SHA-256 so the receiver can tell a missing or a
24
+ // changed file by name. A folder, not a second zip: big data stays usable as it
25
+ // is, and ZIP's 4 GB ceiling never comes up.
26
+ //
27
+ // Pure where it can be: buildBundle / readBundle take bytes and return bytes or
28
+ // parts; the plane (host/planes/bundle.mjs) does the file system and the doors.
29
+
30
+ import crypto from "node:crypto";
31
+ import path from "node:path";
32
+ import { writeZip, readZip, listZip, safeEntryName } from "./zip.mjs";
33
+
34
+ export const BUNDLE_FORMAT = "beatrina-bundle";
35
+ export const BUNDLE_VERSION = 1;
36
+ export const BUNDLE_EXT = ".btrn";
37
+ /** Data travels inside up to this many compressed bytes; past it, beside. */
38
+ export const DATA_INSIDE_MAX = 100 * 1024 * 1024;
39
+ /** Reading: the whole archive may not expand past this. */
40
+ export const BUNDLE_READ_MAX = 2 * 1024 ** 3;
41
+
42
+ export const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
43
+ const fail = (code, message) => Object.assign(new Error(message), { code });
44
+
45
+ /** `/w/paper.btrn` → `/w/paper.btrn-data` — where beside-mode data lives. */
46
+ export const dataFolderFor = (bundlePath) => `${bundlePath}-data`;
47
+
48
+ /** A data path the bundle may carry: relative, safe, a file (not a folder). */
49
+ export function safeDataPath(rel) {
50
+ const r = String(rel ?? "").replace(/\\/g, "/");
51
+ return safeEntryName(r) && !r.endsWith("/") && r.length <= 512;
52
+ }
53
+
54
+ /**
55
+ * Build a .btrn.
56
+ *
57
+ * @param {object} parts
58
+ * @param {string} parts.source the document's text
59
+ * @param {string} parts.sourceName its name inside, e.g. "paper.qmd"
60
+ * @param {string} [parts.title]
61
+ * @param {string|null} [parts.results] the results record, JSON text (figures external)
62
+ * @param {Array<{name:string, data:Buffer, mime?:string}>} [parts.figures] results/figures/<name>
63
+ * @param {Array<{path:string, bytes:Buffer}>} [parts.data] files the code reads, by relative path
64
+ * @param {string} [parts.app] the app version that wrote it
65
+ * @param {Date} [parts.created]
66
+ * @param {number} [parts.insideMax] DATA_INSIDE_MAX unless a test says otherwise
67
+ * @param {string} [parts.bundleName] the .btrn's own file name, for the beside folder's name
68
+ * @returns {{zip:Buffer, manifest:object, beside:Array<{path:string, bytes:Buffer}>}}
69
+ * `beside` is the data to write in <bundle>-data/ when it did not fit inside
70
+ */
71
+ export function buildBundle(parts) {
72
+ const {
73
+ source, sourceName, title = "", results = null, figures = [],
74
+ data = [], app = "", created = new Date(), insideMax = DATA_INSIDE_MAX, bundleName = "",
75
+ } = parts || {};
76
+ if (typeof source !== "string") throw fail("bundle_input", "a bundle needs the document's source");
77
+ if (!/^[^/\\]+\.(qmd|Rmd|rmd|md)$/.test(String(sourceName || ""))) {
78
+ throw fail("bundle_input", `the document inside must be a .qmd, .Rmd or .md file name, not ${JSON.stringify(sourceName)}`);
79
+ }
80
+ const files = [];
81
+ const seen = new Set();
82
+ const add = (name, bytes, compress = true) => {
83
+ if (seen.has(name)) throw fail("bundle_input", `${name} twice`);
84
+ seen.add(name);
85
+ files.push({ name, data: bytes, compress });
86
+ };
87
+ add(sourceName, Buffer.from(source, "utf8"));
88
+ if (results != null) {
89
+ add("results/results.json", Buffer.from(String(results), "utf8"));
90
+ for (const f of figures || []) {
91
+ if (!safeEntryName(`results/figures/${f.name}`) || f.name.includes("/")) throw fail("bundle_input", `bad figure name ${JSON.stringify(f.name)}`);
92
+ add(`results/figures/${f.name}`, f.data, false);
93
+ }
94
+ }
95
+ const dataFiles = (data || []).map((d) => { // each: {path, bytes, kind?}
96
+ const rel = String(d.path).replace(/\\/g, "/");
97
+ if (!safeDataPath(rel)) throw fail("bundle_bad_path", `a data path must be relative and stay inside the document's folder: ${JSON.stringify(d.path)}`);
98
+ return { path: rel, bytes: d.bytes, size: d.bytes.length, sha256: sha256(d.bytes), kind: d.kind || "data" };
99
+ });
100
+
101
+ const manifestFor = (mode, dataEntries) => ({
102
+ format: BUNDLE_FORMAT, version: BUNDLE_VERSION, app, created: created.toISOString(), title,
103
+ source: sourceName,
104
+ entries: dataEntries.concat(files).map((f) => ({ name: f.name, size: f.data.length, sha256: sha256(f.data) })),
105
+ data: {
106
+ mode, ...(mode === "beside" ? { folder: `${path.basename(bundleName || sourceName.replace(/\.[^.]+$/, ".btrn"))}-data` } : {}),
107
+ files: dataFiles.map(({ path: p, size, sha256: h, kind }) => ({ path: p, size, sha256: h, kind })),
108
+ },
109
+ });
110
+ const zipWith = (manifest, dataEntries) => writeZip([
111
+ { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest, null, 1), "utf8") },
112
+ ...files, ...dataEntries,
113
+ ], { date: created });
114
+
115
+ // Inside first; measure what the data costs compressed, and move it beside
116
+ // only when it passes the cap.
117
+ const inside = dataFiles.map((d) => ({ name: `data/${d.path}`, data: d.bytes, compress: true }));
118
+ let zip = zipWith(manifestFor(dataFiles.length ? "inside" : "none", inside), inside);
119
+ const dataBytes = listZip(zip).filter((e) => e.name.startsWith("data/")).reduce((a, e) => a + e.csize, 0);
120
+ if (dataBytes <= insideMax) {
121
+ return { zip, manifest: manifestFor(dataFiles.length ? "inside" : "none", inside), beside: [] };
122
+ }
123
+ const manifest = manifestFor("beside", []);
124
+ zip = zipWith(manifest, []);
125
+ return { zip, manifest, beside: dataFiles.map((d) => ({ path: d.path, bytes: d.bytes })), dataCompressed: dataBytes };
126
+ }
127
+
128
+ /**
129
+ * Read a .btrn: every part, VERIFIED. The manifest's SHA-256 for each entry is
130
+ * checked, and an entry the manifest does not list is refused — nothing is
131
+ * half-loaded.
132
+ *
133
+ * @param {Buffer} buf
134
+ * @returns {{manifest:object, source:string, sourceName:string, results:string|null,
135
+ * figures:Map<string,Buffer>, data:Map<string,Buffer>}}
136
+ */
137
+ export function readBundle(buf, { maxTotal = BUNDLE_READ_MAX } = {}) {
138
+ const all = readZip(buf, { maxTotal });
139
+ const rawManifest = all.get("manifest.json");
140
+ if (!rawManifest) throw fail("bundle_no_manifest", "not a Beatrina document (no manifest.json)");
141
+ let manifest;
142
+ try { manifest = JSON.parse(rawManifest.toString("utf8")); } catch { throw fail("bundle_bad_manifest", "manifest.json is not JSON"); }
143
+ if (manifest.format !== BUNDLE_FORMAT) throw fail("bundle_bad_manifest", `not a Beatrina document (format ${JSON.stringify(manifest.format)})`);
144
+ if (!(Number(manifest.version) >= 1)) throw fail("bundle_bad_manifest", "the manifest names no version");
145
+ if (Number(manifest.version) > BUNDLE_VERSION) {
146
+ throw fail("bundle_newer", `this document was written by a newer Beatrina (bundle version ${manifest.version}); update Beatrina to open it`);
147
+ }
148
+ const listed = new Map((manifest.entries || []).map((e) => [e.name, e]));
149
+ for (const [name, bytes] of all) {
150
+ if (name === "manifest.json") continue;
151
+ const want = listed.get(name);
152
+ if (!want) throw fail("bundle_unlisted", `${name} is in the archive but not in its manifest`);
153
+ if (want.size !== bytes.length || want.sha256 !== sha256(bytes)) throw fail("bundle_digest", `${name} does not match its manifest — the document is damaged`);
154
+ }
155
+ for (const name of listed.keys()) if (!all.has(name)) throw fail("bundle_missing", `the manifest lists ${name}, which is not in the archive`);
156
+ const sourceName = String(manifest.source || "");
157
+ if (!all.has(sourceName)) throw fail("bundle_missing", "the document itself is missing");
158
+ const figures = new Map();
159
+ const data = new Map();
160
+ for (const [name, bytes] of all) {
161
+ if (name.startsWith("results/figures/")) figures.set(name.slice("results/figures/".length), bytes);
162
+ else if (name.startsWith("data/")) data.set(name.slice("data/".length), bytes);
163
+ }
164
+ return {
165
+ manifest, sourceName, source: all.get(sourceName).toString("utf8"),
166
+ results: all.has("results/results.json") ? all.get("results/results.json").toString("utf8") : null,
167
+ figures, data,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * What opening a bundle would do to each data file, pure: compare the
173
+ * manifest's digest with the file at the document's folder.
174
+ *
175
+ * @param {object} manifest
176
+ * @param {(rel:string) => (Buffer|null)} readLocal the file beside the document, or null
177
+ * @returns {Array<{path:string, size:number, kind:string, state:'absent'|'same'|'differs'}>}
178
+ */
179
+ export function dataPlan(manifest, readLocal) {
180
+ return ((manifest && manifest.data && manifest.data.files) || []).map((f) => {
181
+ const local = readLocal(f.path);
182
+ const state = !local ? "absent" : sha256(local) === f.sha256 ? "same" : "differs";
183
+ return { path: f.path, size: f.size, kind: f.kind || "data", state };
184
+ });
185
+ }
186
+
187
+ /** `paper.btrn` → `paper.qmd` — the name the document takes inside. */
188
+ export function innerNameFor(bundlePath, format = "qmd") {
189
+ const stem = path.basename(String(bundlePath || "document")).replace(/\.btrn$/i, "") || "document";
190
+ const ext = { qmd: "qmd", rmd: "Rmd", md: "md" }[String(format).toLowerCase()] || "qmd";
191
+ return `${stem}.${ext}`;
192
+ }
193
+
194
+ export default { buildBundle, readBundle, dataPlan, innerNameFor, dataFolderFor, safeDataPath, sha256 };
@@ -13,6 +13,8 @@
13
13
  // client is not a browser and therefore is this same OS user; anywhere the port
14
14
  // is reachable by someone else the same header proves nothing, so it is refused.
15
15
 
16
+ import { envReader } from "./env-names.mjs";
17
+
16
18
  export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "[::1]", "localhost"];
17
19
 
18
20
  /** Split a comma/space-separated environment value into trimmed, non-empty parts. */
@@ -45,8 +47,8 @@ export function isLoopbackBind(bind) {
45
47
  * `errors` is EMPTY when the posture is safe to serve. A non-empty `errors`
46
48
  * must abort startup; it is never a warning.
47
49
  */
48
- export function carmarDeployment(port, env = envFromProcess()) {
49
- let bind = env("CARMAR_BIND", "127.0.0.1").trim();
50
+ export function beatrinaDeployment(port, env = envFromProcess()) {
51
+ let bind = env("BEATRINA_BIND", "127.0.0.1").trim();
50
52
  if (!bind) bind = "127.0.0.1";
51
53
  const loopback = isLoopbackBind(bind);
52
54
 
@@ -54,9 +56,9 @@ export function carmarDeployment(port, env = envFromProcess()) {
54
56
  // the same-origin case. Off loopback the server is behind a proxy, so its own
55
57
  // page arrives with the PROXY's origin — which the operator must name.
56
58
  const selfOrigins = [`http://${bind}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`];
57
- const origins = unique([...selfOrigins, ...splitList(env("CARMAR_ORIGINS", ""))]);
59
+ const origins = unique([...selfOrigins, ...splitList(env("BEATRINA_ORIGINS", ""))]);
58
60
 
59
- let hostsEnv = splitList(env("CARMAR_HOSTS", ""));
61
+ let hostsEnv = splitList(env("BEATRINA_HOSTS", ""));
60
62
  const defaultHosts = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
61
63
  // A configured host may or may not carry a port. Accept both spellings so an
62
64
  // operator writing `stats.example.edu` is not silently refused behind a proxy
@@ -68,31 +70,31 @@ export function carmarDeployment(port, env = envFromProcess()) {
68
70
  // allowing an Origin-less client. Binding off loopback breaks it, but so does
69
71
  // a shared multi-user host or a container whose 127.0.0.1 is forwarded out,
70
72
  // so the posture is declarable in its own right.
71
- const requireOrigin = env("CARMAR_REQUIRE_ORIGIN", "") === "1" || !loopback;
73
+ const requireOrigin = env("BEATRINA_REQUIRE_ORIGIN", "") === "1" || !loopback;
72
74
 
73
- const trustProxy = env("CARMAR_TRUST_PROXY", "") === "1";
74
- const userHeader = env("CARMAR_USER_HEADER", "X-Forwarded-User").trim();
75
- const proxyAddrs = splitList(env("CARMAR_TRUSTED_PROXY", "127.0.0.1 ::1"));
76
- const allowOpen = env("CARMAR_ALLOW_UNAUTHENTICATED", "") === "1";
75
+ const trustProxy = env("BEATRINA_TRUST_PROXY", "") === "1";
76
+ const userHeader = env("BEATRINA_USER_HEADER", "X-Forwarded-User").trim();
77
+ const proxyAddrs = splitList(env("BEATRINA_TRUSTED_PROXY", "127.0.0.1 ::1"));
78
+ const allowOpen = env("BEATRINA_ALLOW_UNAUTHENTICATED", "") === "1";
77
79
 
78
80
  const errors = [];
79
81
  if (!loopback) {
80
82
  if (!hostsEnv.length) {
81
- errors.push(`CARMAR_BIND is ${bind} but CARMAR_HOSTS is unset. Off loopback the `
83
+ errors.push(`BEATRINA_BIND is ${bind} but BEATRINA_HOSTS is unset. Off loopback the `
82
84
  + "Host allow-list is the only defence against DNS rebinding, so it must "
83
- + "name the hostname readers will use (e.g. CARMAR_HOSTS=stats.example.edu).");
85
+ + "name the hostname readers will use (e.g. BEATRINA_HOSTS=stats.example.edu).");
84
86
  }
85
87
  if (!trustProxy && !allowOpen) {
86
- errors.push(`CARMAR_BIND is ${bind} with no authentication in front of it. Put `
88
+ errors.push(`BEATRINA_BIND is ${bind} with no authentication in front of it. Put `
87
89
  + "Beatrina behind a reverse proxy that authenticates and set "
88
- + "CARMAR_TRUST_PROXY=1, or set CARMAR_ALLOW_UNAUTHENTICATED=1 to serve code "
90
+ + "BEATRINA_TRUST_PROXY=1, or set BEATRINA_ALLOW_UNAUTHENTICATED=1 to serve code "
89
91
  + "execution to everyone who can reach this port. See docs/server.md.");
90
92
  }
91
93
  if (trustProxy && !userHeader) {
92
- errors.push("CARMAR_TRUST_PROXY=1 requires a non-empty CARMAR_USER_HEADER.");
94
+ errors.push("BEATRINA_TRUST_PROXY=1 requires a non-empty BEATRINA_USER_HEADER.");
93
95
  }
94
96
  if (trustProxy && !proxyAddrs.length) {
95
- errors.push("CARMAR_TRUST_PROXY=1 requires CARMAR_TRUSTED_PROXY to name the proxy's address.");
97
+ errors.push("BEATRINA_TRUST_PROXY=1 requires BEATRINA_TRUSTED_PROXY to name the proxy's address.");
96
98
  }
97
99
  }
98
100
 
@@ -127,7 +129,7 @@ export function carmarDeployment(port, env = envFromProcess()) {
127
129
  *
128
130
  * @param {{REMOTE_ADDR?: string} & Record<string, string>} req a Rook-shaped
129
131
  * request: `REMOTE_ADDR` plus `HTTP_*` header keys
130
- * @param {ReturnType<typeof carmarDeployment>} deployment
132
+ * @param {ReturnType<typeof beatrinaDeployment>} deployment
131
133
  * @returns {string}
132
134
  */
133
135
  export function proxyUser(req, deployment) {
@@ -150,9 +152,14 @@ export function rookOf(req) {
150
152
  return out;
151
153
  }
152
154
 
153
- /** A getter with `Sys.getenv`'s signature over `process.env`. */
155
+ /**
156
+ * A getter with `Sys.getenv`'s signature over `process.env` — dual-read: a
157
+ * `CARMAR_*` name asked for here is also answered by its `BEATRINA_*` spelling
158
+ * (host/env-names.mjs), so the call sites above keep the vocabulary the
159
+ * deployment vectors in test/host-deployment.test.mjs use.
160
+ */
154
161
  export function envFromProcess(source = process.env) {
155
- return (name, unset = "") => (source[name] == null ? unset : String(source[name]));
162
+ return envReader(source);
156
163
  }
157
164
 
158
165
  function unique(list) {