Skip to content

feat: password change, account deletion, and writes while offline - #5

Merged
taoeffect merged 8 commits into
mainfrom
feat/account-and-offline
Sep 28, 2026
Merged

taoeffect merged 8 commits into
mainfrom
feat/account-and-offline

Conversation

@akhileshthite

@akhileshthite akhileshthite commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

Password change. Proves the current password like login, sends the new one to /zkpp/:id/updatePasswordHash, then one key update signed by the old password key, with the salt-update token. Only the two password-derived keys are replaced. The everyday signing, encryption and server-authorization keys stay the same and only get their secrets encrypted again to the new password key.

Account deletion. Signup now sends shelter-deletion-token-digest and keeps the token in the contract, encrypted to the password key, same as Group Income. Deleting proves the password, decrypts the token and calls chelonia/out/deleteContract. The server removes the account and every list it created. The username stays reserved afterwards (chel 3.4.0 behaviour, noted in docs/login.md).

Offline. Writes made while the socket is down go into Chelonia's persistent action queue, kept under one localStorage key, and show on top of the list until the server has them. retryAll runs on reconnect. The list is no longer read only while offline.

Fixed on the way:

  • The app ran Chelonia with an in-memory message log, empty after every reload, so the first new event on an unchanged contract failed with "No latest HEAD". Now built with LIGHTWEIGHT_CLIENT=true, like Group Income.
  • A key update needs the key's id and public data even when only the secret changes, or Chelonia cannot match the decrypted secret.
  • After a password change, a fresh login cannot read the events before the key update on its first pass, so login() syncs once more when keys were missing.

The identity contract changed, so the version is 0.2.0.

Testing

By hand on localhost, fresh database:

  • Offline: server killed, two writes made (add and complete), shown at once with "2 waiting so far", flushed when the server came back, reload shows the server's copy, queue empty.
  • Password change: changed, logged out, old password refused, new password logs in with the username, list and todo intact. Re-run from a clean account after each fix.
  • Deletion: session gone, storage empty, login refused, and on the server both the account and its list answer 410.
  • Everything that already worked is covered by the suites, run after each change: lint clean, 14 unit, 23 e2e from a wiped build. The old "read only while unreachable" test now tests the offline queue.

AI Usage: Claude Fable 5.1 Max

Self review: Approved

@akhileshthite akhileshthite self-assigned this Sep 11, 2026
@akhileshthite akhileshthite added documentation Improvements or additions to documentation enhancement New feature or request labels Sep 11, 2026
@akhileshthite

akhileshthite commented Sep 11, 2026 •

Copy link
Copy Markdown
Member Author

/review


AI review started.

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: glm-5.3
Click to expand review

I'll review this PR thoroughly. Let me start by exploring the repository structure and reading the changed files in full context.Now let me examine the library APIs being used, to verify correctness of the new code.node_modules isn't installed. Let me install dependencies so I can verify the library APIs used by the new code.The repo filesystem is read-only. I'll install the dependencies in /tmp instead to inspect the library source.While that installs, let me read the remaining source files.The library source is available. Let me read the persistent-actions module to verify the offline queue's assumptions.Now let me verify the library APIs used by the new code. I'll check several in parallel.The server code is in the chel repo (Deno binary here). Let me first verify the client-side lib APIs: keyUpdate, deleteContract, encryptedIncomingData, and secret key storage format.Let me check how OP_KEY_UPDATE processes entries (whether fields like purpose are merged or replaced), and how serialize(name) uses the name argument.Now let me check the server-side flow for updatePasswordHash in the chel repo (v3.4.0, referenced in local files) to understand failure semantics between the hash update and the key update.The server stages the salt swap atomically with event acceptance — good. Now let me verify the KV update error paths and mirror behavior, which the offline queue depends on.Let me verify a few remaining behaviors: where missingDecryptionKeyIds is written, how kv/set handles non-network errors, and whether chelonia.db/get uses this.Let me check validateKeyUpdatePermissions — the new ipk/iek entries omit purpose/ringLevel/permissions, and new keys are stored without merging.Let me check the remaining spec files for references to removed UI behavior, and look at the helpers.Let me try running the unit tests and lint using the /tmp-installed modules to verify the tree is sound.Tests and lint pass. Let me verify one more suspicion — crypto.randomUUID in the lib's PersistentAction requires a secure context, which this codebase explicitly documents not assuming.Let me get exact line numbers in AccountPanel.vue for citations.# Review: password change, account deletion, and writes while offline

