diff options
Diffstat (limited to 'crates/chat-server')
| -rw-r--r-- | crates/chat-server/Cargo.toml | 4 | ||||
| -rw-r--r-- | crates/chat-server/src/app.rs | 199 | ||||
| -rw-r--r-- | crates/chat-server/src/app/cli.rs | 167 | ||||
| -rw-r--r-- | crates/chat-server/src/app/http.rs (renamed from crates/chat-server/src/http.rs) | 4 | ||||
| -rw-r--r-- | crates/chat-server/src/app/maintenance.rs (renamed from crates/chat-server/src/maintenance.rs) | 8 | ||||
| -rw-r--r-- | crates/chat-server/src/app/service.rs (renamed from crates/chat-server/src/service.rs) | 16 | ||||
| -rw-r--r-- | crates/chat-server/src/app/store.rs (renamed from crates/chat-server/src/store.rs) | 24 | ||||
| -rw-r--r-- | crates/chat-server/src/main.rs | 202 | ||||
| -rw-r--r-- | crates/chat-server/tests/browser.ts | 104 | ||||
| -rw-r--r-- | crates/chat-server/tests/progress.ts | 28 | ||||
| -rw-r--r-- | crates/chat-server/web/app.ts | 165 | ||||
| -rw-r--r-- | crates/chat-server/web/worker.ts | 4 |
12 files changed, 561 insertions, 364 deletions
diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml index 6ebb2df..9a6ef7e 100644 --- a/crates/chat-server/Cargo.toml +++ b/crates/chat-server/Cargo.toml @@ -31,14 +31,14 @@ stack-config = { path = "../config", version = "0.1.0" } anyhow = "1" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } base64 = "0.22" -clap = { version = "4.6", features = ["derive"] } +clap = "4.6" hex = "0.4" openssl = "0.10" rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } serde_json = "1" stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" } tokio-util = { version = "0.7", features = ["io"] } -tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "signal", "sync", "fs"] } [dev-dependencies] tempfile = "3" diff --git a/crates/chat-server/src/app.rs b/crates/chat-server/src/app.rs new file mode 100644 index 0000000..31efd28 --- /dev/null +++ b/crates/chat-server/src/app.rs @@ -0,0 +1,199 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +#![doc = "Encrypted private chat server and backend maintenance commands."] + +mod cli; +mod http; +mod maintenance; +mod service; +mod store; + +use crate::app::cli::Arguments; +use anyhow::{Context, Result, ensure}; +use std::io::{Read, Write}; +use std::net::SocketAddr; +use std::process::ExitCode; + +fn initialize(root: &std::path::Path) -> Result<()> { + let env = root.join(".env"); + let existing = if env.exists() { + std::fs::read_to_string(&env)? + } else { + String::new() + }; + ensure!( + !existing + .lines() + .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), + "CHAT_SETUP_TOKEN already exists" + ); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&env)?; + std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; + writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; + if !existing + .lines() + .any(|line| line.starts_with("CHAT_ORIGIN=")) + { + writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; + } + stack_config::initialize_public(root) +} + +fn healthcheck(mut address: SocketAddr) -> Result<()> { + if address.ip().is_unspecified() { + address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + } + let mut socket = + std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?; + socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; + socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?; + let mut response = String::new(); + socket.take(8192).read_to_string(&mut response)?; + ensure!( + response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"), + "Chat health endpoint failed" + ); + Ok(()) +} + +fn validate_origin( + configuration: &stack_config::Config, + bind: SocketAddr, + proxy: bool, +) -> Result<()> { + let origin = configuration.value("CHAT_ORIGIN")?; + if proxy { + let public = configuration.public_values(None)?; + let canonical = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing public chat origin")?; + let local = format!( + "http://{}:{}", + configuration.value("CHAT_LOCAL_HOST")?, + configuration.port("LOCAL_HTTP_PORT")? + ); + ensure!( + origin == canonical || origin == local, + "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname" + ); + } else { + ensure!( + bind.ip().is_loopback() && origin == format!("http://{bind}"), + "Direct development chat requires an exact loopback HTTP origin" + ); + } + Ok(()) +} + +async fn serve(arguments: Arguments) -> Result<()> { + let root = arguments.root.canonicalize()?; + if arguments.init { + return initialize(&root); + } + let configuration = if arguments.listener.proxy { + stack_config::Config::from_environment()? + } else { + stack_config::Config::load(&root)? + }; + let bind = arguments + .listener + .bind + .unwrap_or(configuration.chat_bind()?); + if arguments.healthcheck { + return healthcheck(bind); + } + let data = root.join("var/data/chat"); + std::fs::create_dir_all(&data)?; + std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; + let database = data.join("chat.sqlite"); + let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint { + Some(maintenance::lock(&data)?) + } else { + None + }; + let mut store = store::Store::open(&database)?; + std::fs::set_permissions( + &database, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + if arguments.owner_fingerprint { + return maintenance::fingerprint(&store); + } + if let Some(expected) = arguments.reset_owner { + return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); + } + if let Some(destination) = arguments.snapshot { + store.snapshot(&destination)?; + std::fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + return Ok(()); + } + let origin = configuration.value("CHAT_ORIGIN")?; + validate_origin(&configuration, bind, arguments.listener.proxy)?; + let setup = configuration + .value("CHAT_SETUP_TOKEN") + .context("Run --init to configure chat")?; + let state = service::Service::new(store, origin.to_owned(), setup.to_owned()); + let listener = tokio::net::TcpListener::bind(bind).await?; + writeln!( + std::io::stdout().lock(), + "Chat ready: {origin} (owner setup: /#setup)" + )?; + let local = (arguments.listener.proxy && origin.starts_with("http://")) + .then(|| configuration.port("LOCAL_HTTP_PORT")) + .transpose()?; + http::serve(listener, state, &root, configuration.public_values(local)?).await +} + +fn execute(arguments: Arguments) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(serve(arguments)) +} + +#[doc = "Runs the checked command-line entry point and returns its process status."] +#[must_use] +pub fn run() -> ExitCode { + let arguments = match Arguments::try_parse() { + Ok(arguments) => arguments, + Err(error) => { + let status = if error.use_stderr() { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + }; + drop(error.print()); + return status; + } + }; + match execute(arguments) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(writeln!( + std::io::stderr().lock(), + "Chat server failed: {error:#}" + )); + ExitCode::FAILURE + } + } +} diff --git a/crates/chat-server/src/app/cli.rs b/crates/chat-server/src/app/cli.rs new file mode 100644 index 0000000..1296df4 --- /dev/null +++ b/crates/chat-server/src/app/cli.rs @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +use clap::{Arg, ArgAction, Command}; +use std::ffi::OsString; +use std::net::SocketAddr; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Arguments { + pub(super) root: PathBuf, + + pub(super) listener: Listener, + + pub(super) healthcheck: bool, + + pub(super) init: bool, + + pub(super) reset_owner: Option<String>, + + pub(super) owner_fingerprint: bool, + + pub(super) snapshot: Option<PathBuf>, +} + +#[derive(Debug)] +pub(super) struct Listener { + pub(super) bind: Option<SocketAddr>, + + pub(super) proxy: bool, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .value_parser(clap::builder::PathBufValueParser::new()) +} + +fn command() -> Command { + Command::new("stack-chat-server").about("Run private chat directly or behind the stack gateway") + .args([ + path("root").default_value("."), + Arg::new("bind").long("bind").value_parser(str::parse::<SocketAddr>), + flag("proxy").help("Run behind the stack gateway using its explicit container environment"), + flag("healthcheck").help("Check the local HTTP health endpoint without opening the database"), + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint", "snapshot"]), + Arg::new("reset-owner").long("reset-owner").value_name("FINGERPRINT").conflicts_with_all(["owner-fingerprint", "snapshot"]) + .help("Permanently clear the matching owner registration and all chat history after key loss"), + flag("owner-fingerprint").conflicts_with("snapshot").help("Print the registered owner's public fingerprint"), + path("snapshot"), + ]) +} + +impl Arguments { + pub(super) fn try_parse() -> Result<Self, clap::Error> { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>, + ) -> Result<Self, clap::Error> { + let mut values = command().try_get_matches_from(arguments)?; + let root = values.remove_one("root").ok_or_else(|| { + clap::Error::raw( + clap::error::ErrorKind::MissingRequiredArgument, + "Missing application root", + ) + })?; + Ok(Self { + root, + listener: Listener { + bind: values.remove_one("bind"), + proxy: values.get_flag("proxy"), + }, + healthcheck: values.get_flag("healthcheck"), + init: values.get_flag("init"), + reset_owner: values.remove_one("reset-owner"), + owner_fingerprint: values.get_flag("owner-fingerprint"), + snapshot: values.remove_one("snapshot"), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Arguments, command}; + use anyhow::{Result, ensure}; + use std::path::Path; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn listener_defaults_and_maintenance_conflicts() -> Result<()> { + let defaults = Arguments::try_parse_from(["stack-chat-server"])?; + anyhow::ensure!((defaults.root) == (Path::new(".")), "Test invariant failed"); + anyhow::ensure!( + !defaults.listener.proxy && defaults.listener.bind.is_none(), + "Test invariant failed" + ); + let parsed = Arguments::try_parse_from([ + "stack-chat-server", + "--root", + "/tmp/stack", + "--bind", + "127.0.0.1:9180", + "--proxy", + "--healthcheck", + ])?; + anyhow::ensure!( + (parsed.root) == (Path::new("/tmp/stack")), + "Test invariant failed" + ); + ensure!( + parsed + .listener + .bind + .is_some_and(|address| address.port() == 9180), + "Listener port differs" + ); + anyhow::ensure!( + parsed.listener.proxy && parsed.healthcheck, + "Test invariant failed" + ); + for args in [ + vec!["stack-chat-server", "--bind", "garbage"], + vec!["stack-chat-server", "--init", "--snapshot", "snapshot"], + vec![ + "stack-chat-server", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec![ + "stack-chat-server", + "--owner-fingerprint", + "--snapshot", + "snapshot", + ], + ] { + anyhow::ensure!( + Arguments::try_parse_from(args).is_err(), + "Test invariant failed" + ); + } + Ok(()) + } +} diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/app/http.rs index cb21b1f..5b1941a 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/app/http.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -use crate::service::Service; +use crate::app::service::Service; use anyhow::Result; use axum::{ Json, Router, @@ -212,7 +212,7 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { secured(response) } -pub(crate) async fn serve( +pub(super) async fn serve( listener: tokio::net::TcpListener, service: Service, root: &Path, diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/app/maintenance.rs index d416d73..647dff9 100644 --- a/crates/chat-server/src/maintenance.rs +++ b/crates/chat-server/src/app/maintenance.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -use crate::store::Store; +use crate::app::store::Store; use anyhow::{Context, Result, ensure}; use std::fs::{File, OpenOptions}; use std::io::Write; @@ -21,7 +21,7 @@ use std::net::{SocketAddr, TcpListener}; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; -pub(crate) fn lock(directory: &Path) -> Result<File> { +pub(super) fn lock(directory: &Path) -> Result<File> { let file = OpenOptions::new() .read(true) .write(true) @@ -35,7 +35,7 @@ pub(crate) fn lock(directory: &Path) -> Result<File> { Ok(file) } -pub(crate) fn fingerprint(store: &Store) -> Result<()> { +pub(super) fn fingerprint(store: &Store) -> Result<()> { let certificate = store.owner()?.context("No owner identity is registered")?; writeln!( std::io::stdout().lock(), @@ -45,7 +45,7 @@ pub(crate) fn fingerprint(store: &Store) -> Result<()> { Ok(()) } -pub(crate) fn reset( +pub(super) fn reset( store: &mut Store, expected: &str, address: SocketAddr, diff --git a/crates/chat-server/src/service.rs b/crates/chat-server/src/app/service.rs index 3f9b6ee..d5433e7 100644 --- a/crates/chat-server/src/service.rs +++ b/crates/chat-server/src/app/service.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -use crate::store::Store; +use crate::app::store::Store; use anyhow::{Context, Result, ensure}; use base64::{Engine, prelude::BASE64_STANDARD}; use serde_json::{Value, json}; @@ -32,9 +32,9 @@ struct Session { expiry: Instant, } -pub(crate) struct Service { - pub(crate) store: Store, - pub(crate) origin: String, +pub(super) struct Service { + pub(super) store: Store, + pub(super) origin: String, setup_token: String, challenges: HashMap<String, Challenge>, sessions: HashMap<String, Session>, @@ -42,13 +42,13 @@ pub(crate) struct Service { attempts: u16, } -pub(crate) fn token() -> Result<String> { +pub(super) fn token() -> Result<String> { let mut bytes = [0; 32]; openssl::rand::rand_bytes(&mut bytes)?; Ok(hex::encode(bytes)) } -pub(crate) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { +pub(super) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { request .get(key) .and_then(Value::as_str) @@ -73,7 +73,7 @@ fn identifier(value: &str) -> Result<()> { } impl Service { - pub(crate) fn new(store: Store, origin: String, setup_token: String) -> Self { + pub(super) fn new(store: Store, origin: String, setup_token: String) -> Self { Self { store, origin, @@ -201,7 +201,7 @@ impl Service { Ok(conversation.into()) } - pub(crate) fn call(&mut self, request: &Value) -> Result<Value> { + pub(super) fn call(&mut self, request: &Value) -> Result<Value> { match field(request, "op")? { "config" => Ok( json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}), diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/app/store.rs index 0e09de7..23e8ba1 100644 --- a/crates/chat-server/src/store.rs +++ b/crates/chat-server/src/app/store.rs @@ -18,18 +18,18 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde_json::{Value, json}; use std::path::Path; -pub(crate) struct Store { +pub(super) struct Store { connection: Connection, } impl Store { - pub(crate) fn open(path: &Path) -> Result<Self> { + pub(super) fn open(path: &Path) -> Result<Self> { let connection = Connection::open(path)?; connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?; Ok(Self { connection }) } - pub(crate) fn owner(&self) -> Result<Option<String>> { + pub(super) fn owner(&self) -> Result<Option<String>> { Ok(self .connection .query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| { @@ -38,14 +38,14 @@ impl Store { .optional()?) } - pub(crate) fn install_owner(&self, cert: &str) -> Result<()> { + pub(super) fn install_owner(&self, cert: &str) -> Result<()> { ensure!(self.owner()?.is_none(), "Already initialized"); self.connection .execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?; Ok(()) } - pub(crate) fn reset_owner(&mut self, expected: &str) -> Result<()> { + pub(super) fn reset_owner(&mut self, expected: &str) -> Result<()> { let owner = self.owner()?.context("No owner identity is registered")?; let fingerprint = stack_chat_crypto::public_identity(&owner)?; ensure!( @@ -58,7 +58,7 @@ impl Store { Ok(()) } - pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> { + pub(super) fn register(&self, id: &str, cert: &str) -> Result<()> { if self.connection.query_row( "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", [id], @@ -77,7 +77,7 @@ impl Store { Ok(()) } - pub(crate) fn conversation(&self, id: &str) -> Result<Value> { + pub(super) fn conversation(&self, id: &str) -> Result<Value> { let (cert, profile): (String, Option<String>) = self.connection.query_row( "SELECT cert,profile FROM conversations WHERE id=?1", [id], @@ -93,7 +93,7 @@ impl Store { ) } - pub(crate) fn inbox(&self) -> Result<Value> { + pub(super) fn inbox(&self) -> Result<Value> { let mut statement = self .connection .prepare("SELECT id FROM conversations ORDER BY id")?; @@ -107,7 +107,7 @@ impl Store { Ok(json!({"conversations":conversations})) } - pub(crate) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { + pub(super) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { self.connection.execute( "UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL", params![id, ciphertext], @@ -115,7 +115,7 @@ impl Store { Ok(()) } - pub(crate) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> { + pub(super) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> { ensure!( count <= 50 && before <= i64::MAX.unsigned_abs(), "Invalid history window" @@ -125,7 +125,7 @@ impl Store { Ok(json!({"conversation":self.conversation(id)?,"messages":messages})) } - pub(crate) fn send( + pub(super) fn send( &mut self, conversation: &str, id: &str, @@ -173,7 +173,7 @@ impl Store { Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?})) } - pub(crate) fn snapshot(&self, destination: &Path) -> Result<()> { + pub(super) fn snapshot(&self, destination: &Path) -> Result<()> { ensure!(!destination.exists(), "Snapshot destination already exists"); self.connection .backup("main", destination, None::<fn(rusqlite::backup::Progress)>)?; diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs index 1d600b7..942a379 100644 --- a/crates/chat-server/src/main.rs +++ b/crates/chat-server/src/main.rs @@ -13,204 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -#![doc = "Encrypted private chat server and backend maintenance commands."] +#![doc = "Checked command-line entry point for the personal infrastructure stack."] -mod http; -mod maintenance; -mod service; -mod store; +mod app; -use anyhow::{Context, Result, ensure}; -use clap::Parser; -use std::io::{Read, Write}; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::process::ExitCode; +pub use app::run; -#[derive(Debug, Parser)] -#[command(about = "Run private chat directly or behind the stack gateway")] -struct Arguments { - #[arg(long, default_value = ".")] - root: PathBuf, - #[command(flatten)] - listener: Listener, - #[arg( - long, - help = "Check the local HTTP health endpoint without opening the database" - )] - healthcheck: bool, - #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])] - init: bool, - #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")] - reset_owner: Option<String>, - #[arg( - long, - conflicts_with = "snapshot", - help = "Print the registered owner's public fingerprint" - )] - owner_fingerprint: bool, - #[arg(long)] - snapshot: Option<PathBuf>, -} - -#[derive(Debug, clap::Args)] -struct Listener { - #[arg(long)] - bind: Option<SocketAddr>, - #[arg( - long, - help = "Run behind the stack gateway using its explicit container environment" - )] - proxy: bool, -} - -fn initialize(root: &std::path::Path) -> Result<()> { - let env = root.join(".env"); - let existing = if env.exists() { - std::fs::read_to_string(&env)? - } else { - String::new() - }; - ensure!( - !existing - .lines() - .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), - "CHAT_SETUP_TOKEN already exists" - ); - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&env)?; - std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; - writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; - if !existing - .lines() - .any(|line| line.starts_with("CHAT_ORIGIN=")) - { - writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; - } - stack_config::initialize_public(root) -} - -fn healthcheck(mut address: SocketAddr) -> Result<()> { - if address.ip().is_unspecified() { - address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); - } - let mut socket = - std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?; - socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; - socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; - socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?; - let mut response = String::new(); - socket.take(8192).read_to_string(&mut response)?; - ensure!( - response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"), - "Chat health endpoint failed" - ); - Ok(()) -} - -fn validate_origin( - configuration: &stack_config::Config, - bind: SocketAddr, - proxy: bool, -) -> Result<()> { - let origin = configuration.value("CHAT_ORIGIN")?; - if proxy { - let public = configuration.public_values(None)?; - let canonical = public - .get("CHAT_PUBLIC_ORIGIN") - .context("Missing public chat origin")?; - let local = format!( - "http://{}:{}", - configuration.value("CHAT_LOCAL_HOST")?, - configuration.port("LOCAL_HTTP_PORT")? - ); - ensure!( - origin == canonical || origin == local, - "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname" - ); - } else { - ensure!( - bind.ip().is_loopback() && origin == format!("http://{bind}"), - "Direct development chat requires an exact loopback HTTP origin" - ); - } - Ok(()) -} - -async fn run(arguments: Arguments) -> Result<()> { - let root = arguments.root.canonicalize()?; - if arguments.init { - return initialize(&root); - } - let configuration = if arguments.listener.proxy { - stack_config::Config::from_environment()? - } else { - stack_config::Config::load(&root)? - }; - let bind = arguments - .listener - .bind - .unwrap_or(configuration.chat_bind()?); - if arguments.healthcheck { - return healthcheck(bind); - } - let data = root.join("var/data/chat"); - std::fs::create_dir_all(&data)?; - std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; - let database = data.join("chat.sqlite"); - let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint { - Some(maintenance::lock(&data)?) - } else { - None - }; - let mut store = store::Store::open(&database)?; - std::fs::set_permissions( - &database, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - )?; - if arguments.owner_fingerprint { - return maintenance::fingerprint(&store); - } - if let Some(expected) = arguments.reset_owner { - return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy); - } - if let Some(destination) = arguments.snapshot { - store.snapshot(&destination)?; - std::fs::set_permissions( - destination, - std::os::unix::fs::PermissionsExt::from_mode(0o600), - )?; - return Ok(()); - } - let origin = configuration.value("CHAT_ORIGIN")?; - validate_origin(&configuration, bind, arguments.listener.proxy)?; - let setup = configuration - .value("CHAT_SETUP_TOKEN") - .context("Run --init to configure chat")?; - let state = service::Service::new(store, origin.to_owned(), setup.to_owned()); - let listener = tokio::net::TcpListener::bind(bind).await?; - writeln!( - std::io::stdout().lock(), - "Chat ready: {origin} (owner setup: /#setup)" - )?; - let local = (arguments.listener.proxy && origin.starts_with("http://")) - .then(|| configuration.port("LOCAL_HTTP_PORT")) - .transpose()?; - http::serve(listener, state, &root, configuration.public_values(local)?).await -} - -#[tokio::main] -async fn main() -> ExitCode { - match run(Arguments::parse()).await { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - drop(writeln!( - std::io::stderr().lock(), - "Chat server failed: {error:#}" - )); - ExitCode::FAILURE - } - } +fn main() -> std::process::ExitCode { + run() } diff --git a/crates/chat-server/tests/browser.ts b/crates/chat-server/tests/browser.ts index 74a9df2..468a5f5 100644 --- a/crates/chat-server/tests/browser.ts +++ b/crates/chat-server/tests/browser.ts @@ -19,20 +19,20 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; +import { spawn, execFileSync, spawnSync, execFile, type SpawnSyncReturns } from 'node:child_process'; import { promisify } from 'node:util'; import { chromium, firefox, type Browser, type BrowserContext, type Page } from 'playwright'; -import { libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js'; +import { closeBrowser, libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js'; const run = promisify(execFile); const testRoot = process.env['CHAT_TEST_ROOT']; const packaged = Boolean(testRoot); -let root = testRoot ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +let root = testRoot !== undefined && testRoot.length > 0 ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); const initialRoot = root; const token = packaged - ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) || '' + ? ((await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) ?? '') : randomBytes(32).toString('hex'); -const origin = process.env['CHAT_TEST_ORIGIN'] || `http://127.0.0.1:${process.env['CHAT_TEST_PORT'] || '9081'}`; +const origin = (process.env['CHAT_TEST_ORIGIN'] ?? `http://127.0.0.1:${(process.env['CHAT_TEST_PORT'] ?? '9081')}`); if (packaged) { const fixture = await readFile(join(root,'.env'),'utf8'); const address = new URL(origin); @@ -53,32 +53,32 @@ if (packaged) { await mkdir(join(root,'var'), {recursive:true}); await symlink(resolve('var/build'),join(root,'var/build')); } -async function stack(...args: string[]) { +async function stack(...args: string[]): Promise<{ stdout: string; stderr: string; }> { return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); } -async function restorePackage() { +async function restorePackage(): Promise<void> { const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); const fingerprint = required(required(listed.stdout.split('\n').find(line => line.startsWith('fpr:')), 'Missing key fingerprint').split(':')[9], 'Missing key fingerprint field'); const key = join(testHome,'recipient.asc'); const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); await writeFile(key,exported.stdout); const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key); - const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1]; - assert.ok(archive,backup.stdout); + const archive = (/Encrypted export: (.+)/.exec(backup.stdout))?.[1]; + assert.ok(archive !== undefined && archive.length > 0,backup.stdout); assert.equal((await stat(archive)).mode & 0o777,0o600); assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600); await stopServer(); - const destination = `${initialRoot}-restored-${++restorations}`; + const destination = `${initialRoot}-restored-${String(++restorations)}`; await stack('restore',archive,destination); root = destination; assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8')); for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1); await launchServer(); - console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); + console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${String(restorations)}.`); } let server: ReturnType<typeof spawn> | undefined; let serverErrors = ''; -async function verifyGateway() { +async function verifyGateway(): Promise<void> { const url = new URL(origin); const args = ['--noproxy','*','--silent','--show-error','--fail']; if (url.hostname.endsWith('.localhost')) { @@ -90,7 +90,7 @@ async function verifyGateway() { const health = await run('curl',[...args,origin + '/healthz']); assert.equal(health.stdout,'ok\n'); } -async function launchServer() { +async function launchServer(): Promise<void> { if (packaged) { await stack('up','--production'); let failure; @@ -101,17 +101,20 @@ async function launchServer() { throw failure; } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); - server.stderr?.on('data', bytes => { serverErrors += bytes.toString(); }); + server.stderr?.on('data', (bytes: unknown) => { + if (!Buffer.isBuffer(bytes)) throw new Error('Expected server diagnostics as bytes'); + serverErrors += bytes.toString(); + }); await Promise.race([ - new Promise<void>((ready, reject) => { server?.stdout?.once('data', () => ready()); server?.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), - new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), + new Promise<void>((ready, reject) => { server?.stdout?.once('data', () => { ready(); }); server?.once('exit', () => { reject(new Error((serverErrors.length > 0 ? serverErrors : 'Server exited'))); }); }), + new Promise((_, reject) => setTimeout(() => { reject(new Error('Server did not start')); }, 20000).unref()), ]); } -async function stopServer() { +async function stopServer(): Promise<void> { if (packaged) { await stack('down'); return; } - if (!server) return; + if (server === undefined) return; if (server.exitCode !== null || server.signalCode !== null) return; - const stopped = new Promise<void>(done => server?.once('exit', () => done())); + const stopped = new Promise<void>(done => server?.once('exit', () => { done(); })); server.kill('SIGINT'); await stopped; } @@ -128,21 +131,21 @@ if (libre) { 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, 'browser.sessionstore.resume_from_crash':false, }}); - browser = { close: () => required(persistent, 'Missing Firefox context').close() }; + browser = { close: (): Promise<void> => required(persistent, 'Missing Firefox context').close() }; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); } -const deadline = setTimeout(() => browser.close(), 300000); +const deadline = setTimeout(() => { closeBrowser(browser); }, 300000); const contexts: BrowserContext[] = []; const external: string[] = []; const errors: string[] = []; const bodies: ReturnType<typeof requestBody>[] = []; -async function page() { - const context = required(persistent || await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context'); +async function page(): Promise<Page> { + const context = required(persistent ?? await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context'); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); const navigate = page.goto.bind(page); - page.goto = async (...args: Parameters<Page['goto']>) => { + page.goto = async (...args: Parameters<Page['goto']>): ReturnType<Page['goto']> => { for (let attempt = 0; ; attempt += 1) { try { return await navigate(...args); } catch (error) { if (attempt >= 2 || !(error instanceof Error && error.message.includes('ERR_NETWORK_CHANGED'))) throw error; } @@ -155,15 +158,15 @@ async function page() { }); return page; } -async function waitEntry(page: Page) { await page.locator('#create-choice').waitFor({state:'visible'}); } -async function waitStatus(page: Page, text: string) { await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value), text); } -async function create(page: Page, name: string, owner = false) { +async function waitEntry(page: Page): Promise<void> { await page.locator('#create-choice').waitFor({state:'visible'}); } +async function waitStatus(page: Page, text: string): Promise<void> { await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value), text); } +async function create(page: Page, name: string, owner = false): Promise<{ name: string; mimeType: string; buffer: Buffer; }> { const previousAuthentications = bodies.filter(body => body.op === 'authenticate').length; if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); await page.fill('#name', name); await page.fill('#new-password', password); await page.fill('#confirm-password', password); - const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status')?.textContent || '')).then(async () => { throw new Error(await page.locator('#status').textContent() || 'Missing status'); })]); + const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test((document.getElementById('status')?.textContent ?? ''))).then(async () => { throw new Error((await page.locator('#status').textContent() ?? 'Missing status')); })]); await page.click('#create-form button'); const download = await downloaded; const bytes = await readFile(required(await download.path(), 'Missing download')); @@ -180,18 +183,18 @@ async function create(page: Page, name: string, owner = false) { assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; } -async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password) { +async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password): Promise<void> { await page.setInputFiles('#file', file); await page.fill('#password', phrase); await page.click('#unlock-form button'); } -async function storageEmpty(page: Page) { +async function storageEmpty(page: Page): Promise<void> { assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); assert.deepEqual(await page.context().cookies(),[]); } -async function verifyLibreJS() { +async function verifyLibreJS(): Promise<void> { let executed = false; const controlServer = createServer((request,response) => { response.setHeader('Cache-Control','no-store'); @@ -221,12 +224,15 @@ try { await malformed.close(); const owner = await page(); const response = await owner.goto(origin); - await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); + await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async (error: unknown) => { + const currentStatus = await owner.locator('#status').textContent(); + throw new Error(`${String(error)}; status=${currentStatus ?? ''}`, { cause: error }); + }); await owner.click('#owner-choice'); - assert.match(required(response, 'Missing response').headers()['cache-control'] || '', /no-store/); - assert.match(required(response, 'Missing response').headers()['content-security-policy'] || '', /frame-ancestors 'none'/); + assert.match((required(response, 'Missing response').headers()['cache-control'] ?? ''), /no-store/); + assert.match((required(response, 'Missing response').headers()['content-security-policy'] ?? ''), /frame-ancestors 'none'/); await owner.locator('#setup-token').waitFor({state:'visible'}); - assert.match(await owner.locator('#create-title').textContent() || '',/owner only/); + assert.match((await owner.locator('#create-title').textContent() ?? ''),/owner only/); assert.equal(await owner.locator('.introduction h1').count(),0); assert.equal(await owner.locator('.libre-badge a[rel=jslicense]').count(),1); assert.equal(await owner.locator('.source-footer a[rel=jslicense]').count(),0); @@ -234,7 +240,7 @@ try { const ownerFile = await create(owner,'Doan',true); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); - assert.match(await owner.locator('#who').textContent() || '', /OWNER/); + assert.match((await owner.locator('#who').textContent() ?? ''), /OWNER/); const visitor = await page(); await visitor.goto(origin); await waitEntry(visitor); @@ -246,7 +252,7 @@ try { await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); assert.equal(await visitor.locator('#messages li').count(),0); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 0/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 0/); const malicious = '<script>window.chatInjection = true</script> hello λ'; await visitor.fill('#text',malicious); await visitor.click('#send-form button'); @@ -254,7 +260,7 @@ try { await owner.click('#refresh-inbox'); await owner.locator('#conversations button').first().click(); await waitStatus(owner,'Conversation opened'); - assert.match(await owner.locator('#thread-name').textContent() || '',/A friend/); + assert.match((await owner.locator('#thread-name').textContent() ?? ''),/A friend/); assert.equal(await owner.locator('#messages li').count(),0); await owner.route('**/api', async route => { const body = requestBody(required(route.request().postData(), 'Missing API body')); @@ -280,7 +286,7 @@ try { await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 2 \| Sent: 1 \| Received: 1/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 2 \| Sent: 1 \| Received: 1/); let interrupted = false; await visitor.route('**/api',async route => { const body = requestBody(required(route.request().postData(), 'Missing API request')); @@ -290,10 +296,10 @@ try { await visitor.fill('#text','Safe retry after a lost response'); await visitor.click('#send-form button'); await visitor.locator('#retry').waitFor({state:'visible'}); - await visitor.waitForFunction(() => !document.querySelector<HTMLButtonElement>('#retry')?.disabled); + await visitor.waitForFunction(() => !(document.querySelector<HTMLButtonElement>('#retry')?.disabled === true)); await visitor.click('#retry'); await waitStatus(visitor,'Encrypted message stored'); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/); await visitor.reload(); await waitEntry(visitor); assert.equal(await visitor.locator('#messages li').count(),0); @@ -302,12 +308,12 @@ try { await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/); assert.equal(await visitor.locator('#messages li').count(),0); await visitor.fill('#history-count','1'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); - assert.match(await visitor.locator('#messages p').textContent() || '',/Safe retry/); + assert.match((await visitor.locator('#messages p').textContent() ?? ''),/Safe retry/); await visitor.click('#older'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); @@ -329,7 +335,7 @@ try { await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); - assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); + assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); @@ -342,7 +348,7 @@ try { await owner.click('#owner-open'); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); - assert.match(await owner.locator('#who').textContent() || '',/OWNER/); + assert.match((await owner.locator('#who').textContent() ?? ''),/OWNER/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await visitor.click('#lock'); @@ -358,7 +364,7 @@ try { await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); for (let index = 1; index <= 51; index += 1) { - await visitor.fill('#text',`Batch fixture ${index}`); + await visitor.fill('#text',`Batch fixture ${String(index)}`); await visitor.click('#send-form button'); await waitStatus(visitor,'Encrypted message stored'); } @@ -392,7 +398,7 @@ try { } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); - const administrative = (...args: string[]) => packaged + const administrative = (...args: string[]): SpawnSyncReturns<string> => packaged ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); @@ -426,10 +432,10 @@ try { await waitStatus(visitor,'Pinned owner key differs'); assert.equal(await visitor.locator('#chat').isVisible(),false); assert.equal(serverErrors,''); - if (process.env['CHAT_SCREENSHOT']) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); + if (((process.env['CHAT_SCREENSHOT'] ?? '').length > 0)) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { const addon = libreJSAddon(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); - assert.ok(addon?.active,'LibreJS must actually be active'); + assert.ok(addon.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); } console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, malformed configuration and history rejection, no browser storage or external requests, no private uploads or database plaintext.'); diff --git a/crates/chat-server/tests/progress.ts b/crates/chat-server/tests/progress.ts index 59c939d..610488c 100644 --- a/crates/chat-server/tests/progress.ts +++ b/crates/chat-server/tests/progress.ts @@ -14,12 +14,12 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import assert from 'node:assert/strict'; -import { createServer, type ServerResponse } from 'node:http'; +import type { ServerResponse } from 'node:http'; import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { chromium, firefox, type BrowserContext, type Page } from 'playwright'; -import { libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; +import { asyncServer, libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; const libre = process.env['CHAT_LIBREJS'] === '1'; const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); @@ -31,7 +31,7 @@ let configReady: Promise<void> | undefined; let signalConfig: (() => void) | undefined; let origin = ''; const errors: string[] = []; -const server = createServer(async (request,response) => { +const server = asyncServer(async (request,response) => { response.setHeader('Cache-Control','no-store'); if (request.url === '/crypto.wasm') { if (mode === 'failure') { response.writeHead(503); response.end(); return; } @@ -43,9 +43,9 @@ const server = createServer(async (request,response) => { } if (request.url === '/api') { configuration = response; required(signalConfig, 'Missing configuration signal')(); return; } const assets: Record<string, string> = { '/':'crates/chat-server/web/index.html','/app.js':'var/build/typescript/crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; - const path = assets[request.url || '']; - if (!path) { response.writeHead(404); response.end(); return; } - const mime = request.url === '/' ? 'text/html' : request.url?.endsWith('.js') ? 'text/javascript' : 'text/css'; + const path = assets[(request.url ?? '')]; + if (path === undefined || path.length === 0) { response.writeHead(404); response.end(); return; } + const mime = request.url === '/' ? 'text/html' : (request.url?.endsWith('.js') === true) ? 'text/javascript' : 'text/css'; response.setHeader('Content-Type',mime); let source = await readFile(resolve(path),'utf8'); if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); @@ -60,12 +60,12 @@ if (libre) { const addon = await readFile(resolve('.tools/librejs.xpi')); await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); - browser = {close:() => context.close()}; + browser = {close:(): Promise<void> => context.close()}; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); context = await browser.newContext(); } -async function open(selected: 'known' | 'unknown' | 'failure') { +async function open(selected: 'known' | 'unknown' | 'failure'): Promise<Page> { mode = selected; configuration = undefined; configReady = new Promise(done => { signalConfig = done; }); @@ -75,11 +75,11 @@ async function open(selected: 'known' | 'unknown' | 'failure') { await page.goto(origin); return page; } -async function waitStatus(page: Page, part: string) { - await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value),part); - return await page.locator('#status').textContent() || ''; +async function waitStatus(page: Page, part: string): Promise<string> { + await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value),part); + return (await page.locator('#status').textContent() ?? ''); } -async function complete(page: Page, offset: number) { +async function complete(page: Page, offset: number): Promise<void> { required(transfer, 'Missing transfer').end(bytes.subarray(offset)); await waitStatus(page,'Connecting to chat'); await configReady; @@ -98,7 +98,7 @@ try { assert.match(await waitStatus(known,'2.0 /'),/39%/); await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); await known.clock.fastForward(44000); - assert.ok(!(await known.locator('#status').textContent() || '').includes('stalled')); + assert.ok(!((await known.locator('#status').textContent() ?? '')).includes('stalled')); await complete(known,2097152); const unknown = await open('unknown'); assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); @@ -111,7 +111,7 @@ try { await waitStatus(stalled,'1.0 /'); await stalled.clock.fastForward(45001); await waitStatus(stalled,'stalled for 45 seconds'); - assert.match(await stalled.locator('#status').textContent() || '',/reload/); + assert.match((await stalled.locator('#status').textContent() ?? ''),/reload/); assert.equal(await stalled.locator('#owner-choice').isVisible(),false); await stalled.close(); assert.deepEqual(errors,[]); diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts index 79a827e..de3abb7 100644 --- a/crates/chat-server/web/app.ts +++ b/crates/chat-server/web/app.ts @@ -17,7 +17,7 @@ function element(id: string): HTMLElement { const value = document.getElementById(id); - if (!value) throw new Error(`Missing interface element: ${id}`); + if (!(value !== null)) throw new Error(`Missing interface element: ${id}`); return value; } @@ -57,23 +57,28 @@ const workers = new Map<Worker, () => void>(); const blobs = new Set<string>(); function configuration(): StackChat.Configuration { - if (!config) throw new Error('Chat configuration is not loaded'); + if (config === undefined) throw new Error('Chat configuration is not loaded'); return config; } +function hasOwner(): boolean { + const owner = config?.owner; + return owner !== undefined && owner !== null && owner.length > 0; +} + function ownerKey(): string { const owner = configuration().owner; - if (!owner) throw new Error('Owner setup is required first'); + if (owner === null || owner.length === 0) throw new Error('Owner setup is required first'); return owner; } function activeSession(): StackChat.Session { - if (!session) throw new Error('Unlock your identity file first'); + if (session === undefined) throw new Error('Unlock your identity file first'); return session; } function conversation(): StackChat.Conversation { - if (!selected) throw new Error('Choose a conversation first'); + if (selected === undefined) throw new Error('Choose a conversation first'); return selected; } @@ -171,41 +176,41 @@ async function api<K extends keyof StackChat.Api>(request: StackChat.Api[K]['req }); const result = record(await response.json()); if (current !== generation) throw new Error('Session locked'); - if (!response.ok || result['error']) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed'); + if (!response.ok || 'error' in result) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed'); return apiReaders[request.op](result); } async function cryptoOperation<K extends keyof StackChat.Crypto>(request: StackChat.Crypto[K]['request'] & { op: K }): Promise<StackChat.Crypto[K]['response']> { const current = generation; - if (!wasm) throw new Error('Cryptographic module is not loaded'); + if (wasm === undefined) throw new Error('Cryptographic module is not loaded'); const bytes = await wasm; if (current !== generation) throw new Error('Session locked'); return new Promise<StackChat.Crypto[K]['response']>((resolve, reject) => { const worker = new Worker('/worker.js'); - const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; + const stop = (): void => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000); workers.set(worker, () => { stop(); reject(new Error('Session locked')); }); - worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); }; - worker.onmessage = (event: MessageEvent<unknown>) => { + worker.onerror = (): void => { stop(); reject(new Error('Cryptographic worker failed')); }; + worker.onmessage = (event: MessageEvent<unknown>): void => { stop(); if (current !== generation) { reject(new Error('Session locked')); return; } try { const message = record(event.data); if ('error' in message) throw new Error(stringField(message, 'error')); resolve(cryptoReaders[request.op](message['result'])); - } catch (error) { reject(error); } + } catch (error) { reject(error instanceof Error ? error : new Error('Cryptographic response rejected', { cause: error })); } }; worker.postMessage({ bytes, request }); }); } function protectRequest<K extends 'encrypt' | 'decrypt' | 'decrypt_batch'>(op: K, fields: Omit<StackChat.Crypto[K]['request'], keyof StackChat.Protected | 'op'>): StackChat.Protected & { op: K } & typeof fields { - if (!credentials) throw new Error('Unlock your identity file first'); + if (credentials === undefined) throw new Error('Unlock your identity file first'); return { op, ...credentials, origin: location.origin, ...fields }; } -function clearState() { +function clearState(): void { generation += 1; busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; @@ -227,13 +232,18 @@ function clearState() { } for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren(); for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false); - show('entry', Boolean(config?.owner)); + show('entry', hasOwner()); show('owner-entry'); setStatus('Locked. Select your file and enter its password to return.'); } -function action(id: string, callback: () => Promise<void>): void { - element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => { +function background(operation: () => Promise<void>): void { + const current = generation; + operation().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); +} + +function action(id: string, callback: () => void | Promise<void>): void { + const handler = async (event: Event): Promise<void> => { event.preventDefault(); if (busy) return; busy = true; @@ -248,7 +258,8 @@ function action(id: string, callback: () => Promise<void>): void { for (const button of document.querySelectorAll('button')) button.disabled = false; } } - }); + }; + element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', event => { background(() => handler(event)); }); } function download(file: string): void { @@ -261,13 +272,13 @@ function download(file: string): void { setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000); } -async function createIdentity() { +async function createIdentity(): Promise<void> { const password = input('new-password').value; if (password !== input('confirm-password').value) throw new Error('Passwords do not match'); - if (!configuration().owner && element('setup-label').hidden) throw new Error('Owner setup is required first'); - setupToken = !configuration().owner ? input('setup-token').value : undefined; + if (!hasOwner() && element('setup-label').hasAttribute('hidden')) throw new Error('Owner setup is required first'); + setupToken = !hasOwner() ? input('setup-token').value : undefined; setStatus('Generating your purpose-specific identity and protecting its file...'); - const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner || '' }); + const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner ?? '' }); pendingFile = result.file; download(result.file); form('create-form').reset(); @@ -277,16 +288,16 @@ async function createIdentity() { setStatus('Reopen your downloaded identity file to verify recovery.'); } -async function unlockIdentity() { +async function unlockIdentity(): Promise<void> { const file = input('file').files?.[0]; - if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); + if (file === undefined || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); const fileText = await file.text(); - if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); + if (((pendingFile ?? '').length > 0) && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); const candidate = { file: fileText, password: input('password').value }; form('unlock-form').reset(); setStatus('Unlocking locally and verifying your identity...'); const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin }); - if (configuration().owner && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); + if (hasOwner() && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken }); const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge }); const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature }); @@ -301,7 +312,7 @@ async function unlockIdentity() { show('owner-access', false); show('unlock', false); show('chat'); - element('who').textContent = `${identity.name} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`; + element('who').textContent = `${identity.name} [${activeSession().owner ? 'OWNER' : 'VISITOR'}]`; element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; if (authenticated.owner) { activeSession().owner = true; @@ -329,15 +340,15 @@ function checkedPayload(payload: string, kind: 'profile' | 'message', conversati return { id: stringField(value, 'id'), text: stringField(value, 'text') }; } -async function inbox() { +async function inbox(): Promise<void> { const result = await api({ op: 'inbox' }); element('conversations').replaceChildren(); for (const conversation of result.conversations) { const button = document.createElement('button'); - button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`; - button.addEventListener('click', async () => { + button.textContent = `${conversation.id.slice(0, 12)} / ${String(conversation.total)} messages`; + const handler = async (): Promise<void> => { if (busy) return; - if (pendingSend) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } + if ((pendingSend !== undefined)) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } busy = true; const current = generation; setStatus('Opening conversation...'); @@ -351,8 +362,9 @@ async function inbox() { element('messages').replaceChildren(); show('thread'); let name = conversation.id.slice(0, 12); - if (conversation.profile) { - const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile })); + const certificate = conversation.profile; + if (certificate !== undefined && certificate !== null && certificate.length > 0) { + const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: certificate })); if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch'); const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id); if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile'); @@ -363,7 +375,8 @@ async function inbox() { setStatus('Conversation opened. Choose how many messages to load.'); } catch (error) { setStatus(errorMessage(error)); } finally { if (generation === current) busy = false; } - }); + }; + button.addEventListener('click', () => { background(handler); }); element('conversations').append(button); } } @@ -371,10 +384,10 @@ async function inbox() { function counters(conversation: StackChat.Conversation): void { if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count'); input('history-count').max = String(conversation.total); - element('history-range').textContent = `Latest messages (0-${conversation.total})`; + element('history-range').textContent = `Latest messages (0-${String(conversation.total)})`; const sent = activeSession().owner ? conversation.owner_sent : conversation.visitor_sent; const received = activeSession().owner ? conversation.visitor_sent : conversation.owner_sent; - element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`; + element('counts').textContent = `Total: ${String(conversation.total)} | Sent: ${String(sent)} | Received: ${String(received)}`; } async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> { @@ -382,14 +395,14 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; - if (!message) throw new Error('Missing history message'); + if (message === undefined) throw new Error('Missing history message'); const item = document.createElement('li'); const label = document.createElement('small'); - label.textContent = `#${message.seq} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`; + label.textContent = `#${String(message.seq)} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`; const text = document.createElement('p'); try { const entry = decrypted.messages[index]; - if (!entry || 'error' in entry) throw new Error('Invalid ciphertext or signature'); + if (entry === undefined || 'error' in entry) throw new Error('Invalid ciphertext or signature'); if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); const payload = checkedPayload(entry.payload, 'message', conversation().id, message.sender); if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); @@ -400,7 +413,7 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> } } -async function refreshCounts() { +async function refreshCounts(): Promise<StackChat.Conversation> { const conversationId = conversation().id; const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 }); if (selected?.id === conversationId) counters(result.conversation); @@ -408,10 +421,10 @@ async function refreshCounts() { } async function loadHistory(count: number, cursor = 0): Promise<void> { - if (!selected) throw new Error('Choose a conversation first'); + if (selected === undefined) throw new Error('Choose a conversation first'); const currentConversation = await refreshCounts(); - if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${currentConversation.total} messages`); - if (!cursor) { element('messages').replaceChildren(); before = 0; } + if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${String(currentConversation.total)} messages`); + if (!(cursor !== 0)) { element('messages').replaceChildren(); before = 0; } if (count === 0) { input('history-count').value = '0'; show('older', false); @@ -426,10 +439,10 @@ async function loadHistory(count: number, cursor = 0): Promise<void> { const result = await api({ op: 'history', conversation: conversation().id, count: size, before: next }); counters(result.conversation); if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response'); - if (!result.messages.length) { exhausted = true; break; } + if (!(result.messages.length !== 0)) { exhausted = true; break; } const sequence = Math.min(...result.messages.map(message => message.seq)); if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor'); - setStatus(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`); + setStatus(`Decrypting and verifying selected messages locally (${String(count - remaining + result.messages.length)}/${String(count)})...`); await renderHistoryBatch(result.messages); remaining -= result.messages.length; next = sequence; @@ -440,8 +453,8 @@ async function loadHistory(count: number, cursor = 0): Promise<void> { setStatus('Selected history decrypted and verified locally.'); } -async function submitPending() { - if (!pendingSend) throw new Error('No pending message'); +async function submitPending(): Promise<void> { + if (pendingSend === undefined) throw new Error('No pending message'); const result = await api(pendingSend); counters(result.conversation); pendingSend = undefined; @@ -450,11 +463,11 @@ async function submitPending() { setStatus('Encrypted message stored. Load history to read messages.'); } -async function send() { - if (!selected) throw new Error('Choose a conversation first'); - if (pendingSend) throw new Error('Retry or lock before composing another message'); +async function send(): Promise<void> { + if (selected === undefined) throw new Error('Choose a conversation first'); + if ((pendingSend !== undefined)) throw new Error('Retry or lock before composing another message'); const text = messageInput().value; - if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); + if (!((text.trim()).length > 0) || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); const recipient = activeSession().owner ? conversation().public : ownerKey(); setStatus('Signing and encrypting for both participants locally...'); @@ -464,62 +477,66 @@ async function send() { await submitPending(); } -function ownerSetup() { +function ownerSetup(): void { show('entry', false); show('owner-entry', false); show('unlock', false); - show('create', !configuration().owner); - show('owner-access', Boolean(configuration().owner)); - show('setup-label', !configuration().owner); + show('create', !hasOwner()); + show('owner-access', hasOwner()); + show('setup-label', !hasOwner()); element('create-title').textContent = 'Create the owner identity (owner only)'; setStatus(''); } -action('create-choice', async () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); }); -action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); }); -action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); }); -action('owner-open', async () => { show('owner-access', false); show('unlock'); setStatus(''); }); +action('create-choice', () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); }); +action('open-choice', () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); }); +action('owner-choice', () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); }); +action('owner-open', () => { show('owner-access', false); show('unlock'); setStatus(''); }); action('create-form', createIdentity); action('unlock-form', unlockIdentity); element('history-count').addEventListener('focus', () => { const current = generation; - if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) setStatus(errorMessage(error)); }); + if ((session !== undefined) && (selected !== undefined) && !busy) refreshCounts().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); }); action('history-form', () => loadHistory(Number(input('history-count').value))); action('send-form', send); action('retry', submitPending); action('refresh-inbox', inbox); action('older', () => loadHistory(Number(input('history-count').value), before)); -element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); }); +element('lock').addEventListener('click', () => { + const current = generation; + if (session !== undefined) api({ op: 'lock' }).catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); + clearState(); +}); window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); -async function downloadCrypto() { +async function downloadCrypto(): Promise<ArrayBuffer> { const controller = new AbortController(); let timer: ReturnType<typeof setTimeout> | undefined; let reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>> | undefined; - const refreshTimeout = () => { + const refreshTimeout = (): void => { clearTimeout(timer); - timer = setTimeout(() => controller.abort(), 45000); + timer = setTimeout(() => { controller.abort(); }, 45000); }; try { refreshTimeout(); const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal }); - if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`); - const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length')); + if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${String(response.status)}). Reload to try again.`); + const length = ((response.headers.get('content-encoding') ?? '').length > 0) ? 0 : Number(response.headers.get('content-length')); const total = Number.isSafeInteger(length) && length > 0 ? length : 0; const chunks: Uint8Array<ArrayBuffer>[] = []; let received = 0; - const progress = () => { + const progress = (): void => { const loaded = (received / 1048576).toFixed(1); - if (total) { + if ((total !== 0)) { const percent = Math.min(100, Math.floor(received * 100 / total)); const filled = Math.floor(percent / 5); - setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); + setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${String(percent)}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); } else setStatus(`Downloading cryptographic module... ${loaded} MiB received`); }; progress(); - if (!response.body) throw new Error('Cryptographic module response has no body'); + if (!(response.body !== null)) throw new Error('Cryptographic module response has no body'); reader = response.body.getReader(); for (;;) { const { done, value } = await reader.read(); @@ -529,13 +546,13 @@ async function downloadCrypto() { received += value.byteLength; progress(); } - if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.'); + if (!(received !== 0)) throw new Error('The cryptographic module download was empty. Reload to try again.'); const bytes = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes.buffer; } catch (error) { - if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.'); + if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.', { cause: error }); throw error; } finally { clearTimeout(timer); @@ -543,18 +560,18 @@ async function downloadCrypto() { } } -async function start() { +async function start(): Promise<void> { wasm = downloadCrypto(); await wasm; setStatus('Cryptographic module downloaded. Connecting to chat...'); config = await api({ op: 'config' }); if (configuration().origin !== location.origin) throw new Error('Server origin mismatch'); show('owner-entry'); - show('entry', Boolean(configuration().owner)); + show('entry', hasOwner()); setStatus(''); if (location.hash === '#setup') ownerSetup(); } -start().catch(error => setStatus(errorMessage(error))); +start().catch((error: unknown) => { setStatus(errorMessage(error)); }); // @license-end diff --git a/crates/chat-server/web/worker.ts b/crates/chat-server/web/worker.ts index d4367bf..d3a1c68 100644 --- a/crates/chat-server/web/worker.ts +++ b/crates/chat-server/web/worker.ts @@ -17,9 +17,9 @@ importScripts('/wasi.mjs'); -self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>) => { +self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>): Promise<void> => { try { - const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request); + const result = await stackChatWasi.runCommand(event.data.bytes, event.data.request); self.postMessage({ result }); } catch { self.postMessage({ error: 'Cryptographic operation failed. Check your file, password, origin, and sender.' }); |
