@syncular/client 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
@@ -232,6 +232,28 @@ subscription that could download the protected rows again. This method does
232
232
  not authenticate a directive, revoke server authority, delete app-owned files,
233
233
  or remove a key from the OS secure store.
234
234
 
235
+ For a race-free bootstrap, construct the client/worker handle with
236
+ `securityPreflight: true`. Before activation, protected reads, writes,
237
+ subscriptions, sync/realtime, blobs, and the automatic host loop fail with
238
+ `client.security_preflight_required`; status, local revision, lifecycle, and
239
+ the exact local purge remain available.
240
+
241
+ ```ts
242
+ const client = await createSyncClientHandle({
243
+ ...config,
244
+ securityPreflight: true,
245
+ });
246
+
247
+ await client.purgeLocalData(directive.plan);
248
+ await client.activateSecurity({ encryption: acceptedKeyring });
249
+ ```
250
+
251
+ Use `beginSecurityPreflight()` before a live key rotation/revocation. It gates
252
+ new calls immediately, disconnects realtime, waits for in-flight core/blob work,
253
+ and releases the old keyring before resolving. In multi-tab mode the gate
254
+ belongs to the single shared leader replica. Direct clients expose the same
255
+ lifecycle with an `EncryptionConfig`; Worker handles use the portable keyring.
256
+
235
257
  Within one local SQLite transaction the engine deletes exactly the matching
236
258
  synced rows, lets generated FTS triggers remove their projections, drops every
237
259
  whole pending commit with a matching operation, restores/replays unrelated
package/dist/client.d.ts CHANGED
@@ -147,6 +147,25 @@ export interface SyncClientConfig {
147
147
  * `client.decrypt_failed`, never silent plaintext).
148
148
  */
149
149
  readonly encryption?: EncryptionConfig;
150
+ /**
151
+ * Open the local replica in the fail-closed security preflight state.
152
+ *
153
+ * Preflight opens/migrates the database but suppresses every protected read,
154
+ * mutation, subscription, transport, realtime, presence, and blob operation.
155
+ * Only lifecycle/status inspection and `purgeLocalData` remain available.
156
+ * Install the post-authentication keyring and release the gate with
157
+ * `activateSecurity`. This is mutually exclusive with `encryption`: secure
158
+ * hosts must not materialize key bytes before their preflight has passed.
159
+ */
160
+ readonly securityPreflight?: boolean;
161
+ }
162
+ /** The fail-closed local-replica security lifecycle shared by every host. */
163
+ export type SecurityLifecycle = 'preflight' | 'active';
164
+ /** Stable client-local error while protected operations are preflight-gated. */
165
+ export declare const SECURITY_PREFLIGHT_REQUIRED_CODE = "client.security_preflight_required";
166
+ /** Key material installed atomically when a direct client becomes active. */
167
+ export interface SecurityActivation {
168
+ readonly encryption?: EncryptionConfig;
150
169
  }
151
170
  /** §8.6 a peer's ephemeral presence document on a scope key. */