I verified the library APIs against @chelonia/lib 1.5.0 / @chelonia/crypto 1.0.1 source and the server flow against chel v3.4.0 (the salt swap is staged atomically with event acceptance, so the two-step password change has no bricked-account window — that part is sound). Unit tests (14) and lint pass. No 🔴 issues found; here is everything else, most important first.


1. 🟡 The offline queue crashes on non-secure contexts, losing the write

  • Addressed
  • Dismissed

@chelonia/lib's PersistentAction generates its id with crypto.randomUUID() (persistent-actions.ts:73 in the lib), which only exists in secure contexts. This codebase explicitly supports plain-http LAN origins — src/chelonia/todos.js:106-107:

// Not crypto.randomUUID: that needs a secure context, and opening the demo
// from another machine on http://192.168.x.x is not one.

So on http://192.168.x.x:8000, the first offline write throws TypeError: crypto.randomUUID is not a function inside queueWrite (src/chelonia/offline.js:64), the change is lost, and the user sees the generic "That change could not be saved." The whole feature this PR adds is dead in exactly the environment the app says it supports (notably, the lib's own files.ts:348-350 defensively falls back when randomUUID is missing; persistent-actions.ts does not). A tiny polyfill at startup fixes it, e.g. in setupOfflineQueue or main.js:

// PersistentAction ids come from crypto.randomUUID, which only exists in
// secure contexts. The demo also runs on plain http over the LAN.
if (typeof crypto.randomUUID !== 'function') {
  crypto.randomUUID = () => {
    const b = crypto.getRandomValues(new Uint8Array(16))
    b[6] = (b[6] & 0x0f) | 0x40
    b[8] = (b[8] & 0x3f) | 0x80
    const hex = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
    return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
  }
}

At minimum this deserves a TODO referencing an upstream issue, like the other documented workarounds in AGENTS.md.

2. 🟡 showAccount is never reset when the session ends

  • Addressed
  • Dismissed

src/components/App.vue:20 and src/components/App.vue:62:

const showAccount = ref(false)
<AccountPanel v-else-if="showAccount" @close="showAccount = false" />

The footer's "log out" button stays visible while the panel is open, and account deletion ends the session too — but nothing resets showAccount. After logging out (or deleting the account) and logging back in in the same tab, the app opens on the AccountPanel instead of the lists, and the user has to find "Back to the lists". Fix:

watch(loggedIn, (value) => { if (!value) showAccount.value = false })

3. 🟡 A queued write the server will never accept retries forever, and the give-up handler is unreachable

  • Addressed
  • Dismissed

src/chelonia/offline.js:22-25 only configures retrySeconds:

sbp('chelonia.persistentActions/configure', {
  databaseKey: QUEUE_KEY,
  options: { retrySeconds: 15 }
})

maxAttempts stays at its default Infinity, so in the lib's handleError the action is never exhausted and PERSISTENT_ACTION_TOTAL_FAILURE is never emitted — the handler at src/chelonia/offline.js:30-33 is dead code:

sbp('okTurtles.events/on', PERSISTENT_ACTION_TOTAL_FAILURE, ({ id, error }) => {
  console.error('[todomvc] gave up on a queued write', error)
  forget({ id })
})

Meanwhile chelonia/kv/set throws ChelErrorUnexpectedHttpResponseCode for any definitive rejection (list deleted by its owner, revoked access, etc. — verified at chelonia.ts:2822-2825 in the lib), which is not a TypeError, so it is retried every 15 s forever while the UI shows "Sending N changes…" indefinitely with no error. Capping maxAttempts would fix the zombie but break long offline periods, which is the feature's whole point. The precise distinction is available on PERSISTENT_ACTION_FAILURE, which carries the error: network failures are TypeErrors, definitive answers are not:

sbp('okTurtles.events/on', PERSISTENT_ACTION_FAILURE, ({ id, error }) => {
  // fetch rejects with a TypeError when the server never answered; anything
  // else means the server answered and will not take this write.
  if (error instanceof TypeError) return
  console.error('[todomvc] the server rejected a queued write', error)
  sbp('chelonia.persistentActions/cancel', id)
  forget({ id })
})

