@korajs/server 1.0.0-beta.11 → 1.0.0-beta.13

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/dist/index.d.cts CHANGED
@@ -1,7 +1,9 @@
1
- import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, OperationTransform, VersionVector, TimeSource } from '@korajs/core';
1
+ import { KoraError, Operation, VersionVector, HLCTimestamp, RecordFieldVersions, FoldState, HybridLogicalClock, SchemaDefinition, OperationTransform, BlobRef, KoraEventEmitter, TimeSource, ScopeMap as ScopeMap$1 } from '@korajs/core';
2
+ export { SCOPE_CLAIMS_KEY, claimScopes } from '@korajs/core';
2
3
  import * as _korajs_sync from '@korajs/sync';
3
- import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
4
- import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-CU1BLWgr.cjs';
4
+ import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, EncryptionKeyRequestMessage, EncryptionKeyPutMessage, EncryptionKeyResponseMessage, WrappedKeyRecord, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
5
+ import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-BGQkRCI4.cjs';
6
+ import { SideEffectOp } from '@korajs/merge';
5
7
  import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
6
8
  import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
7
9
 
@@ -36,7 +38,15 @@ type RoutePredicate = Record<string, RoutePredicateOperators>;
36
38
  */
37
39
  type ScopeMap = Record<string, Record<string, unknown>>;
38
40
  declare const DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
39
- /** Canonicalize bounded `$in` predicates so equivalent authorization has one signature. */
41
+ /**
42
+ * Canonicalize bounded `$in` predicates so equivalent authorization has one signature.
43
+ *
44
+ * Fails closed: an `undefined`/`null` predicate value (also inside `$in`) would match
45
+ * every record lacking the field, so it is refused (RT-8).
46
+ *
47
+ * @throws {InvalidScopePredicateError} On an undefined/null predicate value
48
+ * @throws {ScopePredicateLimitError} On a malformed or oversized `$in`
49
+ */
40
50
  declare function normalizeScopeMap(scopes: ScopeMap, maxValues?: number): ScopeMap;
41
51
  /**
42
52
  * Returns true if an operation is visible to a session based on its scopes.
@@ -47,7 +57,91 @@ declare function normalizeScopeMap(scopes: ScopeMap, maxValues?: number): ScopeM
47
57
  * - All scoped field/value pairs must match the operation snapshot
48
58
  */
49
59
  declare function operationMatchesScopes(op: Operation, scopes: ScopeMap | undefined, fullRecord?: Record<string, unknown> | null): boolean;
60
+ /** Why {@link authorizeUplinkWrite} refused a write. */
61
+ type UplinkAuthorizationCode = 'SCOPE_VIOLATION' | 'INVALID_OPERATION';
62
+ /** Result of {@link authorizeUplinkWrite}. */
63
+ type UplinkAuthorizationResult = {
64
+ allowed: true;
65
+ } | {
66
+ allowed: false;
67
+ code: UplinkAuthorizationCode;
68
+ message: string;
69
+ };
70
+ /**
71
+ * Thrown by a server store when the uplink authorization re-check, evaluated inside
72
+ * the store's apply critical section against the row as it is at commit time,
73
+ * refuses an operation. Nothing was written.
74
+ */
75
+ declare class UplinkAuthorizationError extends KoraError {
76
+ readonly rejectionCode: UplinkAuthorizationCode;
77
+ constructor(rejectionCode: UplinkAuthorizationCode, message: string, context?: Record<string, unknown>);
78
+ }
79
+ /**
80
+ * Decides whether an untrusted writer (a sync session or a scoped route) may apply
81
+ * `op`, given the record as it is currently stored and the writer's scope.
82
+ *
83
+ * This is the single authorization rule for every untrusted write path (sync
84
+ * uploads, `request.kora` routes, the conditional store path and the Yjs doc
85
+ * channel). It never reads `op.previousData` for scope decisions, because it is
86
+ * attacker-controlled, and it never lets op fields name a different record:
87
+ *
88
+ * 1. `op.data` / `op.previousData` must not carry an `id` other than `op.recordId`
89
+ * (`INVALID_OPERATION`), whether or not scopes are configured.
90
+ * 2. Without scopes every (well-formed) write is allowed.
91
+ * 3. If a stored row exists (including a soft-deleted one) it must be in scope,
92
+ * judged on the stored values only, for inserts, updates and deletes alike. This
93
+ * stops editing, deleting or overwriting (same-id insert) another tenant's record.
94
+ * 4. The post-image must be in scope: `{...stored, ...op.data}` for updates,
95
+ * `op.data` for inserts and the stored row for deletes. This stops moving a
96
+ * record out of scope (ownership transfer is a trusted server-route operation).
97
+ *
98
+ * In both images `id` is `op.recordId`, assigned last.
99
+ *
100
+ * @param op - The operation the writer wants to apply
101
+ * @param storedRow - The record as stored now (including soft-deleted), or null
102
+ * @param scopes - The writer's uplink scope map, or undefined for no restriction
103
+ * @returns Whether the write is allowed, and why not when it is refused
104
+ */
105
+ declare function authorizeUplinkWrite(op: Operation, storedRow: Record<string, unknown> | null | undefined, scopes: ScopeMap | undefined): UplinkAuthorizationResult;
50
106
 
107
+ /** A stored legacy pair, reported to {@link ApplyRemoteOptions.onLegacySequencePair}. */
108
+ interface LegacySequencePair {
109
+ operationId: string;
110
+ nodeId: string;
111
+ sequenceNumber: number;
112
+ /** The operations that already held the sequence. */
113
+ holderIds: string[];
114
+ /** True when accepted because the writer is a legacy client (not a pre-epoch holder). */
115
+ legacyWriter: boolean;
116
+ }
117
+ /**
118
+ * How the server resolved an uploaded operation it did NOT store under the submitted
119
+ * (node, sequence) (RT-43, RT-47):
120
+ * - `'ignored'`: the validator answered `ignore` (handled out of band, nothing stored);
121
+ * - `'refused'`: a terminal (non-retriable) rejection; the same id is answered with the
122
+ * original rejection forever after;
123
+ * - `'stored-elsewhere'`: the id is stored under another sequence of the same node (a
124
+ * client sequence repair renumbered it), acknowledged as a duplicate.
125
+ * A retriable rejection, and a `SEQUENCE_CONFLICT` (the client renumbers and resubmits
126
+ * under the same id), are never resolutions.
127
+ */
128
+ type OperationResolutionOutcome = 'ignored' | 'refused' | 'stored-elsewhere';
129
+ /** A durable record of how an uploaded operation was resolved without being stored. */
130
+ interface OperationResolution {
131
+ operationId: string;
132
+ nodeId: string;
133
+ sequenceNumber: number;
134
+ outcome: OperationResolutionOutcome;
135
+ /** Rejection code, for `'refused'`. */
136
+ code: string | null;
137
+ /** Rejection message, for `'refused'`. */
138
+ message: string | null;
139
+ }
140
+ /** Where a stored operation sits in its node's sequence space. */
141
+ interface StoredOperationKey {
142
+ nodeId: string;
143
+ sequenceNumber: number;
144
+ }
51
145
  /**
52
146
  * A materialized record reconstructed from the operation log
53
147
  * or read from a materialized collection table.
@@ -100,6 +194,12 @@ interface ConditionalApplyContext {
100
194
  * it into operation creation) so they sort strictly after every prior write.
101
195
  */
102
196
  clock: HybridLogicalClock;
197
+ /**
198
+ * Read a record as stored (including a soft-deleted one) inside the store's
199
+ * conditional-apply transaction, for authorizing the built operations against the
200
+ * same state they commit over. Optional; callers fall back to a plain read.
201
+ */
202
+ readStoredRow?: (collection: string, id: string) => Promise<MaterializedRecord | null>;
103
203
  }
104
204
  /** Result of {@link ServerStore.applyConditional}. */