152
171
  export interface PresencePeer {
@@ -216,6 +235,20 @@ export declare class SyncClient {
216
235
  /** Acquire leadership, create local tables, resolve the clientId. */
217
236
  start(): Promise<void>;
218
237
  close(): Promise<void>;
238
+ /** Current fail-closed local-replica security state. */
239
+ get securityLifecycle(): SecurityLifecycle;
240
+ /**
241
+ * Block new protected operations immediately, then wait for every already
242
+ * serialized database/network operation to settle before releasing key
243
+ * references. Hosts await this barrier before applying a quarantine purge.
244
+ */
245
+ beginSecurityPreflight(): Promise<void>;
246
+ /**
247
+ * Atomically install the post-authentication keyring and release the gate.
248
+ * Persisted subscriptions/outbox work produces one exact startup intent only
249
+ * after activation, never while the local quarantine decision is pending.
250
+ */
251
+ activateSecurity(options?: SecurityActivation): Promise<void>;
219
252
  get clientId(): string;
220
253
  /** The underlying database — raw SQL is the local query API (B3). */
221
254
  get database(): ClientDatabase;
package/dist/client.js CHANGED
@@ -21,6 +21,8 @@ import { assertReadOnlyQuery } from './query-guard.js';
21
21
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
22
22
  import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
23
23
  import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
24
+ /** Stable client-local error while protected operations are preflight-gated. */
25
+ export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
24
26
  /**
25
27
  * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
26
28
  * registered and not pending. A unit with zero server rows still becomes
@@ -72,6 +74,7 @@ export class SyncClient {
72
74
  #schema;
73
75
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
74
76
  #encryption;
77
+ #securityLifecycle;
75
78
  #now;
76
79
  #outcomeRetentionMaxEntries;
77
80
  #started = false;
@@ -124,11 +127,21 @@ export class SyncClient {
124
127
  * sections, when the seam is quiescent.
125
128
  */
126
129
  #opChain = Promise.resolve();
130
+ /** Async protected operations outside the SQLite serialization chain
131
+ * (blob I/O and realtime connect). Security preflight waits for this set to
132
+ * drain after synchronously closing the gate. */
133
+ #protectedAsync = new Set();
134
+ #preflightBarrier;
127
135
  constructor(config) {
136
+ if (config.securityPreflight === true && config.encryption !== undefined) {
137
+ throw new ClientSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
138
+ }
128
139
  this.#config = config;
129
140
  this.#db = config.database;
130
141
  this.#schema = compileClientSchema(config.schema);
131
142
  this.#encryption = config.encryption;
143
+ this.#securityLifecycle =
144
+ config.securityPreflight === true ? 'preflight' : 'active';
132
145
  this.#now = config.now ?? Date.now;
133
146
  const outcomeRetentionMaxEntries = config.limits?.outcomeRetentionMaxEntries ?? 1_000;
134
147
  if (!Number.isSafeInteger(outcomeRetentionMaxEntries) ||
@@ -192,7 +205,7 @@ export class SyncClient {
192
205
  const startupWork = this.#schemaFloor === undefined &&
193
206
  (listOutbox(this.#db).length > 0 ||
194
207
  subscriptions.some((sub) => sub.status === 'active'));
195
- if (startupWork) {
208
+ if (startupWork && this.#securityLifecycle === 'active') {
196
209
  this.#needsPull = true;
197
210
  this.#config.onSyncNeeded?.('startup');
198
211
  this.#config.onSyncIntent?.({ kind: 'interactive' });
@@ -294,12 +307,66 @@ export class SyncClient {
294
307
  this.#lease = undefined;
295
308
  this.#started = false;
296
309
  }
310
+ /** Current fail-closed local-replica security state. */
311
+ get securityLifecycle() {
312
+ return this.#securityLifecycle;
313
+ }
314
+ /**
315
+ * Block new protected operations immediately, then wait for every already
316
+ * serialized database/network operation to settle before releasing key
317
+ * references. Hosts await this barrier before applying a quarantine purge.
318
+ */
319
+ beginSecurityPreflight() {
320
+ this.#requireStarted();
321
+ if (this.#preflightBarrier !== undefined)
322
+ return this.#preflightBarrier;
323
+ this.#securityLifecycle = 'preflight';
324
+ this.disconnectRealtime();
325
+ const barrier = (async () => {
326
+ await Promise.allSettled([this.#opChain, ...this.#protectedAsync]);
327
+ this.disconnectRealtime();
328
+ this.#encryption = undefined;
329
+ this.#syncOutstanding = false;
330
+ })();
331
+ this.#preflightBarrier = barrier;
332
+ void barrier.then(() => {
333
+ if (this.#preflightBarrier === barrier)
334
+ this.#preflightBarrier = undefined;
335
+ }, () => {
336
+ if (this.#preflightBarrier === barrier)
337
+ this.#preflightBarrier = undefined;
338
+ });
339
+ return barrier;
340
+ }
341
+ /**
342
+ * Atomically install the post-authentication keyring and release the gate.
343
+ * Persisted subscriptions/outbox work produces one exact startup intent only
344
+ * after activation, never while the local quarantine decision is pending.
345
+ */
346
+ async activateSecurity(options = {}) {
347
+ this.#requireStarted();
348
+ if (this.#securityLifecycle === 'active') {
349
+ throw new ClientSyncError('sync.invalid_request', 'activateSecurity requires the client to be in security preflight');
350
+ }
351
+ await (this.#preflightBarrier ?? this.#opChain);
352
+ this.#encryption = options.encryption;
353
+ this.#securityLifecycle = 'active';
354
+ const startupWork = this.#schemaFloor === undefined &&
355
+ (listOutbox(this.#db).length > 0 ||
356
+ loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
357
+ if (startupWork) {
358
+ this.#setSyncNeeded(true);
359
+ this.#config.onSyncNeeded?.('startup');
360
+ this.#config.onSyncIntent?.({ kind: 'interactive' });
361
+ }
362
+ }
297
363
  // -- accessors ------------------------------------------------------------
298
364
  get clientId() {
299
365
  return this.#clientId;
300
366
  }
301
367
  /** The underlying database — raw SQL is the local query API (B3). */
302
368
  get database() {
369
+ this.#requireActive();
303
370
  return this.#db;
304
371
  }
305
372
  /**
@@ -311,6 +378,7 @@ export class SyncClient {
311
378
  * internals read `this.#db` directly and skip this method by design.
312
379
  */
313
380
  query(sql, params) {
381
+ this.#requireActive();
314
382
  assertReadOnlyQuery(sql);
315
383
  return stripSyncColumns(this.#db.query(sql, params));
316
384
  }
@@ -325,7 +393,7 @@ export class SyncClient {
325
393
  * `windowState()` across separate worker/IPC calls.
326
394
  */
327
395
  querySnapshot(spec) {
328
- this.#requireStarted();
396
+ this.#requireActive();
329
397
  assertReadOnlyQuery(spec.sql);
330
398
  return this.#db.transaction(() => {
331
399
  const revision = getLocalRevision(this.#db);
@@ -456,6 +524,13 @@ export class SyncClient {
456
524
  this.#opChain = next.then(() => undefined, () => undefined);
457
525
  return next;
458
526
  }
527
+ #runProtectedAsync(fn) {
528
+ this.#requireActive();
529
+ const task = Promise.resolve().then(fn);
530
+ this.#protectedAsync.add(task);
531
+ void task.then(() => this.#protectedAsync.delete(task), () => this.#protectedAsync.delete(task));
532
+ return task;
533
+ }
459
534
  // -- blobs (§5.9) ---------------------------------------------------------
460
535
  /**
461
536
  * Stage a blob for attachment (§5.9.7): hash the bytes into the content
@@ -464,7 +539,10 @@ export class SyncClient {
464
539
  * a `blob_ref` column of a mutation. The referencing row MUST be written
465
540
  * (via `mutate`) after this call so upload-before-push holds (§5.9.3).
466
541
  */
467
- async uploadBlob(bytes, options) {
542
+ uploadBlob(bytes, options) {
543
+ return this.#runProtectedAsync(() => this.#uploadBlob(bytes, options));
544
+ }
545
+ async #uploadBlob(bytes, options) {
468
546
  if (this.#config.blobs === undefined) {
469
547
  throw new ClientSyncError('sync.invalid_request', 'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
470
548
  }
@@ -496,7 +574,10 @@ export class SyncClient {
496
574
  * transport (§5.9.5), verifies the content address, caches, and returns.
497
575
  * Accepts a raw `blob_ref` column string or a bare `blobId`.
498
576
  */
499
- async fetchBlob(blobIdOrRef) {
577
+ fetchBlob(blobIdOrRef) {
578
+ return this.#runProtectedAsync(() => this.#fetchBlob(blobIdOrRef));
579
+ }
580
+ async #fetchBlob(blobIdOrRef) {
500
581
  const blobId = blobIdOrRef.startsWith('sha256:')
501
582
  ? blobIdOrRef
502
583
  : parseBlobRef(blobIdOrRef).blobId;
@@ -550,7 +631,10 @@ export class SyncClient {
550
631
  enforceBlobCacheCap(this.#db, cap);
551
632
  }
552
633
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
553
- async flushBlobUploads() {
634
+ flushBlobUploads() {
635
+ return this.#runProtectedAsync(() => this.#flushBlobUploads());
636
+ }
637
+ async #flushBlobUploads() {
554
638
  const transport = this.#config.blobs;
555
639
  if (transport === undefined || !this.#hasBlobs)
556
640
  return;
@@ -598,19 +682,21 @@ export class SyncClient {
598
682
  await transport.upload(blobId, bytes, mediaType);
599
683
  }
600
684
  get conflicts() {
685
+ this.#requireActive();
601
686
  return this.#conflicts;
602
687
  }
603
688
  get rejections() {
689
+ this.#requireActive();
604
690
  return this.#rejections;
605
691
  }
606
692
  /** One durable final outcome by the originating client commit id. */
607
693
  commitOutcome(clientCommitId) {
608
- this.#requireStarted();
694
+ this.#requireActive();
609
695
  return readCommitOutcome(this.#db, clientCommitId);
610
696
  }
611
697
  /** Newest-first durable outcome journal. */
612
698
  commitOutcomes(query = {}) {
613
- this.#requireStarted();
699
+ this.#requireActive();
614
700
  return listCommitOutcomes(this.#db, query);
615
701
  }
616
702
  /**
@@ -620,7 +706,7 @@ export class SyncClient {
620
706
  * dismissed. The transition is one-way and survives restart.
621
707
  */
622
708
  resolveCommitOutcome(input) {
623
- this.#requireStarted();
709
+ this.#requireActive();
624
710
  const current = readCommitOutcome(this.#db, input.clientCommitId);
625
711
  if (current === undefined) {
626
712
  throw new ClientSyncError('sync.outcome_not_found', `no durable outcome exists for ${JSON.stringify(input.clientCommitId)}`);
@@ -704,11 +790,13 @@ export class SyncClient {
704
790
  * Ephemeral — reflects only what the socket has delivered.
705
791
  */
706
792
  presence(scopeKey) {
793
+ this.#requireActive();
707
794
  const peers = this.#presence.get(scopeKey);
708
795
  return peers === undefined ? [] : [...peers.values()];
709
796
  }
710
797
  /** Every scope key this client currently has presence state for. */
711
798
  presenceKeys() {
799
+ this.#requireActive();
712
800
  return [...this.#presence.keys()];
713
801
  }
714
802
  /**
@@ -731,7 +819,7 @@ export class SyncClient {
731
819
  * by the server with `presence.forbidden`.
732
820
  */
733
821
  setPresence(scopeKey, doc) {
734
- this.#requireStarted();
822
+ this.#requireActive();
735
823
  const socket = this.#socket;
736
824
  if (socket === undefined) {
737
825
  throw new ClientSyncError('sync.invalid_request', 'setPresence requires a connected realtime socket (§8.6)');
@@ -757,20 +845,20 @@ export class SyncClient {
757
845
  (urlCapable ? ACCEPT_SIGNED_URLS : 0));
758
846
  }
759
847
  subscriptions() {
760
- this.#requireStarted();
848
+ this.#requireActive();
761
849
  return loadSubscriptions(this.#db);
762
850
  }
763
851
  subscription(id) {
764
- this.#requireStarted();
852
+ this.#requireActive();
765
853
  return getSubscription(this.#db, id);
766
854
  }
767
855
  pendingCommits() {
768
- this.#requireStarted();
856
+ this.#requireActive();
769
857
  return listOutbox(this.#db);
770
858
  }
771
859
  // -- subscriptions ----------------------------------------------------------
772
860
  subscribe(input) {
773
- this.#requireStarted();
861
+ this.#requireActive();
774
862
  if (!this.#schema.tables.has(input.table)) {
775
863
  throw new ClientSyncError('sync.unknown_table', `subscribe: unknown local table ${JSON.stringify(input.table)}`);
776
864
  }
@@ -794,7 +882,7 @@ export class SyncClient {
794
882
  });
795
883
  }
796
884
  unsubscribe(id) {
797
- this.#requireStarted();
885
+ this.#requireActive();
798
886
  deleteSubscription(this.#db, id);
799
887
  }
800
888
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -816,7 +904,7 @@ export class SyncClient {
816
904
  }
817
905
  /** Exact core command result consumed by automatic host loops (§7.5). */
818
906
  async setWindowCommand(base, units) {
819
- this.#requireStarted();
907
+ this.#requireActive();
820
908
  const table = this.#table(base.table);
821
909
  if (!table.scopeColumnByVariable.has(base.variable)) {
822
910
  throw new ClientSyncError('sync.invalid_request', `setWindow: table ${JSON.stringify(base.table)} has no scope variable ${JSON.stringify(base.variable)} (§4.8)`);
@@ -879,7 +967,7 @@ export class SyncClient {
879
967
  * advances past -1 with no resume token held).
880
968
  */
881
969
  windowState(base) {
882
- this.#requireStarted();
970
+ this.#requireActive();
883
971
  const baseKey = windowBaseKey(base);
884
972
  const live = loadWindowUnits(this.#db, baseKey);
885
973
  const pending = [];
@@ -978,7 +1066,7 @@ export class SyncClient {
978
1066
  return this.#recordMutations(mutations);
979
1067
  }
980
1068
  #recordMutations(mutations, changedFieldsByIndex = []) {
981
- this.#requireStarted();
1069
+ this.#requireActive();
982
1070
  const clientCommitId = crypto.randomUUID();
983
1071
  const operations = mutations.map((mutation, index) => {
984
1072
  const table = this.#table(mutation.table);
@@ -1039,7 +1127,7 @@ export class SyncClient {
1039
1127
  * an error — there is no base to merge into.
1040
1128
  */
1041
1129
  patch(table, rowId, partial, options) {
1042
- this.#requireStarted();
1130
+ this.#requireActive();
1043
1131
  const compiled = this.#table(table);
1044
1132
  const pkColumn = compiled.columns[compiled.primaryKeyIndex];
1045
1133
  const rows = this.#db.query(`SELECT * FROM ${quoteIdent(compiled.name)} WHERE ${quoteIdent(pkColumn.name)} = ?`, [rowId]);
@@ -1310,7 +1398,7 @@ export class SyncClient {
1310
1398
  * `setWindow` at an await point.
1311
1399
  */
1312
1400
  sync() {
1313
- this.#requireStarted();
1401
+ this.#requireActive();
1314
1402
  if (this.#syncOutstanding) {
1315
1403
  return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
1316
1404
  }
@@ -1489,13 +1577,15 @@ export class SyncClient {
1489
1577
  round.reject(new ClientSyncError('sync.transport_failed', reason, true));
1490
1578
  }
1491
1579
  // -- realtime (§8 client side) ----------------------------------------------
1492
- async connectRealtime() {
1493
- this.#requireStarted();
1580
+ connectRealtime() {
1581
+ return this.#runProtectedAsync(() => this.#connectRealtime());
1582
+ }
1583
+ async #connectRealtime() {
1494
1584
  const connector = this.#config.realtime;
1495
1585
  if (connector === undefined) {
1496
1586
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
1497
1587
  }
1498
- this.#socket = await connector({
1588
+ const socket = await connector({
1499
1589
  onText: (text) => this.#handleRealtimeText(text),
1500
1590
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
1501
1591
  onClose: () => {
@@ -1504,6 +1594,11 @@ export class SyncClient {
1504
1594
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1505
1595
  },
1506
1596
  });
1597
+ if (this.#securityLifecycle === 'preflight') {
1598
+ socket.close();
1599
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
1600
+ }
1601
+ this.#socket = socket;
1507
1602
  }
1508
1603
  disconnectRealtime() {
1509
1604
  this.#socket?.close();
@@ -2410,4 +2505,10 @@ export class SyncClient {
2410
2505
  throw new ClientSyncError('sync.invalid_request', 'SyncClient.start() has not completed');
2411
2506
  }
2412
2507
  }
2508
+ #requireActive() {
2509
+ this.#requireStarted();
2510
+ if (this.#securityLifecycle === 'preflight') {
2511
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
2512
+ }
2513
+ }
2413
2514
  }
@@ -88,8 +88,11 @@ export function startSyncWorker(overrides = {}) {
88
88
  let backgroundDue = Number.POSITIVE_INFINITY;
89
89
  function runAutoSync() {
90
90
  autoSyncScheduled = false;
91
- if (closed || client === undefined)
91
+ if (closed ||
92
+ client === undefined ||
93
+ client.securityLifecycle === 'preflight') {
92
94
  return;
95
+ }
93
96
  const running = client;
94
97
  void serializedSync(() => running.syncUntilIdle())
95
98
  .then((summary) => {
@@ -106,7 +109,11 @@ export function startSyncWorker(overrides = {}) {
106
109
  });
107
110
  }
108
111
  function consumeSyncIntent(intent) {
109
- if (!autoSync || closed || client === undefined || intent.kind === 'none') {
112
+ if (!autoSync ||
113
+ closed ||
114
+ client === undefined ||
115
+ client.securityLifecycle === 'preflight' ||
116
+ intent.kind === 'none') {
110
117
  return;
111
118
  }
112
119
  if (intent.kind === 'background') {
@@ -220,6 +227,9 @@ export function startSyncWorker(overrides = {}) {
220
227
  ...(config.encryption !== undefined
221
228
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
222
229
  : {}),
230
+ ...(config.securityPreflight !== undefined
231
+ ? { securityPreflight: config.securityPreflight }
232
+ : {}),
223
233
  onSyncNeeded: (reason) => {
224
234
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
225
235
  consumeSyncIntent({ kind: 'interactive' });
@@ -257,6 +267,22 @@ export function startSyncWorker(overrides = {}) {
257
267
  return { clientId: started.clientId };
258
268
  }
259
269
  const api = {
270
+ securityLifecycle: () => requireClient().securityLifecycle,
271
+ beginSecurityPreflight: async () => {
272
+ if (backgroundTimer !== undefined)
273
+ clearTimeout(backgroundTimer);
274
+ backgroundTimer = undefined;
275
+ backgroundDue = Number.POSITIVE_INFINITY;
276
+ autoSyncScheduled = false;
277
+ await requireClient().beginSecurityPreflight();
278
+ },
279
+ activateSecurity: async (options = {}) => {
280
+ await requireClient().activateSecurity({
281
+ ...(options.encryption !== undefined
282
+ ? { encryption: encryptionConfigFromKeyring(options.encryption) }
283
+ : {}),
284
+ });
285
+ },
260
286
  subscribe: (input) => requireClient().subscribe(input),
261
287
  unsubscribe: (id) => requireClient().unsubscribe(id),
262
288
  setWindow: async (base, units) => {
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import type { WakeReason } from '@syncular/core';
25
25
  import type { BlobRef, CachedBlob } from './blob.js';
26
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
26
+ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
27
27
  import type { SqlRow, SqlValue } from './database.js';
28
28
  import type { EncryptionKeyringConfig } from './encryption.js';
29
29
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
@@ -35,7 +35,7 @@ import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } fro
35
35
  import type { ClientSchema } from './schema.js';
36
36
  import type { SubscriptionRecord } from './state.js';
37
37
  import type { WindowBase } from './window.js';
38
- import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape } from './worker-protocol.js';
38
+ import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape, type WorkerSecurityActivation } from './worker-protocol.js';
39
39
  export type HandleRole = 'leader' | 'follower';
40
40
  export type BrowserReplicaMode = {
41
41
  readonly mode: 'shared';
@@ -69,6 +69,8 @@ export interface SyncClientHandleConfig {
69
69
  readonly endpoints: WorkerEndpoints;
70
70
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
71
71
  readonly encryption?: EncryptionKeyringConfig;
72
+ /** Open the worker-owned replica behind the fail-closed security gate. */
73
+ readonly securityPreflight?: boolean;
72
74
  readonly clientId?: string;
73
75
  readonly limits?: SyncClientLimits;
74
76
  /** Worker-side host loop (§8.4); default true. */
@@ -170,6 +172,9 @@ export declare class SyncClientHandle {
170
172
  onRoleChange(listener: (role: HandleRole) => void): () => void;
171
173
  onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
172
174
  subscribe(input: SubscribeInput): Promise<void>;
175
+ securityLifecycle(): Promise<SecurityLifecycle>;
176
+ beginSecurityPreflight(): Promise<void>;
177
+ activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
173
178
  unsubscribe(id: string): Promise<void>;
174
179
  setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
175
180
  windowState(base: WindowBase): Promise<WindowState>;
@@ -206,6 +206,15 @@ export class SyncClientHandle {
206
206
  subscribe(input) {
207
207
  return this.#call('subscribe', [input]);
208
208
  }
209
+ securityLifecycle() {
210
+ return this.#call('securityLifecycle', []);
211
+ }
212
+ beginSecurityPreflight() {
213
+ return this.#call('beginSecurityPreflight', []);
214
+ }
215
+ activateSecurity(options = {}) {
216
+ return this.#call('activateSecurity', [options]);
217
+ }
209
218
  unsubscribe(id) {
210
219
  return this.#call('unsubscribe', [id]);
211
220
  }
@@ -433,6 +442,9 @@ function buildInitConfig(config) {
433
442
  ...(config.encryption !== undefined
434
443
  ? { encryption: config.encryption }
435
444
  : {}),
445
+ ...(config.securityPreflight !== undefined
446
+ ? { securityPreflight: config.securityPreflight }
447
+ : {}),
436
448
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
437
449
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
438
450
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
@@ -665,6 +677,13 @@ async function bootFollower(config, lockName, lock, parts) {
665
677
  catch {
666
678
  /* bind timed out — return the (degraded but functional) handle anyway */
667
679
  }
680
+ // Init configuration belongs to the one leader worker. A newly opened
681
+ // follower that explicitly requests security preflight must therefore put
682
+ // the shared origin replica behind the same barrier before it is returned;
683
+ // otherwise an already-running leader would silently ignore the request.
684
+ if (config.securityPreflight === true) {
685
+ await handle.beginSecurityPreflight();
686
+ }
668
687
  return handle;
669
688
  }
670
689
  function resolveReplicaConfig(config) {
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob.js';
21
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
21
+ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
23
  import type { EncryptionKeyringConfig } from './encryption.js';
24
24
  import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
@@ -65,6 +65,8 @@ export interface WorkerInitConfig {
65
65
  readonly endpoints: WorkerEndpoints;
66
66
  /** Portable raw keyring installed inside the worker-owned client core. */
67
67
  readonly encryption?: EncryptionKeyringConfig;
68
+ /** Open the worker-owned replica behind the fail-closed security gate. */
69
+ readonly securityPreflight?: boolean;
68
70
  readonly clientId?: string;
69
71
  readonly limits?: SyncClientLimits;
70
72
  /**
@@ -78,7 +80,14 @@ export interface WorkerInitConfig {
78
80
  export interface WorkerInitResult {
79
81
  readonly clientId: string;
80
82
  }
83
+ /** Structured-clone-safe key material installed at security activation. */
84
+ export interface WorkerSecurityActivation {
85
+ readonly encryption?: EncryptionKeyringConfig;
86
+ }
81
87
  export interface WorkerApi {
88
+ securityLifecycle(): SecurityLifecycle;
89
+ beginSecurityPreflight(): Promise<void>;
90
+ activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
82
91
  subscribe(input: SubscribeInput): void;
83
92
  unsubscribe(id: string): void;
84
93
  /** §4.8 windowed subscriptions: set the live units for a window base. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.14",
3
+ "version": "0.15.15",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.14"
84
+ "@syncular/core": "0.15.15"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.14",
95
+ "@syncular/server": "0.15.15",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -315,6 +315,29 @@ export interface SyncClientConfig {
315
315
  * `client.decrypt_failed`, never silent plaintext).
316
316
  */
317
317
  readonly encryption?: EncryptionConfig;
318
+ /**
319
+ * Open the local replica in the fail-closed security preflight state.
320
+ *
321
+ * Preflight opens/migrates the database but suppresses every protected read,
322
+ * mutation, subscription, transport, realtime, presence, and blob operation.
323
+ * Only lifecycle/status inspection and `purgeLocalData` remain available.
324
+ * Install the post-authentication keyring and release the gate with
325
+ * `activateSecurity`. This is mutually exclusive with `encryption`: secure
326
+ * hosts must not materialize key bytes before their preflight has passed.
327
+ */
328
+ readonly securityPreflight?: boolean;
329
+ }
330
+
331
+ /** The fail-closed local-replica security lifecycle shared by every host. */
332
+ export type SecurityLifecycle = 'preflight' | 'active';
333
+
334
+ /** Stable client-local error while protected operations are preflight-gated. */
335
+ export const SECURITY_PREFLIGHT_REQUIRED_CODE =
336
+ 'client.security_preflight_required';
337
+
338
+ /** Key material installed atomically when a direct client becomes active. */
339
+ export interface SecurityActivation {
340
+ readonly encryption?: EncryptionConfig;
318
341
  }
319
342
 
320
343
  /** §8.6 a peer's ephemeral presence document on a scope key. */
@@ -471,7 +494,8 @@ export class SyncClient {
471
494
  readonly #db: ClientDatabase;
472
495
  readonly #schema: CompiledClientSchema;
473
496
  /** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
474
- readonly #encryption: EncryptionConfig | undefined;
497
+ #encryption: EncryptionConfig | undefined;
498
+ #securityLifecycle: SecurityLifecycle;
475
499
  readonly #now: () => number;
476
500
  readonly #outcomeRetentionMaxEntries: number;
477
501
  #started = false;
@@ -524,12 +548,25 @@ export class SyncClient {
524
548
  * sections, when the seam is quiescent.
525
549
  */
526
550
  #opChain: Promise<unknown> = Promise.resolve();
551
+ /** Async protected operations outside the SQLite serialization chain
552
+ * (blob I/O and realtime connect). Security preflight waits for this set to
553
+ * drain after synchronously closing the gate. */
554
+ readonly #protectedAsync = new Set<Promise<unknown>>();
555
+ #preflightBarrier: Promise<void> | undefined;
527
556
 
528
557
  constructor(config: SyncClientConfig) {
558
+ if (config.securityPreflight === true && config.encryption !== undefined) {
559
+ throw new ClientSyncError(
560
+ 'sync.invalid_request',
561
+ 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
562
+ );
563
+ }
529
564
  this.#config = config;
530
565
  this.#db = config.database;
531
566
  this.#schema = compileClientSchema(config.schema);
532
567
  this.#encryption = config.encryption;
568
+ this.#securityLifecycle =
569
+ config.securityPreflight === true ? 'preflight' : 'active';
533
570
  this.#now = config.now ?? Date.now;
534
571
  const outcomeRetentionMaxEntries =
535
572
  config.limits?.outcomeRetentionMaxEntries ?? 1_000;
@@ -613,7 +650,7 @@ export class SyncClient {
613
650
  this.#schemaFloor === undefined &&
614
651
  (listOutbox(this.#db).length > 0 ||
615
652
  subscriptions.some((sub) => sub.status === 'active'));
616
- if (startupWork) {
653
+ if (startupWork && this.#securityLifecycle === 'active') {
617
654
  this.#needsPull = true;
618
655
  this.#config.onSyncNeeded?.('startup');
619
656
  this.#config.onSyncIntent?.({ kind: 'interactive' });
@@ -722,6 +759,68 @@ export class SyncClient {
722
759
  this.#started = false;
723
760
  }
724
761
 
762
+ /** Current fail-closed local-replica security state. */
763
+ get securityLifecycle(): SecurityLifecycle {
764
+ return this.#securityLifecycle;
765
+ }
766
+
767
+ /**
768
+ * Block new protected operations immediately, then wait for every already
769
+ * serialized database/network operation to settle before releasing key
770
+ * references. Hosts await this barrier before applying a quarantine purge.
771
+ */
772
+ beginSecurityPreflight(): Promise<void> {
773
+ this.#requireStarted();
774
+ if (this.#preflightBarrier !== undefined) return this.#preflightBarrier;
775
+ this.#securityLifecycle = 'preflight';
776
+ this.disconnectRealtime();
777
+ const barrier = (async () => {
778
+ await Promise.allSettled([this.#opChain, ...this.#protectedAsync]);
779
+ this.disconnectRealtime();
780
+ this.#encryption = undefined;
781
+ this.#syncOutstanding = false;
782
+ })();
783
+ this.#preflightBarrier = barrier;
784
+ void barrier.then(
785
+ () => {
786
+ if (this.#preflightBarrier === barrier)
787
+ this.#preflightBarrier = undefined;
788
+ },
789
+ () => {
790
+ if (this.#preflightBarrier === barrier)
791
+ this.#preflightBarrier = undefined;
792
+ },
793
+ );
794
+ return barrier;
795
+ }
796
+
797
+ /**
798
+ * Atomically install the post-authentication keyring and release the gate.
799
+ * Persisted subscriptions/outbox work produces one exact startup intent only
800
+ * after activation, never while the local quarantine decision is pending.
801
+ */
802
+ async activateSecurity(options: SecurityActivation = {}): Promise<void> {
803
+ this.#requireStarted();
804
+ if (this.#securityLifecycle === 'active') {
805
+ throw new ClientSyncError(
806
+ 'sync.invalid_request',
807
+ 'activateSecurity requires the client to be in security preflight',
808
+ );
809
+ }
810
+ await (this.#preflightBarrier ?? this.#opChain);
811
+ this.#encryption = options.encryption;
812
+ this.#securityLifecycle = 'active';
813
+ const startupWork =
814
+ this.#schemaFloor === undefined &&
815
+ (listOutbox(this.#db).length > 0 ||
816
+ loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
817
+ if (startupWork) {
818
+ this.#setSyncNeeded(true);
819
+ this.#config.onSyncNeeded?.('startup');
820
+ this.#config.onSyncIntent?.({ kind: 'interactive' });
821
+ }
822
+ }
823
+
725
824
  // -- accessors ------------------------------------------------------------
726
825
 
727
826
  get clientId(): string {
@@ -730,6 +829,7 @@ export class SyncClient {
730
829
 
731
830
  /** The underlying database — raw SQL is the local query API (B3). */
732
831
  get database(): ClientDatabase {
832
+ this.#requireActive();
733
833
  return this.#db;
734
834
  }
735
835
 
@@ -742,6 +842,7 @@ export class SyncClient {
742
842
  * internals read `this.#db` directly and skip this method by design.
743
843
  */
744
844
  query(sql: string, params?: readonly SqlValue[]): SqlRow[] {
845
+ this.#requireActive();
745
846
  assertReadOnlyQuery(sql);
746
847
  return stripSyncColumns(this.#db.query(sql, params));
747
848
  }
@@ -758,7 +859,7 @@ export class SyncClient {
758
859
  * `windowState()` across separate worker/IPC calls.
759
860
  */
760
861
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> {
761
- this.#requireStarted();
862
+ this.#requireActive();
762
863
  assertReadOnlyQuery(spec.sql);
763
864
  return this.#db.transaction(() => {
764
865
  const revision = getLocalRevision(this.#db);
@@ -901,6 +1002,17 @@ export class SyncClient {
901
1002
  return next;
902
1003
  }
903
1004
 
1005
+ #runProtectedAsync<T>(fn: () => Promise<T>): Promise<T> {
1006
+ this.#requireActive();
1007
+ const task = Promise.resolve().then(fn);
1008
+ this.#protectedAsync.add(task);
1009
+ void task.then(
1010
+ () => this.#protectedAsync.delete(task),
1011
+ () => this.#protectedAsync.delete(task),
1012
+ );
1013
+ return task;
1014
+ }
1015
+
904
1016
  // -- blobs (§5.9) ---------------------------------------------------------
905
1017
 
906
1018
  /**
@@ -910,7 +1022,14 @@ export class SyncClient {
910
1022
  * a `blob_ref` column of a mutation. The referencing row MUST be written
911
1023
  * (via `mutate`) after this call so upload-before-push holds (§5.9.3).
912
1024
  */
913
- async uploadBlob(
1025
+ uploadBlob(
1026
+ bytes: Uint8Array,
1027
+ options?: { readonly mediaType?: string; readonly name?: string },
1028
+ ): Promise<BlobRef> {
1029
+ return this.#runProtectedAsync(() => this.#uploadBlob(bytes, options));
1030
+ }
1031
+
1032
+ async #uploadBlob(
914
1033
  bytes: Uint8Array,
915
1034
  options?: { readonly mediaType?: string; readonly name?: string },
916
1035
  ): Promise<BlobRef> {
@@ -950,7 +1069,11 @@ export class SyncClient {
950
1069
  * transport (§5.9.5), verifies the content address, caches, and returns.
951
1070
  * Accepts a raw `blob_ref` column string or a bare `blobId`.
952
1071
  */
953
- async fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
1072
+ fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
1073
+ return this.#runProtectedAsync(() => this.#fetchBlob(blobIdOrRef));
1074
+ }
1075
+
1076
+ async #fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
954
1077
  const blobId = blobIdOrRef.startsWith('sha256:')
955
1078
  ? blobIdOrRef
956
1079
  : parseBlobRef(blobIdOrRef).blobId;
@@ -1021,7 +1144,11 @@ export class SyncClient {
1021
1144
  }
1022
1145
 
1023
1146
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
1024
- async flushBlobUploads(): Promise<void> {
1147
+ flushBlobUploads(): Promise<void> {
1148
+ return this.#runProtectedAsync(() => this.#flushBlobUploads());
1149
+ }
1150
+
1151
+ async #flushBlobUploads(): Promise<void> {
1025
1152
  const transport = this.#config.blobs;
1026
1153
  if (transport === undefined || !this.#hasBlobs) return;
1027
1154
  for (const pending of listPendingUploads(this.#db)) {
@@ -1086,22 +1213,24 @@ export class SyncClient {
1086
1213
  }
1087
1214
 
1088
1215
  get conflicts(): readonly ConflictRecord[] {
1216
+ this.#requireActive();
1089
1217
  return this.#conflicts;
1090
1218
  }
1091
1219
 
1092
1220
  get rejections(): readonly RejectionRecord[] {
1221
+ this.#requireActive();
1093
1222
  return this.#rejections;
1094
1223
  }
1095
1224
 
1096
1225
  /** One durable final outcome by the originating client commit id. */
1097
1226
  commitOutcome(clientCommitId: string): CommitOutcome | undefined {
1098
- this.#requireStarted();
1227
+ this.#requireActive();
1099
1228
  return readCommitOutcome(this.#db, clientCommitId);
1100
1229
  }
1101
1230
 
1102
1231
  /** Newest-first durable outcome journal. */
1103
1232
  commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
1104
- this.#requireStarted();
1233
+ this.#requireActive();
1105
1234
  return listCommitOutcomes(this.#db, query);
1106
1235
  }
1107
1236
 
@@ -1112,7 +1241,7 @@ export class SyncClient {
1112
1241
  * dismissed. The transition is one-way and survives restart.
1113
1242
  */
1114
1243
  resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
1115
- this.#requireStarted();
1244
+ this.#requireActive();
1116
1245
  const current = readCommitOutcome(this.#db, input.clientCommitId);
1117
1246
  if (current === undefined) {
1118
1247
  throw new ClientSyncError(
@@ -1226,12 +1355,14 @@ export class SyncClient {
1226
1355
  * Ephemeral — reflects only what the socket has delivered.
1227
1356
  */
1228
1357
  presence(scopeKey: string): readonly PresencePeer[] {
1358
+ this.#requireActive();
1229
1359
  const peers = this.#presence.get(scopeKey);
1230
1360
  return peers === undefined ? [] : [...peers.values()];
1231
1361
  }
1232
1362
 
1233
1363
  /** Every scope key this client currently has presence state for. */
1234
1364
  presenceKeys(): string[] {
1365
+ this.#requireActive();
1235
1366
  return [...this.#presence.keys()];
1236
1367
  }
1237
1368
 
@@ -1256,7 +1387,7 @@ export class SyncClient {
1256
1387
  * by the server with `presence.forbidden`.
1257
1388
  */
1258
1389
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
1259
- this.#requireStarted();
1390
+ this.#requireActive();
1260
1391
  const socket = this.#socket;
1261
1392
  if (socket === undefined) {
1262
1393
  throw new ClientSyncError(
@@ -1289,24 +1420,24 @@ export class SyncClient {
1289
1420
  }
1290
1421
 
1291
1422
  subscriptions(): SubscriptionRecord[] {
1292
- this.#requireStarted();
1423
+ this.#requireActive();
1293
1424
  return loadSubscriptions(this.#db);
1294
1425
  }
1295
1426
 
1296
1427
  subscription(id: string): SubscriptionRecord | undefined {
1297
- this.#requireStarted();
1428
+ this.#requireActive();
1298
1429
  return getSubscription(this.#db, id);
1299
1430
  }
1300
1431
 
1301
1432
  pendingCommits(): OutboxCommit[] {
1302
- this.#requireStarted();
1433
+ this.#requireActive();
1303
1434
  return listOutbox(this.#db);
1304
1435
  }
1305
1436
 
1306
1437
  // -- subscriptions ----------------------------------------------------------
1307
1438
 
1308
1439
  subscribe(input: SubscribeInput): void {
1309
- this.#requireStarted();
1440
+ this.#requireActive();
1310
1441
  if (!this.#schema.tables.has(input.table)) {
1311
1442
  throw new ClientSyncError(
1312
1443
  'sync.unknown_table',
@@ -1334,7 +1465,7 @@ export class SyncClient {
1334
1465
  }
1335
1466
 
1336
1467
  unsubscribe(id: string): void {
1337
- this.#requireStarted();
1468
+ this.#requireActive();
1338
1469
  deleteSubscription(this.#db, id);
1339
1470
  }
1340
1471
 
@@ -1362,7 +1493,7 @@ export class SyncClient {
1362
1493
  base: WindowBase,
1363
1494
  units: readonly string[],
1364
1495
  ): Promise<CommandResult<void>> {
1365
- this.#requireStarted();
1496
+ this.#requireActive();
1366
1497
  const table = this.#table(base.table);
1367
1498
  if (!table.scopeColumnByVariable.has(base.variable)) {
1368
1499
  throw new ClientSyncError(
@@ -1429,7 +1560,7 @@ export class SyncClient {
1429
1560
  * advances past -1 with no resume token held).
1430
1561
  */
1431
1562
  windowState(base: WindowBase): WindowState {
1432
- this.#requireStarted();
1563
+ this.#requireActive();
1433
1564
  const baseKey = windowBaseKey(base);
1434
1565
  const live = loadWindowUnits(this.#db, baseKey);
1435
1566
  const pending: string[] = [];
@@ -1540,7 +1671,7 @@ export class SyncClient {
1540
1671
  mutations: readonly MutationInput[],
1541
1672
  changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
1542
1673
  ): string {
1543
- this.#requireStarted();
1674
+ this.#requireActive();
1544
1675
  const clientCommitId = crypto.randomUUID();
1545
1676
  const operations: OutboxOperation[] = mutations.map((mutation, index) => {
1546
1677
  const table = this.#table(mutation.table);
@@ -1617,7 +1748,7 @@ export class SyncClient {
1617
1748
  partial: Readonly<Record<string, unknown>>,
1618
1749
  options?: { readonly baseVersion?: number },
1619
1750
  ): string {
1620
- this.#requireStarted();
1751
+ this.#requireActive();
1621
1752
  const compiled = this.#table(table);
1622
1753
  const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
1623
1754
  const rows = this.#db.query(
@@ -1951,7 +2082,7 @@ export class SyncClient {
1951
2082
  * `setWindow` at an await point.
1952
2083
  */
1953
2084
  sync(): Promise<SyncSummary> {
1954
- this.#requireStarted();
2085
+ this.#requireActive();
1955
2086
  if (this.#syncOutstanding) {
1956
2087
  return Promise.reject(
1957
2088
  new ClientSyncError(
@@ -2159,8 +2290,11 @@ export class SyncClient {
2159
2290
 
2160
2291
  // -- realtime (§8 client side) ----------------------------------------------
2161
2292
 
2162
- async connectRealtime(): Promise<void> {
2163
- this.#requireStarted();
2293
+ connectRealtime(): Promise<void> {
2294
+ return this.#runProtectedAsync(() => this.#connectRealtime());
2295
+ }
2296
+
2297
+ async #connectRealtime(): Promise<void> {
2164
2298
  const connector = this.#config.realtime;
2165
2299
  if (connector === undefined) {
2166
2300
  throw new ClientSyncError(
@@ -2168,7 +2302,7 @@ export class SyncClient {
2168
2302
  'no realtime connector configured',
2169
2303
  );
2170
2304
  }
2171
- this.#socket = await connector({
2305
+ const socket = await connector({
2172
2306
  onText: (text) => this.#handleRealtimeText(text),
2173
2307
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
2174
2308
  onClose: () => {
@@ -2177,6 +2311,14 @@ export class SyncClient {
2177
2311
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2178
2312
  },
2179
2313
  });
2314
+ if (this.#securityLifecycle === 'preflight') {
2315
+ socket.close();
2316
+ throw new ClientSyncError(
2317
+ SECURITY_PREFLIGHT_REQUIRED_CODE,
2318
+ 'realtime connected after the client entered security preflight',
2319
+ );
2320
+ }
2321
+ this.#socket = socket;
2180
2322
  }
2181
2323
 
2182
2324
  disconnectRealtime(): void {
@@ -3336,4 +3478,14 @@ export class SyncClient {
3336
3478
  );
3337
3479
  }
3338
3480
  }
3481
+
3482
+ #requireActive(): void {
3483
+ this.#requireStarted();
3484
+ if (this.#securityLifecycle === 'preflight') {
3485
+ throw new ClientSyncError(
3486
+ SECURITY_PREFLIGHT_REQUIRED_CODE,
3487
+ 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
3488
+ );
3489
+ }
3490
+ }
3339
3491
  }
@@ -143,7 +143,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
143
143
 
144
144
  function runAutoSync(): void {
145
145
  autoSyncScheduled = false;
146
- if (closed || client === undefined) return;
146
+ if (
147
+ closed ||
148
+ client === undefined ||
149
+ client.securityLifecycle === 'preflight'
150
+ ) {
151
+ return;
152
+ }
147
153
  const running = client;
148
154
  void serializedSync(() => running.syncUntilIdle())
149
155
  .then((summary) => {
@@ -160,7 +166,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
160
166
  }
161
167
 
162
168
  function consumeSyncIntent(intent: SyncIntent): void {
163
- if (!autoSync || closed || client === undefined || intent.kind === 'none') {
169
+ if (
170
+ !autoSync ||
171
+ closed ||
172
+ client === undefined ||
173
+ client.securityLifecycle === 'preflight' ||
174
+ intent.kind === 'none'
175
+ ) {
164
176
  return;
165
177
  }
166
178
  if (intent.kind === 'background') {
@@ -303,6 +315,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
303
315
  ...(config.encryption !== undefined
304
316
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
305
317
  : {}),
318
+ ...(config.securityPreflight !== undefined
319
+ ? { securityPreflight: config.securityPreflight }
320
+ : {}),
306
321
  onSyncNeeded: (reason) => {
307
322
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
308
323
  consumeSyncIntent({ kind: 'interactive' });
@@ -344,6 +359,21 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
344
359
  }
345
360
 
346
361
  const api: WorkerApi = {
362
+ securityLifecycle: () => requireClient().securityLifecycle,
363
+ beginSecurityPreflight: async () => {
364
+ if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
365
+ backgroundTimer = undefined;
366
+ backgroundDue = Number.POSITIVE_INFINITY;
367
+ autoSyncScheduled = false;
368
+ await requireClient().beginSecurityPreflight();
369
+ },
370
+ activateSecurity: async (options = {}) => {
371
+ await requireClient().activateSecurity({
372
+ ...(options.encryption !== undefined
373
+ ? { encryption: encryptionConfigFromKeyring(options.encryption) }
374
+ : {}),
375
+ });
376
+ },
347
377
  subscribe: (input) => requireClient().subscribe(input),
348
378
  unsubscribe: (id) => requireClient().unsubscribe(id),
349
379
  setWindow: async (base, units) => {
@@ -32,6 +32,7 @@ import type {
32
32
  QuerySnapshot,
33
33
  RejectionRecord,
34
34
  SchemaFloor,
35
+ SecurityLifecycle,
35
36
  SubscribeInput,
36
37
  SyncClientLimits,
37
38
  SyncSummary,
@@ -87,6 +88,7 @@ import {
87
88
  type WorkerInitConfig,
88
89
  type WorkerInitResult,
89
90
  type WorkerMethod,
91
+ type WorkerSecurityActivation,
90
92
  type WorkerToMainMessage,
91
93
  } from './worker-protocol';
92
94
 
@@ -140,6 +142,8 @@ export interface SyncClientHandleConfig {
140
142
  readonly endpoints: WorkerEndpoints;
141
143
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
142
144
  readonly encryption?: EncryptionKeyringConfig;
145
+ /** Open the worker-owned replica behind the fail-closed security gate. */
146
+ readonly securityPreflight?: boolean;
143
147
  readonly clientId?: string;
144
148
  readonly limits?: SyncClientLimits;
145
149
  /** Worker-side host loop (§8.4); default true. */
@@ -423,6 +427,18 @@ export class SyncClientHandle {
423
427
  return this.#call('subscribe', [input]);
424
428
  }
425
429
 
430
+ securityLifecycle(): Promise<SecurityLifecycle> {
431
+ return this.#call('securityLifecycle', []);
432
+ }
433
+
434
+ beginSecurityPreflight(): Promise<void> {
435
+ return this.#call('beginSecurityPreflight', []);
436
+ }
437
+
438
+ activateSecurity(options: WorkerSecurityActivation = {}): Promise<void> {
439
+ return this.#call('activateSecurity', [options]);
440
+ }
441
+
426
442
  unsubscribe(id: string): Promise<void> {
427
443
  return this.#call('unsubscribe', [id]);
428
444
  }
@@ -724,6 +740,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
724
740
  ...(config.encryption !== undefined
725
741
  ? { encryption: config.encryption }
726
742
  : {}),
743
+ ...(config.securityPreflight !== undefined
744
+ ? { securityPreflight: config.securityPreflight }
745
+ : {}),
727
746
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
728
747
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
729
748
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
@@ -1007,6 +1026,13 @@ async function bootFollower(
1007
1026
  } catch {
1008
1027
  /* bind timed out — return the (degraded but functional) handle anyway */
1009
1028
  }
1029
+ // Init configuration belongs to the one leader worker. A newly opened
1030
+ // follower that explicitly requests security preflight must therefore put
1031
+ // the shared origin replica behind the same barrier before it is returned;
1032
+ // otherwise an already-running leader would silently ignore the request.
1033
+ if (config.securityPreflight === true) {
1034
+ await handle.beginSecurityPreflight();
1035
+ }
1010
1036
  return handle;
1011
1037
  }
1012
1038
 
@@ -27,6 +27,7 @@ import type {
27
27
  QuerySnapshot,
28
28
  RejectionRecord,
29
29
  SchemaFloor,
30
+ SecurityLifecycle,
30
31
  SubscribeInput,
31
32
  SyncClientLimits,
32
33
  SyncSummary,
@@ -100,6 +101,8 @@ export interface WorkerInitConfig {
100
101
  readonly endpoints: WorkerEndpoints;
101
102
  /** Portable raw keyring installed inside the worker-owned client core. */
102
103
  readonly encryption?: EncryptionKeyringConfig;
104
+ /** Open the worker-owned replica behind the fail-closed security gate. */
105
+ readonly securityPreflight?: boolean;
103
106
  readonly clientId?: string;
104
107
  readonly limits?: SyncClientLimits;
105
108
  /**
@@ -115,11 +118,19 @@ export interface WorkerInitResult {
115
118
  readonly clientId: string;
116
119
  }
117
120
 
121
+ /** Structured-clone-safe key material installed at security activation. */
122
+ export interface WorkerSecurityActivation {
123
+ readonly encryption?: EncryptionKeyringConfig;
124
+ }
125
+
118
126
  // ---------------------------------------------------------------------------
119
127
  // The logical API — the one shared shape (worker implements, handle projects)
120
128
  // ---------------------------------------------------------------------------
121
129
 
122
130
  export interface WorkerApi {
131
+ securityLifecycle(): SecurityLifecycle;
132
+ beginSecurityPreflight(): Promise<void>;
133
+ activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
123
134
  subscribe(input: SubscribeInput): void;
124
135
  unsubscribe(id: string): void;
125
136
  /** §4.8 windowed subscriptions: set the live units for a window base. */