@@ -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 ( / r e q u i r e a u t o c o m m i t / 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 ( / r e q u i r e a u t o c o m m i t / 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 ( / i n v a l i d ( p r o o f | c l a i m s J S O N ) / i) ;
297+ }
298+ expect ( await client . ops . all ( ) ) . toHaveLength ( 0 ) ;
299+ } finally {
300+ await client . close ( ) ;
301+ }
302+ } ) ;
303+
96304for ( const scenario of treecrdtEngineConformanceScenarios ( ) ) {
97305 test ( `sqlite engine conformance (node): ${ scenario . name } ` , async ( ) => {
98306 let persistentDir : string | null = null ;
0 commit comments