105
205
  interface ConditionalApplyResult {
@@ -119,6 +219,30 @@ interface ConditionalApplyResult {
119
219
  interface DeliveredOperation {
120
220
  operation: Operation;
121
221
  deliverySequence: number;
222
+ /**
223
+ * The record's scope-relevant values around this operation, captured from the
224
+ * server's own rows when it was applied (RT-14, RT-15). Absent (or null) for
225
+ * operations stored before snapshots existed that could not be backfilled.
226
+ */
227
+ scopeSnapshot?: OperationScopeSnapshot | null;
228
+ }
229
+ /**
230
+ * Scope-relevant field values of a record, captured by the store from its own rows
231
+ * when an operation is applied (never from the writer's `previousData`). Download
232
+ * visibility of a historical operation is judged on `post`, so a later ownership
233
+ * transfer does not disclose the record's earlier history to the new owner (RT-14),
234
+ * and a scope exit is judged from `pre` to `post` (RT-15).
235
+ *
236
+ * Only scalar fields (string, number, boolean, enum, timestamp) and `id` are kept:
237
+ * scope predicates compare with `Object.is`, so other kinds can never match. A
238
+ * string longer than {@link MAX_SCOPE_SNAPSHOT_STRING_LENGTH} is left out (fails
239
+ * closed for a scope on such a field) so a snapshot never copies large text.
240
+ */
241
+ interface OperationScopeSnapshot {
242
+ /** Values before the operation; null when the record did not exist or was deleted. */
243
+ pre: Record<string, unknown> | null;
244
+ /** Values after it; a delete keeps the record's last values; null when unknown. */
245
+ post: Record<string, unknown> | null;
122
246
  }
123
247
  /**
124
248
  * Options for querying a materialized collection table.
@@ -137,11 +261,266 @@ interface CollectionQueryOptions {
137
261
  /** Include soft-deleted records (default: false) */
138
262
  includeDeleted?: boolean;
139
263
  }
264
+ /**
265
+ * Options for {@link ServerStore.applyRemoteOperation}.
266
+ */
267
+ interface ApplyRemoteOptions {
268
+ /**
269
+ * Authorization re-check for an operation submitted by an untrusted writer.
270
+ *
271
+ * The store calls it inside its apply critical section (the SQLite write
272
+ * transaction, the in-memory store's synchronous apply, or a Postgres
273
+ * transaction holding the per-record advisory lock shared with conditional
274
+ * applies) with the record as stored at that moment, including a soft-deleted
275
+ * one, or null when none exists. Returning a refusal aborts the apply and the
276
+ * store throws {@link UplinkAuthorizationError}; nothing is written.
277
+ *
278
+ * This closes the window between a caller's own pre-check and the write, where an
279
+ * ownership change or a same-id insert committed by another writer (or another
280
+ * server instance) could otherwise slip in.
281
+ */
282
+ authorize?: (storedRow: MaterializedRecord | null) => UplinkAuthorizationResult;
283
+ /**
284
+ * The writer does not reserve its sequence numbers inside the write transaction
285
+ * (a client that did not advertise the `sequenceReservation` handshake capability,
286
+ * Kora <= beta.12; RT-37). A different operation already holding the
287
+ * `(nodeId, sequenceNumber)` then does not refuse this one: both are stored as a
288
+ * legacy pair (each keeps its own delivery sequence, so both are delivered) instead
289
+ * of throwing {@link SequenceConflictError}. Default false: enforce.
290
+ */
291
+ legacySequenceWriter?: boolean;
292
+ /**
293
+ * Called once the operation was committed as the second holder of its sequence (a
294
+ * legacy pair), for logging and diagnostics. Never called for a refused write.
295
+ */
296
+ onLegacySequencePair?: (pair: LegacySequencePair) => void;
297
+ }
298
+ /** Options of {@link ServerStore.setSchema}. */
299
+ interface ServerSchemaOptions {
300
+ /** Schema transforms the store folds with (transforms at fold time, RT-84). */
301
+ operationTransforms?: readonly OperationTransform[];
302
+ }
303
+ /** One stored end-to-end key record (opaque JSON) and its owner, as backups carry it. */
304
+ interface EncryptionKeyRecordRow {
305
+ owner: string;
306
+ keyring: string;
307
+ revision: number;
308
+ /** The record's JSON, exactly as stored. */
309
+ record: string;
310
+ }
140
311
  /**
141
312
  * Server-side store interface. Extends SyncStore with lifecycle,
142
313
  * introspection, and materialization methods needed by the sync server.
143
314
  */
144
315
  interface ServerStore extends SyncStore {
316
+ /**
317
+ * Apply an operation to the log and materialized state. With
318
+ * `options.authorize`, the authorization is re-checked atomically with the write
319
+ * (see {@link ApplyRemoteOptions}). Stores written before this option existed
320
+ * may ignore it; callers always pre-check as well.
321
+ *
322
+ * Built-in stores throw {@link SequenceConflictError} when a different operation
323
+ * already holds the operation's `(nodeId, sequenceNumber)` under enforcement
324
+ * (unless `options.legacySequenceWriter`, see {@link judgeSequenceHolders}), and return
325
+ * `'duplicate'` (writing nothing) for an operation id already stored, decided
326
+ * atomically with the write.
327
+ */
328
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
329
+ /**
330
+ * The version vector as committed in the shared database, read fresh (SRV-4). A
331
+ * store shared by several server instances must implement it: its synchronous
332
+ * {@link SyncStore.getVersionVector} can only reflect this instance's own writes.
333
+ * Optional; callers fall back to `getVersionVector()`.
334
+ */
335
+ readVersionVector?(): Promise<VersionVector>;
336
+ /**
337
+ * Several records of one collection by id, as stored (soft-deleted ones included),
338
+ * in one read: `id = ANY(...)` on Postgres, chunked `IN (...)` on SQLite (LMS #11).
339
+ * Ids with no row are absent from the result. Optional; the delivery stream falls
340
+ * back to one lookup per operation without it.
341
+ */
342
+ findRecordsByIds?(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
343
+ /**
344
+ * Which of `ids` the operation log already holds, with the node and sequence each is
345
+ * stored under, in one read per batch (chunked `IN (...)`). The sync session asks before any other per-operation check, so a
346
+ * device re-uploading history the server already stores (the one-time upgrade
347
+ * re-upload, or an op the client's sequence repair renumbered under its original
348
+ * id) is acknowledged as a duplicate instead of being re-judged by today's
349
+ * authorization and validators. Optional; sessions fall back to a per-operation
350
+ * (node, sequence) lookup without it.
351
+ */
352
+ findStoredOperations?(ids: string[]): Promise<Map<string, StoredOperationKey>>;
353
+ /**
354
+ * Durably record how an uploaded operation was resolved without being stored under
355
+ * its sequence (RT-43, RT-47; see {@link OperationResolution}). Idempotent per
356
+ * operation id: the first resolution wins. The session awaits it BEFORE answering the
357
+ * client, so a client never acts on a resolution the server could forget. Optional
358
+ * for custom stores: without it an ignored tail op may be re-validated at reconnect
359
+ * and a refused id re-judged on resubmission.
360
+ */
361
+ recordOperationResolution?(resolution: OperationResolution): Promise<void>;
362
+ /**
363
+ * The resolutions recorded for `ids` under `nodeId` (ids resolved under another node
364
+ * are absent: the answer never crosses devices or tenants).
365
+ */
366
+ findOperationResolutions?(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
367
+ /** The highest sequence number of `nodeId` with a recorded resolution (0 when none). */
368
+ getResolvedThrough?(nodeId: string): Promise<number>;
369
+ /**
370
+ * Forget the resolution recorded for `operationId` under `nodeId` (no-op when none).
371
+ * The session calls it for a stale `stored-elsewhere` record: one whose id the batch
372
+ * lookup did not find stored (RT-51). The operation is then judged normally, and its
373
+ * real outcome is recorded in place of the stale one. Optional for custom stores:
374
+ * without it the stale record is ignored but kept.
375
+ */
376
+ deleteOperationResolution?(nodeId: string, operationId: string): Promise<void>;
377
+ /**
378
+ * Every operation of `nodeId` at or below `throughSequence` that shares its sequence
379
+ * with another stored operation (a legacy pair, RT-37), ordered by sequence then
380
+ * delivery. A version-vector client reporting `throughSequence` for the node may hold
381
+ * only one of a pair, and a range read above its entry can never return the other
382
+ * (RT-48). Optional; without it such clients miss the second op of a pair.
383
+ */
384
+ getSequencePairOperations?(nodeId: string, throughSequence: number): Promise<Operation[]>;
385
+ /**
386
+ * The distinct node ids of the operations with `deliverySequence >
387
+ * afterDeliverySequence`. Optional; lets a handshake stop judging which nodes a
388
+ * scoped client will hear from as soon as every candidate was found visible.
389
+ */
390
+ getNodeIdsAfterDelivery?(afterDeliverySequence: number): Promise<string[]>;
391
+ /**
392
+ * Bind a client node id to the authenticated principal that first used it.
393
+ * Returns true when the node id is already owned by `userId`, was released by an
394
+ * admin (see {@link releaseNodeClaim}; the caller takes it over), or is unclaimed
395
+ * AND has no operation history. Returns false when another principal owns it, or
396
+ * when it is unclaimed but already has operations in the log: history written
397
+ * before node claims existed has no recorded writer, so nobody may adopt it
398
+ * until an admin releases it (RT-5). Called at handshake when auth is configured,
399
+ * so one user cannot upload operations under another user's device id. Every
400
+ * write after this check is preceded by a claim, so going forward the claim row
401
+ * records the writer of a node's history. Must be atomic per node id. Optional
402
+ * for custom stores; the built-in stores persist the claim.
403
+ */
404
+ claimNode?(nodeId: string, userId: string): Promise<boolean>;
405
+ /**
406
+ * Admin release of a node id (RT-5): the next principal to claim it takes it
407
+ * over, even when the node has operation history. Returns true when the node had
408
+ * a claim or history to release, false when it was unknown.
409
+ */
410
+ releaseNodeClaim?(nodeId: string): Promise<boolean>;
411
+ /**
412
+ * The owner a node id is currently claimed by, or null when unclaimed (RT-21).
413
+ * Optional; anonymous provisional claims need it together with
414
+ * {@link replaceNodeClaim}.
415
+ */
416
+ getNodeClaimOwner?(nodeId: string): Promise<string | null>;
417
+ /**
418
+ * Atomically replace the owner of a node claim, only if it is still
419
+ * `expectedOwner` (compare-and-set, RT-21). Returns true when replaced. Used to
420
+ * confirm or re-issue an anonymous device's provisional claim; never creates one.
421
+ */
422
+ replaceNodeClaim?(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
423
+ /**
424
+ * The wrapped encryption key record (JSON) of one owner's keyring, or null (ENC-1).
425
+ * The record holds only salt, KDF parameters and wrapped keys: never a usable key.
426
+ * Optional for custom stores; without it the server answers key requests with
427
+ * `unsupported` (it never keeps key records in memory only, which would fork a
428
+ * user's keys after a restart).
429
+ */
430
+ getEncryptionKeyRecord?(owner: string, keyring: string): Promise<string | null>;
431
+ /**
432
+ * Write a key record with compare-and-set: store `record` at `revision` only when the
433
+ * stored revision is `expectedRevision` (0: no record yet). Returns false when
434
+ * another write won. Must be atomic per (owner, keyring) across server instances.
435
+ */
436
+ putEncryptionKeyRecord?(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
437
+ /**
438
+ * Stored key records: every one (for `exportBackup`; RT-104: a server restored from
439
+ * its backup must still hold the records its encrypted history needs), or one owner's
440
+ * (the key service tells another keyring's history from a lost record with it).
441
+ * Optional.
442
+ */
443
+ listEncryptionKeyRecords?(owner?: string): Promise<EncryptionKeyRecordRow[]>;
444
+ /**
445
+ * Key ids named by stored encrypted operations (their envelope's `keyId`), a sample
446
+ * of at most `limit` distinct ids (RT-104). `nodeOwner` restricts it to operations of
447
+ * nodes claimed by that principal (see {@link claimNode}); null means every node (a
448
+ * server without authentication, whose clients share one keyring). The key service
449
+ * reports them when an owner has no key record, so a new device can tell a lost
450
+ * record (encrypted history exists) from a first one. Optional; without it a new
451
+ * device cannot tell, and a fork it creates is merged later by a device holding the
452
+ * old ring.
453
+ */
454
+ getEncryptedKeyIds?(nodeOwner: string | null, limit: number): Promise<string[]>;
455
+ /**
456
+ * Record that `owner` holds the bytes behind a blob content hash (it pushed them,
457
+ * proving possession) (RT-11). Idempotent. Optional; without it the sync server
458
+ * keeps ownership in memory (lost on restart, not shared between instances).
459
+ */
460
+ recordBlobOwner?(hash: string, owner: string): Promise<void>;
461
+ /** The owners recorded for each hash (an empty list for unowned hashes). */
462
+ getBlobOwners?(hashes: string[]): Promise<Map<string, string[]>>;
463
+ /**
464
+ * Atomically make `owner` the first owner of a hash nobody owns yet. Returns true
465
+ * when `owner` owns the hash afterwards (newly, or already), false when someone
466
+ * else does. Must be atomic per hash across server instances.
467
+ */
468
+ claimBlobIfUnowned?(hash: string, owner: string): Promise<boolean>;
469
+ /**
470
+ * The scope snapshots captured when the given operations were applied (RT-14).
471
+ * Operations without one are absent from the result.
472
+ */
473
+ getOperationScopeSnapshots?(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
474
+ /**
475
+ * The greatest HLC timestamp among every stored operation of one record (its
476
+ * newest field write), or null when the record has no operations (RT-19). A
477
+ * scope-entry operation is stamped with it so it never overrides newer client data.
478
+ */
479
+ getRecordLatestTimestamp?(collection: string, recordId: string): Promise<HLCTimestamp | null>;
480
+ /**
481
+ * Per-field versions of one live record, folded from its stored operations exactly
482
+ * as the materialization folds its values (RT-27): each field's version is the HLC
483
+ * of the write that produced its current value, plus the record's first and newest
484
+ * operation. Null when the record is deleted or has no operations. A scope-entry
485
+ * operation carries them so a receiver resolves every field on its own.
486
+ */
487
+ getRecordFieldVersions?(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
488
+ /**
489
+ * The record's fold state (W7): the per-field CRDT state its row is projected from.
490
+ * Null when the record has no operations or its collection is not materialized. A
491
+ * scope-entry operation carries it (filtered to the fields the receiver may see) so
492
+ * the receiver merges richtext, counter and resolver fields exactly (RT-29).
493
+ */
494
+ getRecordFoldState?(collection: string, recordId: string): Promise<FoldState | null>;
495
+ /** Every stored operation of one record, in delivery (commit) order. */
496
+ getRecordOperations?(collection: string, recordId: string): Promise<Operation[]>;
497
+ /**
498
+ * The record's row as it would be after merging `op` into its fold state, with
499
+ * nothing written: the candidate a Tier-2 constraint check judges at ingest. Null
500
+ * when the record would not be live (deleted, or never inserted).
501
+ */
502
+ previewOperation?(op: Operation): Promise<MaterializedRecord | null>;
503
+ /**
504
+ * Node ids whose operations win `merge('server-authoritative')` fields in the fold:
505
+ * this store's own node id (every server-originated operation is authored by it)
506
+ * plus any configured extras. The sync server advertises them in the handshake so
507
+ * clients fold with the same authority.
508
+ */
509
+ getAuthoritativeNodeIds?(): string[];
510
+ /** Explicit authoritative ids the deployment revoked (RT-81); advertised at handshake. */
511
+ getRevokedAuthoritativeNodeIds?(): string[];
512
+ /**
513
+ * Every explicit id the deployment ever held authoritative, revoked ones included
514
+ * (RT-81). The session never accepts one as a device node id.
515
+ */
516
+ getEverAuthoritativeNodeIds?(): string[];
517
+ /**
518
+ * Keyed id of a server-derived operation (cascade, set-null, constraint correction):
519
+ * deterministic across every instance of the deployment, unpredictable to clients
520
+ * (RT-64). Built-in stores key it with a persisted deployment secret. A store
521
+ * without it falls back to the unkeyed `deriveSideEffectOpId`.
522
+ */
523
+ deriveServerOperationId?(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
145
524
  /** Close the store and release resources */
146
525
  close(): Promise<void>;
147
526
  /** Get the total number of stored operations */
@@ -188,10 +567,23 @@ interface ServerStore extends SyncStore {
188
567
  * tables from the operation log.
189
568
  *
190
569
  * @param schema - The schema definition describing all collections
570
+ * @param options - `operationTransforms`: the schema transforms the store folds with
571
+ * (transforms at fold time, RT-84). Pass the same list as the sync server's
572
+ * `operationTransforms`, so the startup re-materialization folds with them once.
191
573
  */
192
- setSchema(schema: SchemaDefinition): Promise<void>;
574
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
193
575
  /** Schema used for materialized tables and server-side validation, if set. */
194
576
  getSchema(): SchemaDefinition | null;
577
+ /**
578
+ * Set the schema transforms the store folds with (RT-84). Operations are stored
579
+ * exactly as uploaded; the fold merges each operation as
580
+ * `operationSchemaView(op, schema.version, transforms)` reads it. Transforms are part
581
+ * of the fold plan fingerprint: a change re-folds every record (once). The sync
582
+ * server calls this with its `operationTransforms` at construction.
583
+ */
584
+ setOperationTransforms?(transforms: readonly OperationTransform[]): Promise<void>;
585
+ /** The schema transforms the store folds with (empty when none). */
586
+ getOperationTransforms?(): readonly OperationTransform[];
195
587
  /**
196
588
  * Get all records from a materialized collection.
197
589
  * When schema is set, reads directly from the collection table (O(1) indexed).
@@ -303,6 +695,68 @@ interface ApplyServerOperationResult {
303
695
  appliedOperations: Operation[];
304
696
  /** Rejection reason when the operation was not applied */
305
697
  rejection?: OperationRejection;
698
+ /**
699
+ * Side effects of an applied delete that were NOT derived because
700
+ * {@link ApplyServerOperationOptions.isAuthoredSideEffect} claimed them (RT-69). The
701
+ * caller must derive each one with {@link deriveServerSideEffects} unless the
702
+ * author's own copy is stored.
703
+ */
704
+ deferredSideEffects?: SideEffectOp[];
705
+ }
706
+ /**
707
+ * Decides whether an untrusted writer may cause one referential side effect of its
708
+ * delete. `effect` is the side effect shaped as the operation the server would
709
+ * write; `stored` is the affected record as stored now (including soft-deleted).
710
+ */
711
+ type SideEffectAuthorizer = (effect: Operation, stored: MaterializedRecord | null) => UplinkAuthorizationResult;
712
+ /** Options for {@link applyServerOperation}. */
713
+ interface ApplyServerOperationOptions {
714
+ /**
715
+ * Uplink authorization for an untrusted writer, re-evaluated by the store inside
716
+ * its apply critical section against the row as stored at commit time (see
717
+ * `ApplyRemoteOptions.authorize`). A refusal becomes a non-retriable rejection and
718
+ * nothing is written.
719
+ */
720
+ authorize?: ApplyRemoteOptions['authorize'];
721
+ /**
722
+ * Authorization of the referential side effects (cascade delete, set-null) an
723
+ * untrusted writer's delete would cause (RT-10). Every side effect is checked
724
+ * against the writer's own scope BEFORE the delete is applied; if any falls
725
+ * outside it, the whole delete is refused and nothing is written.
726
+ *
727
+ * Design: integrity is judged on ALL children, in every tenant (a restrict or a
728
+ * cross-tenant cascade is never silently skipped, which would leave dangling
729
+ * references), but the writer learns nothing about children it cannot see: a
730
+ * restrict violation and an unauthorized side effect are refused with the same
731
+ * generic `RESTRICTED` rejection, with no ids and no counts. Setting `authorize`
732
+ * alone also switches restrict refusals to the generic form.
733
+ */
734
+ authorizeSideEffect?: SideEffectAuthorizer;
735
+ /**
736
+ * The writer is a legacy client that does not reserve sequence numbers (RT-37): a
737
+ * different operation under a held `(nodeId, sequenceNumber)` is stored as a legacy
738
+ * pair instead of being refused with `SEQUENCE_CONFLICT`. Applies to the primary
739
+ * operation only; server-originated side effects are always enforced.
740
+ */
741
+ legacySequenceWriter?: boolean;
742
+ /** See `ApplyRemoteOptions.onLegacySequencePair`; for the primary operation. */
743
+ onLegacySequencePair?: ApplyRemoteOptions['onLegacySequencePair'];
744
+ /**
745
+ * True for a referential side effect of this delete that the writer authored itself
746
+ * (its own cascade or set-null, later in the same upload, RT-69). Such an effect is
747
+ * not derived now but returned in `deferredSideEffects`, so the log stores one copy
748
+ * per child instead of the author's and the server's. Effects it does not claim (a
749
+ * child the author did not know, a legacy client that authors no cascades) are
750
+ * derived as before.
751
+ */
752
+ isAuthoredSideEffect?: (effect: SideEffectOp) => boolean;
753
+ /**
754
+ * The operation as the server schema reads it (transforms at fold time, RT-84), when
755
+ * the caller already computed it. Shape, constraint and referential checks judge the
756
+ * view; the store keeps `op` exactly as given and folds the same view. Default: the
757
+ * store's own view (`serverOperationView`).
758
+ */
759
+ view?: Operation;
306
760
  }
307
761
 
308
762
  /**
@@ -314,6 +768,17 @@ interface ServerMetricsSnapshot {
314
768
  peakConnections: number;
315
769
  connectionsTotal: number;
316
770
  operationsReceived: number;
771
+ batchesReceived: number;
772
+ uniqueOperationsReceived: number;
773
+ duplicateOperationsReceived: number;
774
+ rejectedOperations: number;
775
+ /**
776
+ * Operations from protocol-1 (beta.12) clients stored with an id that could not be
777
+ * verified (RT-71). Non-zero only while beta.12 clients write `undefined` members.
778
+ */
779
+ unverifiedLegacyOperations: number;
780
+ /** Uploads refused for reusing a stored operation id with other content (RT-77). */
781
+ forgedDuplicates: number;
317
782
  operationsSent: number;
318
783
  bytesReceived: number;
319
784
  bytesSent: number;
@@ -332,6 +797,10 @@ interface ClientMetrics {
332
797
  state: SessionState$1;
333
798
  connectedAt: number;
334
799
  operationsReceived: number;
800
+ batchesReceived: number;
801
+ uniqueOperationsReceived: number;
802
+ duplicateOperationsReceived: number;
803
+ rejectedOperations: number;
335
804
  operationsSent: number;
336
805
  authContext: AuthContext | null;
337
806
  }
@@ -340,6 +809,12 @@ declare class ServerMetricsCollector {
340
809
  private peakConnections;
341
810
  private connectionsTotal;
342
811
  private operationsReceived;
812
+ private batchesReceived;
813
+ private uniqueOperationsReceived;
814
+ private duplicateOperationsReceived;
815
+ private rejectedOperations;
816
+ private unverifiedLegacyOperations;
817
+ private forgedDuplicates;
343
818
  private operationsSent;
344
819
  private bytesReceived;
345
820
  private bytesSent;
@@ -357,9 +832,17 @@ declare class ServerMetricsCollector {
357
832
  /** Record authentication context for a session. */
358
833
  recordAuth(sessionId: string, authContext: AuthContext | null): void;
359
834
  /** Record operations received from a client. */
360
- recordReceived(sessionId: string, count: number, byteSize: number): void;
835
+ recordReceived(sessionId: string, count: number, byteSize: number, outcomes?: {
836
+ unique: number;
837
+ duplicates: number;
838
+ rejected: number;
839
+ }): void;
361
840
  /** Record operations sent to a client. */
362
841
  recordSent(sessionId: string, count: number, byteSize: number): void;
842
+ /** Record an operation stored with an unverified legacy id (RT-71). */
843
+ recordUnverifiedLegacyOperation(): void;
844
+ /** Record an upload refused for reusing a stored id with other content (RT-77). */
845
+ recordForgedDuplicate(): void;
363
846
  /** Record an error. */
364
847
  recordError(): void;
365
848
  /** Set the schema version. */
@@ -424,6 +907,210 @@ declare function createSilentLogger(): Logger;
424
907
  */
425
908
  declare function createDefaultLogger(): Logger;
426
909
 
910
+ /**
911
+ * Resolves a blob chunk by content hash from server-side storage. Optional: when
912
+ * provided, the server can answer chunk requests directly from its own store
913
+ * (central-store deployment). When absent, the server acts as a pure relay,
914
+ * forwarding chunk requests to peer sessions and their responses back.
915
+ */
916
+ type ResolveBlobChunk = (hash: string) => Promise<Uint8Array | null>;
917
+
918
+ /** Input to {@link BlobAccessIndex.authorizeReference}. */
919
+ interface BlobReferenceRequest {
920
+ /** The content hash (blob, manifest or chunk) the writer wants to reference. */
921
+ hash: string;
922
+ /** The writer's download scope, or undefined for an unscoped writer. */
923
+ scopes: ScopeMap | undefined;
924
+ /**
925
+ * Stable ownership key of the writer: the user id for a signed-in principal, the
926
+ * node-claim owner for an anonymous device, a `kora:route:` key for a scoped route.
927
+ */
928
+ owner: string;
929
+ /** Hashes the written record already references as stored (unchanged references). */
930
+ alreadyReferenced?: ReadonlySet<string>;
931
+ }
932
+ /**
933
+ * Answers "does a live record inside this download scope reference this blob
934
+ * hash?" for the blob relay (RT-1), and "may this writer reference this hash?" at
935
+ * ingest (RT-11).
936
+ *
937
+ * **A content hash is not a secret.** Hashes of well-known content can be computed
938
+ * by anyone, and they travel in records, manifests and logs. So knowing a hash never
939
+ * grants access: a write may reference a hash only when the writer can already read
940
+ * a record that references it, or has proven possession of the bytes (pushed them).
941
+ * Only in peer-relay mode (no central store, so nothing can be pre-claimed and then
942
+ * read back from the server) may the first writer of a hash nobody references or
943
+ * owns claim it by reference alone.
944
+ *
945
+ * A hash is referenced in a scope when a live (not deleted) record in scope has a
946
+ * blob field whose `BlobRef.manifestHash` (or, for a bare reference without a
947
+ * manifest, `BlobRef.hash`) equals it, or when it is a chunk listed in such a manifest and the chunk is unowned or shares an owner with
948
+ * the manifest (so a crafted manifest listing someone else's chunk hashes exposes
949
+ * nothing). Manifests are read through `resolveBlobChunk` (central store) or learned
950
+ * from manifest bytes verified in transit.
951
+ *
952
+ * Cost: the referenced set of a scope is built by a scoped query over every
953
+ * collection that declares a blob field (O(records in scope with blobs)). It is
954
+ * cached per scope, rebuilt after a write to one of the blob collections it scanned
955
+ * ({@link invalidate}) or after `ttlMs` (writes through another server instance),
956
+ * and concurrent lookups for one scope share a single rebuild (RT-17). A full hash
957
+ * index in the store would make this O(1); this is the interim, correctness-first
958
+ * form.
959
+ */
960
+ declare class BlobAccessIndex {
961
+ private readonly store;
962
+ private readonly resolveBlobChunk;
963
+ private readonly ttlMs;
964
+ private readonly byScope;
965
+ /** Rebuilds in progress per scope key, shared by concurrent lookups (RT-17). */
966
+ private readonly inflight;
967
+ /** manifestHash -> chunk hashes it lists (only from bytes verified against the hash). */
968
+ private readonly manifestChunks;
969
+ /** chunkHash -> manifests listing it. */
970
+ private readonly chunkManifests;
971
+ /** Ownership fallback for stores without blob-owner persistence. */
972
+ private readonly memoryOwners;
973
+ private globalGeneration;
974
+ private readonly collectionGeneration;
975
+ constructor(store: ServerStore, resolveBlobChunk: ResolveBlobChunk | null, ttlMs?: number);
976
+ /**
977
+ * Forget cached scope sets because records changed. With `collections`, only sets
978
+ * built from those collections are dropped (a write to a collection without blob
979
+ * fields drops nothing); without, every set is dropped (for example writes made
980
+ * through another server instance).
981
+ */
982
+ invalidate(collections?: Iterable<string>): void;
983
+ /**
984
+ * True when a live record inside `scopes` references `hash`.
985
+ *
986
+ * @param scopes - The session's download scope, or undefined for unscoped
987
+ * @param hash - The requested content hash
988
+ */
989
+ isReferenced(scopes: ScopeMap | undefined, hash: string): Promise<boolean>;
990
+ /**
991
+ * Decide whether a writer may reference `hash` in a blob field (RT-11). Allowed
992
+ * when the record already referenced it, when a live record inside the writer's
993
+ * download scope references it, when the writer owns it (pushed the bytes, or
994
+ * claimed it first), or when nobody references, owns or stores it yet: then the
995
+ * writer claims it, atomically, so a later forger cannot.
996
+ */
997
+ authorizeReference(request: BlobReferenceRequest): Promise<boolean>;
998
+ /** True when `owner` pushed (or first claimed) the bytes behind `hash`. */
999
+ isOwnedBy(hash: string, owner: string): Promise<boolean>;
1000
+ /**
1001
+ * Record that `owner` pushed bytes verified to hash to `hash` (proof of
1002
+ * possession), and learn the chunk list when the bytes are a manifest.
1003
+ */
1004
+ recordPush(hash: string, bytes: Uint8Array, owner: string): Promise<void>;
1005
+ /**
1006
+ * When `bytes` are a manifest, true only if the pusher may reference every chunk
1007
+ * it lists (the same rule as {@link authorizeReference}); true for non-manifest
1008
+ * bytes. A manifest listing someone else's chunks is refused before it is stored.
1009
+ */
1010
+ authorizeManifestPush(bytes: Uint8Array, scopes: ScopeMap | undefined, owner: string): Promise<boolean>;
1011
+ /**
1012
+ * Learn a manifest's chunk list from bytes already verified to hash to `hash`.
1013
+ * Bytes that are not a manifest are ignored. Unowned chunks of an owned manifest
1014
+ * are claimed for the manifest's owner, so a manifest crafted later cannot adopt
1015
+ * them.
1016
+ */
1017
+ observeVerifiedBytes(hash: string, bytes: Uint8Array): void;
1018
+ private claimChunksForManifestOwner;
1019
+ private currentStamp;
1020
+ private isCurrent;
1021
+ private blobCollections;
1022
+ private directHashes;
1023
+ private build;
1024
+ /** Read unknown manifests from the central store, verifying them against their hash. */
1025
+ private loadManifests;
1026
+ private ownersOf;
1027
+ private addOwner;
1028
+ private claim;
1029
+ private rememberMemoryOwner;
1030
+ private rememberManifest;
1031
+ private forgetManifest;
1032
+ }
1033
+
1034
+ /**
1035
+ * Minimal interface for a ws.WebSocket instance.
1036
+ * Allows dependency injection for testing without importing ws directly.
1037
+ */
1038
+ interface WsWebSocket {
1039
+ readyState: number;
1040
+ send(data: string | Uint8Array, callback?: (err?: Error) => void): void;
1041
+ close(code?: number, reason?: string): void;
1042
+ on(event: string, listener: (...args: unknown[]) => void): void;
1043
+ removeAllListeners(): void;
1044
+ /** Send a ping frame (ws). Without it, no liveness probing happens. */
1045
+ ping?(): void;
1046
+ /** Destroy the socket at once, emitting `close` (ws). Falls back to `close()`. */
1047
+ terminate?(): void;
1048
+ /** Bytes queued and not yet written to the network (ws). */
1049
+ readonly bufferedAmount?: number;
1050
+ }
1051
+ /**
1052
+ * Options for WsServerTransport.
1053
+ */
1054
+ interface WsServerTransportOptions {
1055
+ /** Message serializer. Defaults to JsonMessageSerializer. */
1056
+ serializer?: MessageSerializer;
1057
+ /**
1058
+ * Interval between WebSocket pings, in ms (SRV-6). A connection that leaves
1059
+ * `maxMissedPongs` pings in a row unanswered (and sends nothing else meanwhile) is
1060
+ * terminated, which runs Kora's normal close path. Defaults to 25 seconds; 0
1061
+ * disables probing. Keep it at 15 seconds or more on mobile networks.
1062
+ */
1063
+ heartbeatIntervalMs?: number;
1064
+ /** Unanswered pings before the connection is terminated. Defaults to 2. */
1065
+ maxMissedPongs?: number;
1066
+ /**
1067
+ * Bytes that may be queued for this client before it is disconnected as a slow
1068
+ * consumer. Defaults to 32 MiB; 0 disables the ceiling.
1069
+ */
1070
+ maxBufferedBytes?: number;
1071
+ }
1072
+ /**
1073
+ * Server-side transport wrapping a ws.WebSocket connection.
1074
+ * Created for each incoming client connection.
1075
+ *
1076
+ * It probes liveness with WebSocket pings (both the standalone server and
1077
+ * `createProductionServer` create their connections through it), so a half-open
1078
+ * connection, a peer that vanished without a FIN, is detected and closed instead of
1079
+ * holding a session forever.
1080
+ */
1081
+ declare class WsServerTransport implements ServerTransport {
1082
+ private readonly ws;
1083
+ private readonly serializer;
1084
+ private messageHandler;
1085
+ private closeHandler;
1086
+ private errorHandler;
1087
+ private readonly maxMissedPongs;
1088
+ private readonly maxBufferedBytes;
1089
+ private heartbeatTimer;
1090
+ private missedPongs;
1091
+ private terminated;
1092
+ constructor(ws: WsWebSocket, options?: WsServerTransportOptions);
1093
+ send(message: SyncMessage): void;
1094
+ onMessage(handler: ServerMessageHandler): void;
1095
+ onClose(handler: ServerCloseHandler): void;
1096
+ onError(handler: ServerErrorHandler): void;
1097
+ isConnected(): boolean;
1098
+ close(code?: number, reason?: string): void;
1099
+ bufferedAmount(): number;
1100
+ /**
1101
+ * Ping every `intervalMs`; terminate once `maxMissedPongs` pings in a row went
1102
+ * unanswered. A pong or any inbound message proves the peer alive.
1103
+ */
1104
+ private startHeartbeat;
1105
+ private stopHeartbeat;
1106
+ /**
1107
+ * Drop the connection at once. ws emits `close` from `terminate()`, which runs
1108
+ * Kora's normal close path (session, relays and side channels are released).
1109
+ */
1110
+ private terminate;
1111
+ private setupListeners;
1112
+ }
1113
+
427
1114
  /**
428
1115
  * Minimal interface for a ws.WebSocketServer instance.
429
1116
  * Allows dependency injection for testing without importing ws directly.
@@ -442,6 +1129,8 @@ type WsServerConstructor = new (options: {
442
1129
  port?: number;
443
1130
  host?: string;
444
1131
  path?: string;
1132
+ maxPayload?: number;
1133
+ perMessageDeflate?: false | Record<string, unknown>;
445
1134
  }) => WsServerLike;
446
1135
  /**
447
1136
  * Self-hosted sync server. Accepts WebSocket connections from clients,
@@ -464,6 +1153,8 @@ declare class KoraSyncServer {
464
1153
  private readonly schemaVersion;
465
1154
  private readonly supportedSchemaVersions;
466
1155
  private readonly operationTransforms;
1156
+ /** The store's re-fold for the configured transforms (RT-84), awaited by start(). */
1157
+ private storeTransformsReady;
467
1158
  private readonly port;
468
1159
  private readonly host;
469
1160
  private readonly path;
@@ -472,14 +1163,49 @@ declare class KoraSyncServer {
472
1163
  private readonly awarenessRelay;
473
1164
  private readonly yjsDocRelay;
474
1165
  private readonly blobChunkRelay;
1166
+ /** Wrapped encryption key records (ENC-1, D4b), persisted in the server store. */
1167
+ private readonly encryptionKeys;
1168
+ /** Which blob hashes each download scope may obtain (RT-1). */
1169
+ private readonly blobAccess;
475
1170
  private readonly persistBlobChunk;
476
1171
  private readonly maxOperationBytes;
477
1172
  private readonly maxOpsPerMinute;
1173
+ /** Per-principal ingest budget per minute; 0 when disabled. */
1174
+ private readonly maxOpsPerMinutePerUser;
1175
+ private readonly allowLegacyAnonymousClaims;
1176
+ private readonly anonymousClaimTtlMs;
1177
+ private readonly maxOpsPerBatch;
1178
+ private readonly maxMessageBytes;
1179
+ private readonly heartbeatIntervalMs;
1180
+ private readonly appHeartbeatIntervalMs;
1181
+ private readonly encryptionPolicy;
1182
+ private readonly handshakeTimeoutMs;
1183
+ private readonly maxBufferedBytes;
1184
+ private readonly deliveryHighWaterBytes;
1185
+ private readonly perMessageDeflate;
1186
+ /**
1187
+ * Ingest rate limiters by device node id, shared by that node's sessions so a
1188
+ * reconnect does not reset the budget (SRV-6). Entries idle past the window are
1189
+ * dropped by the background tick; insertion order doubles as LRU order.
1190
+ */
1191
+ private readonly rateLimiters;
1192
+ /**
1193
+ * Ingest budgets by authenticated principal, shared by every node of that user, so
1194
+ * minting node ids does not multiply the per-node budget. Expired like the node map.
1195
+ */
1196
+ private readonly userRateLimiters;
1197
+ private readonly blobLimits;
478
1198
  private readonly validateOperation;
479
1199
  private readonly koraContext;
480
1200
  private readonly sessions;
481
- private readonly httpClients;
482
- private readonly httpSessionToClient;
1201
+ /**
1202
+ * HTTP long-poll sessions by their server-issued, high-entropy id (RT-2). Each is
1203
+ * bound to the principal that opened it; every request must re-authenticate as it.
1204
+ */
1205
+ private readonly httpSessions;
1206
+ /** Internal ClientSession id -> HTTP session id, for cleanup on close. */
1207
+ private readonly httpSessionIdBySession;
1208
+ private readonly httpSessionIdleTimeoutMs;
483
1209
  private readonly serverVersion;
484
1210
  private wsServer;
485
1211
  private running;
@@ -492,6 +1218,17 @@ declare class KoraSyncServer {
492
1218
  private deliveryPollTimer;
493
1219
  private lastObservedDeliverySequence;
494
1220
  private deliveryPollInFlight;
1221
+ /**
1222
+ * Re-validates every live session's credential against the auth provider, so a
1223
+ * revocation persisted by ANOTHER server instance (whose in-process revocation
1224
+ * listeners never reach this one) still ends the session here (RT-18).
1225
+ */
1226
+ private readonly sessionRevalidationIntervalMs;
1227
+ private sessionRevalidationTimer;
1228
+ private lastSessionRevalidationAtMs;
1229
+ private sessionRevalidationInFlight;
1230
+ /** Unsubscribes from the auth provider's revocation feed (AUTH-11). */
1231
+ private revocationUnsubscribe;
495
1232
  /**
496
1233
  * Relay operations a client never acknowledged before it disconnected, buffered by
497
1234
  * client node id so they can be redelivered on its next connection. Bounded per
@@ -501,19 +1238,113 @@ declare class KoraSyncServer {
501
1238
  private static readonly MAX_ORPHANED_RELAY_OPS_PER_NODE;
502
1239
  private static readonly ORPHANED_RELAY_TTL_MS;
503
1240
  constructor(config: KoraSyncServerConfig);
1241
+ /**
1242
+ * Follow the auth provider's revocation feed, when it has one, so revoking a
1243
+ * device or user ends its live sessions without any wiring by the app.
1244
+ */
1245
+ private ensureRevocationSubscribed;
1246
+ /**
1247
+ * End live sync sessions whose credential was revoked (AUTH-11).
1248
+ *
1249
+ * Each matching session receives a retriable `AUTH_REVOKED` (or `AUTH_EXPIRED`)
1250
+ * error and is closed. The client refreshes its credentials and re-handshakes,
1251
+ * so the server authenticates it again: a still-valid device reconnects, a
1252
+ * revoked one is refused. Sessions still in their handshake are refused as soon
1253
+ * as their principal is known.
1254
+ *
1255
+ * `createKoraAuthServer().bindSyncServer(server)` calls this on device revoke,
1256
+ * sign-out, password reset or change, and admin revoke. A provider exposing
1257
+ * `onRevoke` (the built-in one does) is followed automatically.
1258
+ *
1259
+ * @param filter - `userId` ends every session of that user; with `deviceId`,
1260
+ * only that device's sessions. At least one of the two is required.
1261
+ * @returns The number of established sessions that were closed
1262
+ *
1263
+ * @example
1264
+ * ```typescript
1265
+ * server.terminateSessions({ userId: 'u1', deviceId: 'laptop' })
1266
+ * ```
1267
+ */
1268
+ terminateSessions(filter: TerminateSessionsFilter): number;
1269
+ /**
1270
+ * Admin release of a device node id (RT-5). The next principal to handshake with
1271
+ * this node id claims it, even when the node already has operation history.
1272
+ *
1273
+ * Use it to hand over operation history written before node claims existed (an
1274
+ * upgrade from beta.12, where nobody may adopt such a node until it is released),
1275
+ * or to reassign a lost device's node id. Live sessions on that node id are ended
1276
+ * with a retriable `NODE_RELEASED` error so the handover starts clean.
1277
+ *
1278
+ * @param nodeId - The device node id to release
1279
+ * @returns True when the node had a claim or history to release; false when it
1280
+ * was unknown or the store does not support node claims
1281
+ *
1282
+ * @example
1283
+ * ```typescript
1284
+ * await server.releaseNodeClaim('0190a1b2-...')
1285
+ * ```
1286
+ */
1287
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
504
1288
  private ensureBackgroundTimersStarted;
1289
+ /**
1290
+ * Re-validate the credential of every live session with the auth provider and end
1291
+ * the sessions whose credential is no longer accepted (RT-18).
1292
+ *
1293
+ * Revocations are persisted by the auth stores (token, device and per-user
1294
+ * cut-offs), but `onRevoke` listeners only run in the process that handled the
1295
+ * revocation. Every instance therefore re-checks its own live sessions
1296
+ * periodically (`sessionRevalidationIntervalMs`, default 30 seconds, also driven by
1297
+ * the delivery poll tick) by authenticating the session's credential again. A
1298
+ * session whose credential is refused, or now resolves to another principal, gets
1299
+ * a retriable `AUTH_REVOKED` (or `AUTH_EXPIRED`) and is closed. A provider error
1300
+ * (for example the auth database is unreachable) ends nothing: it is logged and the
1301
+ * next pass retries, so an outage does not disconnect every client.
1302
+ *
1303
+ * @returns The number of sessions ended by this pass
1304
+ *
1305
+ * @example
1306
+ * ```typescript
1307
+ * // After revoking through another instance's admin API:
1308
+ * await server.revalidateSessions()
1309
+ * ```
1310
+ */
1311
+ revalidateSessions(): Promise<number>;
1312
+ /** Run {@link revalidateSessions} from the delivery poll tick once the interval elapsed. */
1313
+ private maybeRevalidateSessions;
505
1314
  /**
506
1315
  * Retransmit relay batches that connected clients have not acknowledged within
507
1316
  * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
508
1317
  * already-applied operation is harmless (clients dedup by content-addressed id).
509
1318
  */
510
1319
  retransmitPendingRelays(staleMs?: number): void;
1320
+ /**
1321
+ * The ingest rate limiter of a device node, created on first use and shared by
1322
+ * every session of that node, so reconnecting does not buy a fresh budget (SRV-6).
1323
+ */
1324
+ private rateLimiterFor;
1325
+ /**
1326
+ * Forget rate limiters of nodes with no live session whose window has long
1327
+ * passed: their budget would be full again anyway.
1328
+ */
1329
+ private expireIdleRateLimiters;
1330
+ /** Close HTTP long-poll sessions that sent no request within the idle timeout. */
1331
+ private expireIdleHttpSessions;
511
1332
  /**
512
1333
  * Check whether the authoritative delivery log advanced without passing through
513
1334
  * this server instance, then wake delivery-watermark sessions to scan from their
514
1335
  * own acknowledged cursors. Session-level filtering remains the only fan-out path.
515
1336
  */
516
1337
  pollDeliveryLog(): Promise<void>;
1338
+ /**
1339
+ * Invalidate cached blob access sets for the collections written between two
1340
+ * delivery frontiers. Falls back to dropping every set when the gap is large.
1341
+ */
1342
+ private invalidateBlobAccessSince;
1343
+ /**
1344
+ * The blob reference authority shared by every session and route (RT-11).
1345
+ * @internal Used by the route context; not part of the public API.
1346
+ */
1347
+ getBlobAccessIndex(): BlobAccessIndex;
517
1348
  /**
518
1349
  * Buffer relay operations a disconnected client never acknowledged, keyed by its
519
1350
  * node id, deduped by operation id and bounded by count. Redelivered when the
@@ -556,9 +1387,40 @@ declare class KoraSyncServer {
556
1387
  /**
557
1388
  * Handle one HTTP sync request for a long-polling client.
558
1389
  *
559
- * A stable `clientId` identifies the logical connection across requests.
1390
+ * The POST that opens a session (the handshake, sent without `sessionId`) creates
1391
+ * it and answers with a server-issued, high-entropy session id in the
1392
+ * `x-kora-session` response header. Every later request must name that id.
1393
+ *
1394
+ * With an auth provider, EVERY request is authenticated from its `authorization`
1395
+ * header and must resolve to the same principal (user, device and anonymity) as
1396
+ * the request that opened the session and as the session's handshake, so knowing
1397
+ * a session id alone grants nothing (RT-2). Sessions with no request for
1398
+ * `httpSessionIdleTimeoutMs` are closed.
1399
+ *
1400
+ * Responses: 202 (POST accepted), 200/204/304 (GET), 400 (malformed), 401
1401
+ * (credential missing or invalid), 403 (credential of another principal), 404
1402
+ * (unknown or expired session), 405 (method), 410 (session closed).
560
1403
  */
561
1404
  handleHttpRequest(request: HttpSyncRequest): Promise<HttpSyncResponse>;
1405
+ /**
1406
+ * Authenticate one HTTP request. Returns the principal identity, null when the
1407
+ * server has no real auth provider, or 'unauthorized'.
1408
+ */
1409
+ private authenticateHttpRequest;
1410
+ /**
1411
+ * True when a request's principal is the one that opened the session and, once
1412
+ * the handshake authenticated, the session's own principal.
1413
+ */
1414
+ private httpPrincipalMatches;
1415
+ /**
1416
+ * Handle an accepted WebSocket (attach mode with `ws`): wraps it in a
1417
+ * {@link WsServerTransport} configured like the standalone server's (serializer,
1418
+ * ping/pong liveness probing, send-buffer ceiling) and starts a session.
1419
+ *
1420
+ * @param ws - A `ws` WebSocket from `WebSocketServer.handleUpgrade` or `connection`
1421
+ * @returns The session ID
1422
+ */
1423
+ handleWebSocket(ws: WsWebSocket): string;
562
1424
  /**
563
1425
  * Handle an incoming client connection (attach mode).
564
1426
  * Creates a new ClientSession for the transport.
@@ -583,9 +1445,10 @@ declare class KoraSyncServer {
583
1445
  * client only receives the operation if it falls within that client's scope.
584
1446
  *
585
1447
  * @param op - A fully-formed, server-originated operation to apply
1448
+ * @param options - Optional in-store authorization (used by scoped routes)
586
1449
  * @returns The apply result, including any server-generated side-effect ops
587
1450
  */
588
- applyLocalOperation(op: Operation): Promise<ApplyServerOperationResult>;
1451
+ applyLocalOperation(op: Operation, options?: ApplyServerOperationOptions): Promise<ApplyServerOperationResult>;
589
1452
  /**
590
1453
  * Relay already-applied, server-originated operations to every connected client.
591
1454
  * Used by the conditional route apply, which commits its operations atomically
@@ -610,11 +1473,40 @@ declare class KoraSyncServer {
610
1473
  * Get the number of currently connected clients.
611
1474
  */
612
1475
  getConnectionCount(): number;
1476
+ /**
1477
+ * Node ids whose operations win `merge('server-authoritative')` fields in the fold
1478
+ * (W7): the store's own node id, which authors every server-originated operation
1479
+ * (side effects, constraint corrections, route writes), plus any extras the store
1480
+ * was configured with (`authoritativeNodeIds` store option). This is the one source
1481
+ * of truth: every session advertises exactly this list in the handshake, so clients
1482
+ * fold with the same authority as the server's stores.
1483
+ */
1484
+ get authoritativeNodeIds(): string[];
1485
+ /**
1486
+ * Blob access policy (RT-1): true when the session is streaming and a live record
1487
+ * inside its download scope references the hash. Gates the central store.
1488
+ */
1489
+ private sessionReferencesBlob;
1490
+ /** True when a session other than `exceptSessionId` is connected as `nodeId`. */
1491
+ private isNodeLive;
1492
+ /**
1493
+ * Central-store read policy (RT-1, RT-25): the session owns the hash (it pushed
1494
+ * the bytes) or a live record inside its download scope references it.
1495
+ */
1496
+ private sessionMayReadStoredBlob;
1497
+ /**
1498
+ * Blob access policy (RT-1): a request may be forwarded to a peer that shares the
1499
+ * requester's exact download scope (the same tenant view, as for presence), which
1500
+ * keeps manifests handed over out of band working; across scopes, only when both
1501
+ * scopes reference the hash, so neither the hash nor the bytes cross a tenant.
1502
+ */
1503
+ private mayForwardBlobRequest;
613
1504
  private handleRelay;
614
1505
  private handleSessionClose;
615
1506
  private handleAwarenessRelay;
616
1507
  private handleYjsDocRelay;
617
- private getOrCreateHttpClient;
1508
+ /** Open a new HTTP long-poll session bound to `principal`, under a fresh random id. */
1509
+ private openHttpSession;
618
1510
  }
619
1511
 
620
1512
  /**
@@ -798,11 +1690,28 @@ interface AuthContext {
798
1690
  userId: string;
799
1691
  /** Per-collection sync scopes (optional) */
800
1692
  scopes?: Record<string, Record<string, unknown>>;
1693
+ /** Server-authoritative records this session may receive. Overrides `scopes`. */
1694
+ downlinkScopes?: Record<string, Record<string, unknown>>;
1695
+ /** Server-authoritative records this session may upload. Overrides `scopes`. */
1696
+ uplinkScopes?: Record<string, Record<string, unknown>>;
801
1697
  /** Arbitrary metadata about the authenticated user */
802
1698
  metadata?: Record<string, unknown>;
803
- }
804
- /**
805
- * Interface for authenticating incoming client connections.
1699
+ /**
1700
+ * True for an unauthenticated (anonymous) principal, such as the fallback of
1701
+ * `MixedAuthProvider`. Its `userId` is not stable across connections, so node-id
1702
+ * claims are keyed by the shared anonymous owner instead: an anonymous device can
1703
+ * reconnect with its node id, but can never take a signed-in user's node id.
1704
+ */
1705
+ anonymous?: boolean;
1706
+ /**
1707
+ * When the credential that authenticated this session expires (ms since
1708
+ * epoch). A session must not outlive its credential: the sync server can
1709
+ * close it with a retriable AUTH_EXPIRED so the client refreshes (AUTH-11).
1710
+ */
1711
+ expiresAt?: number;
1712
+ }
1713
+ /**
1714
+ * Interface for authenticating incoming client connections.
806
1715
  * Implementations validate tokens and return an AuthContext on success.
807
1716
  */
808
1717
  interface AuthProvider {
@@ -812,6 +1721,33 @@ interface AuthProvider {
812
1721
  * @returns AuthContext if valid, null if rejected
813
1722
  */
814
1723
  authenticate(token: string): Promise<AuthContext | null>;
1724
+ /**
1725
+ * Optional revocation feed. When present, `KoraSyncServer` subscribes on
1726
+ * construction and calls {@link KoraSyncServer.terminateSessions} for every
1727
+ * event, so a revoked device or user loses its live sync sessions immediately
1728
+ * (AUTH-11). The built-in `createKoraAuthServer().auth` provides it.
1729
+ *
1730
+ * @returns An unsubscribe function
1731
+ */
1732
+ onRevoke?(listener: (event: SessionRevocation) => void | Promise<void>): () => void;
1733
+ }
1734
+ /**
1735
+ * Which live sync sessions a credential revocation ends. `userId` alone ends
1736
+ * every session of that user; with `deviceId` only that device's sessions
1737
+ * (matched against `AuthContext.metadata.deviceId`).
1738
+ */
1739
+ interface SessionRevocation {
1740
+ userId?: string;
1741
+ deviceId?: string;
1742
+ }
1743
+ /** Options for {@link KoraSyncServer.terminateSessions}. */
1744
+ interface TerminateSessionsFilter extends SessionRevocation {
1745
+ /**
1746
+ * Error code sent to the client before the session closes. Both are retriable:
1747
+ * the client refreshes its credentials and re-handshakes, and the server then
1748
+ * decides again. Defaults to `'AUTH_REVOKED'`.
1749
+ */
1750
+ code?: 'AUTH_REVOKED' | 'AUTH_EXPIRED';
815
1751
  }
816
1752
  /**
817
1753
  * Configuration for creating a KoraSyncServer.
@@ -829,8 +1765,60 @@ interface KoraSyncServerConfig {
829
1765
  serializer?: MessageSerializer;
830
1766
  /** Event emitter for DevTools integration */
831
1767
  emitter?: KoraEventEmitter;
832
- /** Maximum concurrent client connections. 0 = unlimited. Defaults to 0. */
1768
+ /**
1769
+ * Maximum concurrent client connections (sessions, including ones still
1770
+ * handshaking). A connection over the limit gets a retriable `MAX_CONNECTIONS` error
1771
+ * and is closed. 0 = unlimited. Defaults to 10,000.
1772
+ */
833
1773
  maxConnections?: number;
1774
+ /**
1775
+ * Interval between WebSocket pings, in ms (SRV-6, LMS #12). A connection that
1776
+ * leaves two pings in a row unanswered is terminated, which ends its session.
1777
+ * Applies to the standalone server and `createProductionServer`. Defaults to 25
1778
+ * seconds; 0 disables probing.
1779
+ */
1780
+ heartbeatIntervalMs?: number;
1781
+ /**
1782
+ * Interval of the application-level `heartbeat` message sent to clients that
1783
+ * advertise support for it (browsers cannot see WebSocket pings), in ms. A client
1784
+ * that hears nothing for about 2.5 intervals reconnects. Defaults to 25 seconds; 0
1785
+ * disables it.
1786
+ */
1787
+ appHeartbeatIntervalMs?: number;
1788
+ /**
1789
+ * End-to-end encryption policy (protocol v2, ENC-3). With `required: true` an
1790
+ * uploaded plaintext operation that carries data is refused non-retriably
1791
+ * (`PLAINTEXT_REJECTED`), unless `allowPlaintextMigration` opens a migration window.
1792
+ * Encrypted operations (envelope v2) are always stored opaquely, never transformed.
1793
+ */
1794
+ encryption?: {
1795
+ required: boolean;
1796
+ allowPlaintextMigration?: boolean;
1797
+ };
1798
+ /**
1799
+ * Time a new connection has to send its handshake, in ms. Defaults to 10 seconds;
1800
+ * 0 disables the deadline.
1801
+ */
1802
+ handshakeTimeoutMs?: number;
1803
+ /**
1804
+ * Bytes that may wait unsent for one client (WebSocket send buffer, or the queue of
1805
+ * an HTTP long-poll client that stopped polling) before it is disconnected as a slow
1806
+ * consumer. Defaults to 32 MiB; 0 disables the ceiling. The delivery stream pauses
1807
+ * well before this (`deliveryHighWaterBytes`).
1808
+ */
1809
+ maxBufferedBytes?: number;
1810
+ /**
1811
+ * Queued outbound bytes above which a client's delivery stream pauses until the
1812
+ * client drains them (backpressure). Defaults to 1 MiB.
1813
+ */
1814
+ deliveryHighWaterBytes?: number;
1815
+ /**
1816
+ * WebSocket permessage-deflate compression. `true` (the default) compresses messages
1817
+ * of 1 KiB or more without keeping a compression context between messages, which
1818
+ * cuts sync payloads several-fold on 2G/3G links at a bounded memory cost per
1819
+ * connection. `false` disables it; an object is passed to `ws` as is.
1820
+ */
1821
+ perMessageDeflate?: boolean | Record<string, unknown>;
834
1822
  /** Maximum operations per sync batch. Defaults to 100. */
835
1823
  batchSize?: number;
836
1824
  /**
@@ -858,10 +1846,13 @@ interface KoraSyncServerConfig {
858
1846
  max: number;
859
1847
  };
860
1848
  /**
861
- * Transform accepted legacy client operations into the server schema version
862
- * before validation and materialization. Required when
863
- * `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
864
- * shapes changed across versions.
1849
+ * Schema transforms from older client schema versions to the server schema version.
1850
+ * Required when `supportedSchemaVersions.min` is lower than `schemaVersion` and
1851
+ * operation shapes changed across versions. They run at fold time (RT-84): every
1852
+ * operation is stored and delivered exactly as uploaded; authorization, validators
1853
+ * and the store's fold use its transformed view. The server hands this list to its
1854
+ * store (`setOperationTransforms`); pass it to `store.setSchema` too to fold with it
1855
+ * from the first start. Transforms must be pure and deterministic.
865
1856
  */
866
1857
  operationTransforms?: OperationTransform[];
867
1858
  /** WebSocket path (standalone mode). Defaults to '/'. */
@@ -889,6 +1880,32 @@ interface KoraSyncServerConfig {
889
1880
  * `@korajs/store` derives both from a `ContentAddressedBlobStore`.
890
1881
  */
891
1882
  persistBlobChunk?: (hash: string, bytes: Uint8Array) => Promise<void> | void;
1883
+ /**
1884
+ * Limits on the blob side channel. `maxChunkBytes` (default 1 MiB) caps one
1885
+ * pushed chunk or manifest, `maxBytesPerSession` (default 256 MiB) caps the total
1886
+ * a session may push for central persistence, `maxPendingRequestsPerSession`
1887
+ * (default 256) and `pendingRequestTtlMs` (default 60s) bound the chunk requests
1888
+ * the relay remembers per session.
1889
+ */
1890
+ blobLimits?: {
1891
+ maxChunkBytes?: number;
1892
+ maxBytesPerSession?: number;
1893
+ maxPendingRequestsPerSession?: number;
1894
+ pendingRequestTtlMs?: number;
1895
+ /**
1896
+ * Blob chunk requests one session may make per minute, a budget separate from
1897
+ * `maxOpsPerMinute` (one request per chunk, so a large blob makes many). Over
1898
+ * it, a request is answered with a retriable `throttled` response the client
1899
+ * backs off on (RT-24). Defaults to 6000.
1900
+ */
1901
+ maxRequestsPerMinute?: number;
1902
+ };
1903
+ /**
1904
+ * Largest WebSocket message the standalone server (and `createProductionServer`)
1905
+ * accepts, in bytes. Larger frames are refused by the socket layer before they are
1906
+ * buffered. Defaults to 32 MiB (the `ws` library default is 100 MiB).
1907
+ */
1908
+ maxMessageBytes?: number;
892
1909
  /**
893
1910
  * Maximum serialized byte size of a single client operation accepted at sync
894
1911
  * ingest. Operations larger than this are rejected before materialization.
@@ -897,11 +1914,64 @@ interface KoraSyncServerConfig {
897
1914
  */
898
1915
  maxOperationBytes?: number;
899
1916
  /**
900
- * Maximum operations accepted per connected client per minute (sliding window).
901
- * Operations beyond the limit are rejected until the window resets. Defaults to
902
- * 600. Set once here to enforce one rate cap across every connected client.
1917
+ * Maximum operations accepted per device node per minute (fixed window). The budget
1918
+ * belongs to the node id (bound to its principal when auth is configured), not to
1919
+ * the connection, so reconnecting does not reset it (SRV-6). Operations beyond the
1920
+ * limit get a retriable `RATE_LIMIT` until the window resets. Defaults to 600.
903
1921
  */
904
1922
  maxOpsPerMinute?: number;
1923
+ /**
1924
+ * Maximum operations accepted per authenticated principal per minute, across all of
1925
+ * its device nodes, on top of the per-node `maxOpsPerMinute`. Without it a user could
1926
+ * multiply the per-node budget by minting node ids. Keyed by the provider's user id;
1927
+ * anonymous and unauthenticated sessions (no stable principal) have only the per-node
1928
+ * budget. Defaults to 4 x `maxOpsPerMinute` (2400), so one busy device never meets it
1929
+ * and a user's devices share it. `0` disables the per-user budget.
1930
+ */
1931
+ maxOpsPerMinutePerUser?: number;
1932
+ /**
1933
+ * Largest operation batch accepted from a client in one message. A larger batch is
1934
+ * refused whole with `BATCH_TOO_LARGE` before the server decodes it or reads the
1935
+ * store, so one message cannot buy unbounded work. Defaults to 1000 (the client
1936
+ * sends batches of 100 by default).
1937
+ */
1938
+ maxOpsPerBatch?: number;
1939
+ /**
1940
+ * Accept anonymous devices whose node claim predates confirmed claims (RT-21):
1941
+ * nodes whose operation history predates node claims (a database written by a
1942
+ * beta.12 or older server, which recorded no claims; RT-91), nodes held by the
1943
+ * pre-release shared anonymous owner, and provisional claims that expired without
1944
+ * the device ever confirming its node token (beta.12 and older clients have no
1945
+ * token support). Accepted with a deprecation warning and re-issued a token.
1946
+ * Defaults to `true` for 1.0.0-beta.13; the default flips to `false` in the next
1947
+ * release, after which such devices rotate to a fresh node id instead (beta.12
1948
+ * clients cannot rotate and stay refused).
1949
+ *
1950
+ * With `MixedAuthProvider`, a node with pre-claims history may also be a signed-in
1951
+ * user's beta.12 device: an anonymous device can take such a node, as it could on
1952
+ * beta.12. Set this to `false` if that matters more than keeping anonymous beta.12
1953
+ * devices syncing.
1954
+ */
1955
+ allowLegacyAnonymousClaims?: boolean;
1956
+ /**
1957
+ * How long an anonymous device's provisional node claim may stay unconfirmed and
1958
+ * still be re-issued to a device that presents no token (a handshake response lost
1959
+ * in transit), in milliseconds (RT-21). Defaults to 24 hours.
1960
+ */
1961
+ anonymousClaimTtlMs?: number;
1962
+ /**
1963
+ * How often every live session's credential is re-validated with the auth
1964
+ * provider, in milliseconds (RT-18). A revocation persisted by another server
1965
+ * instance ends the session here within this interval. Defaults to 30 seconds; 0
1966
+ * disables it (sessions then end only through this process's `onRevoke` feed,
1967
+ * `terminateSessions`, or credential expiry).
1968
+ */
1969
+ sessionRevalidationIntervalMs?: number;
1970
+ /**
1971
+ * How long an HTTP long-poll session may go without any request before the server
1972
+ * closes it, in milliseconds. Defaults to 2 minutes; 0 disables expiry.
1973
+ */
1974
+ httpSessionIdleTimeoutMs?: number;
905
1975
  /**
906
1976
  * Adjudicate untrusted client operations before they become authoritative.
907
1977
  *
@@ -917,12 +1987,25 @@ interface KoraSyncServerConfig {
917
1987
  }
918
1988
  /**
919
1989
  * Request envelope for the server-side HTTP sync endpoint.
1990
+ *
1991
+ * Map it from your HTTP framework: `sessionId` from the `x-kora-session` header and
1992
+ * `authorization` from the `Authorization` header, on every request.
920
1993
  */
921
1994
  interface HttpSyncRequest {
922
- /** Stable client identifier for binding HTTP requests to a server session */
923
- clientId: string;
924
1995
  /** HTTP method */
925
1996
  method: 'GET' | 'POST';
1997
+ /**
1998
+ * The server-issued session id (`x-kora-session` request header). Absent only on
1999
+ * the POST that opens a session (the handshake); the response to that POST
2000
+ * carries the new id in its `x-kora-session` header. Never chosen by the client.
2001
+ */
2002
+ sessionId?: string;
2003
+ /**
2004
+ * The raw `Authorization` header (`Bearer <token>`). With an auth provider
2005
+ * configured, EVERY request is authenticated and must resolve to the same
2006
+ * principal and device as the session it names (RT-2).
2007
+ */
2008
+ authorization?: string;
926
2009
  /** Optional raw request payload for POST */
927
2010
  body?: string | Uint8Array;
928
2011
  /** Value of the Content-Type header for POST payloads */
@@ -935,7 +2018,7 @@ interface HttpSyncRequest {
935
2018
  */
936
2019
  interface HttpSyncResponse {
937
2020
  /** HTTP status code */
938
- status: 200 | 202 | 204 | 304 | 400 | 405 | 410;
2021
+ status: 200 | 202 | 204 | 304 | 400 | 401 | 403 | 404 | 405 | 410;
939
2022
  /** Optional raw response payload */
940
2023
  body?: string | Uint8Array;
941
2024
  /** Optional response headers */
@@ -982,6 +2065,20 @@ interface ServerStatus {
982
2065
  errorCount: number;
983
2066
  }
984
2067
 
2068
+ /**
2069
+ * Which reverse proxies in front of the server are trusted to report the client
2070
+ * address in `X-Forwarded-For`.
2071
+ *
2072
+ * - `number`: trust that many hops, counting the socket peer as the first. `1` means
2073
+ * "one proxy (for example a load balancer) sits directly in front of the server".
2074
+ * - `string[]`: trust peers whose address is in one of these IPs or CIDR ranges
2075
+ * (IPv4 and IPv6, for example `['10.0.0.0/8', '::1']`).
2076
+ *
2077
+ * When unset, `X-Forwarded-For` is ignored and the socket address is the client
2078
+ * address, so a directly connected client cannot choose its own address.
2079
+ */
2080
+ type TrustProxySetting = number | string[];
2081
+
985
2082
  interface HttpPollResponse {
986
2083
  status: 200 | 204 | 304 | 410;
987
2084
  body?: string | Uint8Array;
@@ -1001,56 +2098,104 @@ declare class HttpServerTransport implements ServerTransport {
1001
2098
  private connected;
1002
2099
  private nextSequence;
1003
2100
  private readonly queue;
1004
- constructor(serializer: MessageSerializer);
2101
+ private queuedBytes;
2102
+ private readonly maxQueuedBytes;
2103
+ /**
2104
+ * @param serializer - Message serializer
2105
+ * @param options - `maxQueuedBytes`: close the session once this many bytes wait
2106
+ * unpolled (default 32 MiB; 0 disables the ceiling)
2107
+ */
2108
+ constructor(serializer: MessageSerializer, options?: {
2109
+ maxQueuedBytes?: number;
2110
+ });
1005
2111
  send(message: _korajs_sync.SyncMessage): void;
2112
+ /** Bytes queued for the client and not yet polled (delivery-stream backpressure). */
2113
+ bufferedAmount(): number;
1006
2114
  onMessage(handler: ServerMessageHandler): void;
1007
2115
  onClose(handler: ServerCloseHandler): void;
1008
2116
  onError(handler: ServerErrorHandler): void;
1009
2117
  isConnected(): boolean;
2118
+ /**
2119
+ * Close the session. Messages already queued (typically the final error that
2120
+ * explains the close, for example a refused handshake) stay pollable, so a
2121
+ * long-polling client learns why, exactly as a WebSocket client receives the error
2122
+ * frame before the close; once drained, polls answer 410.
2123
+ */
1010
2124
  close(code?: number, reason?: string): void;
2125
+ /** True while messages queued before the close are still waiting to be polled. */
2126
+ hasPending(): boolean;
1011
2127
  receive(payload: string | Uint8Array): void;
1012
2128
  poll(ifNoneMatch?: string): HttpPollResponse;
1013
2129
  private makeEtag;
1014
2130
  }
1015
2131
 
1016
- /**
1017
- * Minimal interface for a ws.WebSocket instance.
1018
- * Allows dependency injection for testing without importing ws directly.
1019
- */
1020
- interface WsWebSocket {
1021
- readyState: number;
1022
- send(data: string | Uint8Array, callback?: (err?: Error) => void): void;
1023
- close(code?: number, reason?: string): void;
1024
- on(event: string, listener: (...args: unknown[]) => void): void;
1025
- removeAllListeners(): void;
2132
+ /** Outcome of one key-service message. */
2133
+ interface KeyServiceOutcome {
2134
+ response: EncryptionKeyResponseMessage;
2135
+ /** The record written, when the message was a successful write (to push to siblings). */
2136
+ written: WrappedKeyRecord | null;
1026
2137
  }
1027
2138
  /**
1028
- * Options for WsServerTransport.
2139
+ * The sync server's key service (ENC-1, decision D4b). Stores each owner's wrapped key
2140
+ * record and serves it back. It never sees a passphrase or a data key: a record is
2141
+ * salt, KDF parameters, AES-GCM-wrapped data keys, key ids and versions.
2142
+ *
2143
+ * Rules it enforces:
2144
+ * - **Owner from the session only.** The owner is the session's authenticated
2145
+ * principal (or the shared anonymous owner on a server without auth); a message
2146
+ * cannot name a user, so no session reads or writes another user's record.
2147
+ * - **Compare-and-set.** A write names the revision it replaces; a concurrent write
2148
+ * loses with `conflict` and the current record, so two devices that both create a
2149
+ * first key converge on one.
2150
+ * - **History is append-only.** A write must raise the revision, keep the ring id and
2151
+ * every key version (same key id), so no device can make old operations unreadable
2152
+ * for the others. A first write (or a re-upload after the record was lost) keeps the
2153
+ * revision it carries, since devices refuse revisions below the one they accepted.
2154
+ * - **Lost records are visible.** A fetch with no record reports key ids of the owner's
2155
+ * stored encrypted operations, so a new device waits for one holding the ring.
2156
+ *
2157
+ * It cannot check the record MAC or that wraps open (only a holder of the ring's master
2158
+ * key can): devices authenticate every record themselves and act on nothing else. A
2159
+ * principal can still overwrite its own record with garbage; devices refuse it and
2160
+ * keep their keys.
1029
2161
  */
1030
- interface WsServerTransportOptions {
1031
- /** Message serializer. Defaults to JsonMessageSerializer. */
1032
- serializer?: MessageSerializer;
2162
+ declare class EncryptionKeyService {
2163
+ private readonly store;
2164
+ constructor(store: ServerStore);
2165
+ /** Whether the store can persist key records (all built-in stores can). */
2166
+ isSupported(): boolean;
2167
+ /**
2168
+ * Answer one key-service message for `owner`.
2169
+ *
2170
+ * @param owner - The session's owner key, or null when the session may not hold keys
2171
+ * @param message - A request or a write
2172
+ */
2173
+ handle(owner: string | null, message: EncryptionKeyRequestMessage | EncryptionKeyPutMessage): Promise<KeyServiceOutcome>;
2174
+ /**
2175
+ * Key ids of the owner's stored encrypted operations (a sample). Reported only while
2176
+ * no record exists: then non-empty means the record was lost, and a new device must
2177
+ * wait for one that holds the ring instead of starting another (RT-104).
2178
+ */
2179
+ private knownKeyIds;
2180
+ private read;
1033
2181
  }
1034
- /**
1035
- * Server-side transport wrapping a ws.WebSocket connection.
1036
- * Created for each incoming client connection.
1037
- */
1038
- declare class WsServerTransport implements ServerTransport {
1039
- private readonly ws;
1040
- private readonly serializer;
1041
- private messageHandler;
1042
- private closeHandler;
1043
- private errorHandler;
1044
- constructor(ws: WsWebSocket, options?: WsServerTransportOptions);
1045
- send(message: SyncMessage): void;
1046
- onMessage(handler: ServerMessageHandler): void;
1047
- onClose(handler: ServerCloseHandler): void;
1048
- onError(handler: ServerErrorHandler): void;
1049
- isConnected(): boolean;
1050
- close(code?: number, reason?: string): void;
1051
- private setupListeners;
2182
+
2183
+ /** What a session charges uploaded operations to. */
2184
+ interface IngestRateLimiter {
2185
+ /** Units allowed per window (the tightest applicable budget). */
2186
+ readonly limit: number;
2187
+ /** Charge `count` units; false when a budget is exceeded (the units are refused). */
2188
+ allow(count?: number): boolean;
2189
+ /** Milliseconds until a refused unit may be retried. */
2190
+ retryAfterMs(): number;
1052
2191
  }
1053
2192
 
2193
+ /**
2194
+ * Session-ending error codes, all retriable. `AUTH_REVOKED` / `AUTH_EXPIRED`: the
2195
+ * client refreshes its credential and re-handshakes. `SCOPE_CHANGED` (RT-26): the
2196
+ * principal's grant changed; the client simply reconnects and gets the new scope.
2197
+ */
2198
+ type SessionTerminationCode = 'AUTH_REVOKED' | 'AUTH_EXPIRED' | 'SCOPE_CHANGED';
1054
2199
  /**
1055
2200
  * Possible states for a client session.
1056
2201
  */
@@ -1066,7 +2211,7 @@ type AwarenessRelayCallback = (sourceSessionId: string, message: AwarenessUpdate
1066
2211
  /**
1067
2212
  * Callback invoked when a session receives a Yjs doc channel update to relay.
1068
2213
  */
1069
- type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage) => void;
2214
+ type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage, storedRecord: MaterializedRecord | null) => void;
1070
2215
  /**
1071
2216
  * Callback invoked when a session receives a blob chunk request to route.
1072
2217
  */
@@ -1108,7 +2253,7 @@ interface ClientSessionOptions {
1108
2253
  min: number;
1109
2254
  max: number;
1110
2255
  };
1111
- /** Transform accepted legacy operations into the server schema before validation. */
2256
+ /** Schema transforms: judged views of older operations (transforms at fold time, RT-84). */
1112
2257
  operationTransforms?: OperationTransform[];
1113
2258
  /** Called when this session has operations to relay to other sessions */
1114
2259
  onRelay?: RelayCallback;
@@ -1120,8 +2265,24 @@ interface ClientSessionOptions {
1120
2265
  onBlobChunkRequest?: BlobChunkRequestCallback;
1121
2266
  /** Called when this session receives a blob chunk response to route back */
1122
2267
  onBlobChunkResponse?: BlobChunkResponseCallback;
2268
+ /** The key service storing wrapped encryption key records (ENC-1). */
2269
+ encryptionKeys?: EncryptionKeyService;
2270
+ /** Called after this session wrote a key record, to push it to the owner's other sessions. */
2271
+ onEncryptionKeyWritten?: (sessionId: string, owner: string, keyring: string, record: WrappedKeyRecord) => void;
2272
+ /** Key-service messages allowed per session per minute. Defaults to 60. */
2273
+ maxKeyRequestsPerMinute?: number;
1123
2274
  /** Persist a client-uploaded blob chunk centrally (keyed by content hash) */
1124
2275
  persistBlobChunk?: PersistBlobChunk;
2276
+ /**
2277
+ * Called once the session completes an accepted handshake and reaches streaming.
2278
+ * Side channels (Yjs doc, blob and awareness relays) register the session here,
2279
+ * never at connect time, so an unauthenticated connection is never on a relay.
2280
+ */
2281
+ onReady?: (sessionId: string) => void;
2282
+ /** Largest single blob chunk this session may push. Defaults to 1 MiB. */
2283
+ maxBlobChunkBytes?: number;
2284
+ /** Total blob bytes this session may push. Defaults to 256 MiB. */
2285
+ maxBlobBytesPerSession?: number;
1125
2286
  /** Called when this session closes */
1126
2287
  onClose?: (sessionId: string) => void;
1127
2288
  /**
@@ -1141,6 +2302,18 @@ interface ClientSessionOptions {
1141
2302
  maxOperationBytes?: number;
1142
2303
  /** Maximum operations accepted per minute for this session. Defaults to 600. */
1143
2304
  maxOpsPerMinute?: number;
2305
+ /**
2306
+ * Blob chunk requests accepted per minute, separate from the operation budget
2307
+ * (RT-24). Defaults to 6000.
2308
+ */
2309
+ maxBlobRequestsPerMinute?: number;
2310
+ /** Length of the blob request window in ms. One minute; shorter only in tests. */
2311
+ blobRequestWindowMs?: number;
2312
+ /**
2313
+ * Largest operation batch accepted in one message. A larger batch is refused whole
2314
+ * (`BATCH_TOO_LARGE`) before it is decoded or the store is read. Defaults to 1000.
2315
+ */
2316
+ maxOpsPerBatch?: number;
1144
2317
  /**
1145
2318
  * Adjudicate untrusted client operations before materialization. When present,
1146
2319
  * each incoming operation is passed to this validator; a `reject` decision
@@ -1149,6 +2322,67 @@ interface ClientSessionOptions {
1149
2322
  validateOperation?: OperationValidator;
1150
2323
  /** Trusted data-plane context handed to the validator (read state, author derived ops). */
1151
2324
  koraContext?: ProductionHttpRouteContext;
2325
+ /**
2326
+ * Blob reference authority (RT-11): decides which content hashes this session may
2327
+ * reference in blob fields and records the bytes it pushes. Without it, blob
2328
+ * references are not checked at ingest.
2329
+ */
2330
+ blobAccess?: BlobAccessIndex;
2331
+ /** See `KoraSyncServerConfig.allowLegacyAnonymousClaims` (RT-21). Defaults to true. */
2332
+ allowLegacyAnonymousClaims?: boolean;
2333
+ /** See `KoraSyncServerConfig.anonymousClaimTtlMs` (RT-21). Defaults to 24 hours. */
2334
+ anonymousClaimTtlMs?: number;
2335
+ /**
2336
+ * True when another live session (not `exceptSessionId`) is connected as `nodeId`.
2337
+ * A provisional anonymous claim is never re-issued away from a connected device.
2338
+ */
2339
+ isNodeLive?: (nodeId: string, exceptSessionId: string) => boolean;
2340
+ /**
2341
+ * Time a new connection has to deliver its handshake, in ms (SRV-6). A connection
2342
+ * that sends none is closed with `HANDSHAKE_TIMEOUT`. Defaults to 10 seconds; 0
2343
+ * disables the deadline.
2344
+ */
2345
+ handshakeTimeoutMs?: number;
2346
+ /**
2347
+ * Queued outbound bytes above which the delivery stream pauses until the client
2348
+ * drains its socket (transports that report `bufferedAmount`). Defaults to 1 MiB.
2349
+ */
2350
+ deliveryHighWaterBytes?: number;
2351
+ /**
2352
+ * The ingest rate limiter for a node, shared across that node's sessions so a
2353
+ * reconnect does not reset it (SRV-6). Called once the handshake binds the node,
2354
+ * with the authenticated principal's user id (null for anonymous and
2355
+ * unauthenticated sessions) so a per-user budget can be layered on top: a user
2356
+ * minting node ids must not multiply the per-node budget.
2357
+ * Without it the session keeps a private limiter.
2358
+ */
2359
+ rateLimiterFor?: (nodeId: string, principal: string | null) => IngestRateLimiter;
2360
+ /**
2361
+ * Interval of the application-level heartbeat sent to clients that advertised
2362
+ * support for it, in ms (LMS #12). The handshake response tells the client the
2363
+ * interval so it can declare the connection dead after missing two. 0 disables it.
2364
+ */
2365
+ appHeartbeatIntervalMs?: number;
2366
+ /**
2367
+ * Node ids whose operations this server authors (protocol v2). Sent to clients in
2368
+ * the handshake response (`authoritativeNodeIds`); only operations from these nodes
2369
+ * may carry server-authored metadata (`fieldVersions`, `foldState`). Defaults to the
2370
+ * ids the store folds with (`ServerStore.getAuthoritativeNodeIds`), which is what
2371
+ * `KoraSyncServer` always passes. Scope-entry operations (`kora:scope-entry`) are not
2372
+ * listed: they carry the server's fold state and are joined, never folded as writes.
2373
+ */
2374
+ authoritativeNodeIds?: readonly string[];
2375
+ /**
2376
+ * End-to-end encryption policy (protocol v2, ENC-3). With `required`, every uploaded
2377
+ * data-bearing operation must carry the encryption envelope; a plaintext one is
2378
+ * refused non-retriably (`PLAINTEXT_REJECTED`), unless `allowPlaintextMigration` is
2379
+ * set for a migration window. Without it, the server stores whatever it receives
2380
+ * (envelope operations always opaquely).
2381
+ */
2382
+ encryption?: {
2383
+ required: boolean;
2384
+ allowPlaintextMigration?: boolean;
2385
+ };
1152
2386
  }
1153
2387
  /**
1154
2388
  * Handles the sync protocol for a single connected client.
@@ -1168,7 +2402,31 @@ declare class ClientSession {
1168
2402
  private state;
1169
2403
  private clientNodeId;
1170
2404
  private authContext;
2405
+ /**
2406
+ * The context the auth provider returned, kept apart from {@link authContext}
2407
+ * (which an unauthenticated session also gets, keyed by node id) so revocation
2408
+ * only ever matches a verified principal.
2409
+ */
2410
+ private principal;
2411
+ /** Closes the session when its credential expires (AUTH-11). */
2412
+ private expiryTimer;
2413
+ /** Revocations that arrived while the handshake was still authenticating. */
2414
+ private readonly pendingRevocations;
2415
+ /** Node token issued by this handshake's first anonymous claim, sent once in the response (RT-12). */
2416
+ private issuedNodeToken;
2417
+ /** The node-claim owner this session holds its node id under (userId, or an anonymous device key). */
2418
+ private nodeOwnerKey;
2419
+ /** A provisional anonymous claim awaiting the device's confirmation (RT-21). */
2420
+ private pendingNodeClaim;
2421
+ private readonly allowLegacyAnonymousClaims;
2422
+ private readonly anonymousClaimTtlMs;
2423
+ private readonly isNodeLive;
2424
+ /** The credential presented at handshake, kept to re-validate the session (RT-18). */
2425
+ private credential;
2426
+ /** The scope the client asked for at handshake, kept to re-resolve scopes (RT-26). */
2427
+ private handshakeScope;
1171
2428
  private syncQuerySubsets;
2429
+ private scopeExitPolicy;
1172
2430
  private resumeDeltaCursor;
1173
2431
  /**
1174
2432
  * The delivery watermark the client reported at handshake, or null when the client
@@ -1179,23 +2437,70 @@ declare class ClientSession {
1179
2437
  private clientDeliveryWatermark;
1180
2438
  /**
1181
2439
  * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
1182
- * watermark, as reported in acks). Incremental streaming pushes resume from here, not
1183
- * from the highest sequence sent, so a dropped or unapplied batch is always
1184
- * re-included by the next push (which re-scans from the acknowledged position). This
1185
- * is what makes streaming recovery independent of any bounded retransmit buffer: there
1186
- * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
1187
- * client's reported watermark at handshake.
2440
+ * watermark, as reported in acks). A retransmission rewinds the send cursor to here,
2441
+ * so a dropped or unapplied batch is always re-included by a re-scan from the
2442
+ * acknowledged position: recovery never depends on a bounded retransmit buffer.
2443
+ * Seeded from the client's reported watermark at handshake.
1188
2444
  */
1189
2445
  private lastAckedDeliverySeq;
1190
2446
  /**
1191
- * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
1192
- * this as its stale window so a slow client does not receive duplicate full-stream
1193
- * batches every server tick while the first batch is still being applied.
2447
+ * The delivery send cursor (SRV-3): the max of the last batch sent. Live pushes
2448
+ * chain from it (base = lastSent), so each operation goes out once per retransmit
2449
+ * epoch instead of the whole unacknowledged backlog on every write. Invariant:
2450
+ * lastAcked <= lastSent. It rewinds to lastAcked only when the outstanding delivery
2451
+ * went unacknowledged for the (backed-off) stale window.
2452
+ */
2453
+ private lastSentDeliverySeq;
2454
+ /** The highest batch max ever sent to this client (acks are capped by it). */
2455
+ private highestSentDeliverySeq;
2456
+ /**
2457
+ * Own-node operations at or below this delivery sequence are streamed to this
2458
+ * client; above it they are skipped (it uploaded them itself). Set at handshake: a
2459
+ * full resync (watermark 0) recovers the client's own history up to the frontier
2460
+ * at that moment; a resume excludes all of them.
2461
+ */
2462
+ private ownOperationsIncludedThrough;
2463
+ /** When the delivery last made progress (an ack advanced, or a fresh send/rewind). */
2464
+ private lastDeliveryProgressAtMs;
2465
+ /** Consecutive rewinds without acknowledgment progress (drives the backoff, LMS #12). */
2466
+ private deliveryRewinds;
2467
+ /**
2468
+ * Sent, not yet acknowledged delivery batches in send order (their max and send
2469
+ * time), for round-trip samples. `resent` marks a re-sent range, which yields no
2470
+ * sample (Karn's rule: its ack cannot be matched to one transmission).
1194
2471
  */
1195
- private lastDeliveryPushAttemptAtMs;
1196
- private deliveryStallRepeatCount;
1197
- /** Serializes incremental delivery pushes so their batches never interleave. */
2472
+ private readonly unackedBatches;
2473
+ private smoothedRttMs;
2474
+ private rttVarianceMs;
2475
+ /**
2476
+ * Retransmit timeout of the delivery stream (RFC 6298 style: smoothed round trip
2477
+ * plus four deviations, floored). An outstanding delivery with no acknowledgment
2478
+ * progress for this long (doubled per consecutive re-send) is re-sent from the
2479
+ * acknowledged position. Round trips include the client's apply time, so a slow
2480
+ * device earns a longer timeout instead of spurious re-sends.
2481
+ */
2482
+ private retransmitTimeoutMs;
2483
+ private retransmitTimer;
2484
+ /** A delivery push is queued but not started: further wake-ups coalesce into it. */
2485
+ private deliveryPushQueued;
2486
+ /** Serializes delivery pushes so their batches never interleave. */
1198
2487
  private deliveryPushChain;
2488
+ /**
2489
+ * Records prefetched for the delivery chunk being filtered (LMS #11: one batched
2490
+ * lookup per collection per chunk instead of one query per operation). Keyed
2491
+ * `collection\u0000id`; null marks a record known to be absent. Only set while a
2492
+ * chunk is filtered.
2493
+ */
2494
+ private recordLookupCache;
2495
+ private readonly deliveryHighWaterBytes;
2496
+ private readonly handshakeTimeoutMs;
2497
+ private handshakeTimer;
2498
+ private handshakeReceived;
2499
+ private readonly rateLimiterFor;
2500
+ private readonly appHeartbeatIntervalMs;
2501
+ private appHeartbeatTimer;
2502
+ /** Last time anything was sent to the client (a heartbeat is skipped when recent). */
2503
+ private lastClientSendAtMs;
1199
2504
  /**
1200
2505
  * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
1201
2506
  * The client acks every operation-batch it applies; on ack the entry is cleared.
@@ -1222,15 +2527,55 @@ declare class ClientSession {
1222
2527
  private readonly onYjsDocUpdate;
1223
2528
  private readonly onBlobChunkRequest;
1224
2529
  private readonly onBlobChunkResponse;
2530
+ private readonly encryptionKeys;
2531
+ private readonly onEncryptionKeyWritten;
2532
+ /** Budget of key-service messages (each may cost a store read and write). */
2533
+ private readonly keyRateLimiter;
1225
2534
  private readonly persistBlobChunk;
1226
2535
  private readonly onClose;
2536
+ private readonly onReady;
2537
+ private readonly maxBlobChunkBytes;
2538
+ private readonly maxBlobBytesPerSession;
2539
+ private blobBytesPushed;
1227
2540
  private readonly onOrphanedRelays;
1228
2541
  private readonly takeOrphanedRelays;
1229
2542
  private readonly maxOperationBytes;
1230
2543
  private readonly maxOpsPerMinute;
1231
- private readonly rateLimiter;
2544
+ private readonly maxOpsPerBatch;
2545
+ private rateLimiter;
2546
+ /** Separate budget for blob chunk requests (RT-24). */
2547
+ private readonly blobRateLimiter;
2548
+ /** Operations refused by the rate limiter (RT-6), for diagnostics. */
2549
+ private rateLimitedOperations;
2550
+ /** Batches refused whole for exceeding {@link maxOpsPerBatch} (RT-6). */
2551
+ private rejectedBatches;
2552
+ /** Blob chunk requests answered "not held" because the session was over budget (RT-17). */
2553
+ private rateLimitedBlobRequests;
2554
+ /**
2555
+ * The client advertised the `sequenceReservation` handshake capability (RT-37): it
2556
+ * reserves sequence numbers in-transaction, so a second operation under a held
2557
+ * (node, sequence) is refused with SEQUENCE_CONFLICT. False for a legacy client
2558
+ * (Kora <= beta.12), whose duplicate pairs are stored instead.
2559
+ */
2560
+ private sequenceReservation;
2561
+ /** Legacy duplicate pairs this session stored (RT-37), for diagnostics. */
2562
+ private legacySequencePairs;
2563
+ /** Protocol version the client declared in its handshake (1 when absent). */
2564
+ private clientProtocolVersion;
2565
+ private readonly authoritativeNodeIds;
2566
+ /** Hash version an undeclared uploaded id was verified as (RT-64), by operation object. */
2567
+ private readonly matchedHashVersions;
2568
+ /** Uploads whose data is stored as the beta.12 writer held it (RT-71). */
2569
+ private readonly restoredLegacyData;
2570
+ /** Operations accepted with an unverified legacy (beta.12) id (RT-71). */
2571
+ private unverifiedLegacyOperations;
2572
+ private forgedDuplicates;
2573
+ private readonly encryptionPolicy;
2574
+ /** Uploaded operations refused because their id is not their content hash (CORE-1). */
2575
+ private invalidOperationIds;
1232
2576
  private readonly validateOperation;
1233
2577
  private readonly koraContext;
2578
+ private readonly blobAccess;
1234
2579
  constructor(options: ClientSessionOptions);
1235
2580
  /**
1236
2581
  * Start handling messages from the client transport.
@@ -1242,9 +2587,13 @@ declare class ClientSession {
1242
2587
  */
1243
2588
  relayOperations(operations: Operation[]): void;
1244
2589
  /**
1245
- * Push newly-available in-scope operations to a delivery-watermark client, resuming
1246
- * from the highest delivery sequence already sent. Pushes are serialized so their
1247
- * batches never interleave (which would break the base/max chain).
2590
+ * Push newly-available in-scope operations to a delivery-watermark client, chained
2591
+ * from the send cursor (SRV-3): only what was not sent yet goes out. `rewind`
2592
+ * first moves the cursor back to the acknowledged position (a retransmission), and
2593
+ * `initial` is the handshake stream (it always ends in a final batch, even an empty
2594
+ * one, so the client completes initial sync). Pushes are serialized so their batches
2595
+ * never interleave (which would break the base/max chain); wake-ups that arrive
2596
+ * while one is queued coalesce into it.
1248
2597
  */
1249
2598
  private pushDeliveryStream;
1250
2599
  private relayVisibleOperations;
@@ -1254,17 +2603,63 @@ declare class ClientSession {
1254
2603
  * Retransmit relay batches this client has not acknowledged within `staleMs`.
1255
2604
  * Called on a periodic tick by the server (and directly by tests). Redelivering an
1256
2605
  * already-applied op is harmless: the client dedups by content-addressed id.
2606
+ *
2607
+ * For a delivery-watermark client it re-sends an outstanding (sent, unacknowledged)
2608
+ * delivery from the acknowledged position, under the same backed-off window as the
2609
+ * retransmit timer; `staleMs` 0 re-sends at once (a deterministic trigger for tests).
1257
2610
  */
1258
2611
  retransmitPendingRelays(staleMs?: number): void;
1259
2612
  /**
1260
2613
  * Wake the durable delivery stream for clients that negotiated delivery
1261
- * watermarks. This is used both for dropped watermark batches and for operations
1262
- * appended by another server/store instance: the session always scans from its
1263
- * own acknowledged cursor and applies its visibility filter before sending.
2614
+ * watermarks: for operations appended by another server/store instance (sent from
2615
+ * the send cursor), and for a delivery that went unacknowledged (re-sent from the
2616
+ * acknowledged position once it made no progress for `staleMs`, backed off while it
2617
+ * stays stuck). `staleMs` 0 re-sends at once.
2618
+ *
2619
+ * @param staleMs - Base stale window before an unacknowledged delivery is re-sent
2620
+ * @param options - `serverFrontier`: the store's max delivery sequence, when known;
2621
+ * `trackStall`: emit `sync:delivery-stalled` after repeated re-sends
1264
2622
  */
1265
2623
  pushDeliveryStreamIfSupported(staleMs?: number, options?: {
1266
2624
  trackStall?: boolean;
2625
+ serverFrontier?: number;
1267
2626
  }): void;
2627
+ /** Book-keeping for one sent delivery batch ending at `max`. */
2628
+ private recordDeliverySent;
2629
+ /**
2630
+ * A delivery acknowledgment: advance the confirmed watermark (a retransmission
2631
+ * rewinds to it), take a round-trip sample, and reset the backoff. The watermark
2632
+ * never passes what was sent: a client cannot acknowledge what it never received.
2633
+ */
2634
+ private noteDeliveryAcked;
2635
+ /** Fold one round-trip sample into the retransmit timeout (RFC 6298). */
2636
+ private recordRoundTrip;
2637
+ /**
2638
+ * The current retransmit window: the timeout, which each re-send doubled (up to
2639
+ * five minutes) and which only a fresh round-trip sample brings back down.
2640
+ */
2641
+ private retransmitWindowMs;
2642
+ /**
2643
+ * (Re)arm the retransmit timer for the outstanding delivery: it fires once no
2644
+ * acknowledgment progress was made for the retransmit window.
2645
+ */
2646
+ private armRetransmitTimer;
2647
+ /**
2648
+ * No acknowledgment progress for the retransmit window: re-send from the
2649
+ * acknowledged position (the batch was dropped, or the client could not apply it),
2650
+ * unless the socket is still draining what was sent.
2651
+ */
2652
+ private onRetransmitTimeout;
2653
+ /** Re-send the outstanding delivery from the acknowledged position. */
2654
+ private rewindDelivery;
2655
+ /** Bytes queued on the transport and not yet written to the network (0 if unknown). */
2656
+ private transportBufferedAmount;
2657
+ /**
2658
+ * Wait until the transport's queued bytes drop to the high-water mark (SRV-5
2659
+ * backpressure), so a slow client never makes the server buffer the whole stream
2660
+ * in socket memory. Resolves early when the session closes.
2661
+ */
2662
+ private waitForSendWindow;
1268
2663
  /**
1269
2664
  * Close this session.
1270
2665
  */
@@ -1275,11 +2670,95 @@ declare class ClientSession {
1275
2670
  */
1276
2671
  private flushOrphanedRelays;
1277
2672
  close(reason?: string): void;
2673
+ /**
2674
+ * End this session if its verified principal matches a credential revocation
2675
+ * (AUTH-11): send a retriable `code` error so the client refreshes and
2676
+ * re-handshakes, then close. A session still authenticating remembers the
2677
+ * revocation and applies it as soon as its principal is known.
2678
+ *
2679
+ * @returns True when the session was closed by this call
2680
+ */
2681
+ terminateIfMatches(filter: SessionRevocation, code: SessionTerminationCode): boolean;
2682
+ /**
2683
+ * Re-authenticate the credential that opened this session (RT-18). The session is
2684
+ * ended with a retriable `AUTH_REVOKED` (`AUTH_EXPIRED` once past its expiry) when
2685
+ * the provider now refuses the credential or resolves it to another principal, for
2686
+ * example after a revocation persisted by another server instance.
2687
+ *
2688
+ * @returns `'terminated'` when this call ended the session, `'error'` when the
2689
+ * provider threw (nothing is ended; the caller retries later), else `'valid'`
2690
+ */
2691
+ revalidateCredential(): Promise<'valid' | 'terminated' | 'error'>;
2692
+ /**
2693
+ * True when `context` (a fresh authentication of this session's principal)
2694
+ * resolves to download or upload scopes other than the ones this session holds.
2695
+ * A grant that no longer resolves at all counts as changed.
2696
+ */
2697
+ private scopesChangedFor;
2698
+ /**
2699
+ * End this session because an admin released its node id (RT-5). Retriable: a
2700
+ * client that still owns the node simply reconnects and claims it again.
2701
+ */
2702
+ endForNodeRelease(): void;
2703
+ private terminate;
2704
+ /** Clear every timer this session owns (expiry, handshake deadline, heartbeat). */
2705
+ private clearSessionTimers;
2706
+ /**
2707
+ * Start the application-level heartbeat for a client that advertised support for it
2708
+ * (LMS #12): a `heartbeat` message whenever nothing else was sent for an interval,
2709
+ * so the client can detect a dead connection it cannot see (browsers never surface
2710
+ * WebSocket pings).
2711
+ */
2712
+ private startAppHeartbeat;
2713
+ /**
2714
+ * Arm the expiry timer from `AuthContext.expiresAt`. Returns false when the
2715
+ * credential has already expired (the caller refuses the handshake).
2716
+ */
2717
+ private armExpiryTimer;
2718
+ private clearExpiryTimer;
1278
2719
  getState(): SessionState;
1279
2720
  getSessionId(): string;
1280
2721
  getClientNodeId(): string | null;
1281
2722
  getAuthContext(): AuthContext | null;
2723
+ /**
2724
+ * The principal the auth provider verified at handshake, or null before (or
2725
+ * without) authentication. Unlike {@link getAuthContext}, never a context
2726
+ * synthesized for an unauthenticated session.
2727
+ */
2728
+ getPrincipal(): AuthContext | null;
1282
2729
  isStreaming(): boolean;
2730
+ /**
2731
+ * Ingest refusals that happen before any store work (RT-6): operations refused by
2732
+ * the per-minute rate limiter and batches refused for exceeding the per-batch cap.
2733
+ */
2734
+ getIngestLimitCounts(): {
2735
+ rateLimitedOperations: number;
2736
+ rejectedBatches: number;
2737
+ rateLimitedBlobRequests: number;
2738
+ };
2739
+ /**
2740
+ * True when the client did not advertise the `sequenceReservation` capability
2741
+ * (RT-37): a legacy client (Kora <= beta.12) that may give two operations one
2742
+ * sequence number. Such a pair is stored and delivered, never refused with
2743
+ * SEQUENCE_CONFLICT. Meaningful once the handshake was accepted.
2744
+ */
2745
+ isLegacySequenceClient(): boolean;
2746
+ /** Legacy duplicate (node, sequence) pairs this session stored (RT-37). */
2747
+ getLegacySequencePairCount(): number;
2748
+ /**
2749
+ * True when a stored record is inside this session's download scope, so a side
2750
+ * channel (for example a Yjs doc update) about it may be delivered here. A record
2751
+ * not stored yet is judged by its id alone.
2752
+ */
2753
+ canReceiveRecord(collection: string, recordId: string, storedRecord: Record<string, unknown> | null): boolean;
2754
+ /**
2755
+ * A stable key for this session's download scope. Presence (awareness) is only
2756
+ * relayed between sessions that share exactly the same key, so it never crosses a
2757
+ * tenant boundary. Unscoped sessions share the key `"*"`.
2758
+ */
2759
+ /** This session's download scope, or undefined when it is unscoped. */
2760
+ getDownlinkScopes(): ScopeMap | undefined;
2761
+ getScopePartitionKey(): string;
1283
2762
  /**
1284
2763
  * Get the transport for this session.
1285
2764
  * Used by the awareness relay to send messages to this client.
@@ -1290,38 +2769,321 @@ declare class ClientSession {
1290
2769
  private sendToClient;
1291
2770
  private enqueueMessage;
1292
2771
  private handleMessageAsync;
2772
+ /**
2773
+ * The owner of this session's encryption key records (ENC-1), or null when it may
2774
+ * hold none: the authenticated user, or on a server without auth the one shared
2775
+ * anonymous owner. An anonymous principal of a mixed provider has no stable
2776
+ * identity, so it holds no keys.
2777
+ */
2778
+ getEncryptionKeyOwner(): string | null;
2779
+ /** Push a key record another session of the same owner wrote (rotation, new passphrase). */
2780
+ pushEncryptionKeyRecord(keyring: string, record: WrappedKeyRecord): void;
2781
+ private handleEncryptionKeyMessage;
1293
2782
  /**
1294
2783
  * Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
1295
2784
  * verified to hash to the declared hash before storing, so a corrupt or
1296
2785
  * mislabeled upload is rejected rather than served later as trusted content.
1297
2786
  */
1298
2787
  private handleBlobChunkPush;
2788
+ /**
2789
+ * Claim (or re-claim) a node id for an anonymous device (RT-12, RT-21).
2790
+ *
2791
+ * A device presenting a token holds the node when the claim is (or can become)
2792
+ * `kora:anon-node:<hash(token)>`: its confirmed claim, a fresh claim, or its own
2793
+ * provisional claim, which presenting the token confirms.
2794
+ *
2795
+ * A device presenting no token gets a fresh token and a PROVISIONAL claim, which it
2796
+ * confirms by acknowledging the response with the token once saved (or by
2797
+ * presenting it next time). Until then the claim may be re-issued to a device that
2798
+ * again presents no token (the response was lost in transit), but only while no
2799
+ * session is connected as that node and before the claim expires. Legacy claims
2800
+ * (expired provisional ones, and nodes of the pre-release shared anonymous owner)
2801
+ * are re-issued only with `allowLegacyAnonymousClaims`, with a deprecation warning.
2802
+ */
2803
+ private claimAnonymousNode;
2804
+ /** The deprecation warning for an anonymous device adopted under a legacy claim (RT-21, RT-91). */
2805
+ private warnLegacyAnonymousClaim;
2806
+ /**
2807
+ * The device proved it saved the node token issued by this handshake: make the
2808
+ * provisional claim permanent (RT-21). A mismatched token is ignored.
2809
+ */
2810
+ private confirmNodeClaim;
2811
+ /**
2812
+ * The download scope references are authorized against (RT-11, RT-13), or
2813
+ * undefined when they are not checked: without a real auth provider there is no
2814
+ * tenant boundary to protect.
2815
+ */
2816
+ private referenceScopes;
2817
+ /**
2818
+ * Stable blob-ownership key of this session's principal: the user id, or for an
2819
+ * anonymous device its node-claim owner (never shared by two anonymous devices).
2820
+ */
2821
+ getBlobOwnerKey(): string;
2822
+ /**
2823
+ * Partition key for peer-to-peer blob forwarding. Sessions with the same download
2824
+ * scope share a tenant view, except anonymous devices: every anonymous session has
2825
+ * the same grant but they are unrelated people, so each is its own partition.
2826
+ */
2827
+ getBlobPartitionKey(): string;
2828
+ /** Authorize foreign-key targets and blob references of an untrusted write. */
2829
+ private authorizeReferences;
1299
2830
  private handleMessageFailure;
1300
2831
  private handleHandshake;
2832
+ /**
2833
+ * The nodes whose operations after `fromDeliverySeq` this scoped session will
2834
+ * receive, for the handshake vector (RT-7). Scans the delivery log without holding
2835
+ * it (memory is O(nodes)), and judges each node only until one of its operations is
2836
+ * visible, so most operations cost one scope comparison and no record read.
2837
+ */
2838
+ private collectVisibleNodeIds;
2839
+ /**
2840
+ * The version vector as committed in the store (SRV-4). A store shared by several
2841
+ * instances reads it fresh; others serve their own (complete) vector.
2842
+ */
2843
+ private readServerVector;
2844
+ /**
2845
+ * Resolve this session's download and upload scopes from an auth context and the
2846
+ * scope the client asked for at handshake. Side-effect free, so revalidation can
2847
+ * recompute it and compare (RT-26). The legacy `scopes` contract remains shorthand
2848
+ * for both directions.
2849
+ */
2850
+ private computeSessionScopes;
1301
2851
  private handleOperationBatch;
1302
- private transformForServerSchema;
1303
- private sendDelta;
1304
2852
  /**
1305
- * Send the gap-free server->client delivery stream, resuming just after
2853
+ * For each delete of this session's node in an upload batch, the batch's later
2854
+ * operations of the same node that name it as a causal parent: candidates for the
2855
+ * author's own copies of its cascades and set-nulls (RT-69). Indexed by the delete's
2856
+ * id, in the server's schema (field names as the server derives effects).
2857
+ */
2858
+ private indexAuthoredSideEffectCopies;
2859
+ /**
2860
+ * The referential effects of a delete that arrived again as a stored duplicate and
2861
+ * are still undone (RT-73): effects the author covers later in this batch are
2862
+ * deferred like a fresh delete's (derived after the batch unless a copy is stored);
2863
+ * the rest are derived now. Derived ids are deterministic, so a concurrent retry or
2864
+ * another instance stores the same operations.
2865
+ *
2866
+ * @returns The derived operations (to relay)
2867
+ */
2868
+ private resumeStoredDeleteEffects;
2869
+ /**
2870
+ * Derive the server's copy of each deferred side effect unless one of the author's
2871
+ * copies is stored (applied or already held). A store with the batch lookup is asked
2872
+ * again, so a copy stored by a concurrent session counts too.
2873
+ */
2874
+ private deriveUncoveredSideEffects;
2875
+ /** {@link ServerStore.findStoredOperations} by id; empty without it or on a failed read. */
2876
+ private findStoredOperationIds;
2877
+ /**
2878
+ * The operation as the server schema reads it (transforms at fold time, RT-84): the
2879
+ * view authorization, validators, constraint checks and scope filters judge, and the
2880
+ * one the server stores fold (core `operationSchemaView`, same transforms). Never
2881
+ * stored: the store keeps the operation as uploaded. An envelope operation is opaque
2882
+ * to the server (NEW-ENC-1) and judged as is; devices transform after decryption.
2883
+ */
2884
+ private schemaView;
2885
+ /**
2886
+ * Integrity checks on an uploaded operation before anything else judges it:
2887
+ * - a version-2 plaintext id must be the content hash (`INVALID_OPERATION_ID`);
2888
+ * - an unknown declared hash version is refused the same way;
2889
+ * - with required encryption, a data-bearing plaintext operation is refused
2890
+ * (`PLAINTEXT_REJECTED`) unless the plaintext migration window is open.
2891
+ *
2892
+ * @returns null when the operation passes, or the refusal
2893
+ */
2894
+ private checkUploadIntegrity;
2895
+ /**
2896
+ * Whether an undeclared version-1 id that does not verify is accepted unverified
2897
+ * (RT-71): only from a protocol-1 session (Kora <= beta.12), and only for the
2898
+ * session's own node.
2899
+ */
2900
+ private acceptsUnverifiedLegacyId;
2901
+ /** Log, count and emit an operation accepted with an unverified legacy id (RT-71). */
2902
+ private reportUnverifiedLegacyOperation;
2903
+ /** Operations this session accepted with an unverified legacy id (RT-71). */
2904
+ getUnverifiedLegacyOperationCount(): number;
2905
+ /**
2906
+ * The operation as the server stores it: an undeclared plaintext id this server
2907
+ * verified declares the version it matched (1, or 2 when the declaration was lost),
2908
+ * so receivers verify it too (RT-64). An absent version is left only on ids nobody
2909
+ * could verify (protocol-1 encrypted payloads, server-side transforms).
2910
+ */
2911
+ private declareVerifiedHashVersion;
2912
+ /**
2913
+ * True for a node id that is (or was) the server's own: the `kora:` namespace, the
2914
+ * store's node id, and every id the server advertises or folds as authoritative
2915
+ * (other instances, legacy server node ids, configured extras). No device may use one
2916
+ * (RT-61), whether or not it has history yet.
2917
+ */
2918
+ private isServerAuthorNodeId;
2919
+ /**
2920
+ * The authoritative ids a handshake advertises (RT-75): only explicit ones (legacy
2921
+ * server node ids, configured extras). Every `kora:server:` id is authoritative by
2922
+ * prefix on every replica, and listing instance ids would make the list differ per
2923
+ * instance and per start, which devices would have to treat as news.
2924
+ */
2925
+ /** The handshake's explicit revocations (RT-81), when the deployment has any. */
2926
+ private advertisedRevocations;
2927
+ private advertisedAuthoritativeNodeIds;
2928
+ /** Node ids this server authors operations under (protocol v2 handshake response). */
2929
+ private serverAuthoritativeNodeIds;
2930
+ /**
2931
+ * A protocol-1 client (Kora <= beta.12) is served for one release (beta.13) with a
2932
+ * deprecation warning: its operation ids are version-1 hashes and its encrypted
2933
+ * payloads have no envelope binding.
2934
+ */
2935
+ private warnLegacyProtocol;
2936
+ /** The in-scope operations a version-vector client is missing (not yet sent). */
2937
+ private collectDeltaOperations;
2938
+ /** Send a collected version-vector delta in paginated, cursor-carrying batches. */
2939
+ private sendCollectedDelta;
2940
+ /**
2941
+ * Stream the gap-free server->client delivery stream, resuming just after
1306
2942
  * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
1307
2943
  * (commit order), scope-filtered for this session, and sent in batches that chain:
1308
2944
  * each batch's `baseDeliverySequence` equals the previous batch's
1309
2945
  * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
1310
2946
  * applies a batch only when its watermark equals the base and advances the
1311
2947
  * watermark to the max, so a dropped batch stalls the watermark and is recovered by
1312
- * the next handshake resend. Because delivery-sequence order respects causal order
1313
- * (a dependency is always committed, and thus sequenced, before its dependent), no
1314
- * topological sort is needed and the client never defers on a missing dependency
1315
- * that is itself in this stream.
2948
+ * a re-send from the acknowledged position. Because delivery-sequence order respects
2949
+ * causal order (a dependency is always committed, and thus sequenced, before its
2950
+ * dependent), no topological sort is needed.
1316
2951
  *
1317
- * The final batch advances the watermark to the highest delivery sequence scanned,
1318
- * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
1319
- * next reconnect. Returns the number of operations actually sent.
2952
+ * Batches go out as each scan chunk is filtered (SRV-5): the server holds at most
2953
+ * one scan chunk plus two batches per stream, never the whole backlog, and the
2954
+ * first batch reaches the client after one chunk. One full batch is held back so
2955
+ * the stream's last batch can be marked `isFinal` (one-batch lookahead). The final
2956
+ * batch carries the highest sequence scanned, not merely the last in-scope one, so
2957
+ * an out-of-scope tail is not re-scanned. Every sent batch advances the send cursor
2958
+ * ({@link lastSentDeliverySeq}). Between batches the stream waits for the client to
2959
+ * drain its socket (backpressure).
2960
+ *
2961
+ * @param fromDeliverySeq - The chain base: the client's watermark or the send cursor
2962
+ * @param finalizeWhenEmpty - Send an empty final batch even when nothing new was
2963
+ * scanned (the handshake stream, so the client completes initial sync)
2964
+ */
2965
+ private streamDelivery;
2966
+ /**
2967
+ * Filter one scan chunk of the delivery log down to what this session receives:
2968
+ * visible operations (each preceded by its scope-entry insert, RT-19) and
2969
+ * retractions. The records the decisions need are fetched for the whole chunk at
2970
+ * once (LMS #11), one batched read per collection instead of one query per op.
1320
2971
  */
1321
- private sendDeliveryStream;
2972
+ private filterDeliveryChunk;
2973
+ /**
2974
+ * Read, in one batched query per collection, every record the visibility decisions
2975
+ * for these operations may look up, when the store supports batched reads. Returns
2976
+ * null (per-operation lookups) otherwise. Over-fetching is harmless: the chunk
2977
+ * bounds it.
2978
+ */
2979
+ private prefetchRecordsFor;
1322
2980
  /** Build, track, and send one chained delivery-stream batch. */
1323
2981
  private sendDeliveryBatch;
2982
+ /** The scope snapshots of these operations, when the store keeps them (RT-14). */
2983
+ private scopeSnapshotsFor;
2984
+ /**
2985
+ * Download visibility of one operation. With a scope snapshot (RT-14) the
2986
+ * operation is judged on the record's scope values right after it was applied, so
2987
+ * history written while a record belonged to someone else stays hidden after an
2988
+ * ownership transfer, and a previous owner still receives its own history. Query
2989
+ * subsets (a client-chosen narrowing, not an authorization) keep using the current
2990
+ * row. Operations without a snapshot (legacy rows, custom stores) fall back to the
2991
+ * current row plus `op.data`.
2992
+ */
1324
2993
  private operationVisibleToClient;
2994
+ /**
2995
+ * Where the store already holds each of `operations` (by id), in one store read.
2996
+ * Null when the store cannot answer by id (a custom store without
2997
+ * `findStoredOperations`, or the read failed): callers then fall back to a
2998
+ * per-operation (node, sequence) lookup and the store's own dedup at apply.
2999
+ */
3000
+ private findStoredOperations;
3001
+ /**
3002
+ * True when the batch lookup alone shows `op` is already stored and may be
3003
+ * acknowledged as a duplicate with no further store work (RT-31, RT-39): this
3004
+ * device's own operation stored under its node (its sequence repair may have
3005
+ * renumbered it, keeping the id), or any other operation stored under exactly its
3006
+ * (node, sequence, id), such as one the server itself delivered and the client
3007
+ * echoed back. Never true for a store without the batch lookup.
3008
+ */
3009
+ private isStoredDuplicate;
3010
+ /**
3011
+ * The operation the store holds under `op`'s id (at the node and sequence the batch
3012
+ * lookup found), or null when it cannot be read (the upload is then judged like any
3013
+ * other: verified, validated, charged, and deduplicated by the store at apply).
3014
+ */
3015
+ private loadStoredOperation;
3016
+ /**
3017
+ * Refuse an upload that reuses a stored operation's id with different content (RT-77):
3018
+ * terminal, nothing recorded or derived, logged and emitted as tampering. An honest
3019
+ * client never does this (ids are content hashes), so the rejection only reaches a
3020
+ * forger. No resolution is recorded: the id belongs to the stored operation.
3021
+ */
3022
+ private refuseForgedDuplicate;
3023
+ /** Uploads refused for reusing a stored id with other content (RT-77). */
3024
+ getForgedDuplicateCount(): number;
3025
+ /**
3026
+ * The recorded resolutions of the batch's own-node operations that the lookup did
3027
+ * not find stored (RT-43, RT-47). Asked only for this session's own node, so the
3028
+ * answer never reveals another device's (or tenant's) refusals.
3029
+ */
3030
+ private findResolutions;
3031
+ /** The highest resolved sequence of `nodeId` (0 without the store method or on error). */
3032
+ private resolvedThrough;
3033
+ /**
3034
+ * Durably record how this session's own operation was resolved without being stored
3035
+ * under its sequence. Awaited before the answer is sent: a failure propagates (the
3036
+ * batch fails and is not acknowledged), so the client never acts on a resolution the
3037
+ * server could forget.
3038
+ */
3039
+ private recordResolution;
3040
+ /**
3041
+ * Refuse an operation for good (non-retriable): remember the refusal (RT-47), then
3042
+ * tell the client. A resubmission of the same id is answered with this rejection
3043
+ * without being judged again.
3044
+ */
3045
+ private refuseTerminally;
3046
+ /**
3047
+ * An own operation acknowledged as a duplicate of the copy stored under ANOTHER
3048
+ * sequence (the client's sequence repair renumbered it, keeping its id): record the
3049
+ * submitted sequence as resolved, so the handshake does not read it as lost (RT-43).
3050
+ */
3051
+ private noteStoredElsewhere;
3052
+ /** Refuse the rest of a batch for the per-minute ingest budget (retriable). */
3053
+ private sendRateLimited;
3054
+ /**
3055
+ * True when the store already holds exactly this operation (same node, sequence and
3056
+ * id). Answered from the batch lookup when the store supports it.
3057
+ */
3058
+ private isStoredOperation;
3059
+ /**
3060
+ * The scopes this session may write under. Fails closed: with auth configured but
3061
+ * no accepted authentication, the session may write nothing (an empty scope map),
3062
+ * never everything.
3063
+ */
3064
+ private uplinkScopes;
3065
+ /**
3066
+ * Upload authorization, independent from the client's downloaded/query view. The
3067
+ * stored row (including a soft-deleted one) is always loaded when scopes apply and
3068
+ * is authoritative; client-supplied previousData is never consulted.
3069
+ */
3070
+ private authorizeClientOperation;
3071
+ /**
3072
+ * True when this update moved the record out of the session's scope, judged on the
3073
+ * store's own before/after values (RT-15), never on the writer's previousData. An
3074
+ * operation without a snapshot never produces a retraction (fail quiet: the
3075
+ * client keeps what it already had, and no other tenant's record id leaks).
3076
+ */
3077
+ private scopeRetractionFor;
3078
+ /**
3079
+ * The scope-entry operation to send before `op` when `op` moved an existing record
3080
+ * into this session's download scope (RT-19), or null. Judged on the store's own
3081
+ * pre/post snapshot; built from the record's CURRENT row, and only while that row
3082
+ * is live and still inside the scope (a record that has since left again, or was
3083
+ * deleted, gets no entry: its later operations retract or delete it anyway).
3084
+ * Operations without a snapshot (legacy rows, custom stores) never produce one.
3085
+ */
3086
+ private scopeEntryFor;
1325
3087
  /**
1326
3088
  * Read a record's current field values from the materialized store for scope /
1327
3089
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
@@ -1330,7 +3092,15 @@ declare class ClientSession {
1330
3092
  */
1331
3093
  private lookupRecordFields;
1332
3094
  private handleAwarenessUpdate;
3095
+ /**
3096
+ * A Yjs doc-channel update is a write to a richtext field: the sender must be
3097
+ * allowed to write the stored record (same rule as operation uploads). The stored
3098
+ * row is handed to the relay so delivery can be limited to sessions whose download
3099
+ * scope contains the record.
3100
+ */
1333
3101
  private handleYjsDocUpdate;
3102
+ /** A legacy client's duplicate pair was stored (RT-37): count it and log it. */
3103
+ private recordLegacySequencePair;
1334
3104
  private sendError;
1335
3105
  /**
1336
3106
  * Reject one specific client operation, tied to its id, so the submitter can
@@ -1339,7 +3109,6 @@ declare class ClientSession {
1339
3109
  * replica ever sees it.
1340
3110
  */
1341
3111
  private sendOperationRejected;
1342
- private setSerializerWireFormat;
1343
3112
  private handleTransportClose;
1344
3113
  }
1345
3114
 
@@ -1381,6 +3150,25 @@ interface ProductionServerConfig {
1381
3150
  * backupToken.
1382
3151
  */
1383
3152
  operationalAuth?: ProductionOperationalAuth;
3153
+ /**
3154
+ * Reverse proxies trusted to report the client address in `X-Forwarded-For`,
3155
+ * which becomes `request.ip` (the key `@korajs/auth` rate-limits sign-in by).
3156
+ * A hop count (`1` for a single load balancer in front) or a list of trusted
3157
+ * proxy IPs / CIDR ranges. When unset, the header is ignored and `request.ip`
3158
+ * is the socket address, so a client cannot pick its own rate-limit bucket.
3159
+ */
3160
+ trustProxy?: TrustProxySetting;
3161
+ /**
3162
+ * Largest request body a custom HTTP route (`httpRoutes`) accepts, in bytes. A
3163
+ * larger body is refused with 413 before it is buffered (SRV-6), so an
3164
+ * unauthenticated client cannot make the server hold arbitrary amounts of memory.
3165
+ * Defaults to 1 MiB.
3166
+ */
3167
+ maxRequestBodyBytes?: number;
3168
+ /**
3169
+ * Largest backup accepted by `/__kora/backup/import`, in bytes. Defaults to 256 MiB.
3170
+ */
3171
+ maxBackupBytes?: number;
1384
3172
  }
1385
3173
  interface ProductionOperationalAuth {
1386
3174
  /** Protects /__kora, /__kora/status, and /__kora/events. */
@@ -1419,7 +3207,10 @@ interface ProductionHttpRoute {
1419
3207
  * A production server handle returned by createProductionServer.
1420
3208
  */
1421
3209
  interface ProductionServer {
1422
- /** Start listening. Returns the URL the server is available at. */
3210
+ /**
3211
+ * Start listening. Returns the URL the server is available at, with the port it
3212
+ * actually bound (pass `port: 0` to let the OS pick a free one).
3213
+ */
1423
3214
  start(): Promise<string>;
1424
3215
  /** Stop the server gracefully. */
1425
3216
  stop(): Promise<void>;
@@ -1471,6 +3262,10 @@ interface TokenAuthProviderOptions {
1471
3262
  /**
1472
3263
  * Validate a token and return an AuthContext if valid, or null if rejected.
1473
3264
  * This is where you implement your auth logic (JWT verification, database lookup, etc.).
3265
+ *
3266
+ * Return `scopes` to grant sync access explicitly. When the returned context
3267
+ * has no scopes, schema-scoped collections are bound to `{ userId }` and any
3268
+ * collection scoped by another key is denied.
1474
3269
  */
1475
3270
  validate: (token: string) => Promise<AuthContext | null>;
1476
3271
  }
@@ -1503,11 +3298,13 @@ interface TokenValidator {
1503
3298
  sub: string;
1504
3299
  dev: string;
1505
3300
  type: string;
3301
+ exp?: number;
1506
3302
  } | null;
1507
3303
  validateTokenWithRevocation?(token: string): Promise<{
1508
3304
  sub: string;
1509
3305
  dev: string;
1510
3306
  type: string;
3307
+ exp?: number;
1511
3308
  } | null>;
1512
3309
  }
1513
3310
  /**
@@ -1550,8 +3347,15 @@ interface KoraAuthProviderOptions {
1550
3347
  */
1551
3348
  deviceTracker?: DeviceToucher;
1552
3349
  /**
1553
- * Optional scope resolver. Called with the user ID to determine
1554
- * which collections/records the user can sync.
3350
+ * Optional scope resolver. Called with the verified user ID to determine
3351
+ * which collections/records the user can sync. Its result is the complete
3352
+ * server grant: a collection it omits is not visible, and the client
3353
+ * handshake can only narrow it. Use `claimScopes({ userId, orgId })` from
3354
+ * `@korajs/core` to bind every schema-scoped collection from verified values.
3355
+ *
3356
+ * When omitted, the grant binds every schema-scoped collection from
3357
+ * `{ userId: <verified sub> }`; collections scoped by any other key are denied
3358
+ * until you supply it.
1555
3359
  */
1556
3360
  resolveScopes?: (userId: string) => Promise<Record<string, Record<string, unknown>>>;
1557
3361
  }
@@ -1605,7 +3409,9 @@ interface MixedAuthProviderOptions {
1605
3409
  */
1606
3410
  primary: AuthProvider;
1607
3411
  /**
1608
- * Scopes to apply to anonymous connections.
3412
+ * Scopes to apply to anonymous connections. This is the complete anonymous
3413
+ * grant: anonymous sessions can sync exactly these collections, and the
3414
+ * client handshake can only narrow them, never add one.
1609
3415
  * Each key is a collection name; the value is a filter object
1610
3416
  * (use `{}` for unrestricted access to that collection).
1611
3417
  *
@@ -1629,10 +3435,10 @@ interface MixedAuthProviderOptions {
1629
3435
  /**
1630
3436
  * Auth provider that supports both authenticated and anonymous connections.
1631
3437
  *
1632
- * When a client connects with a valid token, the primary auth provider
1633
- * handles authentication normally. When a client connects without a token
1634
- * (or with an invalid one), the connection is accepted as anonymous with
1635
- * restricted sync scopes.
3438
+ * When a client connects with a token, the primary auth provider decides:
3439
+ * a valid token authenticates normally and an invalid, expired or revoked one
3440
+ * is rejected (the client refreshes and reconnects). Only a client that sends
3441
+ * no token at all is accepted as anonymous, with exactly `anonymousScopes`.
1636
3442
  *
1637
3443
  * This is the recommended pattern for apps that need public data access
1638
3444
  * alongside authenticated users — for example, a form builder where
@@ -1673,9 +3479,24 @@ declare class MixedAuthProvider implements AuthProvider {
1673
3479
  private readonly anonymousPrefix;
1674
3480
  private anonymousCounter;
1675
3481
  constructor(options: MixedAuthProviderOptions);
1676
- authenticate(token: string): Promise<AuthContext>;
3482
+ /** The primary provider's revocation feed, when it has one. */
3483
+ onRevoke?: AuthProvider['onRevoke'];
3484
+ authenticate(token: string): Promise<AuthContext | null>;
1677
3485
  }
1678
3486
 
3487
+ /** Options for {@link MemoryServerStore}. */
3488
+ interface MemoryServerStoreOptions {
3489
+ /**
3490
+ * Node ids, besides this store's own, whose operations win
3491
+ * `merge('server-authoritative')` fields (legacy server node ids). Every
3492
+ * `kora:server:` node id is authoritative without being listed.
3493
+ */
3494
+ authoritativeNodeIds?: string[];
3495
+ /** Revoked explicit authorities (see `ServerIdentityOptions.revokedAuthoritativeNodeIds`). */
3496
+ revokedAuthoritativeNodeIds?: string[];
3497
+ /** Instance id within the deployment (see `ServerIdentityOptions.instanceId`). */
3498
+ instanceId?: string;
3499
+ }
1679
3500
  /**
1680
3501
  * In-memory server store for testing and quick prototyping.
1681
3502
  * Not suitable for production — data does not survive process restart.
@@ -1687,6 +3508,13 @@ declare class MemoryServerStore implements ServerStore {
1687
3508
  private readonly nodeId;
1688
3509
  private readonly operations;
1689
3510
  private readonly operationIndex;
3511
+ /** (nodeId, sequenceNumber) -> the id of the operation holding it (W3 step 4). */
3512
+ /** (node, sequence) -> ids holding it, in store order (several only for a legacy pair). */
3513
+ private readonly operationIdsBySequence;
3514
+ /** Node -> sequences held by more than one operation (legacy pairs, RT-48). */
3515
+ private readonly pairSequencesByNode;
3516
+ /** Operation id -> how it was resolved without being stored (RT-43, RT-47). */
3517
+ private readonly resolutions;
1690
3518
  private readonly versionVector;
1691
3519
  /**
1692
3520
  * Server-assigned delivery sequence per operation id. Single-process, so a plain
@@ -1697,24 +3525,117 @@ declare class MemoryServerStore implements ServerStore {
1697
3525
  private readonly deliverySeqByOpId;
1698
3526
  private deliverySeqCounter;
1699
3527
  private schema;
3528
+ /** Node id -> the authenticated principal that claimed it (see claimNode). */
3529
+ private readonly nodeOwners;
3530
+ /** Operation id -> scope snapshot captured at apply time (RT-14). */
3531
+ private readonly scopeSnapshots;
3532
+ /** Fields the current snapshots were captured with (RT-20). */
3533
+ private snapshotFingerprint;
3534
+ /** Blob content hash -> owners that pushed or first claimed it (RT-11). */
3535
+ private readonly blobOwners;
3536
+ /** Wrapped encryption key records (ENC-1): owner + keyring -> record JSON and revision. */
3537
+ private readonly encryptionKeyRecords;
1700
3538
  /** Materialized records: collection -> recordId -> record data */
1701
3539
  private readonly materializedRecords;
3540
+ /** Fold state per materialized record (W7): rows are projected from it. */
3541
+ private readonly foldStates;
3542
+ /** Every operation of a record, in store (delivery) order. */
3543
+ private readonly recordOps;
3544
+ private readonly authoritativeNodeIds;
3545
+ /** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
3546
+ private readonly explicitAuthorities;
3547
+ /** Explicit authority over time: revoked and ever-held ids (RT-81). */
3548
+ private readonly authorityHistory;
3549
+ private foldOptions;
3550
+ /** Schema transforms the fold applies (transforms at fold time, RT-84). */
3551
+ private operationTransforms;
1702
3552
  private closed;
1703
- constructor(nodeId?: string);
3553
+ /**
3554
+ * Sequence-enforcement epoch (see SEQUENCE_ENFORCEMENT_EPOCH_KEY). A memory store
3555
+ * starts empty under this release, so 0: every holder was stored under enforcement.
3556
+ * A replace-mode backup import moves it to the restored log's end.
3557
+ */
3558
+ private sequenceEpoch;
3559
+ /** Derivation secret of server-derived ids (RT-64). Per process: nothing persists. */
3560
+ private readonly derivationSecret;
3561
+ /**
3562
+ * @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
3563
+ * `kora:server:` id is used verbatim. Leave unset: the store authors under
3564
+ * `kora:server:<deployment>:<instance>` (a fresh deployment per process, since a
3565
+ * memory store persists nothing).
3566
+ * @param options - Extra authoritative ids and the instance id
3567
+ */
3568
+ constructor(nodeId?: string, options?: MemoryServerStoreOptions);
3569
+ deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
3570
+ getAuthoritativeNodeIds(): string[];
3571
+ /** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
3572
+ getRevokedAuthoritativeNodeIds(): string[];
3573
+ /**
3574
+ * Every explicit id the deployment ever held authoritative, revoked ones included
3575
+ * (RT-81): never accepted as a device node id.
3576
+ */
3577
+ getEverAuthoritativeNodeIds(): string[];
1704
3578
  getVersionVector(): VersionVector;
1705
3579
  getNodeId(): string;
1706
3580
  getSchema(): SchemaDefinition | null;
1707
- setSchema(schema: SchemaDefinition): Promise<void>;
1708
- applyRemoteOperation(op: Operation): Promise<ApplyResult>;
3581
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
3582
+ getOperationTransforms(): readonly OperationTransform[];
3583
+ setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
3584
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
3585
+ private addSequenceHolder;
3586
+ getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
3587
+ recordOperationResolution(resolution: OperationResolution): Promise<void>;
3588
+ findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
3589
+ deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
3590
+ getResolvedThrough(nodeId: string): Promise<number>;
3591
+ findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
3592
+ getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
3593
+ getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
3594
+ getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
3595
+ getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
3596
+ getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
3597
+ previewOperation(op: Operation): Promise<MaterializedRecord | null>;
3598
+ /**
3599
+ * The record's fold state: the stored one for a materialized collection, else
3600
+ * folded from the record's operations (a collection outside the schema).
3601
+ */
3602
+ private readFoldState;
3603
+ private addRecordOp;
3604
+ recordBlobOwner(hash: string, owner: string): Promise<void>;
3605
+ getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
3606
+ claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
3607
+ /** Scope values of a live record before a write; null when absent or deleted. */
3608
+ private scopeValuesBefore;
3609
+ /**
3610
+ * Rebuild missing scope snapshots from the log (migration from a store without
3611
+ * them, or a replace-mode backup import), replaying each record in commit order.
3612
+ */
3613
+ private backfillScopeSnapshots;
1709
3614
  getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
1710
3615
  getOperationCount(): Promise<number>;
1711
3616
  getMaxDeliverySequence(): Promise<number>;
1712
3617
  getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
1713
3618
  materializeCollection(collection: string): Promise<MaterializedRecord[]>;
3619
+ getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
3620
+ findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
1714
3621
  queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
1715
3622
  findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
1716
3623
  countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
1717
3624
  close(): Promise<void>;
3625
+ getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
3626
+ putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
3627
+ listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
3628
+ getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
3629
+ claimNode(nodeId: string, userId: string): Promise<boolean>;
3630
+ getNodeClaimOwner(nodeId: string): Promise<string | null>;
3631
+ replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
3632
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
3633
+ /**
3634
+ * The record as currently stored, including a soft-deleted one (whose last field
3635
+ * values are kept), or null when it was never written. Without a materialized
3636
+ * table, the last known values are replayed from the operation log.
3637
+ */
3638
+ private readStoredRow;
1718
3639
  /**
1719
3640
  * Wipes all in-memory state. For tests and E2E isolation only.
1720
3641
  */
@@ -1728,7 +3649,16 @@ declare class MemoryServerStore implements ServerStore {
1728
3649
  * Get all stored operations (for test assertions).
1729
3650
  */
1730
3651
  getAllOperations(): Operation[];
1731
- private rebuildMaterializedRecord;
3652
+ /**
3653
+ * Merge one just-appended operation into its record's fold state and project the
3654
+ * row: O(fields the operation touches), independent of the record's history
3655
+ * (SRV-7). Falls back to a full re-fold only when the stored state cannot take the
3656
+ * operation (a field's fold kind changed with the schema).
3657
+ */
3658
+ private mergeIntoRecord;
3659
+ /** Persist a record's fold state and project it onto its materialized row. */
3660
+ private writeRecord;
3661
+ /** Re-fold every record of every materialized collection (schema set, backup restore). */
1732
3662
  private backfillAllCollections;
1733
3663
  private materializeFromOps;
1734
3664
  private assertOpen;
@@ -1736,6 +3666,123 @@ declare class MemoryServerStore implements ServerStore {
1736
3666
  private assertCollection;
1737
3667
  }
1738
3668
 
3669
+ /**
3670
+ * Server materialization through the one deterministic fold (W7 Stage B2).
3671
+ *
3672
+ * Every server store (memory, SQLite, Postgres) keeps, per materialized record, the
3673
+ * record's serialized {@link FoldState} next to its collection row. Applying an
3674
+ * operation is "append, then `mergeOp`": O(fields the operation touches), never a
3675
+ * replay of the record's history (SRV-7). The collection row is a pure projection of
3676
+ * the fold state ({@link projectFoldState}), so the server's rows are exactly what a
3677
+ * client that holds the same operations materializes (SRV-1).
3678
+ *
3679
+ * The fold is pure JavaScript: ordering uses HLC stamps compared in JS, never a
3680
+ * database collation (the Phase 1 COLLATE "C" concern disappears for materialization).
3681
+ *
3682
+ * Old helpers (`replayOperationsForRecord`, `mergeArraySet`) are no longer used to
3683
+ * materialize; they stay exported from core for legacy comparison only.
3684
+ */
3685
+
3686
+ /** What a store's startup re-materialization did (W7 step 7). */
3687
+ interface FoldMigrationReport {
3688
+ /** False when no schema was set (nothing is materialized). */
3689
+ ran: boolean;
3690
+ /** Records re-folded and rewritten. */
3691
+ records: number;
3692
+ /**
3693
+ * Records that own quarantined operations (see `ServerLogIntegrityReport`): their log
3694
+ * is incomplete, so their pre-fold rows were kept as a snapshot base with the
3695
+ * remaining operations folded onto it (RT-70), never re-folded from the log alone.
3696
+ */
3697
+ skippedUnclean: number;
3698
+ /** True when the fold plan (field kinds, strategies, resolvers) changed: every record was re-folded. */
3699
+ fullRefold: boolean;
3700
+ }
3701
+
3702
+ /** A row the scan quarantined. */
3703
+ interface ServerLogIntegrityRow {
3704
+ operationId: string;
3705
+ problem: string;
3706
+ detail: string;
3707
+ }
3708
+ /** Result of the server scan. */
3709
+ interface ServerLogIntegrityReport {
3710
+ /** Rows checked by this scan (0 when it already ran on this database). */
3711
+ checkedRows: number;
3712
+ /** Rows this scan moved to the quarantine table. */
3713
+ quarantined: ServerLogIntegrityRow[];
3714
+ /** False when the one-time scan had already run (only the quarantine is reported). */
3715
+ ran: boolean;
3716
+ /** Every row in the quarantine table. */
3717
+ totalQuarantined: number;
3718
+ }
3719
+
3720
+ /**
3721
+ * Stable server identity (RT-61, RT-62, RT-64).
3722
+ *
3723
+ * Every operation the server authors (route writes, cascades, set-nulls, constraint
3724
+ * corrections) is authored by a node id in the reserved namespace
3725
+ * `kora:server:<deploymentId>:<instanceId>`:
3726
+ *
3727
+ * - `deploymentId` is generated once per database and persisted in `kora_server_meta`,
3728
+ * so every instance that shares the database (and every restart) belongs to one
3729
+ * deployment.
3730
+ * - `instanceId` is distinct per running instance, so two instances never author the
3731
+ * same `(nodeId, sequenceNumber)`: SQLite persists one (a SQLite database has one
3732
+ * writer process), Postgres allocates a fresh one from a counter in the database on
3733
+ * every start unless the operator configures a stable `instanceId` per instance.
3734
+ *
3735
+ * Authority (`merge('server-authoritative')`) is the prefix rule: any operation whose
3736
+ * node id starts with {@link SERVER_NODE_PREFIX} is authoritative, on the server and on
3737
+ * every device. Devices can never use a `kora:` node id (the session refuses it at
3738
+ * handshake). Server node ids written before this release (random per-process ids, or
3739
+ * an id the operator configured) are recorded once, at the first start of this release,
3740
+ * in a persisted list of legacy authoritative ids that the handshake keeps advertising,
3741
+ * so earlier server decisions keep winning everywhere.
3742
+ *
3743
+ * Server-derived operation ids (cascades, corrections) are keyed with a persisted,
3744
+ * deployment-wide secret (HMAC-SHA-256), so a writer cannot predict them and occupy one
3745
+ * first; every instance of the deployment derives the same id for the same effect.
3746
+ */
3747
+
3748
+ /** Prefix of every node id the server authors under. */
3749
+ declare const SERVER_NODE_PREFIX = "kora:server:";
3750
+ /** Thrown for an invalid server identity configuration. */
3751
+ declare class ServerIdentityError extends KoraError {
3752
+ constructor(message: string, context?: Record<string, unknown>);
3753
+ }
3754
+ /** Identity options every server store accepts. */
3755
+ interface ServerIdentityOptions {
3756
+ /**
3757
+ * Deprecated. A plain id (for example one set before beta.13) is no longer the id the
3758
+ * server authors under: it is recorded as a legacy authoritative id, so operations
3759
+ * already stored under it keep their authority, and no device may use it. An id in
3760
+ * the `kora:server:` namespace is used verbatim (it must then be unique per instance).
3761
+ */
3762
+ nodeId?: string;
3763
+ /**
3764
+ * Stable id of this instance within the deployment (`[A-Za-z0-9._-]`, at most 64
3765
+ * characters). Must be distinct per running instance. SQLite persists one on its own;
3766
+ * Postgres allocates a new one per start when it is not configured.
3767
+ */
3768
+ instanceId?: string;
3769
+ /**
3770
+ * Extra node ids whose operations win `merge('server-authoritative')` fields. Every id
3771
+ * listed here once stays authoritative (devices keep it too) until it is revoked with
3772
+ * {@link revokedAuthoritativeNodeIds}: removing it from this list does not revoke it.
3773
+ */
3774
+ authoritativeNodeIds?: string[];
3775
+ /**
3776
+ * Explicit authoritative node ids to revoke (RT-81): they stop being authoritative on
3777
+ * the server, the handshake tells every device to drop them (devices re-fold), and they
3778
+ * are still never accepted as device node ids. Revocation is permanent (persisted); an
3779
+ * id may not be both listed in `authoritativeNodeIds` and revoked.
3780
+ */
3781
+ revokedAuthoritativeNodeIds?: string[];
3782
+ }
3783
+ /** True for a node id the server authors under (current or any instance of any deployment). */
3784
+ declare function isServerNodeId(nodeId: string): boolean;
3785
+
1739
3786
  /**
1740
3787
  * PostgreSQL-backed server store using Drizzle ORM.
1741
3788
  * All reads and writes go through Drizzle's typed query builder.
@@ -1744,7 +3791,18 @@ declare class MemoryServerStore implements ServerStore {
1744
3791
  * collection tables for efficient indexed queries (dual-write).
1745
3792
  */
1746
3793
  declare class PostgresServerStore implements ServerStore {
1747
- private readonly nodeId;
3794
+ /** This instance's server node id; loaded from the database during startup (RT-62). */
3795
+ private loadedNodeId;
3796
+ private readonly configuredIdentity;
3797
+ /** Derivation secret of server-derived ids, shared by the deployment (RT-64). */
3798
+ private derivationSecret;
3799
+ /** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
3800
+ private explicitAuthorities;
3801
+ /** Explicit authority over time: revoked and ever-held ids (RT-81). */
3802
+ private authorityHistory;
3803
+ /** Other `kora:server:` nodes with stored operations (advertised for older clients). */
3804
+ /** Records owning quarantined operations: folded onto their kept rows (RT-70). */
3805
+ private quarantine;
1748
3806
  private readonly db;
1749
3807
  private readonly versionVector;
1750
3808
  private readonly ready;
@@ -1758,6 +3816,9 @@ declare class PostgresServerStore implements ServerStore {
1758
3816
  * null after a backup import so it re-seeds from the restored version vector.
1759
3817
  */
1760
3818
  private sequenceCounter;
3819
+ /** See {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}; fixed per database at first start. */
3820
+ private sequenceEpoch;
3821
+ private logIntegrity;
1761
3822
  /**
1762
3823
  * Time source for HLC timestamps on server-originated operations. Injectable so a
1763
3824
  * test can freeze wall-clock time and prove the conditional-apply ordering holds
@@ -1765,7 +3826,54 @@ declare class PostgresServerStore implements ServerStore {
1765
3826
  * exist to make correct). Defaults to the system clock in production.
1766
3827
  */
1767
3828
  private readonly timeSource;
1768
- constructor(db: PostgresJsDatabase, nodeId?: string, timeSource?: TimeSource);
3829
+ private foldOptions;
3830
+ /** Schema transforms the fold applies (transforms at fold time, RT-84). */
3831
+ private operationTransforms;
3832
+ private foldMigration;
3833
+ /**
3834
+ * @param db - The Drizzle database
3835
+ * @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
3836
+ * `kora:server:` id is used verbatim (it must then be unique per instance). Leave
3837
+ * unset: the store authors under `kora:server:<deployment>:<instance>`, with the
3838
+ * deployment persisted in the database and a distinct instance per process (RT-62).
3839
+ * @param timeSource - Clock for server-originated operations (tests)
3840
+ * @param options - Extra authoritative ids and a stable instance id
3841
+ */
3842
+ constructor(db: PostgresJsDatabase, nodeId?: string, timeSource?: TimeSource, options?: Omit<ServerIdentityOptions, 'nodeId'>);
3843
+ /** Resolves once the store's tables and identity are loaded. */
3844
+ whenReady(): Promise<void>;
3845
+ /**
3846
+ * This store's node id and the legacy and configured ids (explicit authorities).
3847
+ * Other `kora:server:` instances are not listed: the prefix rule makes every one of
3848
+ * them authoritative, and a list that grew by one instance id per start would make
3849
+ * every device re-fold after each deploy (RT-75). Handshakes advertise only the
3850
+ * explicit (non-prefixed) ids.
3851
+ */
3852
+ getAuthoritativeNodeIds(): string[];
3853
+ /** Legacy and configured authorities (beside the `kora:server:` namespace). */
3854
+ getLegacyAuthoritativeNodeIds(): string[];
3855
+ /** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
3856
+ getRevokedAuthoritativeNodeIds(): string[];
3857
+ /**
3858
+ * Every explicit id the deployment ever held authoritative, revoked ones included
3859
+ * (RT-81): never accepted as a device node id.
3860
+ */
3861
+ getEverAuthoritativeNodeIds(): string[];
3862
+ deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
3863
+ private get nodeId();
3864
+ /** Remember another `kora:server:` node that authored stored operations. */
3865
+ /**
3866
+ * Load (creating on first start) the persisted server identity under an advisory
3867
+ * lock: the deployment id and derivation secret (one per database, shared by every
3868
+ * instance), a fresh instance id from the database counter (unless configured), and
3869
+ * the legacy authoritative ids. At the first start of this release, every node id
3870
+ * whose operations hold an authority class in a stored fold state (a server decision
3871
+ * folded under an earlier, per-process server node id) is recorded as a legacy
3872
+ * authority, before any re-fold could drop that class.
3873
+ */
3874
+ private loadIdentity;
3875
+ /** What the last startup re-materialization did (W7 step 7). */
3876
+ getFoldMigrationReport(): FoldMigrationReport;
1769
3877
  getVersionVector(): VersionVector;
1770
3878
  /**
1771
3879
  * Atomically reserve this node's next server-operation sequence number. The
@@ -1775,8 +3883,51 @@ declare class PostgresServerStore implements ServerStore {
1775
3883
  reserveSequenceNumber(): number;
1776
3884
  getNodeId(): string;
1777
3885
  getSchema(): SchemaDefinition | null;
1778
- setSchema(schema: SchemaDefinition): Promise<void>;
1779
- applyRemoteOperation(op: Operation): Promise<ApplyResult>;
3886
+ /**
3887
+ * One-time migration (RT-101): drop the enum `CHECK` constraints (and any `NOT NULL`
3888
+ * on a schema field) that beta.12 and earlier DDL put on collection tables, in one
3889
+ * transaction. A check is dropped when it parses as an enum check (one column against
3890
+ * string literals, in every form Postgres normalizes `IN (...)` to, the single-value
3891
+ * `col = 'x'` included, RT-108) on an enum field of the schema, or as the multi-value
3892
+ * `= ANY (ARRAY[...])` shape on another non-internal column; checks added by hand are
3893
+ * kept. Idempotent and resumable: a later start finds nothing to drop.
3894
+ */
3895
+ private relaxValueDomainConstraints;
3896
+ /**
3897
+ * Throws {@link OperationTransformCoverageError} when a schema version in the stored
3898
+ * log has no transform path to `version` (RT-103): those operations would fold as
3899
+ * absent, silently erasing them from their records.
3900
+ */
3901
+ private assertTransformCoverage;
3902
+ getOperationTransforms(): readonly OperationTransform[];
3903
+ setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
3904
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
3905
+ /**
3906
+ * One-time migration of each collection table to the lossless text encoding (RT-65):
3907
+ * rows written before it that contain U+FFFF (the escape introducer) are re-encoded,
3908
+ * so the decoder returns them unchanged. Per table, under an advisory lock, recorded
3909
+ * in `kora_server_meta`.
3910
+ */
3911
+ private migrateTextCodec;
3912
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
3913
+ /**
3914
+ * The version vector as committed in `sync_state`, shared by every instance
3915
+ * (SRV-4). Also refreshes this instance's cache, which `getVersionVector()` serves.
3916
+ */
3917
+ readVersionVector(): Promise<VersionVector>;
3918
+ /**
3919
+ * Insert one operation row inside an append transaction that already holds the
3920
+ * delivery-counter lock. Returns `'duplicate'` (writing nothing) when an operation
3921
+ * with the same id is stored; throws {@link SequenceConflictError} when a DIFFERENT
3922
+ * operation stored after the sequence-enforcement epoch holds its (node, sequence)
3923
+ * and the writer reserves its sequences (W3 step 4); a legacy holder or a legacy
3924
+ * writer (RT-37) is accepted and the row is stored unflagged. Otherwise returns the
3925
+ * verdict the row was stored under. The partial unique index over sole-holder rows
3926
+ * backs the check against a writer that bypasses the counter lock.
3927
+ */
3928
+ private insertOperationRow;
3929
+ /** Advance `sync_state` (the persisted version vector) for an appended operation. */
3930
+ private advanceSyncState;
1780
3931
  /**
1781
3932
  * Conditionally apply operations atomically, serialized across server instances
1782
3933
  * on the target record via a transaction-scoped advisory lock. The lock makes
@@ -1788,7 +3939,37 @@ declare class PostgresServerStore implements ServerStore {
1788
3939
  getOperationCount(): Promise<number>;
1789
3940
  getMaxDeliverySequence(): Promise<number>;
1790
3941
  getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
3942
+ getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
3943
+ getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
3944
+ getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
3945
+ getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
3946
+ getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
3947
+ previewOperation(op: Operation): Promise<MaterializedRecord | null>;
3948
+ recordBlobOwner(hash: string, owner: string): Promise<void>;
3949
+ getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
3950
+ claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
3951
+ /**
3952
+ * Scope values of a record as stored, for an operation's scope snapshot. With
3953
+ * `includeDeleted` false a soft-deleted row counts as absent; with true it keeps
3954
+ * its last values (a delete's post-image).
3955
+ */
3956
+ private readScopeValues;
3957
+ /** Persist the scope snapshot of a just-applied operation (RT-14). */
3958
+ private writeScopeSnapshot;
3959
+ /**
3960
+ * Rebuild missing scope snapshots from the log (migration from a database written
3961
+ * before snapshots existed), replaying each affected record in commit order.
3962
+ */
3963
+ private backfillScopeSnapshots;
1791
3964
  materializeCollection(collection: string): Promise<MaterializedRecord[]>;
3965
+ getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
3966
+ getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
3967
+ recordOperationResolution(resolution: OperationResolution): Promise<void>;
3968
+ findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
3969
+ deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
3970
+ getResolvedThrough(nodeId: string): Promise<number>;
3971
+ findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
3972
+ findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
1792
3973
  queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
1793
3974
  findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
1794
3975
  countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
@@ -1805,28 +3986,105 @@ declare class PostgresServerStore implements ServerStore {
1805
3986
  * write so newly built operations sort strictly after it.
1806
3987
  */
1807
3988
  private latestOperationTimestamp;
3989
+ getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
3990
+ putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
3991
+ listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
3992
+ getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
3993
+ claimNode(nodeId: string, userId: string): Promise<boolean>;
3994
+ getNodeClaimOwner(nodeId: string): Promise<string | null>;
3995
+ replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
3996
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
3997
+ /**
3998
+ * The record as currently stored, including a soft-deleted one (whose last field
3999
+ * values are kept), or null when it was never written. Without a materialized
4000
+ * table, the last known values are replayed from the operation log.
4001
+ */
4002
+ private readStoredRow;
4003
+ /**
4004
+ * Merge a just-appended operation (stored under `deliverySeq`) into its record's
4005
+ * fold state and project the row, inside the append transaction. O(fields the
4006
+ * operation touches): the state is read by primary key and the freshness check is
4007
+ * one index probe (SRV-7).
4008
+ *
4009
+ * Concurrency: every append on every instance holds the delivery-counter row lock
4010
+ * until commit (see nextDeliverySeq), and applyConditional takes its per-record
4011
+ * advisory lock before it, so this read-merge-write of one record's state is
4012
+ * serialized across instances; the `FOR UPDATE` on the state row is defense in
4013
+ * depth. A record whose log grew without this state (an older release during a
4014
+ * rolling upgrade) has its missing tail merged first; an unreadable, newer-than-log
4015
+ * or plan-incompatible state is re-folded from the log.
4016
+ */
4017
+ private mergeIntoRecord;
4018
+ /**
4019
+ * The fold state of a record that owns quarantined operations (RT-70): its stored
4020
+ * state, or its kept row as a snapshot base, with every remaining operation of the
4021
+ * record merged on top (`rebuildFromSnapshot`). Nothing is written.
4022
+ */
4023
+ private rebuildAffected;
4024
+ /** True when the record's operation at delivery sequence `seq` has id `opId`. */
4025
+ private coversStoredOperation;
4026
+ /**
4027
+ * The record's current fold state for a read: the stored one when it covers the
4028
+ * record's whole log, else folded from the log (nothing is written).
4029
+ */
4030
+ private readFoldState;
4031
+ /** A record's operations stored after `afterDeliverySeq`, in delivery order. */
4032
+ private readRecordOperations;
1808
4033
  /**
1809
- * Rebuild a single record in the materialized collection table by replaying
1810
- * all operations for that record.
4034
+ * Persist a record's fold state (covering the log up to `coveredSeq`) and project
4035
+ * it onto the materialized row.
1811
4036
  */
1812
- private rebuildMaterializedRecord;
4037
+ private writeFoldedRecord;
1813
4038
  /**
1814
4039
  * UPSERT a record into the materialized collection table.
1815
4040
  */
1816
4041
  private upsertMaterializedRecord;
1817
4042
  /**
1818
- * Backfill all materialized collection tables from the existing operation log.
4043
+ * {@link writeFoldedRecord} for a batch of records of one collection, in a handful
4044
+ * of multi-row statements (the migration's write path). Rows are chunked so a
4045
+ * statement never nears the 65,535 bind-parameter limit, however wide the collection.
1819
4046
  */
1820
- private backfillAllCollections;
4047
+ private writeFoldedRecords;
1821
4048
  /**
1822
- * Backfill a single collection's materialized table from operations.
4049
+ * Re-materialization migration (W7 step 7), run by setSchema and after a
4050
+ * replace-mode restore: every record whose fold state is missing, stale or built
4051
+ * under a different fold plan is re-folded from its log; nothing else is touched.
4052
+ * See the SQLite store's `rematerialize` for the rules (a record owning quarantined
4053
+ * operations folds onto its kept row, RT-70; idempotent; resumable).
4054
+ *
4055
+ * Postgres specifics: records are paged in batches of
4056
+ * {@link FOLD_MIGRATION_BATCH} through the (collection, record_id, delivery_seq)
4057
+ * index, and every batch is one transaction that first takes the delivery-counter
4058
+ * row lock. Appends on every instance take that lock too, so a batch reads each
4059
+ * record's complete log and writes its state while no append to it can commit: a
4060
+ * live write on another instance during a rolling deploy is never overwritten by a
4061
+ * stale re-fold (NEW-SRV-5), it either committed before the batch (and is folded)
4062
+ * or commits after it (and is merged into the batch's state).
1823
4063
  */
1824
- private backfillCollection;
4064
+ /**
4065
+ * Write the beta.12 clears the ids prove into the stored bodies, once per database
4066
+ * (RT-85, see `provenLegacyClears`), and mark their records' fold states stale.
4067
+ * Idempotent: concurrent first starts write the same bodies.
4068
+ */
4069
+ private canonicalizeLegacyBodies;
4070
+ private rematerialize;
1825
4071
  private buildSelectQuery;
1826
4072
  private buildWhereClause;
1827
4073
  private deserializeRow;
1828
4074
  private materializeFromOpsLog;
1829
4075
  private initialize;
4076
+ /**
4077
+ * The startup log-integrity scan's result (W8 step 0): rows that could not be read
4078
+ * back into an operation are moved to `operations_quarantine` once per database,
4079
+ * before any fold reads them.
4080
+ */
4081
+ getLogIntegrityReport(): Promise<ServerLogIntegrityReport>;
4082
+ /**
4083
+ * One-time scan under the setup advisory lock (concurrent starts run it once). Keyset
4084
+ * pages by id, so a large log is read in bounded chunks; like the delivery-sequence
4085
+ * backfill it runs on the first start of this release only.
4086
+ */
4087
+ private scanLogIntegrityOnce;
1830
4088
  private ensureTables;
1831
4089
  /**
1832
4090
  * Reserve the next delivery sequence inside an open append transaction. The
@@ -1834,6 +4092,11 @@ declare class PostgresServerStore implements ServerStore {
1834
4092
  * serializing assignment into commit order (see the counter table comment).
1835
4093
  */
1836
4094
  private nextDeliverySeq;
4095
+ /**
4096
+ * @param soleHolder - Nothing else held the (node, sequence) at insert: the row is
4097
+ * flagged `seq_unique` and covered by {@link NODE_SEQ_UNIQUE_INDEX}. False for a
4098
+ * legacy pair's second operation and for restored backup rows.
4099
+ */
1837
4100
  private serializeOperation;
1838
4101
  private deserializeOperation;
1839
4102
  private assertOpen;
@@ -1845,8 +4108,14 @@ declare class PostgresServerStore implements ServerStore {
1845
4108
  */
1846
4109
  declare function createPostgresServerStore(options: {
1847
4110
  connectionString: string;
1848
- nodeId?: string;
1849
- }): Promise<PostgresServerStore>;
4111
+ } & ServerIdentityOptions): Promise<PostgresServerStore>;
4112
+ /**
4113
+ * The Postgres server store could not start: the database is unreachable, refused the
4114
+ * credentials, or its tables could not be created. `cause` is the driver's error.
4115
+ */
4116
+ declare class ServerStoreUnavailableError extends KoraError {
4117
+ constructor(connectionString: string, cause: unknown);
4118
+ }
1850
4119
 
1851
4120
  /**
1852
4121
  * SQLite-backed server store using Drizzle ORM.
@@ -1861,46 +4130,206 @@ declare class SqliteServerStore implements ServerStore {
1861
4130
  private readonly db;
1862
4131
  private schema;
1863
4132
  private closed;
1864
- constructor(db: BetterSQLite3Database, nodeId?: string);
4133
+ /** See {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}; fixed per database at first start. */
4134
+ private sequenceEpoch;
4135
+ private logIntegrity;
4136
+ /** Derivation secret of server-derived ids, shared by the deployment (RT-64). */
4137
+ private readonly derivationSecret;
4138
+ /** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
4139
+ private readonly explicitAuthorities;
4140
+ /** Explicit authority over time: revoked and ever-held ids (RT-81). */
4141
+ private readonly authorityHistory;
4142
+ /** Other `kora:server:` nodes with stored operations (advertised for older clients). */
4143
+ /** Records owning quarantined operations: folded onto their kept rows (RT-70). */
4144
+ private quarantine;
4145
+ private foldOptions;
4146
+ /** Schema transforms the fold applies (transforms at fold time, RT-84). */
4147
+ private operationTransforms;
4148
+ private foldMigration;
4149
+ /**
4150
+ * @param db - The Drizzle database
4151
+ * @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
4152
+ * `kora:server:` id is used verbatim. Leave unset: the store authors under
4153
+ * `kora:server:<deployment>:<instance>`, both persisted in the database (RT-62).
4154
+ * @param options - Extra authoritative ids and the instance id
4155
+ */
4156
+ constructor(db: BetterSQLite3Database, nodeId?: string, options?: Omit<ServerIdentityOptions, 'nodeId'>);
4157
+ /**
4158
+ * This store's node id and the legacy and configured ids (explicit authorities).
4159
+ * Other `kora:server:` instances are not listed: the prefix rule makes every one of
4160
+ * them authoritative, and a list that grew by one instance id per start would make
4161
+ * every device re-fold after each deploy (RT-75). Handshakes advertise only the
4162
+ * explicit (non-prefixed) ids.
4163
+ */
4164
+ getAuthoritativeNodeIds(): string[];
4165
+ /** Legacy and configured authorities (beside the `kora:server:` namespace). */
4166
+ getLegacyAuthoritativeNodeIds(): string[];
4167
+ /** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
4168
+ getRevokedAuthoritativeNodeIds(): string[];
4169
+ /**
4170
+ * Every explicit id the deployment ever held authoritative, revoked ones included
4171
+ * (RT-81): never accepted as a device node id.
4172
+ */
4173
+ getEverAuthoritativeNodeIds(): string[];
4174
+ deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
4175
+ /** Remember another `kora:server:` node that authored stored operations. */
4176
+ /**
4177
+ * Load (creating on first start) the persisted server identity: the deployment id,
4178
+ * the derivation secret, this database's instance id and the legacy authoritative
4179
+ * ids. At the first start of this release, every node id whose operations hold an
4180
+ * authority class in a stored fold state (a server decision folded under an earlier,
4181
+ * per-process server node id) is recorded as a legacy authority, before any re-fold
4182
+ * could drop that class.
4183
+ */
4184
+ private loadIdentity;
4185
+ /** What the last startup re-materialization did (W7 step 7). */
4186
+ getFoldMigrationReport(): FoldMigrationReport;
1865
4187
  getVersionVector(): VersionVector;
1866
4188
  getNodeId(): string;
1867
4189
  getSchema(): SchemaDefinition | null;
1868
- setSchema(schema: SchemaDefinition): Promise<void>;
1869
- applyRemoteOperation(op: Operation): Promise<ApplyResult>;
4190
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
4191
+ /**
4192
+ * One-time migration (RT-101): rebuild collection tables that still carry an enum
4193
+ * `CHECK` (or `NOT NULL` on a schema field), in one transaction, keeping rows, indexes
4194
+ * and triggers. Idempotent (a relaxed table is left alone) and resumable (an
4195
+ * interrupted rebuild rolls back and runs again at the next start).
4196
+ */
4197
+ private relaxValueDomainConstraints;
4198
+ /**
4199
+ * Throws {@link OperationTransformCoverageError} when a schema version in the stored
4200
+ * log has no transform path to `version` (RT-103): those operations would fold as
4201
+ * absent, silently erasing them from their records.
4202
+ */
4203
+ private assertTransformCoverage;
4204
+ getOperationTransforms(): readonly OperationTransform[];
4205
+ setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
4206
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
4207
+ private applyInTransaction;
1870
4208
  getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
1871
4209
  getOperationCount(): Promise<number>;
1872
4210
  getMaxDeliverySequence(): Promise<number>;
1873
4211
  getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
4212
+ getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
4213
+ getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
4214
+ getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
4215
+ getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
4216
+ getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
4217
+ previewOperation(op: Operation): Promise<MaterializedRecord | null>;
4218
+ recordBlobOwner(hash: string, owner: string): Promise<void>;
4219
+ getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
4220
+ claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
4221
+ /**
4222
+ * Scope values of a record as stored, for an operation's scope snapshot. With
4223
+ * `includeDeleted` false a soft-deleted row counts as absent (the pre-image of a
4224
+ * write to a deleted record); with true it keeps its last values (a delete's
4225
+ * post-image).
4226
+ */
4227
+ private readScopeValues;
4228
+ /**
4229
+ * Rebuild missing scope snapshots from the log (migration from a database written
4230
+ * before snapshots existed), replaying each affected record in commit order.
4231
+ */
4232
+ private backfillScopeSnapshots;
1874
4233
  materializeCollection(collection: string): Promise<MaterializedRecord[]>;
4234
+ getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
4235
+ getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
4236
+ recordOperationResolution(resolution: OperationResolution): Promise<void>;
4237
+ findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
4238
+ deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
4239
+ getResolvedThrough(nodeId: string): Promise<number>;
4240
+ findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
4241
+ findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
1875
4242
  queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
1876
4243
  findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
1877
4244
  countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
1878
4245
  close(): Promise<void>;
4246
+ getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
4247
+ putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
4248
+ listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
4249
+ getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
4250
+ claimNode(nodeId: string, userId: string): Promise<boolean>;
4251
+ getNodeClaimOwner(nodeId: string): Promise<string | null>;
4252
+ replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
4253
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
4254
+ /**
4255
+ * The record as currently stored, including a soft-deleted one (whose last field
4256
+ * values are kept), or null when it was never written. Without a materialized
4257
+ * table, the last known values are replayed from the operation log.
4258
+ */
4259
+ private readStoredRow;
4260
+ /**
4261
+ * The fold state of a record that owns quarantined operations (RT-70): its stored
4262
+ * state, or its kept row as a snapshot base, with every remaining operation of the
4263
+ * record merged on top (`rebuildFromSnapshot`). Nothing is written.
4264
+ */
4265
+ private rebuildAffected;
1879
4266
  exportBackup(): Promise<Uint8Array>;
1880
4267
  importBackup(data: Uint8Array, merge?: boolean): Promise<{
1881
4268
  operationsRestored: number;
1882
4269
  success: boolean;
1883
4270
  }>;
1884
4271
  /**
1885
- * Rebuild a single record in the materialized collection table by replaying
1886
- * all operations for that record. Called within the applyRemoteOperation
1887
- * transaction for atomic dual-write.
4272
+ * Merge a just-appended operation (stored under `deliverySeq`) into its record's
4273
+ * fold state and project the row, inside the append transaction. O(fields the
4274
+ * operation touches): the stored state is read by primary key and the freshness
4275
+ * check is one index probe, never a scan of the record's history (SRV-7).
4276
+ *
4277
+ * The stored state records the highest delivery sequence it covers. If another
4278
+ * writer appended operations to this record without folding them (an older release
4279
+ * during a rolling upgrade), the missing tail is merged first; if the stored state
4280
+ * is unreadable, newer than the log, or built under an incompatible field kind,
4281
+ * the record is re-folded from its log.
4282
+ */
4283
+ private mergeIntoRecord;
4284
+ /** True when the record's operation at delivery sequence `seq` has id `opId`. */
4285
+ private coversStoredOperation;
4286
+ /**
4287
+ * The record's current fold state for a read: the stored one when it covers the
4288
+ * record's whole log, else folded from the log (nothing is written).
1888
4289
  */
1889
- private rebuildMaterializedRecord;
4290
+ private readFoldState;
4291
+ /** A record's operations stored after `afterDeliverySeq`, in delivery order. */
4292
+ private readRecordOperations;
4293
+ /**
4294
+ * Persist a record's fold state (covering the log up to `coveredSeq`) and project
4295
+ * it onto the materialized row.
4296
+ */
4297
+ private writeFoldedRecord;
1890
4298
  /**
1891
4299
  * UPSERT a record into the materialized collection table.
1892
4300
  * Uses INSERT ... ON CONFLICT (id) DO UPDATE SET for atomic upsert.
1893
4301
  */
1894
4302
  private upsertMaterializedRecord;
1895
4303
  /**
1896
- * Backfill all materialized collection tables from the existing operation log.
1897
- * Called when setSchema() is invoked and operations already exist.
4304
+ * Re-materialization migration (W7 step 7), run by setSchema and after a
4305
+ * replace-mode restore. Every record whose fold state is missing, stale (it covers
4306
+ * less, or more, of the log than is stored) or built under a different fold plan
4307
+ * (field kind, merge strategy, resolver) is re-folded from its log and its row
4308
+ * rewritten; nothing else is touched, so a restart on an up-to-date database does
4309
+ * one aggregate read.
4310
+ *
4311
+ * - Runs only on a log the W8b integrity scan reports clean. With quarantined rows,
4312
+ * rows materialized before the fold existed are kept as they are (re-folding a
4313
+ * log that lost operations would bake the loss in) and the skip is logged loudly;
4314
+ * such a record is folded from its remaining log on its next write. Records with
4315
+ * no row yet, and fold states this release already wrote, are still processed.
4316
+ * - Idempotent and resumable: each batch commits on its own, and staleness is
4317
+ * re-derived from the tables at every start, so a crash resumes where it stopped.
4318
+ * A plan change first marks every stored state stale (one statement), then
4319
+ * records the new fingerprint, so it is resumable too.
4320
+ */
4321
+ /**
4322
+ * One-time migration of each collection table to the lossless text encoding (RT-65):
4323
+ * rows written before it whose raw-string columns contain U+FFFF (the escape
4324
+ * introducer) are re-encoded, so the decoder returns them unchanged.
1898
4325
  */
1899
- private backfillAllCollections;
4326
+ private migrateTextCodec;
1900
4327
  /**
1901
- * Backfill a single collection's materialized table from operations.
4328
+ * Write the beta.12 clears the ids prove into the stored bodies, once per database
4329
+ * (RT-85, see `provenLegacyClears`), and mark their records' fold states stale.
1902
4330
  */
1903
- private backfillCollection;
4331
+ private canonicalizeLegacyBodies;
4332
+ private rematerialize;
1904
4333
  private buildSelectQuery;
1905
4334
  private buildWhereClause;
1906
4335
  private deserializeRow;
@@ -1909,6 +4338,29 @@ declare class SqliteServerStore implements ServerStore {
1909
4338
  * Create the operations and sync_state tables if they don't exist.
1910
4339
  */
1911
4340
  private ensureTables;
4341
+ /**
4342
+ * The startup log-integrity scan's result (W8 step 0): rows that could not be read
4343
+ * back into an operation are moved to `operations_quarantine` once per database,
4344
+ * before any fold reads them.
4345
+ */
4346
+ getLogIntegrityReport(): ServerLogIntegrityReport;
4347
+ private scanLogIntegrityOnce;
4348
+ /**
4349
+ * Index the legacy pairs already in the log once (history written before the
4350
+ * `sequence_pairs` table existed); appends maintain it from then on.
4351
+ */
4352
+ private backfillSequencePairs;
4353
+ /**
4354
+ * Record the sequence-enforcement epoch on the first start of this release (the log's
4355
+ * highest delivery sequence then; see {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}) and
4356
+ * back the append check with the partial unique index over sole-holder rows
4357
+ * ({@link NODE_SEQ_UNIQUE_INDEX}), which therefore always exists: a legacy duplicate
4358
+ * pair is stored unflagged. Replaces the earlier indexes (a full one, created only
4359
+ * when the log had no duplicates, and one over the rows past the epoch, which a
4360
+ * legacy client's pair would violate). Runs in one write transaction, so stores
4361
+ * sharing a file agree on the epoch.
4362
+ */
4363
+ private ensureSequenceEnforcement;
1912
4364
  /**
1913
4365
  * Assign delivery sequences to any operations written before the column existed,
1914
4366
  * then seed the durable counter from the current max. Ordered by
@@ -1922,6 +4374,11 @@ declare class SqliteServerStore implements ServerStore {
1922
4374
  * allocation shared by every SqliteServerStore instance using the same file.
1923
4375
  */
1924
4376
  private nextDeliverySeq;
4377
+ /**
4378
+ * @param soleHolder - Nothing else held the (node, sequence) at insert: the row is
4379
+ * flagged `seq_unique` and covered by {@link NODE_SEQ_UNIQUE_INDEX}. False for a
4380
+ * legacy pair's second operation and for restored backup rows.
4381
+ */
1925
4382
  private serializeOperation;
1926
4383
  private deserializeOperation;
1927
4384
  private assertOpen;
@@ -1934,7 +4391,9 @@ declare class SqliteServerStore implements ServerStore {
1934
4391
  *
1935
4392
  * @param options - Configuration options
1936
4393
  * @param options.filename - Path to SQLite database file. Defaults to ':memory:' for testing.
1937
- * @param options.nodeId - Server node ID. Auto-generated if not provided.
4394
+ * @param options.nodeId - Deprecated (a plain id becomes a legacy authoritative id). Leave
4395
+ * unset: the server authors under the persisted `kora:server:<deployment>:<instance>`.
4396
+ * @param options.instanceId - Optional stable instance id
1938
4397
  * @returns A ready-to-use SqliteServerStore
1939
4398
  *
1940
4399
  * @example
@@ -1951,8 +4410,7 @@ declare class SqliteServerStore implements ServerStore {
1951
4410
  */
1952
4411
  declare function createSqliteServerStore(options: {
1953
4412
  filename?: string;
1954
- nodeId?: string;
1955
- }): SqliteServerStore;
4413
+ } & ServerIdentityOptions): SqliteServerStore;
1956
4414
 
1957
4415
  /**
1958
4416
  * Auth provider that accepts all connections.
@@ -1963,33 +4421,147 @@ declare class NoAuthProvider implements AuthProvider {
1963
4421
  authenticate(_token: string): Promise<AuthContext>;
1964
4422
  }
1965
4423
 
4424
+ /**
4425
+ * Thrown when a sync scope predicate value is `undefined` or `null` (also inside a
4426
+ * `$in` list). Such a value matches every record that lacks the field, so a grant
4427
+ * built from a failed lookup (for example `{ orgId: user.orgId }` with no org)
4428
+ * would expose every unowned record. Kora refuses it instead (fail closed).
4429
+ */
4430
+ declare class InvalidScopePredicateError extends KoraError {
4431
+ readonly collection: string;
4432
+ readonly field: string;
4433
+ constructor(collection: string, field: string);
4434
+ }
4435
+ /** Thrown when a `$in` scope predicate is malformed or larger than the configured limit. */
4436
+ declare class ScopePredicateLimitError extends KoraError {
4437
+ constructor(message: string, context: Record<string, unknown>);
4438
+ }
4439
+
4440
+ interface ResolveSessionScopesOptions {
4441
+ /** Scope map sent by the client during handshake. It can only NARROW the grant. */
4442
+ handshakeScope?: ScopeMap$1;
4443
+ /**
4444
+ * The server grant from the auth provider. Its collection set is authoritative:
4445
+ * a collection it does not list is not visible, whatever the handshake asks for.
4446
+ * It may carry verified claim values under `$claims` (see `claimScopes` in
4447
+ * `@korajs/core`), which are bound to every schema-scoped collection.
4448
+ */
4449
+ authScopes?: ScopeMap$1;
4450
+ /**
4451
+ * Verified, server-derived scope values (for example `{ userId: sub }`). Treated
4452
+ * exactly like `$claims` in `authScopes`. Never pass client-supplied values here.
4453
+ */
4454
+ scopeValues?: Record<string, unknown>;
4455
+ /**
4456
+ * True when an auth provider authenticated this session. An authenticated
4457
+ * session whose provider returned no grant at all is denied every
4458
+ * schema-scoped collection instead of trusting the client handshake.
4459
+ *
4460
+ * Leave it false only for servers without an auth provider (or with
4461
+ * `NoAuthProvider`), where the handshake is a client-side filter, not a grant.
4462
+ * @default false
4463
+ */
4464
+ authenticated?: boolean;
4465
+ /**
4466
+ * What to do when a schema-scoped collection's bindings cannot all be resolved
4467
+ * from the verified claims. `'deny'` hides the collection (fail closed);
4468
+ * `'throw'` raises {@link ScopeRequiredError} so the caller can reject the
4469
+ * handshake with `SCOPE_REQUIRED`.
4470
+ * @default 'deny'
4471
+ */
4472
+ onUnresolved?: 'deny' | 'throw';
4473
+ }
4474
+ /** A schema-scoped collection the session was denied because a binding was unresolved. */
4475
+ interface DeniedScopeCollection {
4476
+ collection: string;
4477
+ /** Scope binding keys (for example `orgId`) that had no verified value. */
4478
+ missingKeys: string[];
4479
+ }
4480
+ /** Detailed result of {@link resolveSessionScopeGrant}. */
4481
+ interface ResolvedSessionScopeGrant {
4482
+ /**
4483
+ * Effective scope map. `undefined` means "no scoping" and is only ever returned
4484
+ * for unauthenticated servers without a grant. An empty object means "nothing
4485
+ * visible".
4486
+ */
4487
+ scopes: ScopeMap$1 | undefined;
4488
+ /** Collections denied because their scope bindings could not be resolved. */
4489
+ denied: DeniedScopeCollection[];
4490
+ }
4491
+ /**
4492
+ * Thrown (with `onUnresolved: 'throw'`) when an authenticated session cannot be
4493
+ * bound to a schema-scoped collection because the server grant lacks a value.
4494
+ */
4495
+ declare class ScopeRequiredError extends KoraError {
4496
+ readonly denied: DeniedScopeCollection[];
4497
+ constructor(denied: DeniedScopeCollection[]);
4498
+ }
4499
+ /**
4500
+ * Resolve the effective per-session sync scope map.
4501
+ *
4502
+ * The result is the INTERSECTION of the server grant and the client handshake:
4503
+ * - The collection set is the grant's collection set. A handshake can never add
4504
+ * a collection.
4505
+ * - Per collection, handshake predicates only narrow: grant fields win, a
4506
+ * handshake field on an unconstrained field is added, and for an `$in` grant a
4507
+ * handshake value is used only when it is a subset of the granted values.
4508
+ * - Schema-scoped collections bound from verified claims fail closed: an
4509
+ * unresolved binding denies the collection instead of producing `{}`.
4510
+ *
4511
+ * Invariant: the effective scope is a subset of the server grant, and no
4512
+ * schema-scoped collection resolves to `{}` from claims for an authenticated
4513
+ * principal.
4514
+ *
4515
+ * @returns The effective scope map; `undefined` only for unauthenticated servers
4516
+ * with neither a grant nor a handshake scope
4517
+ */
4518
+ declare function resolveSessionScopes(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ScopeMap$1 | undefined;
4519
+ /**
4520
+ * Same as {@link resolveSessionScopes}, but also reports which collections were
4521
+ * denied for unresolved bindings, so a caller can surface `SCOPE_REQUIRED`.
4522
+ */
4523
+ declare function resolveSessionScopeGrant(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ResolvedSessionScopeGrant;
4524
+
1966
4525
  /**
1967
4526
  * Server-side awareness relay. Broadcasts ephemeral awareness states
1968
4527
  * (cursor positions, user presence) between connected clients.
1969
4528
  *
1970
- * Awareness data is never persisted. The relay simply forwards awareness
1971
- * updates from one client to all other connected clients, and broadcasts
1972
- * removal notifications when a client disconnects.
4529
+ * Awareness data is never persisted. Each session's awareness `clientId` is bound on
4530
+ * registration: a session may only publish (or clear) its own state, never another
4531
+ * client's, and its updates reach only clients in the same partition.
1973
4532
  */
1974
4533
  declare class AwarenessRelay {
1975
4534
  private readonly clients;
1976
4535
  /**
1977
- * Register a client for awareness broadcasting.
4536
+ * Register a client for awareness broadcasting. A session registers once; later
4537
+ * calls for an already-registered session are ignored, so a client cannot rebind
4538
+ * itself to another client's id.
1978
4539
  *
1979
4540
  * @param sessionId - Unique session identifier
1980
4541
  * @param clientId - Client-assigned awareness ID
1981
4542
  * @param transport - Transport for sending messages to this client
4543
+ * @param partition - Presence partition (clients only see their own partition)
4544
+ */
4545
+ addClient(sessionId: string, clientId: number, transport: ServerTransport, partition?: string): void;
4546
+ /**
4547
+ * True when the session is registered with the relay.
4548
+ *
4549
+ * @param sessionId - Session to check
1982
4550
  */
1983
- addClient(sessionId: string, clientId: number, transport: ServerTransport): void;
4551
+ hasClient(sessionId: string): boolean;
1984
4552
  /**
1985
- * Remove a client and broadcast its removal to all remaining clients.
4553
+ * Remove a client and broadcast its removal to the remaining clients in its partition.
1986
4554
  *
1987
4555
  * @param sessionId - Session ID of the disconnecting client
1988
4556
  */
1989
4557
  removeClient(sessionId: string): void;
1990
4558
  /**
1991
- * Handle an incoming awareness update from a client.
1992
- * Stores the state and relays to all other connected clients.
4559
+ * Handle an incoming awareness update from a registered client.
4560
+ *
4561
+ * Only the sender's own entry is accepted: the message must be stamped with the
4562
+ * sender's bound `clientId`, and only `states[clientId]` is stored and relayed.
4563
+ * Entries for other clients are dropped, so a sender cannot overwrite or remove
4564
+ * anyone else's presence. The sanitized update reaches only the sender's partition.
1993
4565
  *
1994
4566
  * @param sessionId - Session ID of the sending client
1995
4567
  * @param message - The awareness update message
@@ -2023,4 +4595,4 @@ declare class AwarenessRelay {
2023
4595
  */
2024
4596
  declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
2025
4597
 
2026
- export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, DEFAULT_MAX_SCOPE_PREDICATE_VALUES, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, ServerCloseHandler, ServerErrorHandler, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerStatus, type ServerStore, ServerTransport, type SessionState, SqliteServerStore, TokenAuthProvider, type TokenAuthProviderOptions, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection, normalizeScopeMap, operationMatchesScopes };
4598
+ export { type ApplyRemoteOptions, type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, DEFAULT_MAX_SCOPE_PREDICATE_VALUES, type DeniedScopeCollection, type FoldMigrationReport, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, InvalidScopePredicateError, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, type MemoryServerStoreOptions, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type ResolveSessionScopesOptions, type ResolvedSessionScopeGrant, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, SERVER_NODE_PREFIX, ScopePredicateLimitError, ScopeRequiredError, ServerCloseHandler, ServerErrorHandler, ServerIdentityError, type ServerIdentityOptions, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerSchemaOptions, type ServerStatus, type ServerStore, ServerStoreUnavailableError, ServerTransport, type SessionRevocation, type SessionState, type SessionTerminationCode, SqliteServerStore, type StoredOperationKey, type TerminateSessionsFilter, TokenAuthProvider, type TokenAuthProviderOptions, type TrustProxySetting, type UplinkAuthorizationCode, UplinkAuthorizationError, type UplinkAuthorizationResult, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, authorizeUplinkWrite, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection, isServerNodeId, normalizeScopeMap, operationMatchesScopes, resolveSessionScopeGrant, resolveSessionScopes };