Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/entry.rs

61.2 KiB, 1 run

created by r2519314175:981, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The `#[wasm_bindgen]` API surface exposed to the browser.
2//!
3//! Three probes prove the browser vertical end-to-end, no server
4//! involved:
5//!
6//! 1. [`core_probe`] — the wasm module instantiates and a `fe2o3_core`
7//! call path (getrandom-backed RNG, the wasm clock shim, the error
8//! machinery) executes without panicking.
9//! 2. [`write_file`] / [`read_file`] — a byte-exact OPFS round trip
10//! through the [`opfs`](crate::wasm::opfs) edge.
11//! 3. [`llm_probe`] — the real wasm [`LlmClient`](crate::llm::LlmClient)
12//! transport issues a cross-origin `fetch` to a provider and returns
13//! the HTTP status.
14//!
15//! Async functions surface to JS as `Promise`s (via
16//! `wasm-bindgen-futures`); [`Outcome`] errors are mapped to a rejected
17//! `Promise` through [`to_js_err`](crate::wasm::to_js_err).
18
19use crate::llm::LlmClient;
20use crate::tools::FileRoot;
21use crate::wasm::{diamond, opfs, to_js_err};
22
23use oxedyne_fe2o3_graphics::qr::{
24 encode,
25 QrEcc,
26};
27use oxedyne_fe2o3_sbj::{
28 card::{
29 self,
30 Card,
31 Role,
32 },
33 doc::{
34 self,
35 Payload,
36 },
37 envelope,
38 post::{
39 Post,
40 Reference,
41 Target,
42 },
43 share::{
44 self,
45 Share,
46 },
47};
48use oxedyne_fe2o3_stds::media;
49
50use oxedyne_fe2o3_core::prelude::*;
51use oxedyne_fe2o3_core::rand::Rand;
52use oxedyne_fe2o3_core::wasm::{console_log, now_ms};
53
54use wasm_bindgen::prelude::*;
55use wasm_bindgen::JsCast;
56use web_sys::FileSystemDirectoryHandle;
57
58
59/// Default per-turn token cap for the probe client. The value is
60/// irrelevant to a dummy-key probe (the request never reaches
61/// generation), but the field must be set.
62const PROBE_MAX_TOKENS: u32 = 16;
63
64
65/// Send a Rust panic somewhere a person can read it.
66///
67/// WHY THIS EXISTS. There was no panic hook at all, and a wasm panic without one
68/// is the most opaque failure this app can produce: the browser reports a bare
69/// `"Script error."` with no file, no line and no message, the `Promise` the
70/// call was made through NEVER SETTLES, and the module is left poisoned. An
71/// iPhone looped on exactly that for four sessions -- the trail showed
72/// `unlocked`, then `Script error.`, then the step that had started simply never
73/// reporting either way, and then the tab dying. Every diagnosis was a guess
74/// because the one thing that knew what happened had nowhere to say it.
75///
76/// The panic goes to the console AND to `window.DaimondTrail`, which is durable
77/// and survives the reload -- a console nobody can open on a phone is the reason
78/// this took four attempts.
79///
80/// Installed by [`install_panic_hook`], called from every entry point that can
81/// be the first one, because there is no `#[wasm_bindgen(start)]` here.
82fn report_panic(info: &std::panic::PanicHookInfo<'_>) {
83 let msg = fmt!("{}", info);
84 console_log(&msg);
85 let win = match web_sys::window() {
86 Some(w) => w,
87 None => return, // a worker: the console line is all there is
88 };
89 // `window.DaimondTrail.note('wasm panic', msg)`, reached reflectively so a
90 // page without the trail is simply a page that gets the console line.
91 let trail = match js_sys::Reflect::get(&win, &JsValue::from_str("DaimondTrail")) {
92 Ok(t) => t,
93 Err(_) => return,
94 };
95 let note = match js_sys::Reflect::get(&trail, &JsValue::from_str("note")) {
96 Ok(n) => n,
97 Err(_) => return,
98 };
99 if let Ok(f) = note.dyn_into::<js_sys::Function>() {
100 let _ = f.call2(&trail, &JsValue::from_str("WASM PANIC"), &JsValue::from_str(&msg));
101 }
102}
103
104/// Write one line into the durable trail from Rust.
105///
106/// The same reflective call [`report_panic`] makes, exposed for the places where
107/// a step can hang rather than fail. A hang has no error to report and no panic
108/// to hook -- the caller's `Promise` simply never settles -- so the only way to
109/// find it is for the code to say where it got to before it stopped.
110///
111/// Costs a JS call, so it goes at the boundaries of long operations and never
112/// inside a per-file loop.
113pub fn trail(what: &str, detail: &str) {
114 let win = match web_sys::window() {
115 Some(w) => w,
116 None => return,
117 };
118 let t = match js_sys::Reflect::get(&win, &JsValue::from_str("DaimondTrail")) {
119 Ok(t) => t,
120 Err(_) => return,
121 };
122 let n = match js_sys::Reflect::get(&t, &JsValue::from_str("note")) {
123 Ok(n) => n,
124 Err(_) => return,
125 };
126 if let Ok(f) = n.dyn_into::<js_sys::Function>() {
127 let _ = f.call2(&t, &JsValue::from_str(what), &JsValue::from_str(detail));
128 }
129}
130
131/// Grow the linear memory on purpose, so the gauge itself can be proved.
132///
133/// WHY THIS EXISTS, and it is the same reason [`panic_on_purpose`] exists. The
134/// heap gauge read exactly 1 MB through every local experiment — fifteen
135/// Diamonds, seven hundred and fifty version files, twenty engine instances —
136/// and a number that never moves is indistinguishable from a number that cannot.
137/// The phone's trail shows it going 1 → 235 → 1639, so it is not stuck there;
138/// but "it moved once on a device I cannot inspect" is not proof that it tracks
139/// allocation, and four sessions were lost to instruments nobody had watched
140/// working.
141///
142/// Allocates `mb` megabytes, touches every page so nothing is optimised away,
143/// and leaks it deliberately: linear memory never shrinks, so returning it would
144/// prove nothing about the number this reports.
145#[wasm_bindgen]
146pub fn grow_on_purpose(mb: u32) -> f64 {
147 let bytes = (mb as usize) * 1_048_576;
148 let mut v: Vec<u8> = Vec::with_capacity(bytes);
149 // Written, not merely reserved: an untouched reservation can be a mapping
150 // the allocator has not asked the runtime for yet.
151 for i in 0..bytes {
152 v.push((i & 0xff) as u8);
153 }
154 std::mem::forget(v);
155 heap_bytes()
156}
157
158/// The linear memory in whole megabytes, for a trail line.
159///
160/// Separate from [`heap_bytes`] because the JS side wants bytes and every caller
161/// in Rust wants a short number to put beside what it just did.
162pub fn heap_mb() -> u32 {
163 (heap_bytes() / 1_048_576.0) as u32
164}
165
166/// Spell one path component the way the filesystem edge will store it.
167///
168/// Exported so the JavaScript copy of the codec in `cloud.js` can be held to this one rather than
169/// to a reading of it. There are two implementations because the workspace walkers in
170/// `daimond.js` reach the browser's handles directly, and two implementations that are never
171/// compared are two implementations that will drift; `dev/verify_mailnames.mjs` drives both over
172/// one corpus. Nothing in the app calls this — the edge applies the codec itself.
173#[wasm_bindgen]
174pub fn fs_disk_name(name: &str) -> String {
175 crate::fsname::encode(name)
176}
177
178/// Read a stored component back the way the workspace spells it. The inverse of [`fs_disk_name`],
179/// and exported for the same reason.
180#[wasm_bindgen]
181pub fn fs_logical_name(name: &str) -> String {
182 crate::fsname::decode(name)
183}
184
185/// Panic on purpose, so the hook itself can be proved rather than assumed.
186///
187/// A diagnostic that has never been seen working is a diagnostic nobody should
188/// trust the silence of. `verify_wasmpanic` calls this and requires the trail to
189/// name the file and the line.
190#[wasm_bindgen]
191pub fn panic_on_purpose() {
192 panic!("panic_on_purpose: proving the hook reports");
193}
194
195/// How much linear memory this module currently holds, in bytes.
196///
197/// WHY THIS EXISTS. The leading explanation for a phone that unlocks, shows the
198/// app for a second and dies is that iOS Safari is killing the tab for using too
199/// much memory -- there is no `pagehide`, so the tab is not reloading, it is
200/// being taken away. That has been an explanation for four sessions and has
201/// never once been a MEASUREMENT, because Safari exposes no
202/// `performance.memory`. Wasm does: the linear memory only ever grows, and its
203/// size is a number the module can read about itself.
204///
205/// Sampled at a high-water mark rather than on a timer (see `watchHeap` in
206/// daimond.js), so a trail carries the growth curve in a dozen rows rather than
207/// hundreds. If those rows climb to a few hundred megabytes and stop, the answer
208/// is memory and the argument is over; if the tab dies at forty, it never was.
209#[wasm_bindgen]
210pub fn heap_bytes() -> f64 {
211 #[cfg(target_arch = "wasm32")]
212 {
213 // One page is 64 KiB, by the specification, and this is the only place
214 // the app can see its own footprint at all.
215 (core::arch::wasm32::memory_size(0) as f64) * 65536.0
216 }
217 #[cfg(not(target_arch = "wasm32"))]
218 {
219 // The host build has an ordinary allocator and no linear memory to
220 // report. Zero, so a caller reads "not measured" rather than a lie.
221 0.0
222 }
223}
224
225/// Install [`report_panic`], once.
226///
227/// Idempotent by a flag rather than by asking: `std::panic::set_hook` replaces
228/// whatever is there, and replacing it on every call would be harmless but
229/// wasteful on a path that runs per Diamond.
230#[wasm_bindgen]
231pub fn install_panic_hook() {
232 use std::sync::atomic::{AtomicBool, Ordering};
233 static DONE: AtomicBool = AtomicBool::new(false);
234 if DONE.swap(true, Ordering::Relaxed) {
235 return;
236 }
237 std::panic::set_hook(Box::new(report_panic));
238}
239
240/// Run a `fe2o3_core` call path in the browser and return a one-line
241/// summary — the F2 proof that the gated core *runs*, not merely
242/// compiles.
243///
244/// Exercises getrandom (via [`Rand::rand_u64`]), the wasm clock shim
245/// ([`now_ms`]), the console shim ([`console_log`]) and the error
246/// machinery ([`err`]). Never panics.
247#[wasm_bindgen]
248pub fn core_probe() -> Result<String, JsValue> {
249 // getrandom-backed RNG — panics on wasm if the `js` backend is not
250 // wired, so a returned value is itself proof.
251 let r = Rand::rand_u64();
252
253 // Wall-clock via the JS `Date.now()` shim.
254 let t = now_ms();
255
256 // The error machinery must format cleanly under wasm.
257 let sample: Error<ErrTag> = err!("probe sample error"; Test);
258 let err_len = fmt!("{}", sample).len();
259
260 let summary = fmt!(
261 "core ok: rand_u64={:#018x}, now_ms={:.0}, err_fmt_len={}",
262 r, t, err_len,
263 );
264 console_log(&summary);
265 Ok(summary)
266}
267
268/// Write `content` (UTF-8) to `path` in the active Workspace root,
269/// creating parents as needed. Resolves against the FSA real folder when
270/// one is open, else the OPFS sandbox. Rejects on a jail violation or a
271/// filesystem failure.
272#[wasm_bindgen]
273pub async fn write_file(path: String, content: String) -> Result<(), JsValue> {
274 opfs::write_file(FileRoot::Workspace, &path, content.as_bytes()).await.map_err(to_js_err)
275}
276
277/// Read `path` from the active Workspace root (FSA real folder when open,
278/// else OPFS) and return its contents as a UTF-8 string.
279#[wasm_bindgen]
280pub async fn read_file(path: String) -> Result<String, JsValue> {
281 match opfs::read_file(FileRoot::Workspace, &path).await {
282 Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()),
283 Err(e) => Err(to_js_err(e)),
284 }
285}
286
287// ── Bytes, and what they are ─────────────────────────────────────────────
288//
289// [`read_file`] above ends in `from_utf8_lossy`, which is right for the callers it was written
290// for and catastrophic for the one that opens whatever the user clicked. Every byte that is not
291// valid UTF-8 becomes U+FFFD, so opening a PDF filled the document panel with replacement
292// characters and no indication that anything had gone wrong -- the app looked broken rather than
293// the format looking unsupported.
294//
295// There was a binary guard, and it never fired for a file in the user's own folder: it asked
296// `DaimondCloud.fileAt`, which resolves through the cloud offload cache and not the workspace at
297// all. So the guard covered the files least likely to need it.
298//
299// These two functions are the fix, and they are deliberately separate. [`file_probe`] says what a
300// file IS without reading much of it; [`read_bytes`] hands over a range of it byte-exactly. A
301// caller asks the first, decides, and only then asks the second -- which is what keeps a 900 MB
302// video from being materialised in wasm linear memory to find out that it is a video.
303
304/// What a file is, without reading much of it: `{size, media, kind, mime, label, text, disagree}`.
305///
306/// The format comes from [`oxedyne_fe2o3_stds::media::identify`], which reads both the leading
307/// bytes and the name and reports when they disagree. `disagree` is not a warning about a
308/// malformed file -- it is the interesting case, and a viewer that hides it hides how a person
309/// finds a broken export.
310///
311/// Only the first 512 bytes are read, which recognises every format in the table except a tar
312/// archive, whose signature sits at offset 257 and so needs 264 of them.
313///
314/// `media` and `kind` are the enum variant names (`"Png"`, `"Image"`), which are stable because
315/// they are the API; `mime` is what to put on a `Blob`, and `label` is an English fallback for a
316/// caller with nowhere to translate.
317///
318/// # Arguments
319/// * `path` - The path, resolved against the active Workspace root.
320#[wasm_bindgen]
321pub async fn file_probe(path: String) -> Result<String, JsValue> {
322 probe_at(FileRoot::Workspace, &path).await.map_err(to_js_err)
323}
324
325/// [`file_probe`], against Daimond's own store rather than the workspace.
326#[wasm_bindgen]
327pub async fn store_file_probe(path: String) -> Result<String, JsValue> {
328 probe_at(FileRoot::Opfs, &path).await.map_err(to_js_err)
329}
330
331/// Read `len` bytes of a file from `offset`, byte-exactly.
332///
333/// No decoding of any kind happens here, which is the whole point. A caller that wants
334/// characters asks [`file_probe`] first and decodes only what it was told is text.
335///
336/// An `offset` past the end answers with an empty array rather than an error, so a caller can walk
337/// to the end of a file without knowing in advance where the end is.
338///
339/// # Arguments
340/// * `path` - The path, resolved against the active Workspace root.
341/// * `offset` - Where to start, in bytes.
342/// * `len` - How many bytes to take.
343#[wasm_bindgen]
344pub async fn read_bytes(path: String, offset: f64, len: u32) -> Result<js_sys::Uint8Array, JsValue> {
345 match opfs::read_file_range(FileRoot::Workspace, &path, offset, len).await {
346 Ok((bytes, _)) => Ok(js_sys::Uint8Array::from(&bytes[..])),
347 Err(e) => Err(to_js_err(e)),
348 }
349}
350
351/// [`read_bytes`], against Daimond's own store rather than the workspace.
352#[wasm_bindgen]
353pub async fn store_read_bytes(
354 path: String,
355 offset: f64,
356 len: u32,
357)
358 -> Result<js_sys::Uint8Array, JsValue>
359{
360 match opfs::read_file_range(FileRoot::Opfs, &path, offset, len).await {
361 Ok((bytes, _)) => Ok(js_sys::Uint8Array::from(&bytes[..])),
362 Err(e) => Err(to_js_err(e)),
363 }
364}
365
366/// The body of [`file_probe`], over either root.
367///
368/// # Arguments
369/// * `root` - Which root to resolve `path` against.
370/// * `path` - The path.
371async fn probe_at(root: FileRoot, path: &str) -> Outcome<String> {
372 // 512 rather than 264: the extra costs nothing (a `Blob` slice copies only what is read) and
373 // it leaves room for the text-shaped formats, which are recognised from their opening tag.
374 let (head, size) = res!(opfs::read_file_range(root, path, 0.0, 512).await);
375 let id = media::identify(path, &head);
376 // Both halves of a disagreement carry their LABEL as well as their variant name. The
377 // variant name is an identifier -- `Pdf`, `Text` -- and a sentence built from it reads
378 // "The bytes say Pdf", which is the code's word for the format leaking onto the screen.
379 Ok(fmt!(
380 "{{\"size\":{},\"media\":\"{:?}\",\"kind\":\"{:?}\",\"mime\":\"{}\",\"label\":\"{}\",\
381 \"text\":{},\"chars\":{},\"byMagic\":\"{:?}\",\"byMagicLabel\":\"{}\",\
382 \"byName\":\"{:?}\",\"byNameLabel\":\"{}\",\"disagree\":{}}}",
383 size,
384 id.media,
385 id.media.kind(),
386 id.media.mime(),
387 id.media.label(),
388 // `text`: both halves hold -- a format that IS text, and bytes that ARE characters. A
389 // `.json` full of NULs is not something to put on screen as characters however it is
390 // named. This is what a VIEWER wants, because it decides which structured view to draw.
391 id.media.is_text() && media::looks_like_text(&head),
392 // `chars`: the bytes alone. This is what an EDITOR wants, and the two are not the same
393 // question. A `Makefile` or a `README` has no extension to recognise, so its format is
394 // Unknown and `text` is false -- and it is plainly a file somebody wants to edit. Asking
395 // the narrower question would have sent it to a hex dump.
396 media::looks_like_text(&head),
397 id.by_magic,
398 id.by_magic.label(),
399 id.by_name,
400 id.by_name.label(),
401 id.disagree,
402 ))
403}
404
405// ── Daimond's own store, pinned to OPFS ──────────────────────────────────
406//
407// Notes2: *"When I choose the Browser workspace, I see only a mail folder and a
408// test.md, where are all the system files like DAIMOND.md??"*
409//
410// They were where they have always been — at the OPFS root — and the Workspace
411// panel deliberately filtered them out, because a `×` beside `diamonds/` would
412// delete every Diamond the user has. Hiding them answered the wrong question:
413// what was wanted is to SEE the store, not to be able to destroy it.
414//
415// These three are the read side of that, and they pin [`FileRoot::Opfs`] rather
416// than `Workspace` — which is the whole point. With a real folder open,
417// `read_file` resolves against the folder, so a page asking for `DAIMOND.md`
418// gets the PROJECT'S copy and Daimond's own store stays invisible exactly when
419// the user is most likely to go looking for it. `dev/ROOT_SEPARATION.md` §1.1
420// is the map of which resolver does what.
421
422/// List a directory in Daimond's own store (OPFS), never a real folder.
423///
424/// Returns one entry per line: `name\tdir|file\tbytes`. A flat format because
425/// the caller is one function in `daimond.js` and a JSON dependency here would
426/// buy nothing; the names cannot contain a tab, since [`crate::tools`] refuses
427/// a path with a control character in it.
428///
429/// # Arguments
430/// * `path` - Store-relative, `""` or `"."` for the root.
431#[wasm_bindgen]
432pub async fn store_list(path: String) -> Result<String, JsValue> {
433 let entries = opfs::list_dir(FileRoot::Opfs, &path).await.map_err(to_js_err)?;
434 let mut out = String::new();
435 for (name, is_dir, size) in entries {
436 out.push_str(&fmt!("{}\t{}\t{}\n", name, if is_dir { "dir" } else { "file" }, size));
437 }
438 Ok(out)
439}
440
441/// Read a file from Daimond's own store (OPFS), never a real folder.
442#[wasm_bindgen]
443pub async fn store_read(path: String) -> Result<String, JsValue> {
444 match opfs::read_file(FileRoot::Opfs, &path).await {
445 Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()),
446 Err(e) => Err(to_js_err(e)),
447 }
448}
449
450/// Write a file into Daimond's own store (OPFS), never a real folder.
451///
452/// The user editing their own standing instructions or a role prompt while a
453/// project folder is open: without this the save would land in the project and
454/// the file they were looking at would be unchanged.
455#[wasm_bindgen]
456pub async fn store_write(path: String, content: String) -> Result<(), JsValue> {
457 opfs::write_file(FileRoot::Opfs, &path, content.as_bytes()).await.map_err(to_js_err)
458}
459
460/// Write BYTES into Daimond's own store, for a file that is not text.
461///
462/// The counterpart of [`store_read_bytes`], which has existed all along -- so the store could be read
463/// byte for byte and not written that way, and the gap was one-sided rather than a decision.
464///
465/// **This is the door a picture needs to land in a Diamond.** `store_write` takes a `String`, and
466/// `DaimondApp::write_bytes` writes bytes to the WORKSPACE root, which is a real folder on the user's
467/// machine whenever they have one open. So a share carrying a PNG had nowhere to put it inside the
468/// Diamond: the wire was sound and the landing was not.
469///
470/// Everything [`store_write`] says about stamping applies here and applies more, because the files that
471/// come this way are the large ones: a raw OPFS write moves nothing, and a Diamond written into without
472/// being stamped is a Diamond the next sync silently replaces from the other device. Call
473/// [`stamp_diamond`] after.
474#[wasm_bindgen]
475pub async fn store_write_bytes(path: String, bytes: Vec<u8>) -> Result<(), JsValue> {
476 opfs::write_file(FileRoot::Opfs, &path, &bytes).await.map_err(to_js_err)
477}
478
479/// Stamp a Diamond as changed, so what was written inside it travels to the other devices.
480///
481/// **A crystal page writing its own log is a mutation like any other, and `touched` is what
482/// decides whose copy the other device takes.** `store_write` is a raw OPFS write and moves
483/// nothing, so a capp that logged a meal on a phone left that phone looking STALE: the desktop's
484/// copy was strictly fresher, `applyDiamonds` replaces a Diamond wholesale from the fresher side,
485/// and the meal went with the copy it replaced. That is the tag-loss failure of 2026-08-11
486/// arriving through a new door, and it is why this is exported rather than left to the caller to
487/// remember.
488///
489/// # Arguments
490/// * `id` - The Diamond that was written into.
491#[wasm_bindgen]
492pub async fn touch_diamond(id: String) -> Result<(), JsValue> {
493 diamond::touch(&id).await.map_err(to_js_err)
494}
495
496/// Point the file tools / Workspace at a real local folder (FSA mode).
497///
498/// `handle` is a `FileSystemDirectoryHandle` from `showDirectoryPicker()`
499/// in JS, already permission-granted for read/write. Once set, every
500/// [`FileRoot::Workspace`] file tool (`file_read`/`write`/`list`/`edit`/
501/// `delete`/`search`) resolves against the real folder. Daimond's own
502/// Diamond/crystal/`.daimond` storage is unaffected — it pins the OPFS sandbox.
503#[wasm_bindgen]
504pub fn set_workspace_dir(handle: FileSystemDirectoryHandle) {
505 opfs::set_override(handle);
506}
507
508/// Clear any FSA override, returning the file tools / Workspace to the
509/// OPFS sandbox root.
510#[wasm_bindgen]
511pub fn use_opfs_workspace() {
512 opfs::clear_override();
513}
514
515/// The current Workspace file-tool root mode: `"folder"` when an FSA real
516/// folder is open, else `"opfs"`.
517#[wasm_bindgen]
518pub fn workspace_mode() -> String {
519 opfs::workspace_mode()
520}
521
522/// Encode `text` as a QR Code and return its module grid, row-major, one byte
523/// per module: `1` is a dark module, `0` a light one.
524///
525/// The side length is the square root of the returned length, so the caller
526/// needs nothing else to draw the symbol. An empty array means the text would
527/// not fit the largest QR version, which the caller reads as "fall back to the
528/// typed code". Medium error correction is used: robust enough for a phone
529/// camera reading the code off a screen, without inflating the version unduly.
530#[wasm_bindgen]
531pub fn qr_matrix(text: String) -> Vec<u8> {
532 match encode(&text, QrEcc::Medium) {
533 Ok(qr) => {
534 let n = qr.size();
535 let mut out = Vec::with_capacity(n * n);
536 for y in 0..n {
537 for x in 0..n {
538 out.push(if qr.get(x, y) { 1u8 } else { 0u8 });
539 }
540 }
541 out
542 }
543 Err(_) => Vec::new(),
544 }
545}
546
547/// Point every OPFS operation at the current account's subdirectory.
548///
549/// Empty means the primary account (the origin root, unchanged); any other value isolates this
550/// account's workspace and Daimond's own state from every other account at this browser. Set once
551/// at boot from `DaimondAccounts.opfsNs()`, before any file tool runs.
552#[wasm_bindgen]
553pub fn set_account_ns(ns: String) {
554 opfs::set_account_ns(ns);
555}
556
557/// Which permission mode Daimond is in: `ask`, `guarded` or `bypass`.
558///
559/// See [`crate::tools::Mode`]. Read rather than remembered by the page, so a
560/// control that failed to set one draws what is actually in force.
561#[wasm_bindgen]
562pub fn permission_mode() -> String {
563 crate::tools::mode().name().to_string()
564}
565
566/// Did the command behind this tool result run with the network refused?
567///
568/// The user-facing half of a thing that had only ever had a model-facing one. `Tool::run`
569/// writes a note into the result so the MODEL knows why a fetch, install or clone failed and
570/// does not report the project as broken; the person watching the build stop halfway was told
571/// nothing at all, in any language, and learned the reason only if the model chose to relay a
572/// bracketed English note written for itself.
573///
574/// Answered from the result rather than from the chat's state: the chat's state says what the
575/// next command would get, and a line drawn under one command has to say what that one got.
576///
577/// # Arguments
578/// * `result` - The tool result, as the page received it.
579#[wasm_bindgen]
580pub fn ran_without_net(result: &str) -> bool {
581 crate::tools::ran_without_net(result)
582}
583
584/// Move to a permission mode, returning the one it replaced.
585///
586/// This is the ONLY way the mode moves. No tool reaches it, and nothing derived
587/// from anything a model said reaches it: it is the user's own setting arriving
588/// from their own control. A name this build does not know is REFUSED rather
589/// than rounded to the nearest rung, and the mode is left exactly where it was
590/// -- a page that cannot say what it wants keeps the guarded one it had.
591///
592/// # Arguments
593/// * `name` - The mode's name, as `Mode::name` spells it.
594#[wasm_bindgen]
595pub fn set_permission_mode(name: String) -> Result<String, JsValue> {
596 let m = match crate::tools::Mode::parse(&name) {
597 Ok(m) => m,
598 Err(e) => return Err(crate::wasm::to_js_err(e)),
599 };
600 Ok(crate::tools::set_mode(m).name().to_string())
601}
602
603/// Probe the LLM transport: issue a real cross-origin `fetch` to
604/// `base_url` with `api_key` and `model`, returning the HTTP status.
605///
606/// A `401` with a dummy key is success — it proves `fetch` + CORS + the
607/// wasm transport path work end-to-end without a valid key.
608#[wasm_bindgen]
609pub async fn llm_probe(
610 base_url: String,
611 api_key: String,
612 model: String,
613) -> Result<u32, JsValue> {
614 match run_llm_probe(&base_url, &api_key, &model).await {
615 Ok(status) => Ok(status as u32),
616 Err(e) => Err(to_js_err(e)),
617 }
618}
619
620/// Inner probe returning an [`Outcome`], so the transport path uses the
621/// error macros throughout; the `#[wasm_bindgen]` wrapper maps the result
622/// to the JS boundary.
623async fn run_llm_probe(base_url: &str, api_key: &str, model: &str) -> Outcome<u16> {
624 let (secure, host, port, path) = res!(parse_url(base_url));
625 let client = LlmClient::new_with_scheme(
626 &host, port, &path, api_key, model, PROBE_MAX_TOKENS, secure);
627 let status = res!(client.probe_status().await);
628 Ok(status)
629}
630
631/// Split a `scheme://host[:port]/path` URL into `(secure, host, port, path)`.
632///
633/// Both schemes are accepted, on the same terms as
634/// [`crate::wasm::app`]'s `parse_base_url`: `https` for the real providers,
635/// `http` for a local mock. A probe that refused `http` would reject a base
636/// URL the chat path goes on to accept. The port defaults to the scheme's
637/// own default when absent.
638fn parse_url(url: &str) -> Outcome<(bool, String, u16, String)> {
639 let (secure, default_port, rest) = if let Some(r) = url.strip_prefix("https://") {
640 (true, 443u16, r)
641 } else if let Some(r) = url.strip_prefix("http://") {
642 (false, 80u16, r)
643 } else {
644 return Err(err!(
645 "llm_probe: URL '{}' must start with http:// or https://.", url;
646 Invalid, Input));
647 };
648 let (authority, path) = match rest.find('/') {
649 Some(i) => (&rest[..i], &rest[i..]),
650 None => (rest, "/"),
651 };
652 let (host, port) = match authority.rsplit_once(':') {
653 Some((h, p)) => {
654 let port = res!(p.parse::<u16>()
655 .map_err(|e| err!(e, "llm_probe: bad port in '{}'.", url; Invalid, Input)));
656 (h.to_string(), port)
657 }
658 None => (authority.to_string(), default_port),
659 };
660 if host.is_empty() {
661 return Err(err!("llm_probe: empty host in '{}'.", url; Invalid, Input));
662 }
663 Ok((secure, host, port, path.to_string()))
664}
665
666/// What a role is told when the user has not written a prompt of their own.
667///
668/// The browser needs these for two jobs: composing the prompt of a chat or a
669/// worker (both of which it constructs), and seeding `prompts/<role>.md` with
670/// the real text the first time a user opens it to edit. Exporting them keeps
671/// the one definition in [`crate::prompts`] rather than a copy in JavaScript,
672/// where the wording would drift from what the model is actually sent.
673///
674/// An unknown role yields an empty string rather than an error: the caller is
675/// building a prompt, and there is no useful half-measure to return.
676#[wasm_bindgen]
677pub fn default_prompt(role: &str) -> String {
678 match crate::prompts::Role::parse(role) {
679 Ok(r) => r.default_prompt().to_string(),
680 Err(_) => String::new(),
681 }
682}
683
684/// A role's whole system prompt: the user's text (or the default, when it is
685/// empty), plus the rules an edit cannot remove.
686///
687/// This is what the browser hands to a chat or a worker, so the composition is
688/// done in the one place for every role rather than half here and half there.
689#[wasm_bindgen]
690pub fn compose_prompt(role: &str, text: &str) -> String {
691 compose_prompt_for(role, text, "")
692}
693
694/// The same, for a page that knows which model will carry the request.
695///
696/// **Two of the notes are composed on the model** -- `VERIFY_NOTE` and `QUIET_NOTE`, each
697/// measured to be worth its tokens on some models and not on others (see
698/// `prompts::CONDITIONAL` and `dev/PROMPT_NOTES.md`). A model this build has no measurement
699/// for is given both, and so is the empty string, so a caller that does not know which client
700/// will carry the request loses nothing by saying so.
701///
702/// Kept apart from [`compose_prompt`] rather than added as a third argument to it, because a
703/// page that passed the WRONG model would drop a note the model needed -- and the failure of
704/// an absent note is a lost turn, while the cost of a needless one is about a hundred tokens.
705/// A caller with no model in hand should reach for the two-argument form and mean it.
706///
707/// # Arguments
708/// * `model` - The model as the client is configured with it, in the provider's own spelling.
709#[wasm_bindgen]
710pub fn compose_prompt_for(role: &str, text: &str, model: &str) -> String {
711 match crate::prompts::Role::parse(role) {
712 Ok(r) => r.compose_for(text, model),
713 Err(_) => text.to_string(),
714 }
715}
716
717/// The skills this build carries, one name per line, for the `/` menu to list beside the
718/// workspace's own.
719///
720/// A shipped skill has no file, so the menu -- which lists a directory -- cannot see it, and a
721/// command nobody can discover is a command nobody types. `skills::shipped_names` is the one
722/// table both this and `open_command` read, so the menu cannot offer a name that then refuses.
723#[wasm_bindgen]
724pub fn shipped_skills() -> String {
725 crate::skills::shipped_names().join("\n")
726}
727
728/// The subdirectory of a turn's own folder a drafted skill is written into.
729///
730/// The page lists it and the standing prompt names it, and neither carries its own spelling:
731/// a menu looking in one directory while a daimon is told to write in another is a draft
732/// nobody ever sees, and nothing anywhere would say so.
733#[wasm_bindgen]
734pub fn skill_drafts_dir() -> String {
735 crate::skills::DRAFTS_DIR.to_string()
736}
737
738/// Where a drafted skill of `name` is installed, or empty for a name a `/name` could not reach.
739///
740/// **Empty is a refusal and must be treated as one.** `.daimond/` is a denied subtree, and this
741/// is the one write into it the app makes -- at the person's own tap, and only ever at a path
742/// composed here from a bare identifier.
743///
744/// # Arguments
745/// * `name` - The skill's name, as it would be typed after the slash.
746#[wasm_bindgen]
747pub fn skill_install_path(name: &str) -> String {
748 crate::skills::install_path(name)
749}
750
751/// Why this draft is not worth offering to install, or empty where it is.
752///
753/// # Arguments
754/// * `name` - The name taken from the draft file's own stem.
755/// * `text` - The draft file's whole text.
756#[wasm_bindgen]
757pub fn skill_draft_refusal(name: &str, text: &str) -> String {
758 crate::skills::draft_refusal(name, text).unwrap_or_default()
759}
760
761/// The starter `DAIMOND.md` a store that has never held one is given, once.
762///
763/// The text lives in `prompts::INSTRUCTIONS_SEED` rather than in the page, for
764/// `shipped_skills`'s reason: it is checked natively against the skills this build really
765/// carries, so the file cannot tell a user to type a command that would refuse.
766#[wasm_bindgen]
767pub fn instructions_seed() -> String {
768 crate::prompts::INSTRUCTIONS_SEED.to_string()
769}
770
771/// Tell this build what has been measured about which models need which notes.
772///
773/// The shipped table is right on the day it ships and a new model appears every few weeks, so
774/// the findings are data rather than a release -- the same arrangement `set_locked_packs`
775/// uses. Empty text restores the shipped default rather than clearing the table.
776///
777/// # Arguments
778/// * `text` - The table, in `prompts::NOTE_FINDINGS_SHIPPED`'s format: one model to a line,
779/// `<model>: <NOTE> <NOTE>`, `#` opening a comment.
780#[wasm_bindgen]
781pub fn set_note_findings(text: &str) {
782 crate::prompts::set_note_findings(text);
783}
784
785/// The findings table in force, which is the shipped one until a page replaces it.
786///
787/// Handed back as the text that is really running, so an operator console shows a person the
788/// table rather than this build's opinion of it.
789#[wasm_bindgen]
790pub fn note_findings() -> String {
791 crate::prompts::note_findings()
792}
793
794/// The rules appended to every tool-holding role, which a user's edit cannot
795/// take away. Shown above the editor so it is plain what is fixed and why.
796#[wasm_bindgen]
797pub fn safety_clause() -> String {
798 crate::prompts::SAFETY_CLAUSE.to_string()
799}
800
801/// The tools this build gives a chat, as JSON: `[{"tool":…,"blurb":…,"pack":…}]`.
802///
803/// The Tools panel tells a user what Daimond can do, and the only honest source for that is
804/// the registry the agent is actually handed -- a list written out again in JavaScript would
805/// drift, and the first a user would know of it is a tool that does not work or one they never
806/// knew they had.
807///
808/// `pack` is the catalogue key of the pack a tool is sold in, and empty for one Daimond ships
809/// free. It is here because the belt and the shop are no longer the same list: a tool with a
810/// non-empty `pack` is priced by the gateway and listed by `/api/tools`, and a panel that also
811/// showed it under "Built in" would be telling the user it was free.
812#[wasm_bindgen]
813pub fn builtin_tools() -> String {
814 let items = crate::tools::Tool::browser()
815 .iter()
816 .map(|t| fmt!(
817 r#"{{"tool":"{}","blurb":"{}","pack":"{}"}}"#,
818 t.name(),
819 crate::llm::json_escape(t.summary()),
820 t.pack().unwrap_or(""),
821 ))
822 .collect::<Vec<String>>();
823 fmt!("[{}]", items.join(","))
824}
825
826/// Tell this build which tool packs the account has not bought, comma separated.
827///
828/// The gateway is the authority -- `/api/tools` answers, per account, which unlocks are held --
829/// and the page is the courier: it calls this after each read with the packs that came back
830/// locked. Nothing else calls it, and in particular nothing derived from anything a model said
831/// does, exactly as with [`set_permission_mode`].
832///
833/// Passing an empty string locks nothing, which is what a device that has never reached the
834/// gateway is left with. See the section note in [`crate::tools`] for why that is the safe
835/// default rather than the lax one.
836///
837/// # Arguments
838/// * `csv` - The locked pack keys, as the gateway's catalogue spells them.
839#[wasm_bindgen]
840pub fn set_locked_packs(csv: String) {
841 crate::tools::set_locked_packs(&csv);
842}
843
844/// Which packs this build currently holds locked, comma separated.
845///
846/// Read rather than remembered by the page, for the same reason [`permission_mode`] is: a caller
847/// that failed to set one should see what is actually in force, not what it meant to set.
848#[wasm_bindgen]
849pub fn locked_packs() -> String {
850 crate::tools::locked_packs().join(",")
851}
852
853/// Whether a named tool is sold in a pack this account has not bought.
854///
855/// The whole question in one call, so a caller in JavaScript holds NO copy of a pack key: it names
856/// the tool it is about to run and is told whether it may. The mapping from tool to pack and the
857/// state of that pack both live in [`crate::tools`], which is also what
858/// [`Tool::guard`](crate::tools::Tool::guard) consults -- so the human's Compile button and the
859/// model's `typst_compile` cannot come to different conclusions about the same purchase.
860///
861/// A tool this build does not know, and a tool that is shipped free, both answer `false`: there is
862/// nothing to have bought.
863///
864/// # Arguments
865/// * `name` - The tool's stable name, as `Tool::name` spells it, e.g. `typst_compile`.
866#[wasm_bindgen]
867pub fn tool_locked(name: String) -> bool {
868 match crate::tools::Tool::from_name(&name).and_then(|t| t.pack()) {
869 Some(pack) => crate::tools::pack_locked(pack),
870 None => false,
871 }
872}
873
874
875// ─────────────────────────────────────────────────────────────────────────────
876// SIGNED ARTEFACTS: MESSAGES AND IDENTITY CARDS
877// ─────────────────────────────────────────────────────────────────────────────
878//
879// A message and an identity card travel as SBJ artefacts: a signed envelope around a canonical
880// payload, whose hash IS the artefact's address. Everything below is the byte work -- canonical
881// encoding, the SHA3-256 address, the envelope, the signing input, the assembled file -- and NONE
882// of it is the signing.
883//
884// THE SEAM, AND WHY IT IS HERE. The device signing key is a non-extractable WebCrypto `CryptoKey`
885// held by `identity.js`. It cannot be exported, so it cannot be handed to wasm, and that is a
886// property worth keeping rather than a limitation to work around: a key that never crosses a
887// module boundary cannot be leaked by anything on the other side of it. So wasm hands out a
888// SIGNING INPUT and takes a SIGNATURE back, and never sees a secret in either direction.
889//
890// The work is here rather than in JavaScript because the address function is SHA3-256, which
891// WebCrypto does not implement, and because a canonical encoding written twice is a canonical
892// encoding that will eventually disagree with itself -- and two encodings of one message are two
893// addresses.
894
895/// A message being composed, before it is encoded and signed.
896///
897/// A struct rather than one call with a dozen parameters, because a message may carry up to four
898/// references and each of the four kinds is named differently. Nothing here is signed or sealed;
899/// this is a draft, and [`PostDraft::encode`] is where it becomes bytes.
900#[wasm_bindgen]
901pub struct PostDraft {
902 /// The message under construction.
903 inner: Post,
904}
905
906#[wasm_bindgen]
907impl PostDraft {
908
909 /// Start a message: its text, the recipient's public key, and this message's nonce.
910 ///
911 /// The nonce is the caller's, from `crypto.getRandomValues`, and it is signed: two identical
912 /// messages to one recipient are two addresses rather than one message that appears to have
913 /// been sent once.
914 ///
915 /// # Arguments
916 /// * `body` - The message text.
917 /// * `to` - The recipient's 32-byte public key.
918 /// * `nonce` - 16 random bytes.
919 #[wasm_bindgen(constructor)]
920 pub fn new(body: String, to: Vec<u8>, nonce: Vec<u8>) -> PostDraft {
921 PostDraft {
922 inner: Post {
923 body,
924 to,
925 nonce,
926 reply_to: None,
927 refs: Vec::new(),
928 },
929 }
930 }
931
932 /// Say which message this one answers, by that message's address.
933 #[wasm_bindgen(js_name = replyTo)]
934 pub fn reply_to(&mut self, addr: Vec<u8>) {
935 self.inner.reply_to = Some(addr);
936 }
937
938 /// Point at a proposal on a forge repository.
939 #[wasm_bindgen(js_name = addProposal)]
940 pub fn add_proposal(&mut self, account: String, repo: String, number: u32, fallback: String) {
941 self.inner.refs.push(Reference {
942 target: Target::Proposal { account, repo, number },
943 fallback,
944 });
945 }
946
947 /// Point at a release build.
948 #[wasm_bindgen(js_name = addBuild)]
949 pub fn add_build(&mut self, id: String, fallback: String) {
950 self.inner.refs.push(Reference {
951 target: Target::Build { id },
952 fallback,
953 });
954 }
955
956 /// Point at a panel in the reader's own client.
957 #[wasm_bindgen(js_name = addPanel)]
958 pub fn add_panel(&mut self, name: String, fallback: String) {
959 self.inner.refs.push(Reference {
960 target: Target::Panel { name },
961 fallback,
962 });
963 }
964
965 /// Point at a page of the in-app guide, optionally at an anchor within it.
966 ///
967 /// An empty anchor is an ABSENT anchor, not an empty one: an optional field encoded as present
968 /// and empty would be a second encoding of the same message, and so a second address.
969 #[wasm_bindgen(js_name = addGuide)]
970 pub fn add_guide(&mut self, page: String, anchor: String, fallback: String) {
971 self.inner.refs.push(Reference {
972 target: Target::Guide {
973 page,
974 anchor: if anchor.is_empty() { None } else { Some(anchor) },
975 },
976 fallback,
977 });
978 }
979
980 /// The canonical bytes of this message, which are what the address is taken over.
981 ///
982 /// Every rule the schema declares is enforced here, so a message that no reader would accept
983 /// is never given a signature: an over-long body, a nonce of the wrong width, a fifth
984 /// reference, a recipient key that is not 32 bytes. The refusal names what was wrong.
985 pub fn encode(&self) -> Result<Vec<u8>, JsValue> {
986 self.inner.encode().map_err(to_js_err)
987 }
988}
989
990/// A share being composed, before it is encoded and signed.
991///
992/// A struct rather than one call, because a share carries a list of files and each is handed over
993/// with its own bytes. Nothing here is signed or sealed; this is a draft, and
994/// [`ShareDraft::encode`] is where it becomes bytes.
995///
996/// **A share is a COPY the receiver comes to own.** It is re-sealed to their key by the caller,
997/// it lands in their workspace as theirs, and neither side sees the other's changes to it
998/// afterwards. There is no live view here to keep in step and nothing to revoke.
999#[wasm_bindgen]
1000pub struct ShareDraft {
1001 /// The display name of the thing being shared.
1002 name: String,
1003 /// The recipient's 32-byte public key.
1004 to: Vec<u8>,
1005 /// This share's 16 random bytes.
1006 nonce: Vec<u8>,
1007 /// The sender's covering sentence, if they wrote one.
1008 note: Option<String>,
1009 /// The files, in whatever order the caller added them. `encode` puts them in path order.
1010 files: Vec<share::File>,
1011}
1012
1013#[wasm_bindgen]
1014impl ShareDraft {
1015
1016 /// Start a share: the display name, the recipient's public key, and this share's nonce.
1017 ///
1018 /// # Arguments
1019 /// * `name` - The display name of the thing being shared. Advisory, and never an identity.
1020 /// * `to` - The recipient's 32-byte public key.
1021 /// * `nonce` - 16 random bytes, from `crypto.getRandomValues`.
1022 #[wasm_bindgen(constructor)]
1023 pub fn new(name: String, to: Vec<u8>, nonce: Vec<u8>) -> ShareDraft {
1024 ShareDraft { name, to, nonce, note: None, files: Vec::new() }
1025 }
1026
1027 /// Say a covering sentence. An empty one is an ABSENT one, never an empty field.
1028 pub fn note(&mut self, text: String) {
1029 self.note = if text.is_empty() { None } else { Some(text) };
1030 }
1031
1032 /// Add one file: where it goes in the receiver's copy, and what is in it.
1033 ///
1034 /// The path is checked when the share is encoded, not here, so that a caller adding a folder
1035 /// of files is told once what is wrong with it rather than having to catch each addition.
1036 #[wasm_bindgen(js_name = addFile)]
1037 pub fn add_file(&mut self, path: String, body: Vec<u8>) {
1038 self.files.push(share::File { path, body });
1039 }
1040
1041 /// Whether what has been added so far carries a program.
1042 ///
1043 /// For the sender's own screen, so that "this includes a page they will be asked to accept"
1044 /// can be said BEFORE anything is signed. It is the same rule the payload's `code` bit is
1045 /// computed from, asked of the same crate, so the sentence a sender reads and the claim their
1046 /// signature carries cannot disagree.
1047 #[wasm_bindgen(js_name = carriesCode)]
1048 pub fn carries_code(&self) -> bool {
1049 share::code_file(&self.files).is_some()
1050 }
1051
1052 /// The canonical bytes of this share, which are what the address is taken over.
1053 ///
1054 /// Every rule the schema declares is enforced here, so a share no reader would accept is never
1055 /// given a signature: a path that walks out of the Diamond, a path under the sender's own
1056 /// `.daimond/` record, a capp delivery record, a duplicate path, too many files, too many
1057 /// bytes. The refusal names what was wrong.
1058 pub fn encode(&self) -> Result<Vec<u8>, JsValue> {
1059 // `plain` rather than `to_js_err`, for the reason [`sbj_read`] gives: `Display` carries
1060 // ANSI colour, which is rubbish on a screen rather than colour on it, and names a file and
1061 // a line nobody being told why their share was refused wants to read.
1062 Share::new(
1063 self.name.clone(),
1064 self.to.clone(),
1065 self.nonce.clone(),
1066 self.note.clone(),
1067 self.files.clone(),
1068 ).encode().map_err(|e| JsValue::from_str(&e.plain()))
1069 }
1070}
1071
1072/// A share that has been verified, so that its files can be taken out one at a time.
1073///
1074/// [`sbj_read`] says what an artefact IS, in JSON, and a share's file bodies have no business in a
1075/// JSON string: they are bytes, they may be a megabyte of them, and hexadecimal would double that
1076/// on the way through. So the generic reader names a share and lists its paths, and a caller that
1077/// means to open one asks here and takes the bodies as bytes.
1078///
1079/// Holding one *is* holding an artefact whose header, envelope, hash, signature, canonical
1080/// encoding and schema all checked out: [`share_read`] is the only way to obtain one.
1081#[wasm_bindgen]
1082pub struct ShareRead {
1083 /// The verified payload.
1084 inner: Share,
1085 /// The author of the artefact, which is the SENDER's signing key.
1086 author: Vec<u8>,
1087 /// The artefact's address.
1088 addr: Vec<u8>,
1089 /// The envelope's time, in Unix milliseconds.
1090 time: u64,
1091}
1092
1093#[wasm_bindgen]
1094impl ShareRead {
1095
1096 /// The display name the sender gave what they sent. Advisory.
1097 pub fn name(&self) -> String {
1098 self.inner.name.clone()
1099 }
1100
1101 /// Whether the sender marked this share as carrying a program.
1102 ///
1103 /// **The claim is the sender's and the signature covers it.** A relay carrying this artefact
1104 /// cannot set it, clear it, or reach it at all without the signature ceasing to verify, which
1105 /// is the only reason it is worth showing a person.
1106 pub fn code(&self) -> bool {
1107 self.inner.code
1108 }
1109
1110 /// The sender's covering sentence, or an empty string where they wrote none.
1111 pub fn note(&self) -> String {
1112 self.inner.note.clone().unwrap_or_default()
1113 }
1114
1115 /// The key this share was addressed to, which the caller must check is their own.
1116 pub fn to(&self) -> Vec<u8> {
1117 self.inner.to.clone()
1118 }
1119
1120 /// The sender's signing key.
1121 pub fn author(&self) -> Vec<u8> {
1122 self.author.clone()
1123 }
1124
1125 /// The artefact's address, as lowercase hexadecimal.
1126 pub fn address(&self) -> String {
1127 to_hex(&self.addr)
1128 }
1129
1130 /// When the sender says they signed it, in Unix milliseconds. Advisory: it is a clock nobody
1131 /// else can check.
1132 pub fn time(&self) -> f64 {
1133 self.time as f64
1134 }
1135
1136 /// How many files the share carries.
1137 pub fn count(&self) -> usize {
1138 self.inner.files.len()
1139 }
1140
1141 /// The path of file `i`, or an empty string where there is no such file.
1142 pub fn path(&self, i: usize) -> String {
1143 self.inner.files.get(i).map(|f| f.path.clone()).unwrap_or_default()
1144 }
1145
1146 /// The bytes of file `i`, or nothing where there is no such file.
1147 pub fn body(&self, i: usize) -> Vec<u8> {
1148 self.inner.files.get(i).map(|f| f.body.clone()).unwrap_or_default()
1149 }
1150
1151 /// Whether file `i` is one this build considers code.
1152 ///
1153 /// Asked per file so that the receiving side can name what it is asking them to accept, rather
1154 /// than saying "this contains code somewhere".
1155 #[wasm_bindgen(js_name = isCode)]
1156 pub fn is_code(&self, i: usize) -> bool {
1157 self.inner.files.get(i).map(|f| share::is_code_path(&f.path)).unwrap_or(false)
1158 }
1159}
1160
1161/// Verify an artefact and take it apart as a share.
1162///
1163/// Everything [`sbj_read`] does, and then the payload as bytes rather than as JSON. An artefact
1164/// that is not a share is refused by name rather than read as the nearest thing: a caller that
1165/// asked to open a share and was handed a message must be told so.
1166#[wasm_bindgen]
1167pub fn share_read(bytes: &[u8]) -> Result<ShareRead, JsValue> {
1168 let art = match doc::read_artefact(bytes) {
1169 Ok(a) => a,
1170 Err(e) => return Err(JsValue::from_str(&e.plain())),
1171 };
1172 let (env, payload) = art.into_parts();
1173 match payload {
1174 Payload::Share(s) => Ok(ShareRead {
1175 inner: s,
1176 author: env.author,
1177 addr: env.hash,
1178 time: env.time,
1179 }),
1180 other => Err(JsValue::from_str(&fmt!(
1181 "That is not a share; it declares the schema '{}'.", other.schema()))),
1182 }
1183}
1184
1185/// The canonical bytes of an identity card: what a QR code and a paste carry.
1186///
1187/// A bare public key says nothing about which key seals and which signs, carries no label, and
1188/// gives a reader no way to tell a first key from one that replaced another. This says all three.
1189/// The signing key is not a field here: it is the envelope's `author`, so a card has exactly one
1190/// place that says which key composed it.
1191///
1192/// # Arguments
1193/// * `label` - The display name the holder chose. Advisory, and never an identity.
1194/// * `enc` - The holder's 32-byte encryption subkey, which is not the signing key.
1195/// * `prev` - The 32-byte key this one supersedes, or empty for a first card.
1196#[wasm_bindgen]
1197pub fn card_encode(label: String, enc: Vec<u8>, prev: Vec<u8>) -> Result<Vec<u8>, JsValue> {
1198 let card = Card {
1199 label,
1200 enc,
1201 // The only role v0 admits. A second one would be a versioned act rather than a new string
1202 // appearing on the wire, which is why this is not a parameter.
1203 role: Role::Root,
1204 prev: if prev.is_empty() { None } else { Some(prev) },
1205 };
1206 card.encode().map_err(to_js_err)
1207}
1208
1209/// The address of a payload: the SHA3-256 digest its envelope will declare.
1210///
1211/// Here rather than in JavaScript because WebCrypto offers SHA-1, SHA-256, SHA-384 and SHA-512 and
1212/// no SHA-3 at all. A caller may show an address before anything is signed, since the address is
1213/// a property of the payload alone.
1214#[wasm_bindgen]
1215pub fn sbj_address(payload: &[u8]) -> Result<Vec<u8>, JsValue> {
1216 doc::hash_tree(envelope::HASH_SCHEME_SHA3_256, payload).map_err(to_js_err)
1217}
1218
1219/// The bytes the device key must sign for this payload to become an artefact.
1220///
1221/// Half of the seam: the envelope is built here, its signing input handed out, and the secret that
1222/// signs it stays where it is. The envelope is a pure function of these four arguments, which is
1223/// what makes the seam safe to cross -- [`sbj_assemble`] rebuilds the same envelope from the same
1224/// arguments, so a caller cannot sign one envelope and assemble a different one.
1225///
1226/// # Arguments
1227/// * `payload` - The canonical payload bytes.
1228/// * `schema` - The payload's schema, e.g. `daimond/post/0`.
1229/// * `author` - The signer's 32-byte Ed25519 public key.
1230/// * `time` - Unix milliseconds, as `Date.now()` gives them. A `f64` rather than a `u64` because
1231/// a `u64` crosses to JavaScript as a `BigInt`, which every caller would then have to build.
1232#[wasm_bindgen]
1233pub fn sbj_signing_input(
1234 payload: &[u8],
1235 schema: String,
1236 author: &[u8],
1237 time: f64,
1238)
1239 -> Result<Vec<u8>, JsValue>
1240{
1241 let env = match doc::envelope_for(payload, &schema, author, time as u64) {
1242 Ok(e) => e,
1243 Err(e) => return Err(to_js_err(e)),
1244 };
1245 Ok(env.signing_input())
1246}
1247
1248/// The finished artefact: header, envelope, payload, with the signature the caller brings back.
1249///
1250/// The other half of the seam. The signature is not checked here, because what makes an artefact
1251/// sound is that a reader accepts it and nothing else; a signature that does not verify produces a
1252/// file that every reader refuses, including this one.
1253///
1254/// # Arguments
1255/// * `payload` - The same bytes handed to [`sbj_signing_input`].
1256/// * `schema` - The same schema.
1257/// * `author` - The same public key.
1258/// * `time` - The same time. A different one gives a different envelope and a signature over
1259/// nothing.
1260/// * `sig` - The 64-byte Ed25519 signature over the signing input.
1261#[wasm_bindgen]
1262pub fn sbj_assemble(
1263 payload: &[u8],
1264 schema: String,
1265 author: &[u8],
1266 time: f64,
1267 sig: Vec<u8>,
1268)
1269 -> Result<Vec<u8>, JsValue>
1270{
1271 let mut env = match doc::envelope_for(payload, &schema, author, time as u64) {
1272 Ok(e) => e,
1273 Err(e) => return Err(to_js_err(e)),
1274 };
1275 env.sig = sig;
1276 doc::assemble(&env, payload).map_err(to_js_err)
1277}
1278
1279/// A short rendering of a key, for a person's eye. **It decides nothing.**
1280///
1281/// Eighty bits of `SHA-256(domain ‖ key)` in Crockford base 32, in four groups of four. Equality
1282/// is always the full 32-byte key, everywhere, without exception: eighty bits is well within reach
1283/// of somebody who wants two keys to look alike in a list, and anything that COMPARED fingerprints
1284/// to decide whether two keys are the same would be a defect.
1285///
1286/// One implementation, in the format's own crate, called from here. Two renderings of one key
1287/// would eventually differ on a key nobody had tested, and a user would read that as their
1288/// correspondent's key having changed.
1289#[wasm_bindgen]
1290pub fn identity_fingerprint(key: &[u8]) -> String {
1291 card::fingerprint(key)
1292}
1293
1294/// The number two people read to each other to check they hold each other's real keys.
1295///
1296/// Sixty decimal digits in twelve groups of five, over both keys sorted, so both parties compute
1297/// the same number without having to agree who is first.
1298///
1299/// It is read over a channel an attacker cannot silently rewrite -- a voice call, or in person.
1300/// Read over the same channel the keys arrived on it proves nothing, since whatever substituted
1301/// the keys can substitute the number.
1302#[wasm_bindgen]
1303pub fn identity_safety_number(a: &[u8], b: &[u8]) -> String {
1304 card::safety_number(a, b)
1305}
1306
1307/// Verify an artefact and say what it turned out to be, as JSON.
1308///
1309/// THE READER CHECKS, AND NOBODY ELSE. A signature exists so that the person receiving a message
1310/// can check who wrote it; if a server checked it and handed over a tidy result, the recipient
1311/// would have learned only that the server says so, and the signature might as well not be there.
1312/// So the whole verification runs here, on the recipient's own device: magic, envelope, tree
1313/// length, address, signature, and only then is a byte of the payload decoded.
1314///
1315/// What crosses back has passed every one of those. A failure crosses as the words of the error
1316/// and nothing else -- not the file and line, which is the developer's business and not the
1317/// reader's.
1318///
1319/// JSON rather than markup, and read into the DOM with `textContent` rather than `innerHTML`: a
1320/// format whose whole claim is that a message cannot carry code must not have its own reader
1321/// building HTML by string concatenation.
1322///
1323/// # Arguments
1324/// * `bytes` - The whole artefact, header included.
1325#[wasm_bindgen]
1326pub fn sbj_read(bytes: &[u8]) -> Result<String, JsValue> {
1327 let art = match doc::read_artefact(bytes) {
1328 Ok(a) => a,
1329 // `plain` is the words of the error. `Display` carries ANSI colour, which is rubbish on a
1330 // screen rather than colour on it, and `Debug` names a file and a line nobody reading a
1331 // message wants.
1332 Err(e) => return Err(JsValue::from_str(&e.plain())),
1333 };
1334 let (env, payload) = art.into_parts();
1335 let mut out = String::with_capacity(512);
1336 out.push('{');
1337 out.push_str(&fmt!("\"schema\":{},", json_str(&env.schema)));
1338 out.push_str(&fmt!("\"author\":{},", json_str(&to_hex(&env.author))));
1339 out.push_str(&fmt!("\"address\":{},", json_str(&to_hex(&env.hash))));
1340 // A `f64` because the time crosses to JavaScript, where every number is one, and a `u64`
1341 // written into JSON as an integer past 2^53 would be read back changed. A Unix millisecond is
1342 // nowhere near that, so this is exact.
1343 out.push_str(&fmt!("\"time\":{},", env.time as f64));
1344 out.push_str(&fmt!("\"fingerprint\":{},", json_str(&card::fingerprint(&env.author))));
1345 match payload {
1346 Payload::Card(c) => {
1347 out.push_str("\"kind\":\"card\",\"card\":{");
1348 out.push_str(&fmt!("\"label\":{},", json_str(&c.label)));
1349 out.push_str(&fmt!("\"enc\":{},", json_str(&to_hex(&c.enc))));
1350 out.push_str(&fmt!("\"role\":{}", json_str(c.role.as_str())));
1351 match &c.prev {
1352 Some(p) => out.push_str(&fmt!(",\"prev\":{}", json_str(&to_hex(p)))),
1353 None => {},
1354 }
1355 out.push('}');
1356 },
1357 Payload::Post(p) => {
1358 out.push_str("\"kind\":\"post\",\"post\":{");
1359 out.push_str(&fmt!("\"body\":{},", json_str(&p.body)));
1360 out.push_str(&fmt!("\"to\":{},", json_str(&to_hex(&p.to))));
1361 out.push_str(&fmt!("\"nonce\":{}", json_str(&to_hex(&p.nonce))));
1362 match &p.reply_to {
1363 Some(a) => out.push_str(&fmt!(",\"replyTo\":{}", json_str(&to_hex(a)))),
1364 None => {},
1365 }
1366 if !p.refs.is_empty() {
1367 out.push_str(",\"refs\":[");
1368 for (i, r) in p.refs.iter().enumerate() {
1369 if i > 0 {
1370 out.push(',');
1371 }
1372 out.push_str(&fmt!("{{\"kind\":{},\"fallback\":{}}}",
1373 json_str(r.target.key()), json_str(&r.fallback)));
1374 }
1375 out.push(']');
1376 }
1377 out.push('}');
1378 },
1379 Payload::Share(s) => {
1380 // The file BODIES are deliberately absent: they are bytes, there may be a great many
1381 // of them, and hexadecimal through a JSON string would double that on the way. This
1382 // says what the artefact is and what it would write; `share_read` hands over the
1383 // bytes.
1384 out.push_str("\"kind\":\"share\",\"share\":{");
1385 out.push_str(&fmt!("\"name\":{},", json_str(&s.name)));
1386 out.push_str(&fmt!("\"to\":{},", json_str(&to_hex(&s.to))));
1387 out.push_str(&fmt!("\"nonce\":{},", json_str(&to_hex(&s.nonce))));
1388 // The sender's signed claim about whether any of this is a program. It is reported
1389 // whether it is true or false, because a receiver being told nothing and a receiver
1390 // being told "no code" must not look the same on this side either.
1391 out.push_str(&fmt!("\"code\":{},", s.code));
1392 if let Some(n) = &s.note {
1393 out.push_str(&fmt!("\"note\":{},", json_str(n)));
1394 }
1395 out.push_str("\"files\":[");
1396 for (i, f) in s.files.iter().enumerate() {
1397 if i > 0 {
1398 out.push(',');
1399 }
1400 out.push_str(&fmt!("{{\"path\":{},\"bytes\":{},\"code\":{}}}",
1401 json_str(&f.path), f.body.len(), share::is_code_path(&f.path)));
1402 }
1403 out.push_str("]}");
1404 },
1405 // A node tree is an oxeweb document, which this app has no renderer for. Named rather than
1406 // guessed at: a reader that quietly returned nothing would look like a verification
1407 // failure, which this is not.
1408 Payload::Tree { .. } => out.push_str("\"kind\":\"tree\""),
1409 }
1410 out.push('}');
1411 Ok(out)
1412}
1413
1414/// Bytes as lowercase hexadecimal, which is how they cross to JavaScript: JSON has no byte string.
1415fn to_hex(b: &[u8]) -> String {
1416 let mut s = String::with_capacity(b.len() * 2);
1417 for byte in b {
1418 s.push_str(&fmt!("{:02x}", byte));
1419 }
1420 s
1421}
1422
1423/// A string as a JSON string literal, with everything JSON requires escaped.
1424///
1425/// The reader builds its DOM with `textContent`, so an escape missed here could not become a
1426/// script. This is the belt; that is the braces.
1427fn json_str(s: &str) -> String {
1428 let mut out = String::with_capacity(s.len() + 2);
1429 out.push('"');
1430 for c in s.chars() {
1431 match c {
1432 '"' => out.push_str("\\\""),
1433 '\\' => out.push_str("\\\\"),
1434 '\n' => out.push_str("\\n"),
1435 '\r' => out.push_str("\\r"),
1436 '\t' => out.push_str("\\t"),
1437 '\u{08}' => out.push_str("\\b"),
1438 '\u{0C}' => out.push_str("\\f"),
1439 c if (c as u32) < 0x20 => out.push_str(&fmt!("\\u{:04x}", c as u32)),
1440 c => out.push(c),
1441 }
1442 }
1443 out.push('"');
1444 out
1445}