Skip to content

Commit cac68d8

Browse files
Marcus PousetteMarcus Pousette
authored andcommitted
fix(sqlite): isolate local authorization
1 parent ba21c50 commit cac68d8

4 files changed

Lines changed: 326 additions & 29 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+
Verified local op proofs are stored in the standard SQLite op-auth sidecar atomically with the
12+
operation and materialization. A process crash can no longer leave a committed scoped operation
13+
without its original proof. The native row is authoritative, so materialization events are emitted
14+
as soon as that atomic commit succeeds.

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

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,214 @@ test('sqlite auth-aware local write emits materialization after auth succeeds',
9393
}
9494
});
9595

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

0 commit comments

Comments
 (0)