@@ -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+
2327async 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 ( / r e q u i r e a u t o c o m m i t / 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 ( / r e q u i r e a u t o c o m m i t / 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 ( / i n v a l i d p r o o f / 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 ( / m u t a t e d t h e p r o p o s e d o p e r a t i o n / ) ;
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+
97332for ( const scenario of treecrdtEngineConformanceScenarios ( ) ) {
98333 test ( `sqlite engine conformance (node): ${ scenario . name } ` , async ( ) => {
99334 let persistentDir : string | null = null ;
0 commit comments