(Ideally also surface this in TodoApp's error banner the way the online path does.)

4. 🟡 changePassword duplicates retrieveSalt's ZKPP proof block

  • Addressed
  • Dismissed

src/chelonia/auth.js:349-353 is a verbatim copy of src/chelonia/auth.js:121-128 (only the password variable differs), and both also shadow the nonce r with the response r in .then((r) => r.json()):

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))

Extract one helper both call:

// Prove the password the way login does, keeping the parts of the proof
// that later requests need.
async function provePassword (identityContractID, password) {
  const nonce = randomNonce()
  const contract = encodeURIComponent(identityContractID)
  const { authSalt, s, sig } = await request(
    `/zkpp/${contract}/auth_hash?b=${encodeURIComponent(hash(nonce))}`
  ).then((response) => response.json())
  const [c, hc] = computeCAndHc(nonce, s, await hashPassword(password, authSalt))
  return { nonce, s, sig, c, hc }
}

retrieveSalt uses { nonce, s, sig, hc, c } for its query and decryptContractSalt(c, ...); changePassword additionally feeds c to buildUpdateSaltRequestEc.

5. 🟡 The pending-write overlay is kept in two places; rebuild it from the queue instead

  • Addressed
  • Dismissed

src/chelonia/offline.js:59-60 reconstructs the overlay by intersecting the persisted state.pendingWrites copy with the queue's ids:

const kept = new Set(sbp('chelonia.persistentActions/status').map((a) => a.id))
state.pendingWrites = pendingWrites().filter((w) => kept.has(w.id))

state.pendingWrites (inside the saved state blob) and the Chelonia queue (its own localStorage key) are two sources of truth describing the same writes. If the saved state is lost or overwritten (another tab saving the shared state key, a cleared todomvc/chelonia-state), the overlay silently vanishes while the writes still send — the list visually reverts until they land. The invocation already carries everything the overlay needs, so after the cancel loop the list can simply be rebuilt, deleting the second source:

export async function loadOfflineQueue (isOurs) {
  await sbp('chelonia.persistentActions/load')
  for (const action of sbp('chelonia.persistentActions/status')) {
    if (!isOurs(action.invocation[1])) await sbp('chelonia.persistentActions/cancel', action.id)
  }
  state.pendingWrites = sbp('chelonia.persistentActions/status').map(
    ({ id, invocation: [, contractID, op, ...args] }) => ({ id, contractID, op, args })
  )
}

This also makes docs/data.md's "the queue lives under one localStorage key, so it survives a reload" literally true for what is displayed, rather than depending on the state blob staying in sync.

6. ⚪️ write()'s TypeError heuristic can silently swallow genuine bugs

  • Addressed
  • Dismissed

src/chelonia/todos.js:99-103:

} catch (e) {
  // fetch rejects with a TypeError when the server never answered.
  if (!(e instanceof TypeError)) throw e
  queueWrite(invocation, { contractID, op, args })
}

I verified reducer/schema failures are wrapped by the lib into ChelErrorKvValidation/ChelErrorKvUpdateInvalid, so today only fetch produces a raw TypeError here — but any future programming-error TypeError would be quietly queued and retried forever (per issue 3). One cheap guard, since op rides through JSON: make an unknown op a non-TypeError in the registered selector at src/chelonia/todos.js:47-53:

'todomvc/todos/write': (contractID, op, ...args) => {
  if (OPS[op] === undefined) throw new Error(`Unknown todo write: ${op}`)
  return sbp('chelonia/kv/update', { contractID, key: TODOS_KEY, updater: OPS[op](...args) })
}

7. ⚪️ currentTodos throws on an unknown op read back from persisted state

  • Addressed
  • Dismissed

src/chelonia/todos.js:76-79:

.reduce((todos, w) => {
  const next = OPS[w.op](...w.args)(todos)
  return next === KV_NOOP ? todos : next
}, saved)

w.op comes from JSON in localStorage; a corrupted or version-skewed entry makes OPS[w.op] undefined and the TypeError fires inside a Vue computed, breaking the whole list render. Cheap hardening: skip unknown ops (const op = OPS[w.op]; if (!op) return todos).

8. ⚪️ AccountPanel small cleanups

  • Addressed

  • Dismissed

  • src/components/AccountPanel.vue:36 passes '' as the success message (run(() => deleteAccount(...), '')), which just re-clears an already-clear ref; a done = '' default parameter, or dropping the parameter for the delete path, reads better.

  • src/components/AccountPanel.vue:23 logs '[todomvc] account change failed' for deletions too; something like '[todomvc] account action failed' (or per-form labels) avoids the mismatch.

  • deletePassword is never cleared (compare submitPassword, which clears both fields on success at src/components/AccountPanel.vue:31-32). Clear it inside the task so a wrong attempt doesn't leave the password sitting in the input/memory.

