Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
337c121
docs(planning): derived personal drive is identity, not a fallback
cursoragent Aug 15, 2026
be0e91d
feat: derive the personal drive DID from the agent key
cursoragent Aug 15, 2026
35f9baf
chore(lint): add the two blank lines oxlint asks for
joepio Aug 15, 2026
45e69ab
feat(migration): take the old home from the agent secret too
joepio Aug 15, 2026
0ccb0f7
fix(migration): adopt legacy drives hosted on the account's own origin
joepio Aug 15, 2026
0dd164e
fix(migration): keep legacySubject and initialDrive across restarts
joepio Aug 15, 2026
a0ceac6
build: build @tomic/lib before the Tauri app, not after remembering to
joepio Aug 15, 2026
2e496a2
test: cover the derived personal drive under SubtleCrypto
joepio Aug 15, 2026
8606d40
fix(migration): run the legacy adoption once per agent
joepio Aug 15, 2026
2fe356e
fix(migration): materialize the personal drive, don't only derive it
joepio Aug 15, 2026
ba91713
Revert "materialize the personal drive" — it minted hundreds of drives
joepio Aug 15, 2026
fdc7051
fix(lib): derive the personal drive from a deterministic signer
joepio Aug 15, 2026
0409e86
fix(app): materialize the home drive at sign-in, and never repoint th…
joepio Aug 15, 2026
313858d
fix(sync): live updates never landed — echo storm, racy suppression, …
joepio Aug 16, 2026
7e68bf0
fix(sync): "Disconnect" must evict whichever connection holds the link
joepio Aug 16, 2026
e615b40
fix(sync): index what peers send, and report sync honestly
joepio Aug 16, 2026
f610ef8
fix(setup): onboarding must record the drive it just created
joepio Aug 16, 2026
350b56b
feat(sync): a paired node may replicate what its owner can read
joepio Aug 16, 2026
2b0f80b
feat(sync): say what the last sync with a peer actually moved
joepio Aug 16, 2026
a78ef9c
feat(sync): carry presence across a peer link
joepio Aug 16, 2026
f6f6932
fix(sync): detect a half-open peer link instead of writing into it
joepio Aug 16, 2026
2961727
fix(sync): a peer that sends no keepalives is quiet, not dead
joepio Aug 16, 2026
4ac1812
fix(sync): relay drive presence too, not just per-document ephemeral
joepio Aug 16, 2026
6d503f6
fix(sync): the Sync page was wrong in three ways about a healthy link
joepio Aug 16, 2026
7c8d472
fix(presence): a cursor for unsynced content must not throw
joepio Aug 16, 2026
7a133fa
fix(sync): send the edit, not just the cursor, across a peer link
joepio Aug 16, 2026
dc3b2ba
feat(desktop): let an agent drive the app, so collab can be tested wi…
joepio Aug 16, 2026
3e4d27c
docs(planning): a peer's new child resources never reach an open page…
joepio Aug 16, 2026
d7344dc
docs(planning): correct M10 — the row arrives, its contents do not
joepio Aug 16, 2026
e2136a0
docs(planning): the desktop app never retries after its server boots …
joepio Aug 16, 2026
b1a38e0
docs(planning): M11 — the reconnect never runs, and Retry retries the…
joepio Aug 16, 2026
cf3f1f2
fix(sync): label a snapshot as a snapshot on the external-change path
joepio Aug 16, 2026
48873c2
docs(planning): reproduce M9b on a real document, and note sidebar or…
joepio Aug 16, 2026
a688375
fix(sync): recover when a live update can't apply, instead of losing …
joepio Aug 16, 2026
57326a9
fix(table): draw rows that arrive from a peer instead of only this se…
joepio Aug 16, 2026
1f359ca
docs(planning): M9b and M10a verified end-to-end
joepio Aug 16, 2026
7d4f350
fix(ws): a connect that never opened must not pin the auth flag forever
joepio Aug 16, 2026
48af0c2
fix(sidebar): stop keyless children sorting to the top of the tree
joepio Aug 16, 2026
58b798f
style: oxfmt the two new test files
joepio Aug 16, 2026
0ab22fa
test(e2e): let the storage-state origins follow FRONTEND_URL and SERV…
joepio Aug 16, 2026
22ebab5
docs: renumber this branch's findings, and strip conflict markers fro…
joepio Aug 16, 2026
06c59ce
fix(sync): the accepting side must not pair a peer just by recording …
joepio Aug 17, 2026
31ae0b8
docs(planning): four problems from the two-person session (M17-M20)
joepio Aug 17, 2026
5f4ebc2
docs(planning): rows are silently discarded when their class is missi…
joepio Aug 17, 2026
e50ed3c
fix(outbox): stop discarding table rows in silence when the class is …
joepio Aug 17, 2026
2e8dd9d
docs(planning): a table writes its class to a different parent than i…
joepio Aug 17, 2026
9cb6b7e
fix(validation): an unknown class must not reject the write
joepio Aug 17, 2026
0b3a256
docs(planning): state of play for the two-person session findings
joepio Aug 17, 2026
e2fd7c5
fix(invite): land the invitee in the drive they were invited to
joepio Aug 17, 2026
80c5ec6
docs(planning): M17 fixed, M18 root-caused, M22 found
joepio Aug 17, 2026
c1af9e9
fix(sync): reading someone else's resource must not queue a write to it
joepio Aug 17, 2026
3ea75fa
docs(planning): M22 root cause, and the shape of an M18 fix
joepio Aug 17, 2026
2b69cca
fix(auth): agents are publicly readable
joepio Aug 17, 2026
3305479
docs(planning): M18 fixed by making agents public; M19 falls out of it
joepio Aug 17, 2026
b2d168d
fix(test): peer sync pulls must identify themselves as the drive's owner
joepio Aug 17, 2026
8e522c0
fix(commit): a repeat materialization merges, flagged genesis or not
joepio Aug 17, 2026
64fadb5
fix(sign-in): a drive we just wrote is not evidence that we have the …
joepio Aug 17, 2026
63a9914
Revert "fix(sign-in): a drive we just wrote is not evidence that we h…
joepio Aug 17, 2026
3a11d9c
docs(planning): the verification session — two branch failures fixed,…
joepio Aug 17, 2026
46cbe2e
fix(sign-in): ask whether the data is here before writing the drive
joepio Aug 17, 2026
4e51480
docs(planning): M23 fixed — the order was the bug, not the predicate
joepio Aug 17, 2026
bd6af81
fix(invite): carry the derived home drive through invite acceptance
joepio Aug 17, 2026
c9434e6
test(e2e): the offline drive this test creates has to be a non-person…
joepio Aug 17, 2026
3f7b05b
docs(planning): the CI failure list, and a local harness that serves …
joepio Aug 17, 2026
3a31a9a
fix(table): pressing enter must not be swallowed by the previous save
joepio Aug 17, 2026
da736bd
docs(planning): desktop <-> Home Assistant sync verified on today's b…
joepio Aug 17, 2026
d7754ee
test(e2e): guard the keystroke that arrives while the previous save i…
joepio Aug 17, 2026
0e41c0a
docs(planning): the quick-add fix, and two ways CI lied today
joepio Aug 17, 2026
07c5070
fix(table): queue the mid-save keystroke instead of letting saves ove…
joepio Aug 17, 2026
0af67be
docs(planning): withdraw the runner-contention theory, and say what r…
joepio Aug 17, 2026
c85ae43
test(e2e): scope the websocket route to the server's own socket
joepio Aug 17, 2026
8365a1f
fix(invite): accepting an invite must not repoint the device's node i…
joepio Aug 17, 2026
46f798f
docs(planning): the contention was real — WSL loadavg just cannot see…
joepio Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
427 changes: 352 additions & 75 deletions Cargo.lock

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,27 @@ must compute the same differing set on either end of the wire. Both carry the
same test names. A fix to one is a fix to the other; the golden-vector tests
(`item_fingerprint_matches_golden_vector`) pin the hashing, but the *traversal*
is only kept in step by mirroring the tests, so do that deliberately.

