@syncular/tauri 0.15.14 → 0.15.16

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,37 @@ 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
+
57
+ Runtime `setHeaders()` is an active-session operation and is rejected during
58
+ preflight at both the JavaScript and native command boundaries. Supply bootstrap
59
+ headers through trusted plugin configuration; rotate them only after successful
60
+ activation.
61
+
31
62
  ## React availability guard
32
63
 
33
64
  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),
@@ -283,6 +348,7 @@ export class TauriSyncClient {
283
348
  * the realtime socket applies it on its next (re)connect.
284
349
  */
285
350
  async setHeaders(headers) {
351
+ this.#requireActive();
286
352
  const reply = await this.#tauri.invoke(`${PLUGIN}syncular_set_headers`, { headers });
287
353
  if (reply.error !== undefined) {
288
354
  throw new TauriSyncError(reply.error.code, reply.error.message);
@@ -424,11 +490,16 @@ export class TauriSyncClient {
424
490
  async disconnectRealtime() {
425
491
  await this.#command('disconnectRealtime', {});
426
492
  }
427
- /** Detach the event listener; the native core keeps running (host process). */
493
+ /** Shut down the native core, release its keyring, then detach listeners. */
428
494
  async close() {
429
495
  if (this.#closed)
430
496
  return;
431
- this.#closed = true;
497
+ try {
498
+ await this.#command('shutdown', {});
499
+ }
500
+ finally {
501
+ this.#closed = true;
502
+ }
432
503
  this.#unlisten?.();
433
504
  this.#unlisten = undefined;
434
505
  this.#invalidationListeners.clear();
@@ -544,7 +615,11 @@ export async function createTauriSyncClient(config) {
544
615
  const unlisten = await tauri.listen(SYNCULAR_EVENT, (event) => {
545
616
  clientRef.client?.__dispatchEvent(event.payload);
546
617
  });
547
- const client = new TauriSyncClient(tauri, unlisten);
618
+ if (config.securityPreflight === true && config.encryption !== undefined) {
619
+ unlisten();
620
+ throw new TauriSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
621
+ }
622
+ const client = new TauriSyncClient(tauri, unlisten, config.securityPreflight === true ? 'preflight' : 'active');
548
623
  clientRef.client = client;
549
624
  // The native side owns the db path (plugin config); the JS side supplies the
550
625
  // schema, clientId, and limits. `dbPath` is injected by the plugin.
@@ -558,6 +633,9 @@ export async function createTauriSyncClient(config) {
558
633
  ...(config.encryption !== undefined
559
634
  ? { encryption: encodeEncryption(config.encryption) }
560
635
  : {}),
636
+ ...(config.securityPreflight !== undefined
637
+ ? { securityPreflight: config.securityPreflight }
638
+ : {}),
561
639
  },
562
640
  },
563
641
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/tauri",
3
- "version": "0.15.14",
3
+ "version": "0.15.16",
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.16"
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.16"
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
  {
@@ -419,6 +509,7 @@ export class TauriSyncClient {
419
509
  * the realtime socket applies it on its next (re)connect.
420
510
  */
421
511
  async setHeaders(headers: Readonly<Record<string, string>>): Promise<void> {
512
+ this.#requireActive();
422
513
  const reply = await this.#tauri.invoke<CommandReply>(
423
514
  `${PLUGIN}syncular_set_headers`,
424
515
  { headers },
@@ -643,10 +734,14 @@ export class TauriSyncClient {
643
734
  await this.#command('disconnectRealtime', {});
644
735
  }
645
736
 
646
- /** Detach the event listener; the native core keeps running (host process). */
737
+ /** Shut down the native core, release its keyring, then detach listeners. */
647
738
  async close(): Promise<void> {
648
739
  if (this.#closed) return;
649
- this.#closed = true;
740
+ try {
741
+ await this.#command('shutdown', {});
742
+ } finally {
743
+ this.#closed = true;
744
+ }
650
745
  this.#unlisten?.();
651
746
  this.#unlisten = undefined;
652
747
  this.#invalidationListeners.clear();
@@ -770,7 +865,19 @@ export async function createTauriSyncClient(
770
865
  },
771
866
  );
772
867
 
773
- const client = new TauriSyncClient(tauri, unlisten);
868
+ if (config.securityPreflight === true && config.encryption !== undefined) {
869
+ unlisten();
870
+ throw new TauriSyncError(
871
+ 'sync.invalid_request',
872
+ 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
873
+ );
874
+ }
875
+
876
+ const client = new TauriSyncClient(
877
+ tauri,
878
+ unlisten,
879
+ config.securityPreflight === true ? 'preflight' : 'active',
880
+ );
774
881
  clientRef.client = client;
775
882
 
776
883
  // The native side owns the db path (plugin config); the JS side supplies the
@@ -785,6 +892,9 @@ export async function createTauriSyncClient(
785
892
  ...(config.encryption !== undefined
786
893
  ? { encryption: encodeEncryption(config.encryption) }
787
894
  : {}),
895
+ ...(config.securityPreflight !== undefined
896
+ ? { securityPreflight: config.securityPreflight }
897
+ : {}),
788
898
  },
789
899
  },
790
900
  });