9. ⚪️ Naming: OPS is vague, and there are two different keyIdByName helpers

  • Addressed

  • Dismissed

  • src/chelonia/todos.js:24: OPS is the reducer registry; REDUCERS (or WRITES) says what it holds, matching the "reducer is looked up when the write runs" comments.

  • src/chelonia/auth.js:336-337 defines keyIdByName(contractState, name) while src/chelonia/lists.js:98-99 already has a keyIdByName(contractIDOrState, name) with a different (wider) signature. Same name, same purpose, two shapes — export one from a shared module or align the signatures.

10. ⚪️ restoreSession and enterSession duplicate their tail

  • Addressed
  • Dismissed

src/chelonia/auth.js:316-317 and src/chelonia/auth.js:326-327 both end with:

await loadLists(identityContractID)
await loadOfflineQueue((contractID) => currentLists().includes(contractID))

Extract e.g. async function openListsAndQueue (identityContractID) so the isOurs closure and ordering exist once.

11. ⚪️ Session boundaries of the queue are silent

  • Addressed

  • Dismissed

  • Logging out with queued offline writes discards them permanently (src/chelonia/auth.js:480-481, correct since the keys go away with the reset), but nothing warns the user first — the "N waiting" indicator lives only inside TodoApp, which unmounts on logout. A confirm-on-logout-when-pendingWrites().length > 0, or at least a line in the README/docs, would prevent surprise data loss.

  • The overlay is per-tab: writes queued offline in window A do not appear in window B (B loaded the queue at its own boot), though both converge once the writes land. Fine for the demo, but worth a sentence in docs/data.md next to the two-windows story.

  • Related consistency note: ListsBar.vue:23 still computes readOnly from !connection.online, so list create/rename/share stay disabled offline with no explanation, right next to a todo list that now accepts changes offline. Defensible scope (those need contract registration, not a queueable invocation), but the asymmetry is unexplained in the UI.


Review generated using glm-5.3 via Z.AI. Comment /review to re-run.

Comment thread docs/data.md Outdated
Comment thread docs/data.md Outdated
Comment thread docs/data.md Outdated
Comment thread docs/data.md Outdated

@corrideat corrideat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code seems fine, although I should do a second pass.

The comments and documentation, however, could use some improvement. The main concern is around phrasing, with expressions like encrypted to or proved against, which don't seem (to me, anyhow) to convey the meaning they should, as well as some other places were the writing seemed unclear.

I assume most or all of that text was generated with Fable 5.1. If you don't want to manually review all of the phrasing, perhaps Gemini would do a good job at rewriting those parts.

Comment thread docs/data.md Outdated
Comment thread docs/login.md
Comment thread docs/login.md Outdated
Comment thread docs/login.md Outdated
Comment thread docs/login.md Outdated
Comment thread src/chelonia/auth.js Outdated
Comment thread vite.config.js Outdated
Comment thread AGENTS.md
Comment thread src/style.css
Comment thread src/contracts/identity.js Outdated
@akhileshthite

akhileshthite commented Sep 18, 2026 •

Copy link
Copy Markdown
Member Author

/review


AI review started.

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: glm-5.3
Click to expand review

Review: feat: password change, account deletion, and writes while offline

I read the full diff against the pinned @chelonia/lib 1.5.0 sources (persistent-actions, db, zkpp, keyUpdate processing) to verify the queue and key-rotation semantics. The core flows check out (enqueue/load/status shapes, oldKeyId field preservation on OP_KEY_UPDATE, missingDecryptionKeyIds location, deleteContract result shape). The issues below are what remains.


1. 🟡 The "refused by the server" notice survives a reload and reappears in the next session

  • Addressed
  • Dismissed

state.rejectedWrite is part of the reactive root state, so persistState writes it to localStorage (src/chelonia/state.js:36-45), and nothing ever clears it on boot. loadOfflineQueue rebuilds pendingWrites from the queue but leaves the notice alone:

