diff --git a/.github/scripts/mock-registry.py b/.github/scripts/mock-registry.py new file mode 100644 index 00000000..034cc46b --- /dev/null +++ b/.github/scripts/mock-registry.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Mock keypackage/account registry for the chat-cli CI smoketest. + +On startup the client registers its keypackage and account bundle. Publishing the +bundle first fetches any existing record, so the stub answers: + + * POST /v0/keypackage, POST /v0/account -> 200 (accept the write) + * GET (any) -> 404 (nothing published yet) + +A 404 is what a fresh account looks like, which the client reads as "no existing +record". This validates nothing — it only unblocks the smoketest; protocol-level +behavior is covered by the workspace tests. +""" + +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Handler(BaseHTTPRequestHandler): + # Match the client's HTTP/1.1 requests so reqwest frames the response body. + protocol_version = "HTTP/1.1" + + def _drain(self): + # Consume the request body so the client's request completes cleanly. + length = int(self.headers.get("Content-Length", 0)) + if length: + self.rfile.read(length) + + def _reply(self, status): + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_POST(self): + self._drain() + self._reply(200) + + def do_GET(self): + self._drain() + self._reply(404) + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + HTTPServer(("127.0.0.1", 18080), Handler).serve_forever() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edb99a45..c45a5a2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,4 +74,15 @@ jobs: - name: Build chat-cli (logos-delivery) run: nix develop -c bash -c 'LOGOS_DELIVERY_LIB_DIR=./result/lib cargo build --release -p chat-cli' - name: Run chat-cli smoketest - run: nix develop -c ./target/release/chat-cli --name ci-test --smoketest + # The client registers against the keypackage/account registry on + # startup, so stand up a mock that accepts those calls. It runs on the + # system Python (no Nix needed); only chat-cli needs the dev shell. + run: | + python3 .github/scripts/mock-registry.py & + mock_pid=$! + trap 'kill "$mock_pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 50); do + curl -s -o /dev/null http://127.0.0.1:18080/ && break + sleep 0.2 + done + nix develop -c ./target/release/chat-cli --name ci-test --smoketest --registry-url http://127.0.0.1:18080 diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index 61a2f276..bd12b68a 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -9,8 +9,7 @@ use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; use crossbeam_channel::Receiver; use logos_chat::{ - ChatClient, ChatClientBuilder, ChatStore, Event, HttpRegistry, IdentityProvider, - RegistrationService, StorageConfig, Transport, + ChatClient, ChatStore, Event, IdentityProvider, LogosChatClient, RegistrationService, Transport, }; use components::{EmbeddedP2pDeliveryService, P2pConfig}; @@ -79,9 +78,9 @@ struct Cli { #[arg(long)] smoketest: bool, - /// Optional KeyPackage registry base URL. When set, uses the HTTP-backed - /// registry instead of the in-memory `EphemeralRegistry`. - /// Example: `--registry-url http://localhost:8080`. + /// Override the Logos registry endpoint (account + keypackage store). When + /// omitted, the preconfigured endpoint is used. + /// Example: `--registry-url http://127.0.0.1:18080`. #[arg(long)] registry_url: Option, } @@ -127,33 +126,12 @@ fn run(transport: T, cli: &Cli) -> Result<()> { .to_str() .context("db path contains non-UTF-8 characters")? .to_string(); - let storage = StorageConfig::Encrypted { - path: db_str, - key: "chat-cli".to_string(), - }; - - match cli.registry_url.as_deref() { - Some(url) => { - let registry = HttpRegistry::new(url); - let (client, events) = ChatClientBuilder::new() - .transport(transport) - .storage_config(storage) - .registration(registry) - .build() - .map_err(|e| anyhow::anyhow!("{e:?}")) - .context("failed to open chat client with HTTP registry")?; - launch_tui(client, events, cli) - } - None => { - let (client, events) = ChatClientBuilder::new() - .transport(transport) - .storage_config(storage) - .build() - .map_err(|e| anyhow::anyhow!("{e:?}")) - .context("failed to open chat client")?; - launch_tui(client, events, cli) - } - } + + let (client, events) = + LogosChatClient::open(transport, db_str, "chat-cli", cli.registry_url.as_deref()) + .map_err(|e| anyhow::anyhow!("{e:?}")) + .context("failed to open chat client")?; + launch_tui(client, events, cli) } fn launch_tui( diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index becf8520..d916494e 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -4,6 +4,7 @@ mod delegate; mod delivery_in_process; mod errors; mod event; +mod logos; pub use builder::{ChatClientBuilder, Unset}; pub use client::{ChatClient, Transport}; @@ -11,6 +12,7 @@ pub use delegate::DelegateSigner; pub use delivery_in_process::{InProcessDelivery, MessageBus}; pub use errors::ClientError; pub use event::{Event, MessageSender}; +pub use logos::LogosChatClient; // Re-export types callers need to interact with ChatClient. pub use libchat::{ diff --git a/crates/client/src/logos.rs b/crates/client/src/logos.rs new file mode 100644 index 00000000..152fa61f --- /dev/null +++ b/crates/client/src/logos.rs @@ -0,0 +1,61 @@ +//! The opinionated Logos client. +//! +//! [`ChatClientBuilder`] is generic and can only default the zero-config +//! components (random identity, ephemeral registry, in-memory storage) — it has +//! no way to know a registry endpoint or a database path, so its defaults are +//! the test-grade ones. `LogosChatClient` is the layer that *does* commit to a +//! stack: a delegate identity, the HTTP keypackage + account registry, and +//! encrypted on-disk storage. It exists so independently built clients share the +//! same production services instead of each re-deriving them. +//! +//! Only the transport is left to the caller: it carries native dependencies and +//! environment-specific configuration that belong to the binary, not here. + +use crossbeam_channel::Receiver; +use libchat::{ChatStorage, StorageConfig}; + +use crate::ChatClientBuilder; +use crate::client::{ChatClient, Transport}; +use crate::delegate::DelegateSigner; +use crate::errors::ClientError; +use crate::event::Event; +use components::HttpRegistry; + +// The endpoint for account and keypackage registration service. +const REGISTRY_ENDPOINT: &str = "https://devnet.chat-kc.logos.co"; + +/// A [`ChatClient`] wired to the Logos service stack: a [`DelegateSigner`] +/// identity, the HTTP keypackage + account registry ([`HttpRegistry`], which is +/// both the keypackage store and the account → device directory), and encrypted +/// [`ChatStorage`]. Only the transport `T` is supplied by the caller. +pub type LogosChatClient = ChatClient; + +impl LogosChatClient +where + T: Transport + Send + 'static, +{ + /// Open a client on the Logos stack over `transport`, persisting to the + /// encrypted database at `db_path` unlocked with `db_key`. When `registry_url` + /// is `Some`, it overrides the preconfigured registry endpoint (e.g. a local + /// deployment); otherwise the baked-in endpoint is used. + /// + /// `db_path` is a per-client location and `db_key` is a secret, so both are + /// caller-supplied — never baked into the library. + pub fn open( + transport: T, + db_path: impl Into, + db_key: impl Into, + registry_url: Option<&str>, + ) -> Result<(Self, Receiver), ClientError> { + let endpoint = registry_url.unwrap_or(REGISTRY_ENDPOINT); + ChatClientBuilder::new() + .ident(DelegateSigner::random()) + .transport(transport) + .registration(HttpRegistry::new(endpoint)) + .storage_config(StorageConfig::Encrypted { + path: db_path.into(), + key: db_key.into(), + }) + .build() + } +}