aboutsummaryrefslogtreecommitdiff
path: root/crates/chat-server
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
commitbe16d00a8a4750028073e018ab2c58d54af192c4 (patch)
tree3eeabb31508f98a6753a2492238581f888769b4a /crates/chat-server
parentb7374fe09306c495131cf3673addb76a1dcf017d (diff)
Refine chat controls and add explicit owner recovery reset
Simplify the ASCII interface, expose owner setup, and load any selected number of messages using bounded history batches. Add backend-only owner reset with a matching public fingerprint and stopped-service guards, and document the loss of old history and key continuity.
Diffstat (limited to 'crates/chat-server')
-rw-r--r--crates/chat-server/src/main.rs24
-rw-r--r--crates/chat-server/src/maintenance.rs61
-rw-r--r--crates/chat-server/src/store.rs13
-rw-r--r--crates/chat-server/tests/browser.mjs90
-rw-r--r--crates/chat-server/web/app.js140
-rw-r--r--crates/chat-server/web/index.html19
-rw-r--r--crates/chat-server/web/style.css15
7 files changed, 294 insertions, 68 deletions
diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs
index 6c7f4e1..c17a7f6 100644
--- a/crates/chat-server/src/main.rs
+++ b/crates/chat-server/src/main.rs
@@ -16,6 +16,7 @@
#![doc = "Local experimental chat server with ciphertext-only durable storage."]
mod http;
+mod maintenance;
mod service;
mod store;
@@ -33,8 +34,16 @@ struct Arguments {
root: PathBuf,
#[arg(long, default_value = "127.0.0.1:9080")]
bind: SocketAddr,
- #[arg(long)]
+ #[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>,
}
@@ -76,11 +85,22 @@ async fn run(arguments: Arguments) -> Result<()> {
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 store = store::Store::open(&database)?;
+ 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, arguments.bind);
+ }
if let Some(destination) = arguments.snapshot {
store.snapshot(&destination)?;
std::fs::set_permissions(
diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/maintenance.rs
new file mode 100644
index 0000000..eac689d
--- /dev/null
+++ b/crates/chat-server/src/maintenance.rs
@@ -0,0 +1,61 @@
+// 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 crate::store::Store;
+use anyhow::{Context, Result, ensure};
+use std::fs::{File, OpenOptions};
+use std::io::Write;
+use std::net::{SocketAddr, TcpListener};
+use std::os::unix::fs::OpenOptionsExt;
+use std::path::Path;
+
+pub(crate) fn lock(directory: &Path) -> Result<File> {
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(directory.join("server.lock"))?;
+ file.try_lock().context(
+ "Chat is already running. Stop it before starting another server or resetting the owner.",
+ )?;
+ Ok(file)
+}
+
+pub(crate) fn fingerprint(store: &Store) -> Result<()> {
+ let certificate = store.owner()?.context("No owner identity is registered")?;
+ writeln!(
+ std::io::stdout().lock(),
+ "{}",
+ stack_chat_crypto::public_identity(&certificate)?
+ )?;
+ Ok(())
+}
+
+pub(crate) fn reset(store: &mut Store, expected: &str, address: SocketAddr) -> Result<()> {
+ ensure!(
+ address.ip().is_loopback(),
+ "Reset must use the local chat address"
+ );
+ let _port =
+ TcpListener::bind(address).context("Stop the chat server before resetting its owner")?;
+ store.reset_owner(expected)?;
+ writeln!(
+ std::io::stdout().lock(),
+ "Owner registration and chat history cleared. Restart chat and use OWNER SETUP to create a new owner file. Existing backup archives were not changed."
+ )?;
+ Ok(())
+}
diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/store.rs
index fce3bb3..0e09de7 100644
--- a/crates/chat-server/src/store.rs
+++ b/crates/chat-server/src/store.rs
@@ -45,6 +45,19 @@ impl Store {
Ok(())
}
+ pub(crate) 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!(
+ expected.len() == 64 && expected.eq_ignore_ascii_case(&fingerprint),
+ "Owner fingerprint does not match; no data was cleared"
+ );
+ let transaction = self.connection.transaction()?;
+ transaction.execute_batch("DELETE FROM messages; DELETE FROM conversations; DELETE FROM metadata WHERE key='owner'; DELETE FROM sqlite_sequence WHERE name='messages';")?;
+ transaction.commit()?;
+ Ok(())
+ }
+
pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> {
if self.connection.query_row(
"SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)",
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs
index 08c5c88..e12c3da 100644
--- a/crates/chat-server/tests/browser.mjs
+++ b/crates/chat-server/tests/browser.mjs
@@ -19,7 +19,7 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:f
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { randomBytes } from 'node:crypto';
-import { spawn, execFileSync } from 'node:child_process';
+import { spawn, execFileSync, spawnSync } from 'node:child_process';
import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs';
const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-'));
@@ -78,8 +78,10 @@ async function page() {
});
return page;
}
+async function waitEntry(page) { await page.locator('#create-choice').waitFor({state:'visible'}); }
async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); }
async function create(page, name, owner = false) {
+ 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);
@@ -90,7 +92,7 @@ async function create(page, name, owner = false) {
const bytes = await readFile(await download.path());
await waitStatus(page,'Reopen your downloaded');
assert.equal(await page.locator('#chat').isVisible(), false);
- assert.equal(bodies.some(body => body.op === 'authenticate'), owner ? false : true);
+ assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications);
return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes};
}
async function unlock(page, file, phrase = password) {
@@ -126,17 +128,24 @@ async function verifyLibreJS() {
try {
if (libre) await verifyLibreJS();
const owner = await page();
- const response = await owner.goto(origin + '/#setup');
+ const response = await owner.goto(origin);
+ await owner.locator('#owner-choice').waitFor({state:'visible'});
+ await owner.click('#owner-choice');
assert.match(response.headers()['cache-control'], /no-store/);
assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/);
- await waitStatus(owner,'Owner setup.');
+ await owner.locator('#setup-token').waitFor({state:'visible'});
+ 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);
+ assert.equal(await owner.evaluate(() => document.scrollingElement === document.documentElement),true);
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/);
const visitor = await page();
await visitor.goto(origin);
- await waitStatus(visitor,'Choose CREATE');
+ await waitEntry(visitor);
const visitorFile = await create(visitor,'A friend');
assert.ok(!visitorFile.buffer.toString().includes('A friend'));
await unlock(visitor,visitorFile,'wrong-but-long-password');
@@ -182,7 +191,7 @@ try {
await waitStatus(visitor,'Encrypted message stored');
assert.match(await visitor.locator('#counts').textContent(),/Total: 3/);
await visitor.reload();
- await waitStatus(visitor,'Choose CREATE');
+ await waitEntry(visitor);
assert.equal(await visitor.locator('#messages li').count(),0);
assert.equal(await visitor.locator('#file').inputValue(),'');
assert.equal(await visitor.locator('#password').inputValue(),'');
@@ -209,7 +218,7 @@ try {
await rename(snapshot,join(root,'var/data/chat/chat.sqlite'));
await launchServer();
await visitor.reload();
- await waitStatus(visitor,'Choose CREATE');
+ await waitEntry(visitor);
await visitor.click('#open-choice');
await unlock(visitor,visitorFile);
await waitStatus(visitor,'Unlocked in memory only');
@@ -219,9 +228,11 @@ try {
await waitStatus(visitor,'Selected history decrypted');
assert.equal(await visitor.locator('#messages li').count(),3);
assert.ok((await visitor.locator('#messages p').allTextContents()).includes(malicious));
- await owner.reload();
- await waitStatus(owner,'Choose CREATE');
- await owner.click('#open-choice');
+ await owner.goto(origin);
+ await waitEntry(owner);
+ await owner.click('#owner-choice');
+ await owner.locator('#owner-access').waitFor({state:'visible'});
+ await owner.click('#owner-open');
await unlock(owner,ownerFile);
await waitStatus(owner,'Unlocked in memory only');
assert.match(await owner.locator('#who').textContent(),/OWNER/);
@@ -236,6 +247,30 @@ try {
assert.equal(await visitor.locator('#messages li').count(),0);
assert.equal(await visitor.locator('#password').inputValue(),'');
assert.equal(await visitor.locator('#chat').isVisible(),false);
+ await visitor.click('#open-choice');
+ 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.click('#send-form button');
+ await waitStatus(visitor,'Encrypted message stored');
+ }
+ assert.equal(await visitor.locator('#history-count').getAttribute('max'),'54');
+ assert.equal(await visitor.locator('#history-range').textContent(),'Latest messages (0-54)');
+ const historyStart = bodies.length;
+ await visitor.fill('#history-count','54');
+ await visitor.click('#history-form button');
+ await waitStatus(visitor,'Selected history decrypted');
+ assert.equal(await visitor.locator('#messages li').count(),54);
+ const batches = bodies.slice(historyStart).filter(body => body.op === 'history' && body.count > 0).map(body => body.count);
+ assert.deepEqual(batches,[50,4]);
+ assert.equal(await visitor.locator('#messages p').last().textContent(),'Batch fixture 51');
+ assert.equal(await visitor.locator('#older').isVisible(),false);
+ await visitor.fill('#history-count','0');
+ await visitor.click('#history-form button');
+ await waitStatus(visitor,'No message history loaded');
+ assert.equal(await visitor.locator('#messages li').count(),0);
+ await visitor.click('#lock');
await storageEmpty(visitor);
await storageEmpty(owner);
assert.deepEqual(external,[]);
@@ -245,9 +280,40 @@ try {
assert.ok(!JSON.stringify(body).includes(malicious));
}
const database = await readFile(join(root,'var/data/chat/chat.sqlite'));
- for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`);
+ for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`);
const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}});
assert.equal(blocked.status(),403);
+ const administrative = (...args) => spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'});
+ const registered = administrative('--owner-fingerprint');
+ assert.equal(registered.status,0);
+ const fingerprint = registered.stdout.trim();
+ assert.match(fingerprint,/^[0-9A-F]{64}$/);
+ const runningReset = administrative('--reset-owner',fingerprint);
+ assert.notEqual(runningReset.status,0,'Reset must refuse while the server runs');
+ await stopServer();
+ const mismatch = administrative('--reset-owner','0'.repeat(64));
+ assert.notEqual(mismatch.status,0,'Wrong fingerprint must not reset the owner');
+ assert.equal(administrative('--owner-fingerprint').stdout.trim(),fingerprint);
+ const preservedEnv = await readFile(join(root,'.env'));
+ const reset = administrative('--reset-owner',fingerprint);
+ assert.equal(reset.status,0);
+ assert.deepEqual(await readFile(join(root,'.env')),preservedEnv);
+ assert.notEqual(administrative('--owner-fingerprint').status,0);
+ await launchServer();
+ await owner.goto(origin);
+ await owner.locator('#owner-choice').waitFor({state:'visible'});
+ await owner.click('#owner-choice');
+ const replacement = await create(owner,'Replacement owner',true);
+ await unlock(owner,replacement);
+ await waitStatus(owner,'Unlocked in memory only');
+ assert.equal(await owner.locator('#conversations button').count(),0,'Reset must clear conversations');
+ assert.notEqual(administrative('--owner-fingerprint').stdout.trim(),fingerprint);
+ await visitor.goto(origin);
+ await waitEntry(visitor);
+ await visitor.click('#open-choice');
+ await unlock(visitor,visitorFile);
+ 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 (libre) {
@@ -256,7 +322,7 @@ try {
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, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, origin rejection, no browser storage or external requests, no private uploads or database plaintext.');
+ 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, no browser storage or external requests, no private uploads or database plaintext.');
} finally {
clearTimeout(deadline);
await browser.close();
diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js
index c06116b..3c97f66 100644
--- a/crates/chat-server/web/app.js
+++ b/crates/chat-server/web/app.js
@@ -86,12 +86,15 @@ function clearState() {
pendingSend = undefined;
setupToken = undefined;
before = 0;
+ element('history-count').max = '0';
+ element('history-range').textContent = 'Latest messages (0-0)';
for (const input of document.querySelectorAll('input,textarea')) {
if (input.id === 'history-count') input.value = '0'; else input.value = '';
}
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']) show(id, false);
+ for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false);
show('entry', Boolean(config?.owner));
+ show('owner-entry');
status('Locked. Select your file and enter its password to return.');
}
@@ -127,7 +130,7 @@ function download(file) {
async function createIdentity() {
const password = element('new-password').value;
if (password !== element('confirm-password').value) throw new Error('Passwords do not match');
- if (!config.owner && location.hash !== '#setup') throw new Error('Owner setup is required first');
+ if (!config.owner && element('setup-label').hidden) throw new Error('Owner setup is required first');
setupToken = !config.owner ? element('setup-token').value : undefined;
status('Generating your purpose-specific identity and protecting its file...');
const result = await cryptoOperation({ op: 'create', origin: location.origin, name: element('name').value, password, owner: config.owner || '' });
@@ -160,6 +163,8 @@ async function unlockIdentity() {
setupToken = undefined;
config = await api({ op: 'config' });
show('entry', false);
+ show('owner-entry', false);
+ show('owner-access', false);
show('unlock', false);
show('chat');
element('who').textContent = `${identity.name} [${session.owner === true ? 'OWNER' : 'VISITOR'}]`;
@@ -176,7 +181,7 @@ async function unlockIdentity() {
element('conversation-fingerprint').textContent = `Conversation: ${session.id}`;
show('thread');
element('thread-name').textContent = 'You <-> Doan';
- await history(0);
+ await loadHistory(0);
}
status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.');
}
@@ -217,7 +222,7 @@ async function inbox() {
name = profile.name;
}
element('thread-name').textContent = `Doan <-> ${name}`;
- await history(0);
+ await loadHistory(0);
status('Conversation opened. Choose how many messages to load.');
} catch (error) { status(error.message); }
finally { if (generation === current) busy = false; }
@@ -227,42 +232,74 @@ async function inbox() {
}
function counters(conversation) {
+ if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count');
+ element('history-count').max = String(conversation.total);
+ element('history-range').textContent = `Latest messages (0-${conversation.total})`;
const sent = session.owner ? conversation.owner_sent : conversation.visitor_sent;
const received = session.owner ? conversation.visitor_sent : conversation.owner_sent;
element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`;
}
-async function history(count, cursor = 0) {
+async function renderHistoryBatch(messages) {
+ const requests = messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext }));
+ const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests }));
+ for (let index = 0; index < messages.length; index += 1) {
+ const message = messages[index];
+ const item = document.createElement('li');
+ const label = document.createElement('small');
+ label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`;
+ const text = document.createElement('p');
+ try {
+ const entry = decrypted.messages[index];
+ if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch');
+ if (entry.error) throw new Error('Invalid ciphertext or signature');
+ const payload = checkedPayload(entry.payload, 'message', selected.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');
+ text.textContent = payload.text;
+ } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; }
+ item.append(label, text);
+ element('messages').prepend(item);
+ }
+}
+
+async function refreshCounts() {
+ const conversation = selected.id;
+ const result = await api({ op: 'history', conversation, count: 0, before: 0 });
+ if (selected?.id === conversation) counters(result.conversation);
+ return result.conversation;
+}
+
+async function loadHistory(count, cursor = 0) {
if (!selected) throw new Error('Choose a conversation first');
- if (!Number.isSafeInteger(count) || count < 0 || count > 50) throw new Error('Choose 0 to 50 messages');
- const result = await api({ op: 'history', conversation: selected.id, count, before: cursor });
- counters(result.conversation);
- if (!cursor) element('messages').replaceChildren();
- if (result.messages.length) {
- status('Decrypting and verifying selected messages locally...');
- const requests = result.messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext }));
- const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests }));
- for (let index = 0; index < result.messages.length; index += 1) {
- const message = result.messages[index];
- const item = document.createElement('li');
- const label = document.createElement('small');
- label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`;
- const text = document.createElement('p');
- try {
- const entry = decrypted.messages[index];
- if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch');
- if (entry.error) throw new Error('Invalid ciphertext or signature');
- const payload = checkedPayload(entry.payload, 'message', selected.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');
- text.textContent = payload.text;
- } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; }
- item.append(label, text);
- element('messages').prepend(item);
- }
- before = Math.min(...result.messages.map(message => message.seq));
+ const conversation = await refreshCounts();
+ if (!Number.isSafeInteger(count) || count < 0 || count > conversation.total) throw new Error(`Choose 0 to ${conversation.total} messages`);
+ if (!cursor) { element('messages').replaceChildren(); before = 0; }
+ if (count === 0) {
+ element('history-count').value = '0';
+ show('older', false);
+ status('No message history loaded.');
+ return;
+ }
+ let remaining = count;
+ let next = cursor;
+ let exhausted = false;
+ while (remaining > 0) {
+ const size = Math.min(50, remaining);
+ const result = await api({ op: 'history', conversation: selected.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; }
+ 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');
+ status(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`);
+ await renderHistoryBatch(result.messages);
+ remaining -= result.messages.length;
+ next = sequence;
+ before = next;
+ if (result.messages.length < size) { exhausted = true; break; }
}
- show('older', count > 0 && result.messages.length === count);
- status(count === 0 ? 'No message history loaded.' : 'Selected history decrypted and verified locally.');
+ show('older', !exhausted && element('messages').children.length < Number(element('history-count').max));
+ status('Selected history decrypted and verified locally.');
}
async function submitPending() {
@@ -289,15 +326,32 @@ async function send() {
await submitPending();
}
-action('create-choice', async () => { show('entry', false); show('create'); status('Create a purpose-specific protected identity file.'); });
-action('open-choice', async () => { show('entry', false); show('unlock'); status('Select your saved identity file.'); });
+function ownerSetup() {
+ show('entry', false);
+ show('owner-entry', false);
+ show('unlock', false);
+ show('create', !config.owner);
+ show('owner-access', Boolean(config.owner));
+ show('setup-label', !config.owner);
+ element('create-title').textContent = 'Create the owner identity (owner only)';
+ status('');
+}
+
+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'); status(''); });
+action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); status(''); });
+action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
+action('owner-open', async () => { show('owner-access', false); show('unlock'); status(''); });
action('create-form', createIdentity);
action('unlock-form', unlockIdentity);
-action('history-form', () => history(Number(element('history-count').value)));
+element('history-count').addEventListener('focus', () => {
+ const current = generation;
+ if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) status(error.message); });
+});
+action('history-form', () => loadHistory(Number(element('history-count').value)));
action('send-form', send);
action('retry', submitPending);
action('refresh-inbox', inbox);
-action('older', () => history(Number(element('history-count').value), before));
+action('older', () => loadHistory(Number(element('history-count').value), before));
element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); });
window.addEventListener('pagehide', clearState);
window.addEventListener('pageshow', event => { if (event.persisted) clearState(); });
@@ -310,14 +364,10 @@ async function start() {
await wasm;
config = await api({ op: 'config' });
if (config.origin !== location.origin) throw new Error('Server origin mismatch');
- if (!config.owner) {
- if (location.hash === '#setup') {
- show('create');
- show('setup-label');
- element('create-title').textContent = 'Create the dedicated owner identity';
- status('Owner setup. Keep this identity separate from your canonical OpenPGP key.');
- } else { status('Owner setup is not complete. Please return later.'); }
- } else { show('entry'); status('Choose CREATE NEW CHAT or OPEN CHAT FILE.'); }
+ show('owner-entry');
+ show('entry', Boolean(config.owner));
+ status('');
+ if (location.hash === '#setup') ownerSetup();
}
start().catch(error => status(error.message));
diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html
index c240b85..7c2fe8b 100644
--- a/crates/chat-server/web/index.html
+++ b/crates/chat-server/web/index.html
@@ -25,7 +25,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</head>
<body>
<main>
-<div class="libre-badge">[ <a href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span> ]</div>
+<h1 class="sr-only">Message Me</h1>
+<div class="libre-badge">[ <a href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span> ]
+<a class="script-licenses" href="/javascript.html" rel="jslicense">[ JAVASCRIPT LICENSES ]</a></div>
<header>
<pre class="logo-art"><a href="https://doan.luuduct.ai/" class="logo" aria-label="Doan Luu Duc Tai home">
____ ___ _ _ _
@@ -39,8 +41,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="ascii-panel introduction">
<div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div>
<div class="panel-body">
-<h1>MESSAGE ME</h1>
-<p>Private text conversations between you and Doan.</p>
<p class="build-status">EXPERIMENTAL / LOCAL TEST</p>
<p>Keep your file and password. There is no account recovery.<br><a href="/LIMITATIONS.txt">Read the limitations</a> before using this experimental messenger.</p>
</div>
@@ -51,6 +51,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button id="create-choice">CREATE NEW CHAT</button>
<button id="open-choice">OPEN CHAT FILE</button>
</section>
+<section id="owner-entry" class="owner-entry" hidden>
+<button id="owner-choice">OWNER SETUP</button>
+<span class="owner-note">/* Owner (Doan Luu Duc Tai) only. */</span>
+</section>
+<section id="owner-access" hidden>
+<h2>Owner access (owner only)</h2>
+<p>An owner identity is already registered. Open your saved owner file with its password.</p>
+<p>If you lost that file, <a href="/LIMITATIONS.txt">reset the chat service from the backend</a> before creating a new owner identity. A reset clears the chat history; it cannot recover the lost key.</p>
+<button id="owner-open">OPEN OWNER FILE</button>
+</section>
<section id="create" hidden>
<h2 id="create-title">Create a new chat</h2>
<form id="create-form" autocomplete="off">
@@ -79,7 +89,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<h3 id="thread-name"></h3>
<p id="conversation-fingerprint"></p>
<p id="counts"></p>
-<form id="history-form"><div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="history-count">Latest messages (0-50)</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="history-count" type="number" min="0" max="50" step="1" value="0" required></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div><button type="submit">LOAD HISTORY</button></form>
+<form id="history-form" novalidate><div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label id="history-range" class="field-heading" for="history-count">Latest messages (0-0)</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="history-count" type="number" min="0" max="0" step="1" value="0" required></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div><button type="submit">LOAD HISTORY</button></form>
<button id="older" hidden>LOAD OLDER</button>
<ol id="messages" aria-label="Messages"></ol>
<p id="message-limit">Plain text only. Up to 8192 UTF-8 bytes per message.</p>
@@ -92,7 +102,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a>
<a class="dependency-licenses" href="/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a>
<a href="/SOURCE.tar.gz">[ SOURCE CODE ]</a>
-<a href="/javascript.html" rel="jslicense">[ JAVASCRIPT LICENSES ]</a>
</footer>
</main>
</body>
diff --git a/crates/chat-server/web/style.css b/crates/chat-server/web/style.css
index 80b07a6..5587f58 100644
--- a/crates/chat-server/web/style.css
+++ b/crates/chat-server/web/style.css
@@ -29,12 +29,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}
* { box-sizing: border-box; }
-body { overflow-y: scroll; font: 16px/1.5 monospace, monospace; margin: 0; background: var(--background); color: var(--foreground); }
+html { overflow-y: scroll; scrollbar-gutter: stable; }
+body { overflow: visible; font: 16px/1.5 monospace, monospace; margin: 0; background: var(--background); color: var(--foreground); }
main { width: 80ch; max-width: calc(100% - 32px); margin: 16px auto; position: relative; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--link); outline-offset: 4px; }
-.logo-art { font: inherit; line-height: 1.2; margin: 0 0 2em; overflow-x: auto; }
+.logo-art { font: inherit; line-height: 1.2; margin: 0 0 2em; overflow: visible; }
.logo { color: var(--logo); display: inline-block; }
.logo:hover { text-decoration: none; }
.site-nav { display: flex; flex-wrap: wrap; gap: 2ch; margin: 0 0 2em; }
@@ -85,12 +86,18 @@ section { margin: 2em 0; }
#messages li::before { content: '--------------------------------------------------------------------------------'; display: block; overflow: hidden; white-space: pre; margin: 1em 0; }
#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .5em 0 1em; }
#messages small { color: var(--link); font: inherit; }
+#status:empty { display: none; }
#status { min-height: 1.5em; color: var(--link); margin: 2em 0 1em; padding-left: 2ch; position: relative; }
#status::before { content: '>'; color: var(--good); position: absolute; left: 0; font-weight: bold; }
.libre-badge { position: absolute; top: 0; right: 0; color: var(--warning); font-weight: bold; }
-.libre-badge > a { color: inherit; text-decoration: underline wavy; text-underline-offset: .2em; }
+.libre-badge > a[aria-describedby] { color: inherit; text-decoration: underline wavy; text-underline-offset: .2em; }
#js-warning { display: none; position: absolute; top: 2em; right: 0; width: 48ch; max-width: calc(100vw - 32px); padding: 1em 2ch; background: var(--background); color: var(--foreground); border: 1px dashed var(--warning); z-index: 1; font-weight: normal; }
-.libre-badge > a:hover + #js-warning, .libre-badge > a:focus-visible + #js-warning { display: block; }
+.libre-badge > a[aria-describedby]:hover + #js-warning, .libre-badge > a[aria-describedby]:focus-visible + #js-warning { display: block; }
+.script-licenses { display: block; color: var(--link); font-size: .875em; text-align: right; margin-top: .5em; }
+.owner-entry { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5em 1ch; margin: 1em 0; }
+.owner-entry button { margin-right: 0; }
+.owner-note { color: var(--muted); font-size: .875em; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.source-footer { display: flex; flex-direction: column; align-items: flex-end; gap: .5em; text-align: right; margin: 3em 0 1em; font-weight: bold; }
.source-footer::before { content: '________________________________________________________________________________'; display: block; width: 100%; overflow: hidden; white-space: pre; margin-bottom: 1em; font-weight: normal; }
.project-license { color: var(--license); }