summaryrefslogtreecommitdiff
path: root/crates/chat-server
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server')
-rw-r--r--crates/chat-server/Cargo.toml4
-rw-r--r--crates/chat-server/src/app.rs199
-rw-r--r--crates/chat-server/src/app/cli.rs167
-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.rs202
-rw-r--r--crates/chat-server/tests/browser.ts104
-rw-r--r--crates/chat-server/tests/progress.ts28
-rw-r--r--crates/chat-server/web/app.ts165
-rw-r--r--crates/chat-server/web/worker.ts4
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.' });