P2 slice 6: the viewer

`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+6
View File
@@ -194,6 +194,12 @@ pub fn run(
if let Some(why) = &report.healed {
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
}
// The viewer's side channel, made as the books open (ledger_store::ops
// INVARIANT 2), so a viewer already open shows its presence before
// the first books.show. Not the books: a failure only says so.
if let Err(e) = ledger_store::ops::prepare(&data_dir) {
eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})");
}
// Books that keep their own settings never read a settings file, not
// even to start (settings INVARIANT 1); others read it as before.
let mut live = if store.ledger().settings().is_journaled() {
+125 -47
View File
@@ -44,16 +44,19 @@
//! dropped. Tokens are made, listed and revoked only through
//! the admin socket.
//! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go
//! out only by `GET /b/{id}/a/{attachment}` (plan 4.7), each through
//! the same door as 8 and counted for the close like any request:
//! an upload only from the token that began it, a download only with
//! books:read. Each, refused at the door or not, is recorded in the
//! book's access log as `upload` or `download` with its id, and the
//! out only by `GET /b/{id}/a/{attachment}` (a stored file) and `GET
//! /b/{id}/x/{export}` (a file the books made, D-29) (plan 4.7), each
//! through the same door as 8 and counted for the close like any
//! request: an upload only from the token that began it, a download
//! only with books:read, an export only to the token it was made for
//! (whose tool passed its own scope check; no other is asked). Each,
//! refused at the door or not, is recorded in the book's access log
//! as `upload`, `download` or `export` with its id, and the
//! bytes (kept, or read) and their record are one task that runs to
//! its end if the client hangs up (a process killed in that task
//! excepted); bytes whose record can't be written aren't kept, and
//! the books stop. Lapsed uploads are swept
//! every SWEEP_EVERY. Nothing comes in once the books stopped.
//! the books stop. Lapsed uploads and exports are swept every
//! SWEEP_EVERY. Nothing comes in or goes out once the books stopped.
//! ```
use crate::admin::{self, Request, Response};
@@ -108,8 +111,8 @@ type Service = StreamableHttpService<BooksServer, LocalSessionManager>;
enum Book {
/// Open and served.
Open {
/// Its MCP service.
service: Service,
/// Its MCP service (boxed: the other variant is a line of text).
service: Box<Service>,
/// Its store, closed when the server closes (INVARIANT 6).
store: Arc<SharedStore>,
/// Its access log, where requests refused at the door are
@@ -117,6 +120,8 @@ enum Book {
access: Arc<AccessLog>,
/// Its files on their way in (`/b/{id}/u/{upload}`).
uploads: ledger_store::uploads::Uploads,
/// The files it made for agents (`/b/{id}/x/{export}`).
exports: ledger_store::exports::Exports,
},
/// Not opened, with why (INVARIANT 3).
Failed(String),
@@ -271,6 +276,13 @@ impl Books {
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_uploads = uploads.clone();
let exports =
ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_exports = exports.clone();
// The viewer's side channel, made by the server (ops.rs
// INVARIANT 2): a viewer can show its presence from the start.
ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?;
let live = Arc::new(Mutex::new(live));
let store = SharedStore::new(store);
let log = access.clone();
@@ -288,17 +300,19 @@ impl Books {
.with_live_settings(live.clone())
.with_layout(layout.clone())
.with_access(log.clone())
.with_uploads(server_uploads.clone()),
.with_uploads(server_uploads.clone())
.with_exports(server_exports.clone()),
)
},
Arc::new(sessions()),
config,
);
Ok(Book::Open {
service,
service: Box::new(service),
store: shared,
access,
uploads,
exports,
})
}
@@ -1158,6 +1172,36 @@ async fn download(
axum::extract::State(books): axum::extract::State<Arc<Books>>,
axum::extract::Path((book, attachment)): axum::extract::Path<(String, String)>,
request: axum::extract::Request,
) -> axum::response::Response {
fetch(books, book, Wanted::Attachment(attachment), request).await
}
/// `GET /b/{book}/x/{export}`: a file the books made for an agent
/// (exports.rs; D-29), only to the token it was made for (INVARIANT 9);
/// recorded in the access log (action `export`).
async fn export(
axum::extract::State(books): axum::extract::State<Arc<Books>>,
axum::extract::Path((book, id)): axum::extract::Path<(String, String)>,
request: axum::extract::Request,
) -> axum::response::Response {
fetch(books, book, Wanted::Export(id), request).await
}
/// What a GET fetches.
enum Wanted {
/// A stored receipt or document, by its id.
Attachment(String),
/// A file the books made, by its id.
Export(String),
}
/// A download or an export: the door, the read and its record, the
/// headers (INVARIANT 9).
async fn fetch(
books: Arc<Books>,
book: String,
wanted: Wanted,
request: axum::extract::Request,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
@@ -1175,58 +1219,86 @@ async fn download(
books
.last_arrival
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
let (action, subject) = match &wanted {
Wanted::Attachment(id) => ("download", id.clone()),
Wanted::Export(id) => ("export", id.clone()),
};
let asked = Asked {
action: "download",
subject: attachment.clone(),
action,
subject: subject.clone(),
};
let (held, token) = match door(&books, &book, &asked, request.headers()).await {
Ok(passed) => passed,
Err(refused) => return *refused,
};
let Book::Open { access, store, .. } = &held else {
let Book::Open {
access,
store,
exports,
..
} = &held
else {
unreachable!("the door lets only open books through")
};
// The read and its record are one blocking task, run to its end even
// if the client hangs up, and counted until then.
let (access, store_for) = (access.clone(), store.clone());
let (access, store_for, exports) = (access.clone(), store.clone(), exports.clone());
let task = tokio::task::spawn_blocking(move || {
let _counted = _counted;
// An id as the books write it (no sign, no leading zeros), so the
// log holds no alias of it.
let id = attachment
.parse::<u64>()
.ok()
.filter(|n| n.to_string() == attachment);
let found = match (token.scopes.contains(&Scope::Read), id) {
use ledger_store::exports::ExportError;
let not_found = |code, what: &str| {
(
StatusCode::NOT_FOUND,
code,
format!("there is no such {what}"),
)
};
let found = if store_for.is_stopped() {
// Stopped after an internal error: as for uploads.
_ if store_for.is_stopped() => Err((
Err((
StatusCode::SERVICE_UNAVAILABLE,
"stopped",
crate::server::STOPPED.to_string(),
)),
(false, _) => Err((
))
// An export is bound to the token it was made for, whose tool
// passed its own scope check; a stored file needs books:read.
} else if matches!(wanted, Wanted::Attachment(_)) && !token.scopes.contains(&Scope::Read) {
Err((
StatusCode::FORBIDDEN,
"insufficient_scope",
format!(
"this agent's token can't do that: a download needs books:read, and its token ({:?}) hasn't it",
token.name
),
)),
(true, None) => Err((
StatusCode::NOT_FOUND,
"no_such_attachment",
"there is no such attachment".to_string(),
)),
(true, Some(id)) => match store_for.attachment_file(id) {
Ok(Some(file)) => Ok(file),
Ok(None) => Err((
StatusCode::NOT_FOUND,
"no_such_attachment",
"there is no such attachment".to_string(),
)),
// Stopped, or the stored file damaged: not a missing one.
Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)),
},
))
} else {
match &wanted {
Wanted::Export(id) => {
match exports.get(id, &token.id, chrono::Utc::now().timestamp()) {
Ok((x, bytes)) => Ok((x.file_name, x.mime, bytes)),
Err(e @ ExportError::Unknown(_)) => {
Err((StatusCode::NOT_FOUND, "no_such_export", e.to_string()))
}
Err(e @ ExportError::NotYours) => {
Err((StatusCode::FORBIDDEN, "not_yours", e.to_string()))
}
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", e.to_string())),
}
}
// An id as the books write it (no sign, no leading zeros),
// so the log holds no alias of it.
Wanted::Attachment(a) => {
match a.parse::<u64>().ok().filter(|n| n.to_string() == *a) {
None => Err(not_found("no_such_attachment", "attachment")),
Some(id) => match store_for.attachment_file(id) {
Ok(Some(file)) => Ok(file),
Ok(None) => Err(not_found("no_such_attachment", "attachment")),
// The stored file damaged: not a missing one.
Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)),
},
}
}
}
};
let record = Access {
principal: Principal {
@@ -1236,8 +1308,8 @@ async fn download(
},
client: None,
request_id: None,
action: "download".into(),
subject: attachment.clone(),
action: action.into(),
subject,
args_digest: None,
result: match &found {
Ok(_) => Outcome::Ok,
@@ -1346,7 +1418,7 @@ async fn mcp(
let Book::Open { service, .. } = &held else {
unreachable!("the door lets only open books through")
};
let service = service.clone();
let service = (**service).clone();
let principal = |t: &Token| Principal {
id: t.id.clone(),
kind: "agent".into(),
@@ -1690,17 +1762,22 @@ async fn serve(
tokio::select! {
() = sweep_stop.cancelled() => break,
_ = tick.tick() => {
let uploads: Vec<_> = sweeping
let held: Vec<_> = sweeping
.held()
.values()
.filter_map(|b| match b {
Book::Open { uploads, .. } => Some(uploads.clone()),
Book::Open { uploads, exports, .. } => {
Some((uploads.clone(), exports.clone()))
}
Book::Failed(_) => None,
})
.collect();
let now = chrono::Utc::now().timestamp();
let _ = tokio::task::spawn_blocking(move || {
uploads.iter().for_each(|u| u.sweep(now));
for (u, x) in &held {
u.sweep(now);
x.sweep(now);
}
})
.await;
}
@@ -1712,6 +1789,7 @@ async fn serve(
.route("/b/{book}/mcp", axum::routing::any(mcp))
.route("/b/{book}/u/{upload}", axum::routing::put(upload))
.route("/b/{book}/a/{attachment}", axum::routing::get(download))
.route("/b/{book}/x/{export}", axum::routing::get(export))
.fallback(not_found)
.with_state(books.clone());
// The HTTP side keeps answering (refusing, once closing) until the
File diff suppressed because it is too large. Load diff
+114
View File
@@ -24,6 +24,10 @@
//! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
//! before it is decoded.
//! 6. A file the books make for an agent (D-29) is handed over as an
//! export the caller's token fetches with `wanderbooks download`
//! (books served from the data root), or over stdio inline; never
//! by a path.
//! ```
use super::*;
@@ -114,7 +118,83 @@ pub struct BeginUploadOut {
pub existing_attachment_id: Option<u64>,
}
/// `name` made a name the checks take: control and invisible characters
/// and separators dropped or made `_`, cut to fit; `file` if nothing is
/// left.
fn plain_file_name(name: &str) -> String {
let mut out: String = name
.chars()
.filter(|c| !ledger_store::uploads::is_hidden_char(*c))
.map(|c| {
if c.is_control() || c == '/' || c == '\\' {
'_'
} else {
c
}
})
.collect();
while out.len() > ledger_store::uploads::MAX_NAME_BYTES {
out.pop();
}
let out = out.trim().to_string();
if ledger_store::uploads::file_name_problem(&out).is_none() {
out
} else {
"file".to_string()
}
}
impl BooksServer {
/// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export
/// for the caller's token, or inline over stdio.
pub(crate) fn hand_over(
&self,
book: &str,
file_name: &str,
mime: &str,
bytes: Vec<u8>,
) -> Result<HandedFile, String> {
// A name kept before names were checked (a W-9 sealed earlier) is
// made plain rather than refused: the file is the point.
let file_name = match ledger_store::uploads::file_name_problem(file_name) {
None => file_name.to_string(),
Some(_) => plain_file_name(file_name),
};
let mut out = HandedFile {
file_name: file_name.clone(),
mime_type: mime.to_string(),
size_bytes: bytes.len() as u64,
sha256: ledger_store::journal::sha256_hex(&bytes),
download: None,
expires_at: None,
content_base64: None,
};
let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else {
out.content_base64 = Some(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
));
return Ok(out);
};
let kept = exports
.put(
&token,
&file_name,
mime,
&bytes,
chrono::Utc::now().timestamp(),
)
.map_err(|e| e.to_string())?;
out.download = Some(format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
token_flag(),
kept.id
));
out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
Ok(out)
}
/// The token of the call running on this thread (its id), if any.
fn caller_token(&self) -> Option<String> {
CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone()))
@@ -264,3 +344,37 @@ impl BooksServer {
}))
}
}
#[cfg(test)]
mod tests {
use super::plain_file_name;
/// A name kept before names were checked is handed over cleaned, never
/// refused (a W-9 sealed earlier, say).
#[test]
fn an_old_name_is_made_plain() {
assert_eq!(
plain_file_name("W9 \u{2714}\u{FE0F}.pdf"),
"W9 \u{2714}.pdf"
);
assert_eq!(plain_file_name("W-9\u{200E}.pdf"), "W-9.pdf");
assert_eq!(plain_file_name("a/b\\c\n.pdf"), "a_b_c_.pdf");
assert_eq!(plain_file_name("\u{200B}"), "file");
assert_eq!(plain_file_name(".."), "file");
let long = "x".repeat(400);
let cut = plain_file_name(&long);
assert!(cut.len() <= ledger_store::uploads::MAX_NAME_BYTES);
for n in [
"W9 \u{2714}\u{FE0F}.pdf",
"a/b\\c\n.pdf",
&long,
"\u{202E}fdp.exe",
] {
assert_eq!(
ledger_store::uploads::file_name_problem(&plain_file_name(n)),
None,
"{n:?}"
);
}
}
}
+41 -38
View File
@@ -362,15 +362,14 @@ pub struct Vendor1099Params {
/// through 2025, $2,000 from 2026).
pub threshold: Option<String>,
/// True: each line carries its payee's full TIN, read from the TIN
/// vault (or a note saying why not). The output then holds full TINs:
/// for the CPA only, when the human asks for it. Default false (last
/// four digits only).
/// vault (or a note saying why not). Default false (last four digits
/// only).
#[serde(default)]
pub include_tins: bool,
/// Also write the worksheet as CSV to this local path (TINs included
/// only with include_tins). Never inside the books dir, and with TINs
/// an existing file is refused.
pub save_csv_to: Option<String>,
/// True: also hand the worksheet over as a CSV file (TINs included
/// with include_tins), to save with its download command.
#[serde(default)]
pub csv: bool,
}
/// One vendor's year.
@@ -415,11 +414,10 @@ pub struct Vendor1099Out {
pub missing_w9: Vec<String>,
/// Caveats for the human and CPA.
pub notes: Vec<String>,
/// With include_tins: this output holds full TINs. For the CPA only;
/// never paste it anywhere else.
/// With include_tins: this output holds full TINs, for the CPA.
pub warning: Option<String>,
/// The CSV written with save_csv_to, if any.
pub saved_csv: Option<String>,
/// With csv: the worksheet as a CSV file, handed over.
pub csv: Option<HandedFile>,
}
/// Parameters for `books.verify`.
@@ -492,6 +490,8 @@ pub struct ShowParams {
pub struct ShowOut {
/// What the viewer was asked to show.
pub shown: String,
/// Whether a viewer has these books open now.
pub viewer_open: bool,
/// Where this goes and what to tell the human.
pub note: String,
}
@@ -2604,28 +2604,16 @@ pub struct ExportCpaPackageParams {
pub include_receipts: bool,
/// Carry every member's and 1099 payee's full TIN from the TIN vault
/// in the K-1 and 1099 worksheets (default true: the CPA needs them).
/// Then `save_to` is required, outside the books dir, and the package
/// never comes back in the output. False: last fours only, and the
/// zip comes back as base64 unless `save_to` is given.
/// False: last fours only.
#[serde(default = "default_true")]
pub include_tins: bool,
/// Where to write the package: a new `.zip` file, or an existing
/// folder (the zip goes in it as `cpa-package-<as_of>.zip`). Written
/// readable by its owner only. With TINs, never inside the books dir.
pub save_to: Option<String>,
}
/// Output of `books.export_cpa_package`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ExportCpaPackageOut {
/// File name, e.g. "cpa-package-2026-10-11.zip".
pub file_name: String,
/// The zip, base64-encoded: only when it isn't saved (`save_to`
/// absent, which needs include_tins false).
pub content_base64: Option<String>,
/// The path the zip was written to, when saved. Tell the human; never
/// open it, attach it, or copy it into the books.
pub saved_to: Option<String>,
/// The zip: run its `download` to save it.
pub file: HandedFile,
/// Whether the worksheets carry full TINs from the vault.
pub tins_included: bool,
/// With TINs: each member or 1099 payee whose TIN the package lacks,
@@ -2822,24 +2810,39 @@ pub struct TinOut {
pub struct GetW9Params {
/// The party: id or name.
pub party: String,
/// Where to write the W-9 file: a new local file outside the books
/// (the file shows the full TIN). An existing file is refused.
pub save_to: String,
}
/// Result of `books.get_w9`: where the file went, never its contents.
/// A file the books made for an agent, handed over (tools/files.rs
/// INVARIANT 6; D-29).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HandedFile {
/// Its name, e.g. "cpa-package-2026-10-11.zip".
pub file_name: String,
/// Its MIME type.
pub mime_type: String,
/// Its size in bytes.
pub size_bytes: u64,
/// Its SHA-256, hex.
pub sha256: String,
/// The command that saves it in a folder you name (run it; books
/// served by `wanderbooks mcp`). Kept 15 minutes.
#[serde(skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
/// When it lapses (UTC), with `download`.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// Over stdio: its bytes, base64.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
}
/// Result of `books.get_w9`: the W-9 file, handed over.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct GetW9Out {
/// The party.
pub party_id: u64,
/// The path written.
pub path: String,
/// The file's original name.
pub file_name: String,
/// Its size in bytes.
pub size_bytes: u64,
/// What to tell the human.
pub note: String,
/// The file: run its `download` to save it.
pub file: HandedFile,
}
/// Parameters for `books.purge_tin`.