// src/chelonia/offline.js:84-92
export async function loadOfflineQueue (isOurs) {
  await sbp('chelonia.persistentActions/load')
  for (const action of sbp('chelonia.persistentActions/status')) {
    if (!isOurs(action.invocation[1])) await sbp('chelonia.persistentActions/cancel', action.id)
  }
  state.pendingWrites = sbp('chelonia.persistentActions/status').map(
    ({ id, invocation: [, contractID, op, ...args] }) => ({ id, contractID, op, args })
  )
}

The only clears are queueWrite (offline.js:95) and dropPendingWrites (offline.js:107). So after the refused write is dropped and the user reloads (or logs out and back in), an empty, healthy queue still renders A change made offline was refused by the server. in TodoApp.vue until the next queued write happens to clear it. It is transient feedback about a write that no longer exists, so it should be session-scoped:

export async function loadOfflineQueue (isOurs) {
  state.pendingWrites = []
  delete state.rejectedWrite
  await sbp('chelonia.persistentActions/load')
  ...
}

(Clearing pendingWrites up front is also what makes the membership guard in issue 2 sound.)


2. 🟡 Leftover queued writes from a previous account are retried before they can be filtered, and the failure is reported to the wrong user

  • Addressed
  • Dismissed

chelonia.persistentActions/load ends with retryAll() (lib persistent-actions.ts: load → return sbp('chelonia.persistentActions/retryAll')), so every stored action is attempted during the awaited load, before the isOurs filter runs at src/chelonia/offline.js:86-88:

await sbp('chelonia.persistentActions/load')   // fires retryAll() on everything, including foreign writes
for (const action of sbp('chelonia.persistentActions/status')) {
  if (!isOurs(action.invocation[1])) await sbp('chelonia.persistentActions/cancel', action.id)
}

For a foreign contractID the todos slot is not attached, so chelonia/kv/update fails fast with a non-network error. That lands in the failure handler, which treats any non-TypeError as a server refusal:

// src/chelonia/offline.js:35-44
sbp('okTurtles.events/on', PERSISTENT_ACTION_FAILURE, ({ id, error }) => {
  if (error instanceof TypeError) return
  console.error('[todomvc] the server refused a queued write', error)
  state.rejectedWrite = 'A change made offline was refused by the server.'
  sbp('chelonia.persistentActions/cancel', id)
  forget({ id })
})

Net effect, for the exact scenario loadOfflineQueue's comment describes (another account used this browser and never logged out): the write does get dropped, but the newly logged-in user sees A change made offline was refused by the server. for someone else's write, plus a bogus console.error. The isOurs loop itself is effectively dead code in this case, since the handler already cancelled the action. Suggested fix: look the action up and stay quiet about writes that are not ours:

sbp('okTurtles.events/on', PERSISTENT_ACTION_FAILURE, ({ id, error }) => {
  // fetch rejects with a TypeError when the server never answered, which is
  // what the queue is for.
  if (error instanceof TypeError) return
  const action = sbp('chelonia.persistentActions/status').find((a) => a.id === id)
  // A leftover write from a previous account in this browser: drop it quietly.
  if (action && !currentLists().includes(action.invocation[1])) {
    sbp('chelonia.persistentActions/cancel', id)
    return forget({ id })
  }
  console.error('[todomvc] the server refused a queued write', error)
  state.rejectedWrite = 'A change made offline was refused by the server.'
  sbp('chelonia.persistentActions/cancel', id)
  forget({ id })
})

(This needs the currentLists import in offline.js, or the ownership predicate passed into setupOfflineQueue; combined with the state clear from issue 1, the stale-overlay race disappears too.)


3. ⚪️ Two windows clobber each other's queue, contradicting docs/data.md

  • Addressed
  • Dismissed

docs/data.md:67-68 states:

  • The queue belongs to the browser, not to a window, so every window of the same account shares it.

But chelonia.persistentActions/save serializes each window's own in-memory actionsByID to the single QUEUE_KEY (offline.js:67-78). With two windows of the same account queuing while the server is down, the last window to save erases the other's writes from storage; if that other window is then closed before reconnecting, its writes are silently lost. The storage listener in state.js:57-62 only watches the session key, not the queue key. Either soften the doc ("each window keeps its own copy of the queue; closing a window that still owes writes can lose them") or merge instead of overwrite, e.g. read-modify-write keyed per action id — for a demo, the doc fix is the honest option.


4. ⚪️ DRY: the deletion-token decrypt and the "Incorrect password." mapping are duplicated

  • Addressed
  • Dismissed

