beatrina 0.8.7 → 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.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// bundle.mjs (plane) — the supervisor's side of `.btrn` (docs/results-plan.md R2).
|
|
2
|
+
//
|
|
3
|
+
// The format is host/bundle.mjs; this is its file system and its doors:
|
|
4
|
+
//
|
|
5
|
+
// bundle_cap {path, mode: read|write} → a one-use capability for GET/PUT /bundle
|
|
6
|
+
// PUT /bundle the page's parts (source, results record + figures, which
|
|
7
|
+
// files, with their kinds) → the supervisor reads the files from disk
|
|
8
|
+
// ITSELF, builds the zip, writes it atomically (temp + rename),
|
|
9
|
+
// and writes beside-mode data to <bundle>-data/
|
|
10
|
+
// GET /bundle → every part, verified, plus what opening would do to each
|
|
11
|
+
// data file (absent / same / differs beside the document)
|
|
12
|
+
// bundle_extract {path, write:[rel], replace:[rel]} → data unpacked next to the
|
|
13
|
+
// bundle; `replace` only for files the person agreed to replace
|
|
14
|
+
// bundle_probe {dir, paths:[rel]} → which of the paths the code names are
|
|
15
|
+
// files, with sizes (the Save dialog's list)
|
|
16
|
+
// bundle_adopt {dir, from, to, replace?} → copy a file from outside the
|
|
17
|
+
// document's folder into it (the packaging dialog's "Copy in")
|
|
18
|
+
// bundle_list {path} → the manifest, read-only
|
|
19
|
+
//
|
|
20
|
+
// Why the supervisor and not the page: data can be 100 MB, and it is already
|
|
21
|
+
// on this machine's disk; the page sends only what it holds. Why not the
|
|
22
|
+
// worker: packing is not evaluation, and a Save must not queue behind a run.
|
|
23
|
+
//
|
|
24
|
+
// Page-only in BOTH senses (role and class) and agent-refused, all but
|
|
25
|
+
// bundle_list: these read and write files at paths, and an agent works on the
|
|
26
|
+
// notebook through its chunks. Every path is absolute, inside BEATRINA_ROOT,
|
|
27
|
+
// and data paths are confined to the bundle's own folder.
|
|
28
|
+
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import os from "node:os";
|
|
32
|
+
import { secureToken, PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
33
|
+
import { makeRoot } from "./files.mjs";
|
|
34
|
+
import { buildBundle, readBundle, dataPlan, dataFolderFor, safeDataPath, sha256, BUNDLE_EXT } from "../bundle.mjs";
|
|
35
|
+
|
|
36
|
+
const PAGE_REFUSAL = "Only the local notebook page may ask this.";
|
|
37
|
+
const isBundle = (p) => typeof p === "string" && p.toLowerCase().endsWith(BUNDLE_EXT);
|
|
38
|
+
|
|
39
|
+
/** `rel` under `dir`, or null when it would leave it (a symlink included). */
|
|
40
|
+
export function confined(dir, rel) {
|
|
41
|
+
if (!safeDataPath(rel)) return null;
|
|
42
|
+
const base = path.resolve(dir);
|
|
43
|
+
const target = path.resolve(base, rel);
|
|
44
|
+
if (target !== base && !target.startsWith(base + path.sep)) return null;
|
|
45
|
+
let real = target;
|
|
46
|
+
try { real = fs.realpathSync(target); } catch { return target; } // absent: the lexical check stands
|
|
47
|
+
let realBase = base;
|
|
48
|
+
try { realBase = fs.realpathSync(base); } catch { /* keep */ }
|
|
49
|
+
return real === realBase || real.startsWith(realBase + path.sep) ? target : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A path the code names, made absolute against the document's folder: `~/…` is the home folder. */
|
|
53
|
+
export function resolveFrom(dir, raw) {
|
|
54
|
+
const p = String(raw || "").replace(/\\/g, "/");
|
|
55
|
+
if (!p || p.length > 4096 || p.includes("\u0000")) return null;
|
|
56
|
+
if (p === "~" || p.startsWith("~/")) return path.join(os.homedir(), p.slice(1));
|
|
57
|
+
return path.resolve(dir, p);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FOLDER_LIST_MAX = 200;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What one path the code names is, on this disk: a file inside the document's
|
|
64
|
+
* folder (packable as it is), a file OUTSIDE it (absolute, ~, ../ — packable
|
|
65
|
+
* only by copying it in), a folder (with its files, non-recursive, capped), or
|
|
66
|
+
* nothing. Sizes only; no bytes are read.
|
|
67
|
+
*/
|
|
68
|
+
export function probeOne(dir, raw) {
|
|
69
|
+
const written = String(raw).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
70
|
+
const abs = resolveFrom(dir, written);
|
|
71
|
+
// Inside means: it resolves under the document's folder ("../me/x.csv" can).
|
|
72
|
+
const relative = abs ? path.relative(path.resolve(dir), abs).split(path.sep).join("/") : "";
|
|
73
|
+
const inside = abs && relative && !relative.startsWith("../") && !path.isAbsolute(relative) ? confined(dir, relative) : (abs && !relative ? path.resolve(dir) : null);
|
|
74
|
+
let st = null;
|
|
75
|
+
try { st = abs ? fs.statSync(abs) : null; } catch { st = null; }
|
|
76
|
+
const rel = inside ? (relative || ".") : written;
|
|
77
|
+
const out = { path: written, rel, exists: !!(st && st.isFile()), size: st && st.isFile() ? st.size : 0, outside: !inside, abs: abs || null };
|
|
78
|
+
if (st && st.isDirectory()) {
|
|
79
|
+
out.isDir = true;
|
|
80
|
+
let names = [];
|
|
81
|
+
try { names = fs.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isFile() && !e.name.startsWith(".")).map((e) => e.name).sort(); } catch { names = []; }
|
|
82
|
+
out.truncated = names.length > FOLDER_LIST_MAX;
|
|
83
|
+
out.files = names.slice(0, FOLDER_LIST_MAX).map((n) => {
|
|
84
|
+
let size = 0;
|
|
85
|
+
try { size = fs.statSync(path.join(abs, n)).size; } catch { size = 0; }
|
|
86
|
+
return { path: rel === "." ? n : `${rel.replace(/\/$/, "")}/${n}`, size };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Write bytes atomically: a temp file beside the target, then rename. */
|
|
93
|
+
function writeAtomic(target, bytes) {
|
|
94
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
95
|
+
fs.writeFileSync(tmp, bytes, { mode: 0o644 });
|
|
96
|
+
try { fs.renameSync(tmp, target); } catch (e) { try { fs.unlinkSync(tmp); } catch { /* gone */ } throw e; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createPlane({ env, audit }) {
|
|
100
|
+
const root = makeRoot(env("BEATRINA_ROOT"));
|
|
101
|
+
const bodyMax = Math.min(2e9, Math.max(1e6, Number(env("BEATRINA_UPLOAD_MAX")) || 64 * 1024 * 1024));
|
|
102
|
+
const insideMax = Number(env("BEATRINA_BUNDLE_INSIDE_MAX")) > 0 ? Number(env("BEATRINA_BUNDLE_INSIDE_MAX")) : undefined;
|
|
103
|
+
const app = env("BEATRINA_BUILD") || "";
|
|
104
|
+
const caps = new Map();
|
|
105
|
+
const mint = (p, mode) => {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
for (const [k, g] of caps) if (g.expires < now) caps.delete(k);
|
|
108
|
+
const token = secureToken(32);
|
|
109
|
+
caps.set(token, { path: p, mode, expires: now + 60000 });
|
|
110
|
+
return token;
|
|
111
|
+
};
|
|
112
|
+
const take = (token, mode) => {
|
|
113
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9]{32}$/.test(token)) return null;
|
|
114
|
+
const g = caps.get(token);
|
|
115
|
+
caps.delete(token);
|
|
116
|
+
return g && g.mode === mode && g.expires >= Date.now() ? g : null;
|
|
117
|
+
};
|
|
118
|
+
const pageOnly = (rec) => rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class);
|
|
119
|
+
const checkPath = (p) => {
|
|
120
|
+
if (typeof p !== "string" || !path.isAbsolute(p)) return "an absolute path is required";
|
|
121
|
+
if (!isBundle(p)) return `a Beatrina document ends in ${BUNDLE_EXT}`;
|
|
122
|
+
if (!root.within(p)) return root.outsideMsg();
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Read the bundle at p and say what opening it would do to its data. */
|
|
127
|
+
const openParts = (p) => {
|
|
128
|
+
const buf = fs.readFileSync(p);
|
|
129
|
+
const parts = readBundle(buf);
|
|
130
|
+
const dir = path.dirname(p);
|
|
131
|
+
const plan = dataPlan(parts.manifest, (rel) => {
|
|
132
|
+
const t = confined(dir, rel);
|
|
133
|
+
try { return t && fs.statSync(t).isFile() ? fs.readFileSync(t) : null; } catch { return null; }
|
|
134
|
+
});
|
|
135
|
+
// Beside-mode data: does the carrier folder hold what the manifest says?
|
|
136
|
+
if (parts.manifest.data?.mode === "beside") {
|
|
137
|
+
const folder = dataFolderFor(p);
|
|
138
|
+
plan.forEach((f) => {
|
|
139
|
+
const t = confined(folder, f.path);
|
|
140
|
+
let bytes = null;
|
|
141
|
+
try { bytes = t && fs.statSync(t).isFile() ? fs.readFileSync(t) : null; } catch { bytes = null; }
|
|
142
|
+
const want = parts.manifest.data.files.find((x) => x.path === f.path);
|
|
143
|
+
f.carrier = !bytes ? "missing" : sha256(bytes) === want.sha256 ? "ok" : "changed";
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { parts, plan, digest: sha256(buf), bytes: buf.length };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const readBody = (req, max) => new Promise((resolve) => {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
let total = 0;
|
|
152
|
+
let over = false;
|
|
153
|
+
req.on("data", (d) => { total += d.length; if (total > max) { over = true; chunks.length = 0; return; } chunks.push(d); });
|
|
154
|
+
req.on("end", () => resolve({ over, body: over ? Buffer.alloc(0) : Buffer.concat(chunks) }));
|
|
155
|
+
req.on("error", () => resolve({ over, body: Buffer.alloc(0), broken: true }));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
name: "bundle",
|
|
160
|
+
ops: ["bundle_cap", "bundle_extract", "bundle_probe", "bundle_adopt", "bundle_list"],
|
|
161
|
+
agentRefused: ["bundle_cap", "bundle_extract", "bundle_probe", "bundle_adopt"],
|
|
162
|
+
agentRefusalFor: () => PAGE_REFUSAL,
|
|
163
|
+
commands: ["bundle_cap", "bundle_extract", "bundle_probe", "bundle_adopt", "bundle_list"],
|
|
164
|
+
capabilities: ["bundle-v1"],
|
|
165
|
+
|
|
166
|
+
handle(cmd, rec, ctx) {
|
|
167
|
+
if (typeof cmd.id !== "string" || !cmd.id.length) return undefined;
|
|
168
|
+
const reply = (fields) => rec.ws.send(ctx.enc({ type: cmd.type, id: cmd.id, ...fields }));
|
|
169
|
+
if (cmd.type !== "bundle_list" && !pageOnly(rec)) {
|
|
170
|
+
audit("bundle-refused", { class: rec.class || "unknown", detail: cmd.type });
|
|
171
|
+
return reply({ error: PAGE_REFUSAL });
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
if (cmd.type === "bundle_cap") {
|
|
175
|
+
const bad = checkPath(cmd.path);
|
|
176
|
+
if (bad) return reply({ error: bad });
|
|
177
|
+
const mode = cmd.mode === "write" ? "write" : "read";
|
|
178
|
+
if (mode === "read" && !(fs.existsSync(cmd.path) && fs.statSync(cmd.path).isFile())) return reply({ error: "no such file" });
|
|
179
|
+
if (mode === "write" && !fs.existsSync(path.dirname(cmd.path))) return reply({ error: `no such folder: ${path.dirname(cmd.path)}` });
|
|
180
|
+
audit("bundle-cap", { detail: mode });
|
|
181
|
+
return reply({ cap: mint(cmd.path, mode), mode, max: bodyMax });
|
|
182
|
+
}
|
|
183
|
+
if (cmd.type === "bundle_list") {
|
|
184
|
+
const bad = checkPath(cmd.path);
|
|
185
|
+
if (bad) return reply({ error: bad });
|
|
186
|
+
const { parts, plan, digest, bytes } = openParts(cmd.path);
|
|
187
|
+
return reply({ manifest: parts.manifest, data: plan, digest, bytes });
|
|
188
|
+
}
|
|
189
|
+
if (cmd.type === "bundle_probe") {
|
|
190
|
+
const dir = String(cmd.dir || "");
|
|
191
|
+
if (!path.isAbsolute(dir) || !root.within(dir)) return reply({ error: "an absolute folder inside the session's root is required" });
|
|
192
|
+
const paths = Array.isArray(cmd.paths) ? cmd.paths.slice(0, 500).map(String) : [];
|
|
193
|
+
return reply({ files: paths.map((raw) => probeOne(dir, raw)) });
|
|
194
|
+
}
|
|
195
|
+
if (cmd.type === "bundle_adopt") {
|
|
196
|
+
// Copy a file the document reads from OUTSIDE its folder into it
|
|
197
|
+
// (`from` absolute, or ~ / ../ relative to dir), under `to`. Never
|
|
198
|
+
// replaces a different file without `replace: true`; an identical
|
|
199
|
+
// one is simply reused.
|
|
200
|
+
const dir = String(cmd.dir || "");
|
|
201
|
+
if (!path.isAbsolute(dir) || !root.within(dir)) return reply({ error: "an absolute folder inside the session's root is required" });
|
|
202
|
+
const from = resolveFrom(dir, String(cmd.from || ""));
|
|
203
|
+
const to = String(cmd.to || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
204
|
+
const target = confined(dir, to);
|
|
205
|
+
if (!target) return reply({ error: `${to} would leave the document's folder` });
|
|
206
|
+
if (!from || !root.within(from)) return reply({ error: root.outsideMsg() });
|
|
207
|
+
let st = null;
|
|
208
|
+
try { st = fs.statSync(from); } catch { st = null; }
|
|
209
|
+
if (!st || !st.isFile()) return reply({ error: `${cmd.from} is not a file` });
|
|
210
|
+
const bytes = fs.readFileSync(from);
|
|
211
|
+
let there = null;
|
|
212
|
+
try { there = fs.statSync(target).isFile() ? fs.readFileSync(target) : null; } catch { there = null; }
|
|
213
|
+
if (there && sha256(there) === sha256(bytes)) return reply({ ok: true, path: to, same: true, size: bytes.length });
|
|
214
|
+
if (there && cmd.replace !== true) return reply({ exists: true, differs: true, path: to, size: there.length });
|
|
215
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
216
|
+
writeAtomic(target, bytes);
|
|
217
|
+
audit("bundle-adopt", { detail: to });
|
|
218
|
+
return reply({ ok: true, path: to, size: bytes.length, replaced: !!there });
|
|
219
|
+
}
|
|
220
|
+
if (cmd.type === "bundle_extract") {
|
|
221
|
+
const bad = checkPath(cmd.path);
|
|
222
|
+
if (bad) return reply({ error: bad });
|
|
223
|
+
const { parts, plan } = openParts(cmd.path);
|
|
224
|
+
const dir = path.dirname(cmd.path);
|
|
225
|
+
const want = new Set(Array.isArray(cmd.write) ? cmd.write.map(String) : []);
|
|
226
|
+
const replace = new Set(Array.isArray(cmd.replace) ? cmd.replace.map(String) : []);
|
|
227
|
+
const written = []; const skipped = []; const failed = [];
|
|
228
|
+
for (const f of plan) {
|
|
229
|
+
const asked = f.state === "absent" ? want.has(f.path) : f.state === "differs" ? replace.has(f.path) : false;
|
|
230
|
+
if (!asked) { skipped.push({ path: f.path, state: f.state }); continue; }
|
|
231
|
+
const t = confined(dir, f.path);
|
|
232
|
+
if (!t) { failed.push({ path: f.path, error: "outside the document's folder" }); continue; }
|
|
233
|
+
let bytes = parts.data.get(f.path) || null;
|
|
234
|
+
if (!bytes && parts.manifest.data?.mode === "beside") {
|
|
235
|
+
const s = confined(dataFolderFor(cmd.path), f.path);
|
|
236
|
+
try { bytes = s ? fs.readFileSync(s) : null; } catch { bytes = null; }
|
|
237
|
+
}
|
|
238
|
+
const entry = parts.manifest.data.files.find((x) => x.path === f.path);
|
|
239
|
+
if (!bytes || sha256(bytes) !== entry.sha256) { failed.push({ path: f.path, error: "the bundle's copy is missing or changed" }); continue; }
|
|
240
|
+
try { fs.mkdirSync(path.dirname(t), { recursive: true }); writeAtomic(t, bytes); written.push(f.path); }
|
|
241
|
+
catch (e) { failed.push({ path: f.path, error: e.message }); }
|
|
242
|
+
}
|
|
243
|
+
audit("bundle-extract", { detail: `${written.length} written` });
|
|
244
|
+
return reply({ written, skipped, failed });
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return reply({ error: e.message, code: e.code || null });
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
http(req, res, pathname, ctx) {
|
|
253
|
+
if (pathname !== "/bundle") return false;
|
|
254
|
+
const blocked = ctx.controlRejection(req, { allowFile: true, allowApproved: true });
|
|
255
|
+
if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
|
|
256
|
+
const origin = req.headers.origin || "";
|
|
257
|
+
const cors = origin ? { "Access-Control-Allow-Origin": origin, Vary: "Origin" } : {};
|
|
258
|
+
const json = (status, obj) => ctx.respond(res, status, "application/json", JSON.stringify(obj), { ...cors, "Cache-Control": "no-store" });
|
|
259
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
260
|
+
if (method === "OPTIONS") {
|
|
261
|
+
ctx.respond(res, 204, "text/plain", "", { ...cors, "Access-Control-Allow-Methods": "GET, PUT, OPTIONS",
|
|
262
|
+
"Access-Control-Allow-Headers": "X-Beatrina-Cap, Content-Type", "Access-Control-Max-Age": "60" });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (method === "GET") {
|
|
266
|
+
const url = new URL(req.url, "http://x");
|
|
267
|
+
const grant = take(url.searchParams.get("cap"), "read");
|
|
268
|
+
if (!grant) { json(403, { error: "no valid capability" }); return true; }
|
|
269
|
+
try {
|
|
270
|
+
const { parts, plan, digest, bytes } = openParts(grant.path);
|
|
271
|
+
const figures = {};
|
|
272
|
+
for (const [name, buf] of parts.figures) figures[name] = buf.toString("base64");
|
|
273
|
+
json(200, { path: grant.path, manifest: parts.manifest, source: parts.source, sourceName: parts.sourceName,
|
|
274
|
+
results: parts.results, figures, data: plan, digest, bytes });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
json(422, { error: e.message, code: e.code || null });
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (method === "PUT") {
|
|
281
|
+
const grant = take(req.headers["x-beatrina-cap"], "write");
|
|
282
|
+
if (!grant) { req.resume(); json(403, { error: "no valid capability" }); return true; }
|
|
283
|
+
readBody(req, bodyMax).then(({ over, body, broken }) => {
|
|
284
|
+
if (broken) return json(400, { error: "the upload broke off" });
|
|
285
|
+
if (over) return json(413, { error: `over the ${Math.round(bodyMax / 1048576)} MB upload limit` });
|
|
286
|
+
let msg;
|
|
287
|
+
try { msg = JSON.parse(body.toString("utf8")); } catch { return json(400, { error: "not JSON" }); }
|
|
288
|
+
const p = grant.path;
|
|
289
|
+
const dir = path.dirname(p);
|
|
290
|
+
try {
|
|
291
|
+
// The file the page read is the file being replaced — or it changed underneath.
|
|
292
|
+
if (typeof msg.expectedDigest === "string" && msg.expectedDigest && fs.existsSync(p)) {
|
|
293
|
+
if (sha256(fs.readFileSync(p)) !== msg.expectedDigest) {
|
|
294
|
+
return json(409, { error: "changed on disk since it was opened", conflict: true });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const data = [];
|
|
298
|
+
for (const item of Array.isArray(msg.data) ? msg.data : []) {
|
|
299
|
+
// A path, or {path, kind} — kind is what the manifest says the file is FOR.
|
|
300
|
+
const rel = typeof item === "string" ? item : String(item?.path || "");
|
|
301
|
+
const kind = typeof item === "object" && item && /^[a-z]{1,16}$/.test(String(item.kind || "")) ? item.kind : "data";
|
|
302
|
+
const t = confined(dir, String(rel));
|
|
303
|
+
if (!t) return json(400, { error: `${rel} is outside the document's folder`, code: "bundle_bad_path" });
|
|
304
|
+
let st = null;
|
|
305
|
+
try { st = fs.statSync(t); } catch { st = null; }
|
|
306
|
+
if (!st || !st.isFile()) return json(400, { error: `${rel} is not a file next to the document`, code: "bundle_no_data" });
|
|
307
|
+
data.push({ path: String(rel).replace(/\\/g, "/"), bytes: fs.readFileSync(t), kind });
|
|
308
|
+
}
|
|
309
|
+
const figures = (Array.isArray(msg.figures) ? msg.figures : [])
|
|
310
|
+
.map((f) => ({ name: String(f.name), data: Buffer.from(String(f.data || ""), "base64") }));
|
|
311
|
+
const built = buildBundle({
|
|
312
|
+
source: String(msg.source ?? ""), sourceName: String(msg.sourceName || ""), title: String(msg.title || ""),
|
|
313
|
+
results: msg.results == null ? null : String(msg.results), figures,
|
|
314
|
+
data, app, bundleName: path.basename(p),
|
|
315
|
+
...(insideMax ? { insideMax } : {}),
|
|
316
|
+
});
|
|
317
|
+
if (built.beside.length) {
|
|
318
|
+
const folder = dataFolderFor(p);
|
|
319
|
+
for (const d of built.beside) {
|
|
320
|
+
const t = confined(folder, d.path);
|
|
321
|
+
if (!t) return json(400, { error: `${d.path} would leave the data folder` });
|
|
322
|
+
let same = false;
|
|
323
|
+
try { same = sha256(fs.readFileSync(t)) === sha256(d.bytes); } catch { same = false; }
|
|
324
|
+
if (!same) { fs.mkdirSync(path.dirname(t), { recursive: true }); writeAtomic(t, d.bytes); }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
writeAtomic(p, built.zip);
|
|
328
|
+
audit("bundle-write", { detail: `${built.zip.length} bytes, data ${built.manifest.data.mode}` });
|
|
329
|
+
return json(200, { ok: true, path: p, bytes: built.zip.length, digest: sha256(built.zip),
|
|
330
|
+
dataMode: built.manifest.data.mode, data: built.manifest.data.files.map((f) => ({ path: f.path, size: f.size, kind: f.kind })),
|
|
331
|
+
folder: built.beside.length ? dataFolderFor(p) : null });
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return json(e.code && /^bundle_|^zip_/.test(e.code) ? 400 : 500, { error: e.message, code: e.code || null });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
json(405, { error: "GET, PUT or OPTIONS" });
|
|
339
|
+
return true;
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export default { createPlane, confined };
|