From 9139398d465f5899cc31bc4cdd54daa7392ca550 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 12:25:30 -0500 Subject: Migrate browser code and tests to strict TypeScript --- crates/chat-server/web/app.ts | 560 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 crates/chat-server/web/app.ts (limited to 'crates/chat-server/web/app.ts') diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts new file mode 100644 index 0000000..79a827e --- /dev/null +++ b/crates/chat-server/web/app.ts @@ -0,0 +1,560 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 + +function element(id: string): HTMLElement { + const value = document.getElementById(id); + if (!value) throw new Error(`Missing interface element: ${id}`); + return value; +} + +function input(id: string): HTMLInputElement { + const value = element(id); + if (!(value instanceof HTMLInputElement)) throw new Error(`Expected input: ${id}`); + return value; +} + +function form(id: string): HTMLFormElement { + const value = element(id); + if (!(value instanceof HTMLFormElement)) throw new Error(`Expected form: ${id}`); + return value; +} + +function messageInput(): HTMLTextAreaElement { + const value = element('text'); + if (!(value instanceof HTMLTextAreaElement)) throw new Error('Missing message input'); + return value; +} + +const show = (id: string, visible = true): void => { element(id).hidden = !visible; }; +const setStatus = (message: string): void => { element('status').textContent = message; }; +const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Unexpected operation failure'; +let config: StackChat.Configuration | undefined; +let wasm: Promise | undefined; +let credentials: StackChat.Credentials | undefined; +let session: StackChat.Session | undefined; +let selected: StackChat.Conversation | undefined; +let before = 0; +let pendingFile: string | undefined; +let setupToken: string | undefined; +let pendingSend: StackChat.SendRequest | undefined; +let generation = 0; +let busy = false; +const workers = new Map void>(); +const blobs = new Set(); + +function configuration(): StackChat.Configuration { + if (!config) throw new Error('Chat configuration is not loaded'); + return config; +} + +function ownerKey(): string { + const owner = configuration().owner; + if (!owner) throw new Error('Owner setup is required first'); + return owner; +} + +function activeSession(): StackChat.Session { + if (!session) throw new Error('Unlock your identity file first'); + return session; +} + +function conversation(): StackChat.Conversation { + if (!selected) throw new Error('Choose a conversation first'); + return selected; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function record(value: unknown): Record { + if (!isRecord(value)) throw new Error('Invalid response object'); + return value; +} + +function stringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== 'string') throw new Error(`Invalid response field: ${key}`); + return field; +} + +function countField(value: Record, key: string): number { + const field = value[key]; + if (typeof field !== 'number' || !Number.isSafeInteger(field) || field < 0) throw new Error(`Invalid response count: ${key}`); + return field; +} + +function booleanField(value: Record, key: string): boolean { + const field = value[key]; + if (typeof field !== 'boolean') throw new Error(`Invalid response flag: ${key}`); + return field; +} + +function arrayField(value: Record, key: string): unknown[] { + const field: unknown = value[key]; + if (!Array.isArray(field)) throw new Error(`Invalid response list: ${key}`); + return field; +} + +function decodeConversation(value: unknown): StackChat.Conversation { + const item = record(value); + const total = countField(item, 'total'); + const visitor = countField(item, 'visitor_sent'); + const owner = countField(item, 'owner_sent'); + if (total > 10000 || visitor + owner !== total) throw new Error('Invalid conversation count'); + const profile = item['profile']; + if (profile !== undefined && profile !== null && typeof profile !== 'string') throw new Error('Invalid conversation profile'); + return { id: stringField(item, 'id'), public: stringField(item, 'public'), total, visitor_sent: visitor, owner_sent: owner, ...(profile === undefined ? {} : { profile }) }; +} + +function decodeMessage(value: unknown): StackChat.Message { + const item = record(value); + return { seq: countField(item, 'seq'), id: stringField(item, 'id'), sender: stringField(item, 'sender'), ciphertext: stringField(item, 'ciphertext') }; +} + +function decodeDecryption(value: unknown): StackChat.Decryption { + const item = record(value); + return { signer: stringField(item, 'signer'), payload: stringField(item, 'payload') }; +} + +const apiReaders: { [K in keyof StackChat.Api]: (value: unknown) => StackChat.Api[K]['response'] } = { + config(value) { + const item = record(value); + const owner = item['owner']; + if (owner !== null && typeof owner !== 'string') throw new Error('Invalid owner configuration'); + return { owner, origin: stringField(item, 'origin'), protocol: stringField(item, 'protocol') }; + }, + challenge(value) { const item = record(value); return { id: stringField(item, 'id'), challenge: stringField(item, 'challenge') }; }, + authenticate(value) { const item = record(value); return { token: stringField(item, 'token'), id: stringField(item, 'id'), owner: booleanField(item, 'owner'), expires_seconds: countField(item, 'expires_seconds') }; }, + profile(value) { return { stored: booleanField(record(value), 'stored') }; }, + inbox(value) { return { conversations: arrayField(record(value), 'conversations').map(decodeConversation) }; }, + history(value) { const item = record(value); return { conversation: decodeConversation(item['conversation']), messages: arrayField(item, 'messages').map(decodeMessage) }; }, + send(value) { const item = record(value); return { seq: countField(item, 'seq'), conversation: decodeConversation(item['conversation']) }; }, + lock(value) { return { locked: booleanField(record(value), 'locked') }; }, +}; + +const cryptoReaders: { [K in keyof StackChat.Crypto]: (value: unknown) => StackChat.Crypto[K]['response'] } = { + create(value) { return { file: stringField(record(value), 'file') }; }, + open(value) { const item = record(value); return { public: stringField(item, 'public'), fingerprint: stringField(item, 'fingerprint'), owner: stringField(item, 'owner'), name: stringField(item, 'name') }; }, + proof(value) { return { signature: stringField(record(value), 'signature') }; }, + encrypt(value) { return { ciphertext: stringField(record(value), 'ciphertext') }; }, + decrypt: decodeDecryption, + decrypt_batch(value) { + return { messages: arrayField(record(value), 'messages').map(entry => { + const item = record(entry); + return 'error' in item ? { error: stringField(item, 'error') } : decodeDecryption(item); + }) }; + }, +}; + +async function api(request: StackChat.Api[K]['request'] & { op: K }): Promise { + const current = generation; + const response = await fetch('/api', { + method: 'POST', credentials: 'omit', cache: 'no-store', redirect: 'error', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...request, token: session?.token }), + signal: AbortSignal.timeout(20000), + }); + 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'); + return apiReaders[request.op](result); +} + +async function cryptoOperation(request: StackChat.Crypto[K]['request'] & { op: K }): Promise { + const current = generation; + if (!wasm) throw new Error('Cryptographic module is not loaded'); + const bytes = await wasm; + if (current !== generation) throw new Error('Session locked'); + return new Promise((resolve, reject) => { + const worker = new Worker('/worker.js'); + + const stop = () => { 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) => { + 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); } + }; + worker.postMessage({ bytes, request }); + }); +} + +function protectRequest(op: K, fields: Omit): StackChat.Protected & { op: K } & typeof fields { + if (!credentials) throw new Error('Unlock your identity file first'); + return { op, ...credentials, origin: location.origin, ...fields }; +} + +function clearState() { + generation += 1; + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + for (const cancel of workers.values()) cancel(); + workers.clear(); + for (const url of blobs) URL.revokeObjectURL(url); + blobs.clear(); + credentials = undefined; + session = undefined; + selected = undefined; + pendingFile = undefined; + pendingSend = undefined; + setupToken = undefined; + before = 0; + input('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', 'owner-access']) show(id, false); + show('entry', Boolean(config?.owner)); + show('owner-entry'); + setStatus('Locked. Select your file and enter its password to return.'); +} + +function action(id: string, callback: () => Promise): void { + element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => { + event.preventDefault(); + if (busy) return; + busy = true; + setStatus('Working locally...'); + const current = generation; + for (const button of document.querySelectorAll('button:not(#lock)')) button.disabled = true; + try { await callback(); } + catch (error) { if (generation === current) setStatus(errorMessage(error)); } + finally { + if (generation === current) { + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + } + } + }); +} + +function download(file: string): void { + const url = URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' })); + blobs.add(url); + const link = document.createElement('a'); + link.href = url; + link.download = 'stack-chat-identity.chat'; + link.click(); + setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000); +} + +async function createIdentity() { + 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; + 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 || '' }); + pendingFile = result.file; + download(result.file); + form('create-form').reset(); + show('create', false); + show('unlock'); + element('reopen-note').textContent = 'Your file was downloaded. Reopen that exact saved file and enter its password before any message can be sent. Keep a safe backup.'; + setStatus('Reopen your downloaded identity file to verify recovery.'); +} + +async function unlockIdentity() { + const file = input('file').files?.[0]; + if (!file || 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'); + 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.'); + 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 }); + if (authenticated.id !== identity.fingerprint || typeof authenticated.owner !== 'boolean') throw new Error('Authenticated identity mismatch'); + credentials = candidate; + session = { ...identity, ...authenticated }; + pendingFile = undefined; + 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} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`; + element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; + if (authenticated.owner) { + activeSession().owner = true; + show('inbox'); + await inbox(); + } else { + activeSession().owner = false; + const profile = await cryptoOperation(protectRequest('encrypt', { recipient: ownerKey(), payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: activeSession().id, sender: activeSession().id, name: identity.name }) })); + await api({ op: 'profile', conversation: activeSession().id, ciphertext: profile.ciphertext }); + selected = { id: activeSession().id, public: identity.public, total: 0, visitor_sent: 0, owner_sent: 0 }; + element('conversation-fingerprint').textContent = `Conversation: ${activeSession().id}`; + show('thread'); + element('thread-name').textContent = 'You <-> Doan'; + await loadHistory(0); + } + setStatus('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); +} + +function checkedPayload(payload: string, kind: 'profile', conversation: string, sender: string): { name: string }; +function checkedPayload(payload: string, kind: 'message', conversation: string, sender: string): { id: string; text: string }; +function checkedPayload(payload: string, kind: 'profile' | 'message', conversation: string, sender: string): { name: string } | { id: string; text: string } { + const value = record(JSON.parse(payload)); + if (value['v'] !== 1 || value['kind'] !== kind || value['origin'] !== location.origin || value['conversation'] !== conversation || value['sender'] !== sender) throw new Error('Signed message binding mismatch'); + if (kind === 'profile') return { name: stringField(value, 'name') }; + return { id: stringField(value, 'id'), text: stringField(value, 'text') }; +} + +async function inbox() { + 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 () => { + if (busy) return; + if (pendingSend) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } + busy = true; + const current = generation; + setStatus('Opening conversation...'); + try { + selected = conversation; + element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`; + element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`; + pendingSend = undefined; + messageInput().value = ''; + show('retry', false); + 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 })); + 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'); + name = profile.name; + } + element('thread-name').textContent = `Doan <-> ${name}`; + await loadHistory(0); + setStatus('Conversation opened. Choose how many messages to load.'); + } catch (error) { setStatus(errorMessage(error)); } + finally { if (generation === current) busy = false; } + }); + element('conversations').append(button); + } +} + +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})`; + 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}`; +} + +async function renderHistoryBatch(messages: StackChat.Message[]): Promise { + const requests = messages.map(message => ({ sender: message.sender === conversation().id ? conversation().public : ownerKey(), 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]; + if (!message) 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'}`; + 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.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'); + 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 conversationId = conversation().id; + const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 }); + if (selected?.id === conversationId) counters(result.conversation); + return result.conversation; +} + +async function loadHistory(count: number, cursor = 0): Promise { + if (!selected) 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 (count === 0) { + input('history-count').value = '0'; + show('older', false); + setStatus('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: 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; } + 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})...`); + await renderHistoryBatch(result.messages); + remaining -= result.messages.length; + next = sequence; + before = next; + if (result.messages.length < size) { exhausted = true; break; } + } + show('older', !exhausted && element('messages').children.length < Number(input('history-count').max)); + setStatus('Selected history decrypted and verified locally.'); +} + +async function submitPending() { + if (!pendingSend) throw new Error('No pending message'); + const result = await api(pendingSend); + counters(result.conversation); + pendingSend = undefined; + messageInput().value = ''; + show('retry', false); + 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'); + 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'); + 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...'); + const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: conversation().id, sender: activeSession().id, id, text }) })); + pendingSend = { op: 'send', conversation: conversation().id, id, ciphertext: result.ciphertext }; + show('retry'); + await submitPending(); +} + +function ownerSetup() { + 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); + 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-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)); }); +}); +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(); }); +window.addEventListener('pagehide', clearState); +window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); + +async function downloadCrypto() { + const controller = new AbortController(); + let timer: ReturnType | undefined; + let reader: ReadableStreamDefaultReader> | undefined; + const refreshTimeout = () => { + clearTimeout(timer); + 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')); + const total = Number.isSafeInteger(length) && length > 0 ? length : 0; + const chunks: Uint8Array[] = []; + let received = 0; + const progress = () => { + const loaded = (received / 1048576).toFixed(1); + if (total) { + 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)`); + } else setStatus(`Downloading cryptographic module... ${loaded} MiB received`); + }; + progress(); + if (!response.body) throw new Error('Cryptographic module response has no body'); + reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + refreshTimeout(); + chunks.push(value); + received += value.byteLength; + progress(); + } + if (!received) 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.'); + throw error; + } finally { + clearTimeout(timer); + reader?.releaseLock(); + } +} + +async function start() { + 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)); + setStatus(''); + if (location.hash === '#setup') ownerSetup(); +} + +start().catch(error => setStatus(errorMessage(error))); + +// @license-end -- cgit v1.2.3