changePassword and deleteAccount both derive the IEK, call encryptedIncomingData with the same five arguments, and wrap failures in an identical catch:

// src/chelonia/auth.js:381-385
const deletionToken = encryptedToken && encryptedIncomingData(
  identityContractID, identityState, encryptedToken, NaN,
  { [keyId(oldIEK)]: oldIEK }, 'encryptedDeletionToken'
).valueOf()
// src/chelonia/auth.js:462-469
token = encryptedIncomingData(
  identityContractID, identityState, encryptedToken, NaN,
  { [keyId(IEK)]: IEK }, 'encryptedDeletionToken'
).valueOf()
...
if (e instanceof AuthError && e.exact) throw e
throw new AuthError('Incorrect password.', { cause: e })

One helper keeps the additionalData string ('encryptedDeletionToken') and the key-id convention in a single place, so a future change can't fix one path and miss the other:

// Both password paths end here: prove the password and open the deletion token.
async function decryptDeletionToken (identityContractID, identityState, password) {
  try {
    const contractSalt = await retrieveSalt(identityContractID, password)
    const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt)
    return encryptedIncomingData(
      identityContractID, identityState, identityState.attributes.encryptedDeletionToken, NaN,
      { [keyId(IEK)]: IEK }, 'encryptedDeletionToken'
    ).valueOf()
  } catch (e) {
    if (e instanceof AuthError && e.exact) throw e
    throw new AuthError('Incorrect password.', { cause: e })
  }
}

(changePassword can't use retrieveSalt since it needs oldContractSalt from updatePasswordHash, but once it has oldIEK the last four lines are the same call.)


5. ⚪️ Two different private openLists functions, plus currentIdentity duplicating requireIdentity

  • Addressed
  • Dismissed

There are now two module-private functions named openLists with different jobs: src/chelonia/lists.js:79 (retains each list contract and refreshes filters) and src/chelonia/auth.js:341 (loads the lists slot and the offline queue). Anyone grepping for openLists to trace the boot sequence gets both. Rename the auth.js one to what it does:

// src/chelonia/auth.js
// The lists have to be known before the offline queue, so writes belonging to
// whoever used this browser before can be dropped.
async function loadListsAndQueue (identityContractID) {
  await loadLists(identityContractID)
  await loadOfflineQueue((contractID) => currentLists().includes(contractID))
}

Similarly, currentIdentity (auth.js:346-350) is a near-copy of requireIdentity (lists.js:92-96), differing only in throwing AuthError vs Error. Exporting one from lists.js and reusing it (or moving both callers onto the AuthError version) removes the second copy.


6. ⚪️ rejectedWrite() reads like a predicate but returns a message string

  • Addressed
  • Dismissed
// src/chelonia/offline.js:19
export const rejectedWrite = () => state.rejectedWrite ?? ''

A name like rejectedWrite next to pendingWrites suggests "is there a rejected write?" (boolean), but it returns the notice text. The consumer confirms it: const refused = computed(() => rejectedWrite()) in TodoApp.vue:51. Consider rejectedWriteMessage() / refusedWriteNotice(), or store a boolean plus a constant message and expose two getters, so the name carries the type.


7. ⚪️ Misleading comment: retrieveSalt is not "the other half of signup"

  • Addressed
  • Dismissed
// src/chelonia/auth.js:133-135
// The other half of signup: get the same salt back for an existing account.
// The second element is the CID anchoring previously rotated keys, which only
// matters once an app supports password changes.

retrieveSalt is the second half of the password proof (auth_hash + contract_hash), and it runs for existing accounts, so "the other half of signup" points the reader at the wrong flow. Also, the second sentence about "the CID anchoring previously rotated keys" no longer matches the code: the function returns only contractSalt (const [contractSalt] = JSON.parse(...)), and "once an app supports password changes" is now — this PR added password changes. Suggested wording:

// The second half of the password proof: get the contract salt back for an
// existing account, encrypted so that only the completed exchange can read it.

8. ⚪️ Wording of two user-facing strings

  • Addressed
  • Dismissed

src/components/AccountPanel.vue:33:

}, 'Password changed. Use the new one from the next login on.')

"from the next login on" is stilted; "Use the new one next time you log in." says the same thing directly:

}, 'Password changed. Use the new one next time you log in.')

src/chelonia/auth.js:455:

throw new AuthError('This account was made before deleting was possible.')

