From 46c770ed72f0429f805782b5ccf694943cc32f49 Mon Sep 17 00:00:00 2001 From: Akhilesh Thite Date: Fri, 11 Sep 2026 12:11:12 -0700 Subject: [PATCH 1/8] feat: password change, account deletion, and writes while offline --- AGENTS.md | 9 ++ README.md | 7 +- docs/data.md | 13 +++ docs/login.md | 35 +++++++ package.json | 2 +- src/chelonia/auth.js | 177 +++++++++++++++++++++++++++++++- src/chelonia/connection.js | 6 +- src/chelonia/index.js | 6 +- src/chelonia/offline.js | 75 ++++++++++++++ src/chelonia/todos.js | 72 ++++++++++--- src/components/AccountPanel.vue | 77 ++++++++++++++ src/components/App.vue | 4 + src/components/TodoApp.vue | 30 +++--- src/contracts/identity.js | 23 ++++- src/style.css | 26 ++++- test/e2e/chelonia.spec.mjs | 32 +++--- vite.config.js | 7 +- 17 files changed, 534 insertions(+), 67 deletions(-) create mode 100644 src/chelonia/offline.js create mode 100644 src/components/AccountPanel.vue diff --git a/AGENTS.md b/AGENTS.md index 89d4f4d..5288d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ src/chelonia/lists.js creating a list, inviting, joining src/chelonia/lists-model.js the lists schema and its one reducer, both pure src/chelonia/todos.js the todos slot and the six writes src/chelonia/todos-model.js the schema and the reducers, both pure +src/chelonia/offline.js the queue for writes made while the server is away src/components/ Vue, and nothing else scripts/build-contracts.mjs chel manifest -> chel pin -> manifest CID scripts/chel.mjs runs chel from node_modules, see below @@ -45,9 +46,17 @@ database backend, and a `server_id` the server refuses to start without). `chel init` generates it with the in-memory backend, which loses every account on restart, so the script switches it to sqlite under `data/`. +`npm run serve` runs the real server as a child of `scripts/chel.mjs`, so +killing the node process alone leaves it up. Stop it through the port: +`lsof -ti:8000 | xargs kill`. + After a full rebuild, restart `npm run serve`. Vite empties `dist/` and a server that was already running answers 404 until it is restarted. +The app is built with `LIGHTWEIGHT_CLIENT=true` (see `vite.config.js`), the +same as Group Income: the browser keeps no message log, and Chelonia reads each +contract's HEAD from the saved state. + The contract version comes from `version` in `package.json`. Editing a contract without bumping it makes the build stop, since the app would then be built against a manifest the accounts already on the server do not have. diff --git a/README.md b/README.md index 3ad8eeb..030b0d8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,12 @@ scripts, and the files the first run creates, are listed in of the contract. 7. Share a list: press **Share**, open the link in a private window, sign up there and join. Keep the first window open, it is the one that answers. -8. Look at what the server actually has: +8. Change your password from the **account** link at the bottom, then log out + and in with the new one. The same panel deletes the account, along with + the lists it created. +9. Stop the server with Ctrl-C and keep adding todos. They show up straight + away and wait; start the server again and they go through. +10. Look at what the server actually has: ```bash chel eventsAfter 0 diff --git a/docs/data.md b/docs/data.md index eed41b2..62414a6 100644 --- a/docs/data.md +++ b/docs/data.md @@ -46,3 +46,16 @@ declared slot at `rootState._kv[contractID][key]` and updates it from four places: the first load, a push from another client, our own write, and a refetch after the socket reconnects. That state object is a Vue `reactive()`, so a `computed` over it reruns on all four and the list redraws by itself. + +## Offline + +`chelonia/kv/update` needs the server, so while the socket is down a write goes +into Chelonia's persistent action queue instead (`src/chelonia/offline.js`). +The queue stores `[selector, ...args]` as JSON, which is why writes are named +(`'addTodo'`, `'setTitle'`, ...) and the reducer is looked up when the write +runs. The queue lives under one localStorage key, so it survives a reload, and +`retryAll` is called as soon as the socket is back. + +Until a write lands, `currentTodos` applies it on top of the mirror value, so +the list looks the same offline as it will once the server has it. A write that +lands is removed from that overlay on `PERSISTENT_ACTION_SUCCESS`. diff --git a/docs/login.md b/docs/login.md index 83260aa..dbfeb6b 100644 --- a/docs/login.md +++ b/docs/login.md @@ -44,3 +44,38 @@ server answered at all. None of the above. The secrets and the contract state are already in the saved blob, so it only re-syncs. + +## Changing the password + +1. Prove the current password against `/zkpp/:contractID/auth_hash`, as at + login. +2. `POST /zkpp/:contractID/updatePasswordHash` with that proof and the new + password's hash, encrypted to the proof's shared secret + (`buildUpdateSaltRequestEc`). The answer is the old contract salt and a + one-time token. +3. Derive the old `ipk` and `iek` from the old password and salt, and the new + ones from the new password and the new salt. +4. `chelonia/out/keyUpdate`, signed by the old `ipk`, with the token in the + `shelter-salt-update-token` header. `ipk` and `iek` are replaced. `csk`, + `cek` and `#sak` keep their keys and only get their secrets encrypted again + to the new `iek`, so nothing already on the contract has to be rewritten. +5. Publish the deletion token again, encrypted to the new `iek`. +6. Discard all four password keys. + +## Deleting the account + +Signup sends `shelter-deletion-token-digest`, the hash of a random token, and +keeps the token itself in the contract encrypted to the `iek`. So deleting +takes the password. + +1. Prove the password and derive the `iek`, as at login. +2. Decrypt the token out of `attributes.encryptedDeletionToken`. +3. `chelonia/out/deleteContract` with the token. The server answers 202 and + deletes the contract in the background, together with every list this + account created. Lists joined through an invite belong to whoever made them + and stay. +4. Log out locally. + +The username is not freed. chel 3.4.0 keeps the name pointing at the deleted +contract and only lists it as orphaned, so signing up with the same name again +is refused as taken until the server cleans those up. diff --git a/package.json b/package.json index 4ed6a14..07bafe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chelonia-todomvc", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "engines": { diff --git a/src/chelonia/auth.js b/src/chelonia/auth.js index aeb7c49..57ddc6a 100644 --- a/src/chelonia/auth.js +++ b/src/chelonia/auth.js @@ -7,12 +7,13 @@ import sbp from '@sbp/sbp' import { Secret } from '@chelonia/lib/Secret' -import { encryptedOutgoingDataWithRawKey } from '@chelonia/lib/encryptedData' -import { bytesToB64 } from '@chelonia/lib/functions' +import { encryptedIncomingData, encryptedOutgoingDataWithRawKey } from '@chelonia/lib/encryptedData' +import { blake32Hash, bytesToB64 } from '@chelonia/lib/functions' import { base64ToBase64url, boxKeyPair, buildRegisterSaltRequest, + buildUpdateSaltRequestEc, computeCAndHc, decryptContractSalt, hash, @@ -23,12 +24,14 @@ import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, + generateSalt, keyId, keygen, serializeKey } from '@chelonia/crypto' import { API_URL, CONTRACT_NAME } from './config.js' -import { createList, loadLists, retainOrSync } from './lists.js' +import { createList, currentLists, loadLists, retainOrSync } from './lists.js' +import { dropPendingWrites, loadOfflineQueue } from './offline.js' import { clearSavedState, persistState, state } from './state.js' const DEFAULT_LIST_TITLE = 'My todos' @@ -154,6 +157,10 @@ export async function signup ({ username, password }) { const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) const SAK = keygen(EDWARDS25519SHA512BATCH) + // Lets the account delete itself later. The server keeps only the hash, and + // the token sits in the contract encrypted to the IEK, so deleting takes the + // password. + const deletionToken = generateSalt() // Transient, so neither of the password-derived keys reaches the saved state. sbp('chelonia/storeSecretKeys', new Secret([ @@ -170,7 +177,8 @@ export async function signup ({ username, password }) { // this first message. headers: { 'shelter-namespace-registration': username, - 'shelter-salt-registration-token': saltRegistrationToken + 'shelter-salt-registration-token': saltRegistrationToken, + 'shelter-deletion-token-digest': blake32Hash(deletionToken) } }, signingKeyId: keyId(IPK), @@ -231,7 +239,13 @@ export async function signup ({ username, password }) { data: serializeKey(SAK, false) } ], - data: { attributes: { username } } + data: { + attributes: { + username, + encryptedDeletionToken: encryptedOutgoingDataWithRawKey(IEK, deletionToken) + .serialize('encryptedDeletionToken') + } + } }) } catch (e) { // TODO: BEGIN REMOVEME (okTurtles/libcheloniajs#94) @@ -279,6 +293,12 @@ export async function login ({ username, password }) { // Syncing is the recovery step: processing OP_CONTRACT decrypts the CSK, // CEK and SAK with the IEK and stores them persistently. await sbp('chelonia/contract/retain', [identityContractID]) + // After a password change those three are only readable from the key + // update onwards, so the first pass could not open anything before it. + // Go through the log once more now that they are known. + if (state.contracts[identityContractID]?.missingDecryptionKeyIds?.length) { + await sbp('chelonia/contract/sync', [identityContractID], { resync: true }) + } } finally { sbp('chelonia/clearTransientSecretKeys', [keyId(IEK)]) } @@ -294,6 +314,7 @@ export async function restoreSession () { await retainOrSync(identityContractID) sbp('chelonia/kv/refreshFilters') await loadLists(identityContractID) + await loadOfflineQueue((contractID) => currentLists().includes(contractID)) return identityContractID } @@ -303,6 +324,149 @@ async function enterSession (identityContractID) { sbp('chelonia/kv/refreshFilters') await sbp('chelonia/contract/wait', [identityContractID]) await loadLists(identityContractID) + await loadOfflineQueue((contractID) => currentLists().includes(contractID)) +} + +function currentIdentity () { + const identityContractID = state.loggedIn?.identityContractID + if (!identityContractID) throw new AuthError('Not logged in.') + return identityContractID +} + +const keyIdByName = (contractState, name) => + sbp('chelonia/contract/currentKeyIdByName', contractState, name) + +export async function changePassword ({ oldPassword, newPassword }) { + const identityContractID = currentIdentity() + const identityState = state[identityContractID] + const contract = encodeURIComponent(identityContractID) + + // Same proof as login. The new password travels encrypted to that proof's + // shared secret, and the answer is the old salt plus a one-time token that + // lets the next message swap the salts on the server. + let oldContractSalt, newContractSalt, updateToken + try { + const r = randomNonce() + const { authSalt, s, sig } = await request( + `/zkpp/${contract}/auth_hash?b=${encodeURIComponent(hash(r))}` + ).then((r) => r.json()) + const [c, hc] = computeCAndHc(r, s, await hashPassword(oldPassword, authSalt)) + const [salt, Ea] = await buildUpdateSaltRequestEc(newPassword, c) + newContractSalt = salt + const encrypted = await request( + `/zkpp/${contract}/updatePasswordHash`, + form({ r, s, sig, hc: toBase64url(hc), Ea }) + ).then((r) => r.json()) + ;[oldContractSalt, updateToken] = JSON.parse(decryptContractSalt(c, encrypted)) + } catch (e) { + if (e instanceof AuthError && e.exact) throw e + throw new AuthError('Incorrect password.', { cause: e }) + } + + const oldIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, oldPassword, oldContractSalt) + const oldIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, oldPassword, oldContractSalt) + const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, newPassword, newContractSalt) + + // Read while the old IEK is still the current key. + const encryptedToken = identityState.attributes?.encryptedDeletionToken + const deletionToken = encryptedToken && encryptedIncomingData( + identityContractID, identityState, encryptedToken, NaN, + { [keyId(oldIEK)]: oldIEK }, 'encryptedDeletionToken' + ).valueOf() + + sbp('chelonia/storeSecretKeys', new Secret( + [oldIPK, oldIEK, IPK, IEK].map((key) => ({ key, transient: true })) + )) + try { + // Only the two password keys are replaced. The everyday keys stay and get + // their secrets encrypted again to the new IEK, so nothing already on the + // contract has to be rewritten. The same id and public key go back in, + // since Chelonia checks the decrypted secret against the entry's id. + const keep = (name) => { + const id = keyIdByName(identityState, name) + return { + id, + name, + oldKeyId: id, + data: identityState._vm.authorizedKeys[id].data, + meta: { private: { content: encryptedOutgoingDataWithRawKey(IEK, state.secretKeys[id]) } } + } + } + await sbp('chelonia/out/keyUpdate', { + contractID: identityContractID, + contractName: CONTRACT_NAME, + data: [ + { + id: keyId(IPK), + name: 'ipk', + oldKeyId: keyId(oldIPK), + meta: { private: { transient: true } }, + data: serializeKey(IPK, false) + }, + { + id: keyId(IEK), + name: 'iek', + oldKeyId: keyId(oldIEK), + meta: { private: { transient: true } }, + data: serializeKey(IEK, false) + }, + keep('csk'), + keep('cek'), + keep('#sak') + ], + signingKeyId: keyId(oldIPK), + // The server swaps the salts while it accepts this message. + publishOptions: { headers: { 'shelter-salt-update-token': updateToken } } + }) + if (deletionToken) { + await sbp('chelonia/out/actionEncrypted', { + action: `${CONTRACT_NAME}/setDeletionToken`, + contractID: identityContractID, + data: { + encryptedDeletionToken: encryptedOutgoingDataWithRawKey(IEK, deletionToken) + .serialize('encryptedDeletionToken') + }, + signingKeyId: keyIdByName(identityState, 'csk'), + encryptionKeyId: keyIdByName(identityState, 'cek') + }) + } + await sbp('chelonia/contract/wait', [identityContractID]) + } finally { + sbp('chelonia/clearTransientSecretKeys', [oldIPK, oldIEK, IPK, IEK].map(keyId)) + } +} + +export async function deleteAccount ({ password }) { + const identityContractID = currentIdentity() + const identityState = state[identityContractID] + const encryptedToken = identityState?.attributes?.encryptedDeletionToken + if (!encryptedToken) { + throw new AuthError('This account was made before deleting was possible.') + } + + let token + try { + const contractSalt = await retrieveSalt(identityContractID, password) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) + token = encryptedIncomingData( + identityContractID, identityState, encryptedToken, NaN, + { [keyId(IEK)]: IEK }, 'encryptedDeletionToken' + ).valueOf() + } catch (e) { + if (e instanceof AuthError && e.exact) throw e + throw new AuthError('Incorrect password.', { cause: e }) + } + + // The server takes it from here and also deletes the lists this account + // created. Lists it only joined belong to whoever made them. + const [result] = await sbp('chelonia/out/deleteContract', identityContractID, { + [identityContractID]: { token: new Secret(token) } + }) + if (result.status === 'rejected') { + throw new AuthError('Could not delete the account.', { cause: result.reason }) + } + await logout() } // Read from the contract state rather than kept alongside the session, so @@ -313,6 +477,9 @@ export function currentUsername () { } export async function logout () { + // Unsent writes cannot go out without this account's keys. + await dropPendingWrites() + sbp('chelonia.persistentActions/unload') // Stop saving before reset churns through the state, then start again for // whoever logs in next. clearSavedState() diff --git a/src/chelonia/connection.js b/src/chelonia/connection.js index 05bd68a..476d8e5 100644 --- a/src/chelonia/connection.js +++ b/src/chelonia/connection.js @@ -6,10 +6,11 @@ import { PUBSUB_RECONNECTION_SCHEDULED, PUBSUB_RECONNECTION_SUCCEEDED } from '@chelonia/lib/pubsub' +import { retryPendingWrites } from './offline.js' // The socket is the only thing that says the server went away mid-session. -// Reads keep working off the mirror, so without this the app looks fine while -// every write fails. +// Reads keep working off the mirror, so without this the app would not know +// to queue writes instead of sending them. export const connection = reactive({ online: true }) export function watchConnection () { @@ -23,5 +24,6 @@ export function watchConnection () { // Also fires on the first open, not just on a reconnect. sbp('okTurtles.events/on', PUBSUB_RECONNECTION_SUCCEEDED, () => { connection.online = true + retryPendingWrites() }) } diff --git a/src/chelonia/index.js b/src/chelonia/index.js index 7f7ec2c..cbb9e9d 100644 --- a/src/chelonia/index.js +++ b/src/chelonia/index.js @@ -3,17 +3,21 @@ import { watchConnection } from './connection.js' import { persistState } from './state.js' import { restoreSession } from './auth.js' import { defineListsSlot } from './lists.js' +import { setupOfflineQueue } from './offline.js' import { defineTodosSlot } from './todos.js' export async function startChelonia () { watchConnection() await configureChelonia() + setupOfflineQueue() defineListsSlot() defineTodosSlot() persistState() return restoreSession() } -export { AuthError, currentUsername, login, logout, signup } from './auth.js' +export { + AuthError, changePassword, currentUsername, deleteAccount, login, logout, signup +} from './auth.js' export { connection } from './connection.js' export { state } from './state.js' diff --git a/src/chelonia/offline.js b/src/chelonia/offline.js new file mode 100644 index 0000000..2627ef6 --- /dev/null +++ b/src/chelonia/offline.js @@ -0,0 +1,75 @@ +// Writes made while the server is unreachable. +// +// They go into Chelonia's persistent action queue, which retries them until +// the server takes them, and until then they are shown on top of the last +// value the server sent. The queue keeps `[selector, ...args]` as plain JSON, +// so a write is described by name and its reducer is looked up when it runs. + +import sbp from '@sbp/sbp' +import { + PERSISTENT_ACTION_SUCCESS, + PERSISTENT_ACTION_TOTAL_FAILURE +} from '@chelonia/lib/events' +import { state } from './state.js' + +const QUEUE_KEY = 'todomvc/pending-writes' +const NO_WRITES = Object.freeze([]) + +export const pendingWrites = () => state.pendingWrites ?? NO_WRITES + +export function setupOfflineQueue () { + keepQueueInLocalStorage() + sbp('chelonia.persistentActions/configure', { + databaseKey: QUEUE_KEY, + options: { retrySeconds: 15 } + }) + const forget = ({ id }) => { + state.pendingWrites = pendingWrites().filter((w) => w.id !== id) + } + sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, forget) + sbp('okTurtles.events/on', PERSISTENT_ACTION_TOTAL_FAILURE, ({ id, error }) => { + console.error('[todomvc] gave up on a queued write', error) + forget({ id }) + }) +} + +// chelonia.db is an in-memory map in this app, and the queue has to outlive a +// reload, so this one key goes to localStorage instead. +function keepQueueInLocalStorage () { + const get = sbp('sbp/selectors/fn', 'chelonia.db/get') + const set = sbp('sbp/selectors/fn', 'chelonia.db/set') + // Both return a promise, as the originals do and as their callers expect. + sbp('sbp/selectors/overwrite', { + 'chelonia.db/get': async (key) => + key === QUEUE_KEY ? localStorage.getItem(QUEUE_KEY) : get(key), + 'chelonia.db/set': async (key, value) => + key === QUEUE_KEY ? localStorage.setItem(QUEUE_KEY, value) : set(key, value) + }) + sbp('sbp/selectors/lock', ['chelonia.db/get', 'chelonia.db/set']) +} + +// Called once a session is open. Writes for lists this account is not in +// (another account used this browser and never logged out) are dropped. +export async function loadOfflineQueue (isOurs) { + await sbp('chelonia.persistentActions/load') + const queued = sbp('chelonia.persistentActions/status') + for (const action of queued) { + if (!isOurs(action.invocation[1])) await sbp('chelonia.persistentActions/cancel', action.id) + } + const kept = new Set(sbp('chelonia.persistentActions/status').map((a) => a.id)) + state.pendingWrites = pendingWrites().filter((w) => kept.has(w.id)) +} + +export function queueWrite (invocation, write) { + const [id] = sbp('chelonia.persistentActions/enqueue', invocation) + state.pendingWrites = [...pendingWrites(), { id, ...write }] +} + +export const retryPendingWrites = () => sbp('chelonia.persistentActions/retryAll') + +export async function dropPendingWrites () { + for (const { id } of sbp('chelonia.persistentActions/status')) { + await sbp('chelonia.persistentActions/cancel', id) + } + state.pendingWrites = [] +} diff --git a/src/chelonia/todos.js b/src/chelonia/todos.js index 2bf9e05..0a5412b 100644 --- a/src/chelonia/todos.js +++ b/src/chelonia/todos.js @@ -1,7 +1,10 @@ import sbp from '@sbp/sbp' import { CHELONIA_KV_VALIDATION_ERROR } from '@chelonia/lib/events' +import { KV_NOOP } from '@chelonia/lib/kv-constants' import { LIST_CONTRACT_NAME } from './config.js' +import { connection } from './connection.js' import { currentLists } from './lists.js' +import { pendingWrites, queueWrite } from './offline.js' import { state } from './state.js' import { addTodo, @@ -16,6 +19,10 @@ import { const TODOS_KEY = 'todos' const NO_TODOS = Object.freeze({}) +// Reducers by name, because a queued write is stored as JSON and cannot carry +// a function. +const OPS = { addTodo, setCompleted, setTitle, removeTodo, setAllCompleted, removeCompleted } + // One declaration covers the first fetch, the pubsub subscription, the local // mirror, schema validation and the conflict retries. export function defineTodosSlot () { @@ -24,14 +31,27 @@ export function defineTodosSlot () { key: TODOS_KEY, defaultValue: {}, schema: todosSchema, - // Every list we are in, but only once we hold its keys: /kv is authorized - // with the contract's own #sak. Nothing re-runs this when they arrive. - // OP_KEY_SHARE resyncs the contract, and that reconciles the slots. + // Attaches to every list this account is in, but only once we hold that + // list's keys. Between accepting an invite and the owner answering it there + // is nothing here we could read or write: /kv/:contractID/:key is + // authorized with the contract's own #sak. + // + // Nothing re-runs this by hand when the keys finally arrive. Chelonia marks + // the contract dirty on OP_KEY_SHARE and resyncs it, and a resync drops and + // re-adds the subscription, which is what reconciles the slots again. match: (contractID, contractState) => currentLists().includes(contractID) && !!sbp('chelonia/contract/currentKeyIdByName', contractState, '#sak', true) }) + sbp('sbp/selectors/register', { + 'todomvc/todos/write': (contractID, op, ...args) => sbp('chelonia/kv/update', { + contractID, + key: TODOS_KEY, + updater: OPS[op](...args) + }) + }) + // A value that fails the schema never reaches the app: the mirror keeps the // last good one and the slot goes to 'error'. The UI reads that status; this // is here so the reason is visible while developing. @@ -44,10 +64,19 @@ export function defineTodosSlot () { // Reading `entry.value` is what makes a Vue computed re-run when Chelonia // updates the mirror. The value itself comes from the selector, which // substitutes the declared default. See "Consumer caveats" in docs/kv.md. +// +// Writes still waiting for the server are applied on top, in the order they +// were made, so the list looks the same offline as it will once they land. export function currentTodos (contractID) { const entry = mirrorEntry(contractID) if (!entry) return NO_TODOS - return entry.value ?? sbp('chelonia/kv/read', contractID, TODOS_KEY) + const saved = entry.value ?? sbp('chelonia/kv/read', contractID, TODOS_KEY) + return pendingWrites() + .filter((w) => w.contractID === contractID) + .reduce((todos, w) => { + const next = OPS[w.op](...w.args)(todos) + return next === KV_NOOP ? todos : next + }, saved) } // 'non-init' | 'loading' | 'loaded' | 'error' @@ -55,13 +84,24 @@ export function todosStatus (contractID) { return mirrorEntry(contractID)?.status ?? 'non-init' } +export const pendingCount = (contractID) => + pendingWrites().filter((w) => w.contractID === contractID).length + const mirrorEntry = (contractID) => contractID && state._kv?.[contractID]?.[TODOS_KEY] -const write = (contractID, updater) => sbp('chelonia/kv/update', { - contractID, - key: TODOS_KEY, - updater -}) +// Goes straight to the server when it is there. Otherwise, or when the request +// fails before an answer, the write is queued and sent later. +async function write (contractID, op, ...args) { + const invocation = ['todomvc/todos/write', contractID, op, ...args] + if (!connection.online) return queueWrite(invocation, { contractID, op, args }) + try { + await sbp(...invocation) + } catch (e) { + // fetch rejects with a TypeError when the server never answered. + if (!(e instanceof TypeError)) throw e + queueWrite(invocation, { contractID, op, args }) + } +} // Not crypto.randomUUID: that needs a secure context, and opening the demo // from another machine on http://192.168.x.x is not one. @@ -69,17 +109,17 @@ const newId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('') -export const createTodo = (contractID, title) => write(contractID, addTodo({ +export const createTodo = (contractID, title) => write(contractID, 'addTodo', { id: newId(), // Server time, so a tab with a wrong clock sorts the same as everyone else. createdDate: new Date(sbp('chelonia/time')).toISOString(), title -})) +}) export const setTodoCompleted = (contractID, id, completed) => - write(contractID, setCompleted(id, completed)) -export const renameTodo = (contractID, id, title) => write(contractID, setTitle(id, title)) -export const destroyTodo = (contractID, id) => write(contractID, removeTodo(id)) + write(contractID, 'setCompleted', id, completed) +export const renameTodo = (contractID, id, title) => write(contractID, 'setTitle', id, title) +export const destroyTodo = (contractID, id) => write(contractID, 'removeTodo', id) export const completeAllTodos = (contractID, completed) => - write(contractID, setAllCompleted(completed)) -export const clearCompletedTodos = (contractID) => write(contractID, removeCompleted()) + write(contractID, 'setAllCompleted', completed) +export const clearCompletedTodos = (contractID) => write(contractID, 'removeCompleted') diff --git a/src/components/AccountPanel.vue b/src/components/AccountPanel.vue new file mode 100644 index 0000000..0a83e7d --- /dev/null +++ b/src/components/AccountPanel.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/components/App.vue b/src/components/App.vue index c84f601..f57d602 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { currentUsername, logout, state } from '../chelonia/index.js' import { currentLists, listIsPending, readInvite } from '../chelonia/lists.js' +import AccountPanel from './AccountPanel.vue' import AuthView from './AuthView.vue' import JoinView from './JoinView.vue' import ListsBar from './ListsBar.vue' @@ -16,6 +17,7 @@ const username = computed(() => currentUsername()) const lists = computed(() => currentLists()) const invite = ref(readInvite()) const selectedListId = ref(null) +const showAccount = ref(false) // A list that was just joined has no keys yet, so nothing about it can be read. const pending = computed(() => !!selectedListId.value && listIsPending(selectedListId.value)) @@ -57,6 +59,7 @@ async function onLogout () {