@syncular/client 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 +22 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +123 -22
- package/dist/worker-entry.js +28 -2
- package/dist/worker-host.d.ts +7 -2
- package/dist/worker-host.js +19 -0
- package/dist/worker-protocol.d.ts +10 -1
- package/package.json +3 -3
- package/src/client.ts +175 -23
- package/src/worker-entry.ts +32 -2
- package/src/worker-host.ts +26 -0
- package/src/worker-protocol.ts +11 -0
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
848
|
+
this.#requireActive();
|
|
761
849
|
return loadSubscriptions(this.#db);
|
|
762
850
|
}
|
|
763
851
|
subscription(id) {
|
|
764
|
-
this.#
|
|
852
|
+
this.#requireActive();
|
|
765
853
|
return getSubscription(this.#db, id);
|
|
766
854
|
}
|
|
767
855
|
pendingCommits() {
|
|
768
|
-
this.#
|
|
856
|
+
this.#requireActive();
|
|
769
857
|
return listOutbox(this.#db);
|
|
770
858
|
}
|
|
771
859
|
// -- subscriptions ----------------------------------------------------------
|
|
772
860
|
subscribe(input) {
|
|
773
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1493
|
-
this.#
|
|
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
|
-
|
|
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
|
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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) => {
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -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>;
|
package/dist/worker-host.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.15.16",
|
|
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.
|
|
84
|
+
"@syncular/core": "0.15.16"
|
|
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.
|
|
95
|
+
"@syncular/server": "0.15.16",
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
1423
|
+
this.#requireActive();
|
|
1293
1424
|
return loadSubscriptions(this.#db);
|
|
1294
1425
|
}
|
|
1295
1426
|
|
|
1296
1427
|
subscription(id: string): SubscriptionRecord | undefined {
|
|
1297
|
-
this.#
|
|
1428
|
+
this.#requireActive();
|
|
1298
1429
|
return getSubscription(this.#db, id);
|
|
1299
1430
|
}
|
|
1300
1431
|
|
|
1301
1432
|
pendingCommits(): OutboxCommit[] {
|
|
1302
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
2163
|
-
this.#
|
|
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
|
-
|
|
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
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -143,7 +143,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
143
143
|
|
|
144
144
|
function runAutoSync(): void {
|
|
145
145
|
autoSyncScheduled = false;
|
|
146
|
-
if (
|
|
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 (
|
|
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) => {
|
package/src/worker-host.ts
CHANGED
|
@@ -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
|
|
package/src/worker-protocol.ts
CHANGED
|
@@ -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. */
|