`lib/src/genesis.rs` ↔ `browser/lib/src/genesis.ts` also share a personal-drive
derivation (`personal_drive_subject` / `personalDriveSubject`). The cross-lang
vector (`personal_drive_cross_lang_vector`) pins the nonce, signature, and DID.

## Personal drive identity

| Flow | Where |
|---|---|
| Same agent key → same personal-drive DID | `lib/src/genesis.rs`, `browser/lib/src/genesis.test.ts` |
| Cross-language personal-drive vector | `genesis.rs` + `genesis.test.ts` |
| Repeat genesis for that DID merges Loro state | `lib/src/commit.rs::repeat_personal_drive_genesis_merges` |
| Repeat genesis without a cert is still rejected | `lib/src/commit.rs::repeat_genesis_without_cert_is_still_rejected` |
| `createDrive({ personal: true })` uses the derived DID | `browser/lib/src/store.personal-drive.test.ts` |
| Two stores with the same key mint the same subject | `store.personal-drive.test.ts` |
| Extra drives are listed on the derived personal drive | `store.personal-drive.test.ts` |
| Lists from a previous random-DID home are unioned onto the derived drive | `store.personal-drive.test.ts` |
| `Agent.personalDriveSubject` matches the genesis helper | `agent.test.ts` |
| `Db::setup` / `ensure_personal_drive` use the derived DID and are idempotent | `lib/src/db.rs::personal_drive_tests` |
| Extra `Db::create_drive` is listed on the personal drive | `lib/src/db.rs::personal_drive_tests` |

Not covered: Flutter `create_drive` still mints a random DID (the Rust
`ensure_personal_drive` helper exists for `setup()`). E2E sign-in on a second
machine with the old machine offline.
4 changes: 2 additions & 2 deletions browser/data-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,10 @@
},
"scripts": {
"build": "if [ \"$SKIP_WASM_BUILD\" = \"1\" ]; then echo 'SKIP_WASM_BUILD=1, skipping wasm-pack'; else pnpm build:wasm; fi && vite build",
"build:tauri": "pnpm build:wasm && TAURI=1 vite build",
"build:tauri": "pnpm --filter \"@tomic/lib\" --filter \"@tomic/react\" run build && pnpm build:wasm && TAURI=1 vite build",
"build:wasm": "cd ../../wasm && export CARGO_TARGET_DIR=../target/wasm-pack && cargo bin wasm-pack --version && env -u CARGO_ENCODED_RUSTFLAGS -u RUSTFLAGS cargo bin wasm-pack build --target web --out-dir pkg && cp pkg/atomic_wasm.js pkg/atomic_wasm_bg.wasm ../browser/data-browser/public/wasm/",
"dev": "vite",
"dev:tauri": "TAURI=1 vite",
"dev:tauri": "pnpm --filter \"@tomic/lib\" --filter \"@tomic/react\" run build && TAURI=1 vite",
"format-check": "oxfmt -c ../.oxfmtrc.json --check ./src",
"format": "oxfmt -c ../.oxfmtrc.json ./src",
"lint": "oxlint -c ../.oxlintrc.json . && pnpm format-check",
Expand Down
23 changes: 22 additions & 1 deletion browser/data-browser/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,28 @@ const store = new Store({
const initialDrive = driveStorage.get();

if (initialDrive) {
store.setDrive(initialDrive);
// `setDrive` reads an http(s) subject as a SERVER ORIGIN and repoints the
// app at it. That is intended when the user opens an external drive, but
// restoring the last session is not that choice — and a stored URL from a
// previous switch would re-apply it on every launch, stranding the app on a
// server it cannot authenticate against with no obvious way back.
const restoresForeignServer =
/^https?:\/\//.test(initialDrive) &&
(() => {
try {
return new URL(initialDrive).origin !== new URL(serverUrl).origin;
} catch {
return true;
}
})();

if (restoresForeignServer) {
console.warn(
`[atomic] Ignoring stored drive '${initialDrive}': opening it would move this app to another server. Pick it from the drive switcher to switch deliberately.`,
);
} else {
store.setDrive(initialDrive);
}
}

// A deep link into a resource (share/show `?subject=` entry URL) starts the
Expand Down
18 changes: 17 additions & 1 deletion browser/data-browser/src/chunks/RTE/useLoroSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,23 @@ export function useLoroSync(
const unsub = store.subscribeLoroEphemeral(
subject,
(update: Uint8Array) => {
ephemeralStore.apply(update);
try {
ephemeralStore.apply(update);
} catch (e) {
// A cursor can arrive before the content it points into. Positions
// reference Loro containers, and a peer editing a document this
// device has not caught up on yet names containers the local doc
// does not have — Loro throws "The container does not exist in the
// doc". That became routine once presence started crossing peer
// links: the update travels on its own channel and does not wait for
// document state.
//
// Dropping it is correct. Presence is a snapshot of right now, so
// there is nothing to replay — the next update after the document
// catches up applies cleanly. Throwing here only produced an uncaught
// error per keystroke of someone else's typing.
console.debug('[presence] skipped a cursor for unsynced content:', e);
}
},
);

Expand Down
56 changes: 45 additions & 11 deletions browser/data-browser/src/chunks/TablePage/QuickAddBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useStore, type Property, type Resource } from '@tomic/react';
import { useState, type JSX } from 'react';
import { useRef, useState, type JSX } from 'react';
import { styled } from 'styled-components';
import { FaPlus } from 'react-icons/fa6';
import toast from 'react-hot-toast';
Expand Down Expand Up @@ -36,20 +36,20 @@ export function QuickAddBar({
const store = useStore();
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
/**
* Named while a save was still in flight, waiting its turn.
*
* A ref, not state: `submit` below reads it from inside a promise callback,
* where a state value would be whatever it was when that callback was
* created.
*/
const queued = useRef<string[]>([]);

// A spec with a field is asking for a value; creating a blank row instead
// would be a worse guess than doing nothing.
const ready = !spec.field || typed.trim() !== '';

const create = () => {
if (!ready || busy) {
return;
}

const value = typed.trim();
// Cleared up front, not after the save: anything typed while the save is in
// flight would otherwise be wiped when the reset landed.
setTyped('');
const submit = (value: string) => {
setBusy(true);

void createQuickAddRow(store, {
Expand All @@ -66,7 +66,41 @@ export function QuickAddBar({
console.error('Failed to quick-add a row', error);
toast.error(`Could not add the ${tableClass.title.toLowerCase()}`);
})
.finally(() => setBusy(false));
.finally(() => {
const next = queued.current.shift();

if (next === undefined) {
setBusy(false);

return;
}

submit(next);
});
};

const create = () => {
if (!ready) {
return;
}

const value = typed.trim();
// Cleared up front, not after the save: anything typed while the save is in
// flight would otherwise be wiped when the reset landed.
setTyped('');

// Queued rather than dropped. This used to return early while a save was in
// flight, which lost the keystroke in silence — type, press enter, and
// nothing happens: no row, no error, the text still sitting in the field.
// Queueing keeps that from happening without letting the bar fire saves
// concurrently, which is load this runs under on the slowest devices.
if (busy) {
queued.current.push(value);

return;
}

submit(value);
};

return (
Expand Down
27 changes: 27 additions & 0 deletions browser/data-browser/src/chunks/TablePage/TableResource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,33 @@ export const TableResource: React.FC<TableResourceProps> = ({
// missing rows (surfacing as an unhandled rejection in
// `useMemberFromCollection`). The clamp guards that instant; the filter
// rebase effect below then recaptures a fresh baseline.
// A row can also arrive from somewhere this session knows nothing about: a
// paired peer, or another tab on the same drive. That grows the collection
// without going through the new-row flow, so the frozen baseline never moves
// and the grid keeps rendering the count it captured at load — the row is in
// the collection, in the store, complete, and simply never drawn. Measured
// against a paired node: `totalMembers` 8, `aria-setsize` 5.
//
// Freezing exists to stop a materialising session row from remounting; it was
// never meant to hide other people's rows. So account for what this session
// contributed — each materialised draft adds a member while still rendering
// from `newRowSubjects` — and let anything beyond that raise the baseline.
//
// Only ever raises it. Shrink stays with `decrementMemberCount` and the clamp
// below, and session rows keep their `_new:` key through the index shift
// (`itemKey` offsets by `memberCount`), so nothing remounts.
if (baselineMemberCountRef.current !== null) {
const materialisedSessionRows = newRowSubjects.filter(subject =>
store.isAliased(subject),
).length;
const accountedFor =
baselineMemberCountRef.current + materialisedSessionRows;

if (collection.totalMembers > accountedFor) {
baselineMemberCountRef.current += collection.totalMembers - accountedFor;
}
}

const memberCount = Math.min(
baselineMemberCountRef.current ?? collection.totalMembers,
collection.totalMembers,
Expand Down
65 changes: 62 additions & 3 deletions browser/data-browser/src/helpers/agentStorage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Agent, SubtleCryptoProvider, JSCryptoProvider } from '@tomic/react';
import {
Agent,
SubtleCryptoProvider,
JSCryptoProvider,
legacySubjectFromSecret,
} from '@tomic/react';
import { del, get, set } from 'idb-keyval';
import { adoptAgentOnDevice } from './adoptAgent';
import { clearSessionDbKeys, ensureDbKeyOnSignIn } from './localDbKey';
Expand All @@ -8,6 +13,24 @@ const AGENT_IDB_KEY = 'atomic.agent';
interface StoredAgent {
keyPair: CryptoKeyPair;
subject: string;
/**
* Carried across restarts because they only exist on the secret, and the
* secret is read exactly once — at sign-in. Without them a restored Agent
* looks brand-new to the pre-DID migration, which reads both and returns at
* its first line, so a returning user's drives are never adopted. The
* migration then appears to do nothing forever, having run only in the
* session where the secret was pasted.
*/
legacySubject?: string;
initialDrive?: string;
/**
* The derived personal-drive DID. Stored because it cannot be recomputed
* from the non-extractable keypair beside it: deriving it means signing, and
* WebCrypto signatures are not reproducible (see
* `Agent.personalDriveSubject`). Written at sign-in, while the secret is
* still readable.
*/
personalDrive?: string;
}

/**
Expand All @@ -17,6 +40,11 @@ interface StoredAgent {
interface StoredAgentFallback {
privateKey: string;
subject: string;
/** See {@link StoredAgent}. */
legacySubject?: string;
initialDrive?: string;
/** See {@link StoredAgent}. */
personalDrive?: string;
}

const AGENT_FALLBACK_KEY = 'atomic.agent.fallback';
Expand Down Expand Up @@ -44,7 +72,13 @@ export async function getAgentFromIDB(): Promise<Agent | undefined> {
// readable copy below — a corrupt keypair must not lock the user out.
await provider.sign('atomic-key-check');

const agent = new Agent(provider, storedAgent.subject);
const agent = new Agent(
provider,
storedAgent.subject,
storedAgent.initialDrive,
);
agent.legacySubject = storedAgent.legacySubject;
agent.personalDrive = storedAgent.personalDrive;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Upgrade leaves WebCrypto home broken

High Severity

Restoring a pre-upgrade IndexedDB agent never backfills personalDrive, and personalDriveSubject then throws for non-deterministic WebCrypto signers. Desktop/Tauri sessions stay “signed in” but cannot name or materialize the home drive until the secret is pasted again, with no forced re-auth path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 46f798f. Configure here.


// Heal installs written while the readable key was saved
// unconditionally: a plaintext copy beside a non-extractable key hands
Expand All @@ -68,10 +102,15 @@ export async function getAgentFromIDB(): Promise<Agent | undefined> {

if (fallback) {
try {
return new Agent(
const agent = new Agent(
new JSCryptoProvider(fallback.privateKey),
fallback.subject,
fallback.initialDrive,
);
agent.legacySubject = fallback.legacySubject;
agent.personalDrive = fallback.personalDrive;

return agent;
} catch (e) {
console.error('Failed to load agent from fallback:', e);
}
Expand Down Expand Up @@ -135,9 +174,20 @@ export async function saveAgentToIDB(
throw new Error('Subject is required');
}

// Preserve the secret-only fields: this overload re-stores a keypair and
// has no secret to re-derive them from, and dropping them would silently
// disable the migration for a returning user.
const previous = (await get(AGENT_IDB_KEY)) as StoredAgent | undefined;

await set(AGENT_IDB_KEY, {
keyPair: keyPairOrSecret,
subject,
legacySubject:
previous?.subject === subject ? previous.legacySubject : undefined,
initialDrive:
previous?.subject === subject ? previous.initialDrive : undefined,
personalDrive:
previous?.subject === subject ? previous.personalDrive : undefined,
} satisfies StoredAgent);
}

Expand All @@ -149,6 +199,9 @@ async function storeSecret(secret: string): Promise<void> {
// the only moment it passes through JS once the keypair is stored
// non-extractably.
const decoded = JSON.parse(atob(secret));
// Derived here, once, from the raw key — the stored keypair cannot
// reproduce it. See `StoredAgent.personalDrive`.
const personalDrive = await Agent.personalDriveSubjectFromSecret(secret);

{
// Prefer the non-extractable keypair. Once stored this way the private key
Expand All @@ -161,6 +214,9 @@ async function storeSecret(secret: string): Promise<void> {
await set(AGENT_IDB_KEY, {
keyPair,
subject: resolvedSubject,
legacySubject: legacySubjectFromSecret(secret),
initialDrive: decoded.initialDrive,
personalDrive,
} satisfies StoredAgent);
await del(AGENT_FALLBACK_KEY);

Expand All @@ -179,6 +235,9 @@ async function storeSecret(secret: string): Promise<void> {
await set(AGENT_FALLBACK_KEY, {
privateKey: decoded.privateKey,
subject: newSubject,
legacySubject: legacySubjectFromSecret(secret),
initialDrive: decoded.initialDrive,
personalDrive,
} satisfies StoredAgentFallback);
// Drop a keypair from a previous account, so it can't be loaded instead.
await del(AGENT_IDB_KEY);
Expand Down
17 changes: 17 additions & 0 deletions browser/data-browser/src/helpers/managedServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export type ServerPeer = {
deviceName: string | null;
/** Whether it holds a connection to the server right now. */
live: boolean;
/** Unix millis of the last successful sync, if it has ever synced. */
lastSeen?: number;
/** Resources moved by that last sync, each way. Not lifetime totals. */
lastSent?: number;
lastReceived?: number;
};

/**
Expand Down Expand Up @@ -90,6 +95,18 @@ export async function fetchManagedInfo(
nodeId,
deviceName: readString(p?.[peerProps.deviceName]),
live: p?.[peerProps.live] === true,
lastSeen:
typeof p?.[peerProps.lastSeen] === 'number'
? (p[peerProps.lastSeen] as number)
: undefined,
lastSent:
typeof p?.[peerProps.lastSent] === 'number'
? (p[peerProps.lastSent] as number)
: undefined,
lastReceived:
typeof p?.[peerProps.lastReceived] === 'number'
? (p[peerProps.lastReceived] as number)
: undefined,
}
: null;
})
Expand Down
Loading
Loading