"before deleting was possible" parses oddly (the account isn't deleting). Clearer:

throw new AuthError('This account was created before account deletion was supported.')

No 🔴 issues found: the queue's TypeError-means-offline contract holds against the library's error paths (verified against kv.ts/persistent-actions.ts and confirmed by the reload-survival e2e test), the OP_KEY_UPDATE entries correctly preserve purpose/permissions via the library's merge-on-update semantics, and the idempotent reducers make the queue-after-lost-response replay safe.


Review generated using glm-5.3 via Z.AI. Comment /review to re-run.

@taoeffect taoeffect left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM but will wait on @corrideat since this is his domain.

@corrideat corrideat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. I left a few minor comments; consider this approved once those are addressed.

Comment thread docs/login.md Outdated
Comment on lines +8 to +11
The password never leaves the browser, and neither does anything that would let
the server work the password out. What the server keeps instead are two salts
and a hash, and which salt does what is worth knowing before reading the steps:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first sentence is a bit awkwardly phrased, and it also makes a claim that's technically wrong.

The password never leaves the browser: correct
does anything that would let the server work the password out: incorrect, or misleading. The hash (which is server-side) gives the server an advantage for carrying out a brute-force attack.

Comment thread docs/login.md Outdated
Comment on lines +39 to +40
encrypts, and `#sak` is what the server checks before serving the account's
key/value store. Their secret halves are stored inside the contract itself,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True but misleading / incomplete. The #sak exists primarily for accounting purposes.

Comment thread docs/login.md Outdated
the `shelter-namespace-registration` header and the one-time token from step
1 in `shelter-salt-registration-token`, which is what makes the server
accept a contract with no account to bill it to.
5. Keep `csk`, `cek` and `#sak`. Throw away `ipk` and `iek`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keep / throw away description is too simple and potentially confusing. Can you expand it a little to mention it's the secret component of the key?

Comment thread src/chelonia/auth.js
Comment on lines +450 to +452
if (!encryptedToken) {
throw new AuthError('This account was made before account deletion was added.')
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically not possible for this situation to happen: there's no TodoMVC server currently and thus the 'before account deletion was added' has never happened.

The check is correct for a situation that could theoretically arise, but the message should be more generic, like 'No deletion token found for account', 'Missing deletion token', etc.

Comment thread src/contracts/identity.js Outdated
Comment on lines +30 to +31
// Lets the account delete itself later. Accounts made before it
// existed do not have one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment also points out to a situation that can't happen (accounts made before it existed).

Comment thread src/contracts/identity.js Outdated
Comment on lines +17 to +19
// Encrypted data on the wire is a `[keyId, ciphertext]` pair.
const isEncrypted = (v) => Array.isArray(v) && v.length === 2 && v.every((s) => typeof s === 'string')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY violation. Use isRawEncryptedData from @chelonia/lib.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The contract has no imports, so it gets it from the require Chelonia gives the sandbox, and config.js passes the module in.

Comment thread src/chelonia/offline.js
Comment on lines +83 to +85
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

considering an ephemeral DB backend is used otherwise, wouldn't it make more sense to use sessionStorage instead of localStorage?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried both. Sticking with localStorage because with sessionStorage, a change queued offline is gone as soon as the tab closes, and nothing tells you. It does not fix the two window case either, so it gives up a working case for nothing.

Comment thread src/chelonia/offline.js
Comment thread vite.config.js
Comment on lines +25 to +31
// Without this, Chelonia keeps its own copy of every contract's message
// log in `chelonia.db`, which this app leaves as the default in-memory
// map. The saved state survives a reload but that map does not, so the
// first action after a reload fails with "No latest HEAD". An app that
// wants the full mode has to give Chelonia a `chelonia.db` backed by
// something durable, like IndexedDB.
LIGHTWEIGHT_CLIENT: 'true'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note that this really shouldn't be necessary to add as it should be the default, so I opened up an issue for this:

okTurtles/libcheloniajs#104

If you decide to send in a PR for this, make sure the AI doesn't change a bunch of files. It should be 2 lines max change in a single file.

@taoeffect taoeffect left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work @akhileshthite!

I've one minor tiny SBP-related change request and then I think this can be merged!

Want to make sure that if future models train on our codebases they don't develop stupid habits.

@taoeffect taoeffect left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NM, see followup comment. In this case the wrapper function is acceptable.

Comment thread src/chelonia/offline.js
@taoeffect
taoeffect merged commit a82c5e5 into main Sep 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants