@syncular/client 0.15.13 → 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/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);
@@ -835,6 +936,7 @@ export class SyncClient {
835
936
 
836
937
  #statusSnapshot(): SyncStatusSnapshot {
837
938
  return {
939
+ currentSchemaVersion: this.#config.schema.version,
838
940
  outbox: listOutbox(this.#db).length,
839
941
  upgrading: this.#upgrading,
840
942
  leaseState: this.#leaseState,
@@ -900,6 +1002,17 @@ export class SyncClient {
900
1002
  return next;
901
1003
  }
902
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
+
903
1016
  // -- blobs (§5.9) ---------------------------------------------------------
904
1017
 
905
1018
  /**
@@ -909,7 +1022,14 @@ export class SyncClient {
909
1022
  * a `blob_ref` column of a mutation. The referencing row MUST be written
910
1023
  * (via `mutate`) after this call so upload-before-push holds (§5.9.3).
911
1024
  */
912
- 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(
913
1033
  bytes: Uint8Array,
914
1034
  options?: { readonly mediaType?: string; readonly name?: string },
915
1035
  ): Promise<BlobRef> {
@@ -949,7 +1069,11 @@ export class SyncClient {
949
1069
  * transport (§5.9.5), verifies the content address, caches, and returns.
950
1070
  * Accepts a raw `blob_ref` column string or a bare `blobId`.
951
1071
  */
952
- 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> {
953
1077
  const blobId = blobIdOrRef.startsWith('sha256:')
954
1078
  ? blobIdOrRef
955
1079
  : parseBlobRef(blobIdOrRef).blobId;
@@ -1020,7 +1144,11 @@ export class SyncClient {
1020
1144
  }
1021
1145
 
1022
1146
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
1023
- async flushBlobUploads(): Promise<void> {
1147
+ flushBlobUploads(): Promise<void> {
1148
+ return this.#runProtectedAsync(() => this.#flushBlobUploads());
1149
+ }
1150
+
1151
+ async #flushBlobUploads(): Promise<void> {
1024
1152
  const transport = this.#config.blobs;
1025
1153
  if (transport === undefined || !this.#hasBlobs) return;
1026
1154
  for (const pending of listPendingUploads(this.#db)) {
@@ -1085,22 +1213,24 @@ export class SyncClient {
1085
1213
  }
1086
1214
 
1087
1215
  get conflicts(): readonly ConflictRecord[] {
1216
+ this.#requireActive();
1088
1217
  return this.#conflicts;
1089
1218
  }
1090
1219
 
1091
1220
  get rejections(): readonly RejectionRecord[] {
1221
+ this.#requireActive();
1092
1222
  return this.#rejections;
1093
1223
  }
1094
1224
 
1095
1225
  /** One durable final outcome by the originating client commit id. */
1096
1226
  commitOutcome(clientCommitId: string): CommitOutcome | undefined {
1097
- this.#requireStarted();
1227
+ this.#requireActive();
1098
1228
  return readCommitOutcome(this.#db, clientCommitId);
1099
1229
  }
1100
1230
 
1101
1231
  /** Newest-first durable outcome journal. */
1102
1232
  commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
1103
- this.#requireStarted();
1233
+ this.#requireActive();
1104
1234
  return listCommitOutcomes(this.#db, query);
1105
1235
  }
1106
1236
 
@@ -1111,7 +1241,7 @@ export class SyncClient {
1111
1241
  * dismissed. The transition is one-way and survives restart.
1112
1242
  */
1113
1243
  resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
1114
- this.#requireStarted();
1244
+ this.#requireActive();
1115
1245
  const current = readCommitOutcome(this.#db, input.clientCommitId);
1116
1246
  if (current === undefined) {
1117
1247
  throw new ClientSyncError(
@@ -1225,12 +1355,14 @@ export class SyncClient {
1225
1355
  * Ephemeral — reflects only what the socket has delivered.
1226
1356
  */
1227
1357
  presence(scopeKey: string): readonly PresencePeer[] {
1358
+ this.#requireActive();
1228
1359
  const peers = this.#presence.get(scopeKey);
1229
1360
  return peers === undefined ? [] : [...peers.values()];
1230
1361
  }
1231
1362
 
1232
1363
  /** Every scope key this client currently has presence state for. */
1233
1364
  presenceKeys(): string[] {
1365
+ this.#requireActive();
1234
1366
  return [...this.#presence.keys()];
1235
1367
  }
1236
1368
 
@@ -1255,7 +1387,7 @@ export class SyncClient {
1255
1387
  * by the server with `presence.forbidden`.
1256
1388
  */
1257
1389
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
1258
- this.#requireStarted();
1390
+ this.#requireActive();
1259
1391
  const socket = this.#socket;
1260
1392
  if (socket === undefined) {
1261
1393
  throw new ClientSyncError(
@@ -1288,24 +1420,24 @@ export class SyncClient {
1288
1420
  }
1289
1421
 
1290
1422
  subscriptions(): SubscriptionRecord[] {
1291
- this.#requireStarted();
1423
+ this.#requireActive();
1292
1424
  return loadSubscriptions(this.#db);
1293
1425
  }
1294
1426
 
1295
1427
  subscription(id: string): SubscriptionRecord | undefined {
1296
- this.#requireStarted();
1428
+ this.#requireActive();
1297
1429
  return getSubscription(this.#db, id);
1298
1430
  }
1299
1431
 
1300
1432
  pendingCommits(): OutboxCommit[] {
1301
- this.#requireStarted();
1433
+ this.#requireActive();
1302
1434
  return listOutbox(this.#db);
1303
1435
  }
1304
1436
 
1305
1437
  // -- subscriptions ----------------------------------------------------------
1306
1438
 
1307
1439
  subscribe(input: SubscribeInput): void {
1308
- this.#requireStarted();
1440
+ this.#requireActive();
1309
1441
  if (!this.#schema.tables.has(input.table)) {
1310
1442
  throw new ClientSyncError(
1311
1443
  'sync.unknown_table',
@@ -1333,7 +1465,7 @@ export class SyncClient {
1333
1465
  }
1334
1466
 
1335
1467
  unsubscribe(id: string): void {
1336
- this.#requireStarted();
1468
+ this.#requireActive();
1337
1469
  deleteSubscription(this.#db, id);
1338
1470
  }
1339
1471
 
@@ -1361,7 +1493,7 @@ export class SyncClient {
1361
1493
  base: WindowBase,
1362
1494
  units: readonly string[],
1363
1495
  ): Promise<CommandResult<void>> {
1364
- this.#requireStarted();
1496
+ this.#requireActive();
1365
1497
  const table = this.#table(base.table);
1366
1498
  if (!table.scopeColumnByVariable.has(base.variable)) {
1367
1499
  throw new ClientSyncError(
@@ -1428,7 +1560,7 @@ export class SyncClient {
1428
1560
  * advances past -1 with no resume token held).
1429
1561
  */
1430
1562
  windowState(base: WindowBase): WindowState {
1431
- this.#requireStarted();
1563
+ this.#requireActive();
1432
1564
  const baseKey = windowBaseKey(base);
1433
1565
  const live = loadWindowUnits(this.#db, baseKey);
1434
1566
  const pending: string[] = [];
@@ -1539,7 +1671,7 @@ export class SyncClient {
1539
1671
  mutations: readonly MutationInput[],
1540
1672
  changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
1541
1673
  ): string {
1542
- this.#requireStarted();
1674
+ this.#requireActive();
1543
1675
  const clientCommitId = crypto.randomUUID();
1544
1676
  const operations: OutboxOperation[] = mutations.map((mutation, index) => {
1545
1677
  const table = this.#table(mutation.table);
@@ -1616,7 +1748,7 @@ export class SyncClient {
1616
1748
  partial: Readonly<Record<string, unknown>>,
1617
1749
  options?: { readonly baseVersion?: number },
1618
1750
  ): string {
1619
- this.#requireStarted();
1751
+ this.#requireActive();
1620
1752
  const compiled = this.#table(table);
1621
1753
  const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
1622
1754
  const rows = this.#db.query(
@@ -1950,7 +2082,7 @@ export class SyncClient {
1950
2082
  * `setWindow` at an await point.
1951
2083
  */
1952
2084
  sync(): Promise<SyncSummary> {
1953
- this.#requireStarted();
2085
+ this.#requireActive();
1954
2086
  if (this.#syncOutstanding) {
1955
2087
  return Promise.reject(
1956
2088
  new ClientSyncError(
@@ -2158,8 +2290,11 @@ export class SyncClient {
2158
2290
 
2159
2291
  // -- realtime (§8 client side) ----------------------------------------------
2160
2292
 
2161
- async connectRealtime(): Promise<void> {
2162
- this.#requireStarted();
2293
+ connectRealtime(): Promise<void> {
2294
+ return this.#runProtectedAsync(() => this.#connectRealtime());
2295
+ }
2296
+
2297
+ async #connectRealtime(): Promise<void> {
2163
2298
  const connector = this.#config.realtime;
2164
2299
  if (connector === undefined) {
2165
2300
  throw new ClientSyncError(
@@ -2167,7 +2302,7 @@ export class SyncClient {
2167
2302
  'no realtime connector configured',
2168
2303
  );
2169
2304
  }
2170
- this.#socket = await connector({
2305
+ const socket = await connector({
2171
2306
  onText: (text) => this.#handleRealtimeText(text),
2172
2307
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
2173
2308
  onClose: () => {
@@ -2176,6 +2311,14 @@ export class SyncClient {
2176
2311
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2177
2312
  },
2178
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;
2179
2322
  }
2180
2323
 
2181
2324
  disconnectRealtime(): void {
@@ -3335,4 +3478,14 @@ export class SyncClient {
3335
3478
  );
3336
3479
  }
3337
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
+ }
3338
3491
  }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply';
12
+ export * from './availability';
12
13
  export * from './blob';
13
14
  export * from './client';
14
15
  export * from './content-type';
@@ -24,6 +24,7 @@ export interface WindowChange {
24
24
  }
25
25
 
26
26
  export interface SyncStatusSnapshot {
27
+ readonly currentSchemaVersion: number;
27
28
  readonly outbox: number;
28
29
  readonly upgrading: boolean;
29
30
  readonly leaseState: LeaseState | undefined;