@syncular/tauri 0.15.14 → 0.15.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,32 @@ for validating the server-authoritative directive, gating subscriptions before
28
28
  the purge, deleting app-owned drafts/files, and removing the corresponding key
29
29
  from the OS secure store after SQLite cleanup succeeds.
30
30
 
31
+ ## Secure preflight and native disposal
32
+
33
+ Create with `securityPreflight: true` when authentication, signed device
34
+ quarantine, or crash-resumed cleanup must finish before clinical data is
35
+ available. The native database opens and migrates, but query/snapshot, mutation,
36
+ subscription, sync, realtime, presence, blob, and automatic retry work fails
37
+ with `client.security_preflight_required`. Status, local revision, lifecycle,
38
+ and `purgeLocalData` remain available.
39
+
40
+ ```ts
41
+ const client = await createTauriSyncClient({
42
+ schema,
43
+ securityPreflight: true,
44
+ });
45
+
46
+ await client.purgeLocalData(directive.plan);
47
+ await client.activateSecurity({ encryption: acceptedKeyring });
48
+ ```
49
+
50
+ `beginSecurityPreflight()` closes the JavaScript gate synchronously, waits for
51
+ the mutable owner and independent SQLite snapshot reader, disconnects realtime,
52
+ and removes the Rust keyring. `close()` now issues native shutdown before
53
+ detaching listeners, so disposing a resource does not leave a key-bearing core
54
+ behind. The Rust core overwrites owned key buffers on replacement/drop; the app
55
+ still owns OS secure-store deletion and any key buffers it supplied.
56
+
31
57
  ## React availability guard
32
58
 
33
59
  The Tauri bridge carries `currentSchemaVersion`, `schemaFloor`, and migration
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * `invoke`/`listen` either from its ESM entry points, from the ambient
27
27
  * `window.__TAURI__`, or via injected doubles (tests).
28
28
  */
29
- import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
29
+ import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
30
30
  /** One event pushed on `syncular://event` (the derived client-observable set). */
