Skip to content

Commit e054eaa

Browse files
Marcus PousetteMarcus Pousette
authored andcommitted
fix(sqlite): isolate authenticated local commits
1 parent 5158707 commit e054eaa

4 files changed

Lines changed: 380 additions & 32 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@treecrdt/interface': patch
3+
'@treecrdt/wa-sqlite': patch
4+
---
5+
6+
Authorize SQLite local operations from a read-only proposal, then acquire the write lock and
7+
atomically revalidate the clean materialization revision and exact operation before committing.
8+
Concurrent writes now trigger bounded re-authorization instead of entering an auth savepoint, so
9+
an auth rejection cannot roll back unrelated work on the same connection.
10+
11+
The verified local op proof returned by authorization is stored in the standard SQLite op-auth
12+
sidecar atomically with the operation and materialization. A process crash can no longer leave a
13+
committed scoped operation without its original proof. The native row is authoritative, so
14+
materialization events are emitted as soon as that atomic commit succeeds.

packages/treecrdt-sqlite-node/tests/conformance.test.ts

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ function nodeIdFromInt(n: number): string {
2020
return n.toString(16).padStart(32, '0');
2121
}
2222

23+
function validAuthProof() {
24+
return { sig: new Uint8Array(64), proofRef: new Uint8Array(16) };
25+
}
26+
2327
async function createNodeEngine(opts: { docId: string; path?: string }) {
2428
const { default: Database } = await import('better-sqlite3').catch((err) => {
2529
throw new Error(
@@ -73,7 +77,7 @@ test('sqlite auth-aware local write emits materialization after auth succeeds',
7377
const authSession = {
7478
authorizeLocalOps: vi.fn(async () => {
7579
expect(events).toHaveLength(0);
76-
return [{ sig: new Uint8Array(64), proofRef: new Uint8Array(16) }];
80+
return [validAuthProof()];
7781
}),
7882
};
7983
const node = nodeIdFromInt(11);
@@ -94,6 +98,237 @@ test('sqlite auth-aware local write emits materialization after auth succeeds',
9498
}
9599
});
96100

101+
test('sqlite auth wait cannot roll back an unrelated write on the same connection', async () => {
102+
const client = await createNodeEngine({ docId: 'sqlite-auth-local-isolation' });
103+
let authStarted!: () => void;
104+
const started = new Promise<void>((resolve) => {
105+
authStarted = resolve;
106+
});
107+
let rejectAuth!: () => void;
108+
const release = new Promise<void>((resolve) => {
109+
rejectAuth = resolve;
110+
});
111+
const authSession = {
112+
authorizeLocalOps: vi.fn(async () => {
113+
authStarted();
114+
await release;
115+
throw new Error('local auth denied');
116+
}),
117+
};
118+
const deniedNode = nodeIdFromInt(20);
119+
const unrelatedNode = nodeIdFromInt(21);
120+
121+
try {
122+
const denied = client.local.insert(replica, root, deniedNode, { type: 'last' }, null, {
123+
authSession,
124+
});
125+
await started;
126+
127+
const unrelated = await client.local.insert(
128+
replica,
129+
root,
130+
unrelatedNode,
131+
{ type: 'last' },
132+
null,
133+
);
134+
rejectAuth();
135+
136+
await expect(denied).rejects.toThrow('local auth denied');
137+
expect(unrelated.meta.id.counter).toBe(1);
138+
expect(await client.tree.exists(deniedNode)).toBe(false);
139+
expect(await client.tree.exists(unrelatedNode)).toBe(true);
140+
expect(await client.ops.all()).toHaveLength(1);
141+
} finally {
142+
await client.close();
143+
}
144+
});
145+
146+
test('sqlite rejects authenticated prepare and commit inside caller transactions', async () => {
147+
const client = await createNodeEngine({ docId: 'sqlite-auth-outer-transaction' });
148+
const nodeAtPrepare = nodeIdFromInt(22);
149+
const nodeAtCommit = nodeIdFromInt(23);
150+
const prepareAuth = {
151+
authorizeLocalOps: vi.fn(async () => [validAuthProof()]),
152+
};
153+
154+
try {
155+
await client.runner.exec('BEGIN');
156+
await expect(
157+
client.local.insert(replica, root, nodeAtPrepare, { type: 'last' }, null, {
158+
authSession: prepareAuth,
159+
}),
160+
).rejects.toThrow(/require autocommit/i);
161+
expect(prepareAuth.authorizeLocalOps).not.toHaveBeenCalled();
162+
await client.runner.exec('ROLLBACK');
163+
164+
const commitAuth = {
165+
authorizeLocalOps: vi.fn(async () => {
166+
await client.runner.exec('BEGIN');
167+
return [validAuthProof()];
168+
}),
169+
};
170+
await expect(
171+
client.local.insert(replica, root, nodeAtCommit, { type: 'last' }, null, {
172+
authSession: commitAuth,
173+
}),
174+
).rejects.toThrow(/require autocommit/i);
175+
expect(commitAuth.authorizeLocalOps).toHaveBeenCalledTimes(1);
176+
await client.runner.exec('ROLLBACK');
177+
178+
expect(await client.ops.all()).toHaveLength(0);
179+
expect(await client.tree.exists(nodeAtPrepare)).toBe(false);
180+
expect(await client.tree.exists(nodeAtCommit)).toBe(false);
181+
} finally {
182+
try {
183+
await client.runner.exec('ROLLBACK');
184+
} catch {
185+
// no transaction remained open
186+
}
187+
await client.close();
188+
}
189+
});
190+
191+
test('sqlite reauthorizes against fresh tree state after an optimistic conflict', async () => {
192+
const client = await createNodeEngine({ docId: 'sqlite-auth-local-stale-tree' });
193+
const otherReplica = Uint8Array.from(replica, (value, index) =>
194+
index === replica.length - 1 ? value + 1 : value,
195+
);
196+
const sourceParent = nodeIdFromInt(30);
197+
const concurrentParent = nodeIdFromInt(31);
198+
const destination = nodeIdFromInt(32);
199+
const node = nodeIdFromInt(33);
200+
201+
try {
202+
for (const parent of [sourceParent, concurrentParent, destination]) {
203+
await client.local.insert(replica, root, parent, { type: 'last' }, null);
204+
}
205+
await client.local.insert(replica, sourceParent, node, { type: 'last' }, null);
206+
207+
let firstAuthStarted!: () => void;
208+
const firstStarted = new Promise<void>((resolve) => {
209+
firstAuthStarted = resolve;
210+
});
211+
let releaseFirstAuth!: () => void;
212+
const release = new Promise<void>((resolve) => {
213+
releaseFirstAuth = resolve;
214+
});
215+
const proposals: Array<{ op: any; parent: string | null }> = [];
216+
const authSession = {
217+
authorizeLocalOps: vi.fn(async (ops: readonly any[]) => {
218+
proposals.push({ op: ops[0], parent: await client.tree.parent(node) });
219+
if (proposals.length === 1) {
220+
firstAuthStarted();
221+
await release;
222+
}
223+
return [validAuthProof()];
224+
}),
225+
};
226+
227+
const authorizedMove = client.local.move(
228+
replica,
229+
node,
230+
destination,
231+
{ type: 'last' },
232+
{ authSession },
233+
);
234+
await firstStarted;
235+
await client.local.move(otherReplica, node, concurrentParent, { type: 'last' });
236+
releaseFirstAuth();
237+
const committed = await authorizedMove;
238+
239+
expect(authSession.authorizeLocalOps).toHaveBeenCalledTimes(2);
240+
expect(proposals.map((proposal) => proposal.parent)).toEqual([sourceParent, concurrentParent]);
241+
// The conflicting remote-replica write does not consume this replica's counter, so the retry
242+
// deliberately reuses the id only after authorizing its new full body.
243+
expect(proposals[0].op.meta.id.counter).toBe(proposals[1].op.meta.id.counter);
244+
expect(proposals[0].op.meta.lamport).not.toBe(proposals[1].op.meta.lamport);
245+
expect(committed).toEqual(proposals[1].op);
246+
expect(await client.tree.parent(node)).toBe(destination);
247+
expect(await client.ops.all()).toHaveLength(6);
248+
} finally {
249+
await client.close();
250+
}
251+
});
252+
253+
test('sqlite stores local proof material atomically with the operation', async () => {
254+
const client = await createNodeEngine({ docId: 'sqlite-auth-atomic-proof' });
255+
const events: unknown[] = [];
256+
const unsubscribe = client.onMaterialized((event) => events.push(event));
257+
const sig = new Uint8Array(64).fill(7);
258+
const proofRef = new Uint8Array(16).fill(8);
259+
const authSession = {
260+
authorizeLocalOps: vi.fn(async () => [{ sig, proofRef }]),
261+
};
262+
const node = nodeIdFromInt(40);
263+
264+
try {
265+
await client.local.insert(replica, root, node, { type: 'last' }, null, { authSession });
266+
267+
expect(await client.tree.exists(node)).toBe(true);
268+
expect(await client.ops.all()).toHaveLength(1);
269+
expect(events).toHaveLength(1);
270+
expect(
271+
await client.runner.getText('SELECT hex(sig) FROM treecrdt_sync_op_auth WHERE doc_id = ?1', [
272+
'sqlite-auth-atomic-proof',
273+
]),
274+
).toBe(Buffer.from(sig).toString('hex').toUpperCase());
275+
expect(authSession.authorizeLocalOps).toHaveBeenCalledTimes(1);
276+
} finally {
277+
unsubscribe();
278+
await client.close();
279+
}
280+
});
281+
282+
test('sqlite rejects malformed local proof material before committing', async () => {
283+
const client = await createNodeEngine({ docId: 'sqlite-auth-invalid-proof' });
284+
const invalidProofs: any[] = [
285+
undefined,
286+
null,
287+
{ sig: new Uint8Array(63) },
288+
{ sig: new Uint8Array(64) },
289+
{ sig: new Uint8Array(64), proofRef: new Uint8Array(15) },
290+
];
291+
292+
try {
293+
for (let index = 0; index < invalidProofs.length; index += 1) {
294+
const authSession = {
295+
authorizeLocalOps: vi.fn(async () => [invalidProofs[index]!]),
296+
};
297+
await expect(
298+
client.local.insert(replica, root, nodeIdFromInt(41 + index), { type: 'last' }, null, {
299+
authSession,
300+
}),
301+
).rejects.toThrow(/invalid proof/i);
302+
}
303+
expect(await client.ops.all()).toHaveLength(0);
304+
} finally {
305+
await client.close();
306+
}
307+
});
308+
309+
test('sqlite rejects authorization operation mutation before commit', async () => {
310+
const docId = 'sqlite-auth-operation-mutation';
311+
const client = await createNodeEngine({ docId });
312+
const node = nodeIdFromInt(50);
313+
const authSession = {
314+
authorizeLocalOps: vi.fn(async (ops: readonly any[]) => {
315+
ops[0].meta.id.counter += 1;
316+
return [validAuthProof()];
317+
}),
318+
};
319+
320+
try {
321+
await expect(
322+
client.local.insert(replica, root, node, { type: 'last' }, null, { authSession }),
323+
).rejects.toThrow(/mutated the proposed operation/);
324+
325+
expect(await client.tree.exists(node)).toBe(false);
326+
expect(await client.ops.all()).toHaveLength(0);
327+
} finally {
328+
await client.close();
329+
}
330+
});
331+
97332
for (const scenario of treecrdtEngineConformanceScenarios()) {
98333
test(`sqlite engine conformance (node): ${scenario.name}`, async () => {
99334
let persistentDir: string | null = null;

packages/treecrdt-ts/src/engine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ export type LocalWriteOptions = {
145145
/**
146146
* Authorizes the minted local op before it is exposed to callers as committed.
147147
*
148-
* SQLite clients wrap this in a savepoint so auth failures roll back the local
149-
* op and defer materialization events until auth succeeds.
148+
* SQLite clients prepare without writing, authorize the exact proposal, then atomically
149+
* revalidate and commit it. A concurrent write causes a fresh proposal and authorization.
150150
*/
151151
authSession?: LocalWriteAuthSession;
152152
};

0 commit comments

Comments
 (0)