diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 13:08:08 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 13:08:08 -0500 |
| commit | a80df869a92c330fbad9695057720b11a18af901 (patch) | |
| tree | 5307272821cb3adc3f0348413a3b904290890044 /crates/chat-server/web | |
| parent | 9139398d465f5899cc31bc4cdd54daa7392ca550 (diff) | |
Forbid Rust lints and enforce strict typed TypeScript linting
Diffstat (limited to 'crates/chat-server/web')
| -rw-r--r-- | crates/chat-server/web/app.ts | 165 | ||||
| -rw-r--r-- | crates/chat-server/web/worker.ts | 4 |
2 files changed, 93 insertions, 76 deletions
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.' }); |