31
31
  interface SyncularEvent {
32
32
  readonly type: string;
@@ -58,6 +58,8 @@ export interface TauriSyncClientConfig {
58
58
  * encoded into the native command envelope and never sent to the server.
59
59
  */
60
60
  readonly encryption?: EncryptionKeyringConfig;
61
+ /** Open the native replica behind the fail-closed security gate. */
62
+ readonly securityPreflight?: boolean;
61
63
  /**
62
64
  * The Tauri primitives. Omit in a real Tauri webview to auto-resolve from
63
65
  * `@tauri-apps/api` (peer dep) or the ambient `window.__TAURI__`; inject in
@@ -77,9 +79,14 @@ export type BytesEnvelope = {
77
79
  export declare class TauriSyncClient {
78
80
  #private;
79
81
  /** @internal — use {@link createTauriSyncClient}. */
80
- constructor(tauri: TauriApi, unlisten: () => void);
82
+ constructor(tauri: TauriApi, unlisten: () => void, securityLifecycle?: SecurityLifecycle);
81
83
  /** @internal — fan an incoming plugin event out to the local listeners. */
82
84
  __dispatchEvent(event: SyncularEvent): void;
85
+ securityLifecycle(): Promise<SecurityLifecycle>;
86
+ beginSecurityPreflight(): Promise<void>;
87
+ activateSecurity(options?: {
88
+ readonly encryption?: EncryptionKeyringConfig;
89
+ }): Promise<void>;
83
90
  onInvalidate(listener: InvalidationListener): () => void;
84
91
  onChange(listener: ClientChangeListener): () => void;
85
92
  onPresence(listener: (scopeKey: string) => void): () => void;
@@ -135,7 +142,7 @@ export declare class TauriSyncClient {
135
142
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): Promise<void>;
136
143
  connectRealtime(): Promise<void>;
137
144
  disconnectRealtime(): Promise<void>;
138
- /** Detach the event listener; the native core keeps running (host process). */
145
+ /** Shut down the native core, release its keyring, then detach listeners. */
139
146
  close(): Promise<void>;
140
147
  }
141
148
  /** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
package/dist/index.js CHANGED
@@ -26,6 +26,10 @@
26
26
  * `invoke`/`listen` either from its ESM entry points, from the ambient
27
27
  * `window.__TAURI__`, or via injected doubles (tests).
28
28
  */
29
+ // -- Types the bridge speaks (structurally the web-client's) -----------------
30
+ // Most imports stay type-only; the stable preflight error code is shared at
31
+ // runtime so every host surfaces byte-identical policy evidence.
32
+ import { SECURITY_PREFLIGHT_REQUIRED_CODE } from '@syncular/client';
29
33
  /** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
30
34
  export const SYNCULAR_EVENT = 'syncular://event';
31
35
  const PLUGIN = 'plugin:syncular|';
@@ -151,19 +155,44 @@ export class TauriSyncClient {
151
155
  #presenceListeners = new Set();
152
156
  #unlisten;
153
157
  #closed = false;
158
+ #securityLifecycle;
159
+ #preflightBarrier;
154
160
  /** @internal — use {@link createTauriSyncClient}. */
155
- constructor(tauri, unlisten) {
161
+ constructor(tauri, unlisten, securityLifecycle = 'active') {
156
162
  this.#tauri = tauri;
157
163
  this.#unlisten = unlisten;
164
+ this.#securityLifecycle = securityLifecycle;
158
165
  }
159
166
  /** Dispatch a `syncular_command` and unwrap `{result}` / throw on `{error}`. */
160
167
  async #command(method, params) {
168
+ if (this.#closed) {
169
+ throw new TauriSyncError('client.closed', 'the Tauri sync client is closed');
170
+ }
171
+ if (this.#securityLifecycle === 'preflight' &&
172
+ ![
173
+ 'securityLifecycle',
174
+ 'beginSecurityPreflight',
175
+ 'activateSecurity',
176
+ 'purgeLocalData',
177
+ 'localRevision',
178
+ 'statusSnapshot',
179
+ 'shutdown',
180
+ ].includes(method)) {
181
+ this.#throwSecurityPreflight();
182
+ }
161
183
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
162
184
  if (reply.error !== undefined) {
163
185
  throw new TauriSyncError(reply.error.code, reply.error.message);
164
186
  }
165
187
  return reply.result;
166
188
  }
189
+ #throwSecurityPreflight() {
190
+ throw new TauriSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
191
+ }
192
+ #requireActive() {
193
+ if (this.#securityLifecycle === 'preflight')
194
+ this.#throwSecurityPreflight();
195
+ }
167
196
  /** @internal — fan an incoming plugin event out to the local listeners. */
168
197
  __dispatchEvent(event) {
169
198
  switch (event.type) {
@@ -211,6 +240,40 @@ export class TauriSyncClient {
211
240
  }
212
241
  }
213
242
  // -- SyncClientLike --------------------------------------------------------
243
+ securityLifecycle() {
244
+ return Promise.resolve(this.#securityLifecycle);
245
+ }
246
+ beginSecurityPreflight() {
247
+ if (this.#closed) {
248
+ return Promise.reject(new TauriSyncError('client.closed', 'the Tauri sync client is closed'));
249
+ }
250
+ if (this.#preflightBarrier !== undefined)
251
+ return this.#preflightBarrier;
252
+ // Flip synchronously so a same-webview query cannot race the IPC barrier.
253
+ this.#securityLifecycle = 'preflight';
254
+ const barrier = this.#command('beginSecurityPreflight', {}).then(() => { });
255
+ this.#preflightBarrier = barrier;
256
+ void barrier.then(() => {
257
+ if (this.#preflightBarrier === barrier)
258
+ this.#preflightBarrier = undefined;
259
+ }, () => {
260
+ if (this.#preflightBarrier === barrier)
261
+ this.#preflightBarrier = undefined;
262
+ });
263
+ return barrier;
264
+ }
265
+ async activateSecurity(options = {}) {
266
+ if (this.#securityLifecycle === 'active') {
267
+ throw new TauriSyncError('sync.invalid_request', 'activateSecurity requires the client to be in security preflight');
268
+ }
269
+ await this.#preflightBarrier;
270
+ await this.#command('activateSecurity', {
271
+ ...(options.encryption !== undefined
272
+ ? { encryption: encodeEncryption(options.encryption) }
273
+ : {}),
274
+ });
275
+ this.#securityLifecycle = 'active';
276
+ }
214
277
  onInvalidate(listener) {
215
278
  this.#invalidationListeners.add(listener);
216
279
  return () => this.#invalidationListeners.delete(listener);
@@ -224,6 +287,7 @@ export class TauriSyncClient {
224
287
  return () => this.#presenceListeners.delete(listener);
225
288
  }
226
289
  async query(sql, params) {
290
+ this.#requireActive();
227
291
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
228
292
  if (reply.error !== undefined) {
229
293
  throw new TauriSyncError(reply.error.code, reply.error.message);
@@ -232,6 +296,7 @@ export class TauriSyncClient {
232
296
  return rows.map((r) => decodeRow(r));
233
297
  }
234
298
  async querySnapshot(spec) {
299
+ this.#requireActive();
235
300
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query_snapshot`, {
236
301
  sql: spec.sql,
237
302
  params: (spec.params ?? []).map(encodeParam),
@@ -424,11 +489,16 @@ export class TauriSyncClient {
424
489
  async disconnectRealtime() {
425
490
  await this.#command('disconnectRealtime', {});
426
491
  }
427
- /** Detach the event listener; the native core keeps running (host process). */
492
+ /** Shut down the native core, release its keyring, then detach listeners. */
428
493
  async close() {
429
494
  if (this.#closed)
430
495
  return;
431
- this.#closed = true;
496
+ try {
497
+ await this.#command('shutdown', {});
498
+ }
499
+ finally {
500
+ this.#closed = true;
501
+ }
432
502
  this.#unlisten?.();
433
503
  this.#unlisten = undefined;
434
504
  this.#invalidationListeners.clear();
@@ -544,7 +614,11 @@ export async function createTauriSyncClient(config) {
544
614
  const unlisten = await tauri.listen(SYNCULAR_EVENT, (event) => {
545
615
  clientRef.client?.__dispatchEvent(event.payload);
546
616
  });
547
- const client = new TauriSyncClient(tauri, unlisten);
617
+ if (config.securityPreflight === true && config.encryption !== undefined) {
618
+ unlisten();
619
+ throw new TauriSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
620
+ }
621
+ const client = new TauriSyncClient(tauri, unlisten, config.securityPreflight === true ? 'preflight' : 'active');
548
622
  clientRef.client = client;
549
623
  // The native side owns the db path (plugin config); the JS side supplies the
550
624
  // schema, clientId, and limits. `dbPath` is injected by the plugin.
@@ -558,6 +632,9 @@ export async function createTauriSyncClient(config) {
558
632
  ...(config.encryption !== undefined
559
633
  ? { encryption: encodeEncryption(config.encryption) }
560
634
  : {}),
635
+ ...(config.securityPreflight !== undefined
636
+ ? { securityPreflight: config.securityPreflight }
637
+ : {}),
561
638
  },
562
639
  },
563
640
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/tauri",
3
- "version": "0.15.14",
3
+ "version": "0.15.15",
4
4
  "description": "Tauri integration for the Syncular client",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,12 +48,12 @@
48
48
  "test": "bun test"
49
49
  },
50
50
  "dependencies": {
51
- "@syncular/client": "0.15.14"
51
+ "@syncular/client": "0.15.15"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@tauri-apps/api": ">=2.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@syncular/react": "0.15.14"
57
+ "@syncular/react": "0.15.15"
58
58
  }
59
59
  }
package/src/index.ts CHANGED
@@ -27,9 +27,6 @@
27
27
  * `window.__TAURI__`, or via injected doubles (tests).
28
28
  */
29
29
 
30
- // -- Types the bridge speaks (structurally the web-client's) -----------------
31
- // Imported as types only, so the bridge has no runtime dependency on
32
- // @syncular/client (the app already carries it via @syncular/react).
33
30
  import type {
34
31
  ClientChangeBatch,
35
32
  ClientChangeListener,
@@ -49,12 +46,17 @@ import type {
49
46
  RejectionRecord,
50
47
  ResolveCommitOutcomeInput,
51
48
  SchemaFloor,
49
+ SecurityLifecycle,
52
50
  SqlRow,
53
51
  SqlValue,
54
52
  SyncStatusSnapshot,
55
53
  WindowBase,
56
54
  WindowState,
57
55
  } from '@syncular/client';
56
+ // -- Types the bridge speaks (structurally the web-client's) -----------------
57
+ // Most imports stay type-only; the stable preflight error code is shared at
58
+ // runtime so every host surfaces byte-identical policy evidence.
59
+ import { SECURITY_PREFLIGHT_REQUIRED_CODE } from '@syncular/client';
58
60
 
59
61
  /** A driver-protocol reply: `{result}` on success or `{error}` on failure. */
60
62
  interface CommandReply {
@@ -99,6 +101,8 @@ export interface TauriSyncClientConfig {
99
101
  * encoded into the native command envelope and never sent to the server.
100
102
  */
101
103
  readonly encryption?: EncryptionKeyringConfig;
104
+ /** Open the native replica behind the fail-closed security gate. */
105
+ readonly securityPreflight?: boolean;
102
106
  /**
103
107
  * The Tauri primitives. Omit in a real Tauri webview to auto-resolve from
104
108
  * `@tauri-apps/api` (peer dep) or the ambient `window.__TAURI__`; inject in
@@ -249,11 +253,18 @@ export class TauriSyncClient {
249
253
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
250
254
  #unlisten: (() => void) | undefined;
251
255
  #closed = false;
256
+ #securityLifecycle: SecurityLifecycle;
257
+ #preflightBarrier: Promise<void> | undefined;
252
258
 
253
259
  /** @internal — use {@link createTauriSyncClient}. */
254
- constructor(tauri: TauriApi, unlisten: () => void) {
260
+ constructor(
261
+ tauri: TauriApi,
262
+ unlisten: () => void,
263
+ securityLifecycle: SecurityLifecycle = 'active',
264
+ ) {
255
265
  this.#tauri = tauri;
256
266
  this.#unlisten = unlisten;
267
+ this.#securityLifecycle = securityLifecycle;
257
268
  }
258
269
 
259
270
  /** Dispatch a `syncular_command` and unwrap `{result}` / throw on `{error}`. */
@@ -261,6 +272,26 @@ export class TauriSyncClient {
261
272
  method: string,
262
273
  params: Record<string, unknown>,
263
274
  ): Promise<unknown> {
275
+ if (this.#closed) {
276
+ throw new TauriSyncError(
277
+ 'client.closed',
278
+ 'the Tauri sync client is closed',
279
+ );
280
+ }
281
+ if (
282
+ this.#securityLifecycle === 'preflight' &&
283
+ ![
284
+ 'securityLifecycle',
285
+ 'beginSecurityPreflight',
286
+ 'activateSecurity',
287
+ 'purgeLocalData',
288
+ 'localRevision',
289
+ 'statusSnapshot',
290
+ 'shutdown',
291
+ ].includes(method)
292
+ ) {
293
+ this.#throwSecurityPreflight();
294
+ }
264
295
  const reply = await this.#tauri.invoke<CommandReply>(
265
296
  `${PLUGIN}syncular_command`,
266
297
  { command: { method, params } },
@@ -271,6 +302,17 @@ export class TauriSyncClient {
271
302
  return reply.result;
272
303
  }
273
304
 
305
+ #throwSecurityPreflight(): never {
306
+ throw new TauriSyncError(
307
+ SECURITY_PREFLIGHT_REQUIRED_CODE,
308
+ 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
309
+ );
310
+ }
311
+
312
+ #requireActive(): void {
313
+ if (this.#securityLifecycle === 'preflight') this.#throwSecurityPreflight();
314
+ }
315
+
274
316
  /** @internal — fan an incoming plugin event out to the local listeners. */
275
317
  __dispatchEvent(event: SyncularEvent): void {
276
318
  switch (event.type) {
@@ -316,6 +358,52 @@ export class TauriSyncClient {
316
358
 
317
359
  // -- SyncClientLike --------------------------------------------------------
318
360
 
361
+ securityLifecycle(): Promise<SecurityLifecycle> {
362
+ return Promise.resolve(this.#securityLifecycle);
363
+ }
364
+
365
+ beginSecurityPreflight(): Promise<void> {
366
+ if (this.#closed) {
367
+ return Promise.reject(
368
+ new TauriSyncError('client.closed', 'the Tauri sync client is closed'),
369
+ );
370
+ }
371
+ if (this.#preflightBarrier !== undefined) return this.#preflightBarrier;
372
+ // Flip synchronously so a same-webview query cannot race the IPC barrier.
373
+ this.#securityLifecycle = 'preflight';
374
+ const barrier = this.#command('beginSecurityPreflight', {}).then(() => {});
375
+ this.#preflightBarrier = barrier;
376
+ void barrier.then(
377
+ () => {
378
+ if (this.#preflightBarrier === barrier)
379
+ this.#preflightBarrier = undefined;
380
+ },
381
+ () => {
382
+ if (this.#preflightBarrier === barrier)
383
+ this.#preflightBarrier = undefined;
384
+ },
385
+ );
386
+ return barrier;
387
+ }
388
+
389
+ async activateSecurity(
390
+ options: { readonly encryption?: EncryptionKeyringConfig } = {},
391
+ ): Promise<void> {
392
+ if (this.#securityLifecycle === 'active') {
393
+ throw new TauriSyncError(
394
+ 'sync.invalid_request',
395
+ 'activateSecurity requires the client to be in security preflight',
396
+ );
397
+ }
398
+ await this.#preflightBarrier;
399
+ await this.#command('activateSecurity', {
400
+ ...(options.encryption !== undefined
401
+ ? { encryption: encodeEncryption(options.encryption) }
402
+ : {}),
403
+ });
404
+ this.#securityLifecycle = 'active';
405
+ }
406
+
319
407
  onInvalidate(listener: InvalidationListener): () => void {
320
408
  this.#invalidationListeners.add(listener);
321
409
  return () => this.#invalidationListeners.delete(listener);
@@ -332,6 +420,7 @@ export class TauriSyncClient {
332
420
  }
333
421
 
334
422
  async query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]> {
423
+ this.#requireActive();
335
424
  const reply = await this.#tauri.invoke<CommandReply>(
336
425
  `${PLUGIN}syncular_query`,
337
426
  { sql, params: (params ?? []).map(encodeParam) },
@@ -346,6 +435,7 @@ export class TauriSyncClient {
346
435
  async querySnapshot<Row = SqlRow>(
347
436
  spec: QueryReadSpec,
348
437
  ): Promise<QuerySnapshot<Row>> {
438
+ this.#requireActive();
349
439
  const reply = await this.#tauri.invoke<CommandReply>(
350
440
  `${PLUGIN}syncular_query_snapshot`,
351
441
  {
@@ -643,10 +733,14 @@ export class TauriSyncClient {
643
733
  await this.#command('disconnectRealtime', {});
644
734
  }
645
735
 
646
- /** Detach the event listener; the native core keeps running (host process). */
736
+ /** Shut down the native core, release its keyring, then detach listeners. */
647
737
  async close(): Promise<void> {
648
738
  if (this.#closed) return;
649
- this.#closed = true;
739
+ try {
740
+ await this.#command('shutdown', {});
741
+ } finally {
742
+ this.#closed = true;
743
+ }
650
744
  this.#unlisten?.();
651
745
  this.#unlisten = undefined;
652
746
  this.#invalidationListeners.clear();
@@ -770,7 +864,19 @@ export async function createTauriSyncClient(
770
864
  },
771
865
  );
772
866
 
773
- const client = new TauriSyncClient(tauri, unlisten);
867
+ if (config.securityPreflight === true && config.encryption !== undefined) {
868
+ unlisten();
869
+ throw new TauriSyncError(
870
+ 'sync.invalid_request',
871
+ 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
872
+ );
873
+ }
874
+
875
+ const client = new TauriSyncClient(
876
+ tauri,
877
+ unlisten,
878
+ config.securityPreflight === true ? 'preflight' : 'active',
879
+ );
774
880
  clientRef.client = client;
775
881
 
776
882
  // The native side owns the db path (plugin config); the JS side supplies the
@@ -785,6 +891,9 @@ export async function createTauriSyncClient(
785
891
  ...(config.encryption !== undefined
786
892
  ? { encryption: encodeEncryption(config.encryption) }
787
893
  : {}),
894
+ ...(config.securityPreflight !== undefined
895
+ ? { securityPreflight: config.securityPreflight }
896
+ : {}),
788
897
  },
789
898
  },
790
899
  });