@korajs/server 1.0.0-beta.12 → 1.0.0-beta.14

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,277 @@ 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
+ * Atomically claim a node id that has no real owner for `userId` (F1): a node
407
+ * with operation history but no claim (written before node claims existed, by a
408
+ * beta.12 or older server) or one an admin released (owner `''`). A node another
409
+ * principal owns is never taken. One statement, so there is no gap between a
410
+ * release and a claim another claimant could slip into. Returns true when
411
+ * `userId` owns the node afterwards. The server calls it only for a node whose id
412
+ * equals the device id the auth provider verified for that user. Optional for
413
+ * custom stores; without it such nodes stay refused until an admin binds them.
414
+ */
415
+ claimUnownedNode?(nodeId: string, userId: string): Promise<boolean>;
416
+ /**
417
+ * Admin release of a node id (RT-5): the next principal to claim it takes it
418
+ * over, even when the node has operation history. Returns true when the node had
419
+ * a claim or history to release, false when it was unknown.
420
+ */
421
+ releaseNodeClaim?(nodeId: string): Promise<boolean>;
422
+ /**
423
+ * The owner a node id is currently claimed by, or null when unclaimed (RT-21).
424
+ * Optional; anonymous provisional claims need it together with
425
+ * {@link replaceNodeClaim}.
426
+ */
427
+ getNodeClaimOwner?(nodeId: string): Promise<string | null>;
428
+ /**
429
+ * Atomically replace the owner of a node claim, only if it is still
430
+ * `expectedOwner` (compare-and-set, RT-21). Returns true when replaced. Used to
431
+ * confirm or re-issue an anonymous device's provisional claim; never creates one.
432
+ */
433
+ replaceNodeClaim?(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
434
+ /**
435
+ * The wrapped encryption key record (JSON) of one owner's keyring, or null (ENC-1).
436
+ * The record holds only salt, KDF parameters and wrapped keys: never a usable key.
437
+ * Optional for custom stores; without it the server answers key requests with
438
+ * `unsupported` (it never keeps key records in memory only, which would fork a
439
+ * user's keys after a restart).
440
+ */
441
+ getEncryptionKeyRecord?(owner: string, keyring: string): Promise<string | null>;
442
+ /**
443
+ * Write a key record with compare-and-set: store `record` at `revision` only when the
444
+ * stored revision is `expectedRevision` (0: no record yet). Returns false when
445
+ * another write won. Must be atomic per (owner, keyring) across server instances.
446
+ */
447
+ putEncryptionKeyRecord?(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
448
+ /**
449
+ * Stored key records: every one (for `exportBackup`; RT-104: a server restored from
450
+ * its backup must still hold the records its encrypted history needs), or one owner's
451
+ * (the key service tells another keyring's history from a lost record with it).
452
+ * Optional.
453
+ */
454
+ listEncryptionKeyRecords?(owner?: string): Promise<EncryptionKeyRecordRow[]>;
455
+ /**
456
+ * Key ids named by stored encrypted operations (their envelope's `keyId`), a sample
457
+ * of at most `limit` distinct ids (RT-104). `nodeOwner` restricts it to operations of
458
+ * nodes claimed by that principal (see {@link claimNode}); null means every node (a
459
+ * server without authentication, whose clients share one keyring). The key service
460
+ * reports them when an owner has no key record, so a new device can tell a lost
461
+ * record (encrypted history exists) from a first one. Optional; without it a new
462
+ * device cannot tell, and a fork it creates is merged later by a device holding the
463
+ * old ring.
464
+ */
465
+ getEncryptedKeyIds?(nodeOwner: string | null, limit: number): Promise<string[]>;
466
+ /**
467
+ * Record that `owner` holds the bytes behind a blob content hash (it pushed them,
468
+ * proving possession) (RT-11). Idempotent. Optional; without it the sync server
469
+ * keeps ownership in memory (lost on restart, not shared between instances).
470
+ */
471
+ recordBlobOwner?(hash: string, owner: string): Promise<void>;
472
+ /** The owners recorded for each hash (an empty list for unowned hashes). */
473
+ getBlobOwners?(hashes: string[]): Promise<Map<string, string[]>>;
474
+ /**
475
+ * Atomically make `owner` the first owner of a hash nobody owns yet. Returns true
476
+ * when `owner` owns the hash afterwards (newly, or already), false when someone
477
+ * else does. Must be atomic per hash across server instances.
478
+ */
479
+ claimBlobIfUnowned?(hash: string, owner: string): Promise<boolean>;
480
+ /**
481
+ * The scope snapshots captured when the given operations were applied (RT-14).
482
+ * Operations without one are absent from the result.
483
+ */
484
+ getOperationScopeSnapshots?(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
485
+ /**
486
+ * The greatest HLC timestamp among every stored operation of one record (its
487
+ * newest field write), or null when the record has no operations (RT-19). A
488
+ * scope-entry operation is stamped with it so it never overrides newer client data.
489
+ */
490
+ getRecordLatestTimestamp?(collection: string, recordId: string): Promise<HLCTimestamp | null>;
491
+ /**
492
+ * Per-field versions of one live record, folded from its stored operations exactly
493
+ * as the materialization folds its values (RT-27): each field's version is the HLC
494
+ * of the write that produced its current value, plus the record's first and newest
495
+ * operation. Null when the record is deleted or has no operations. A scope-entry
496
+ * operation carries them so a receiver resolves every field on its own.
497
+ */
498
+ getRecordFieldVersions?(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
499
+ /**
500
+ * The record's fold state (W7): the per-field CRDT state its row is projected from.
501
+ * Null when the record has no operations or its collection is not materialized. A
502
+ * scope-entry operation carries it (filtered to the fields the receiver may see) so
503
+ * the receiver merges richtext, counter and resolver fields exactly (RT-29).
504
+ */
505
+ getRecordFoldState?(collection: string, recordId: string): Promise<FoldState | null>;
506
+ /** Every stored operation of one record, in delivery (commit) order. */
507
+ getRecordOperations?(collection: string, recordId: string): Promise<Operation[]>;
508
+ /**
509
+ * The record's row as it would be after merging `op` into its fold state, with
510
+ * nothing written: the candidate a Tier-2 constraint check judges at ingest. Null
511
+ * when the record would not be live (deleted, or never inserted).
512
+ */
513
+ previewOperation?(op: Operation): Promise<MaterializedRecord | null>;
514
+ /**
515
+ * Node ids whose operations win `merge('server-authoritative')` fields in the fold:
516
+ * this store's own node id (every server-originated operation is authored by it)
517
+ * plus any configured extras. The sync server advertises them in the handshake so
518
+ * clients fold with the same authority.
519
+ */
520
+ getAuthoritativeNodeIds?(): string[];
521
+ /** Explicit authoritative ids the deployment revoked (RT-81); advertised at handshake. */
522
+ getRevokedAuthoritativeNodeIds?(): string[];
523
+ /**
524
+ * Every explicit id the deployment ever held authoritative, revoked ones included
525
+ * (RT-81). The session never accepts one as a device node id.
526
+ */
527
+ getEverAuthoritativeNodeIds?(): string[];
528
+ /**
529
+ * Keyed id of a server-derived operation (cascade, set-null, constraint correction):
530
+ * deterministic across every instance of the deployment, unpredictable to clients
531
+ * (RT-64). Built-in stores key it with a persisted deployment secret. A store
532
+ * without it falls back to the unkeyed `deriveSideEffectOpId`.
533
+ */
534
+ deriveServerOperationId?(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
145
535
  /** Close the store and release resources */
146
536
  close(): Promise<void>;
147
537
  /** Get the total number of stored operations */
@@ -188,10 +578,23 @@ interface ServerStore extends SyncStore {
188
578
  * tables from the operation log.
189
579
  *
190
580
  * @param schema - The schema definition describing all collections
581
+ * @param options - `operationTransforms`: the schema transforms the store folds with
582
+ * (transforms at fold time, RT-84). Pass the same list as the sync server's
583
+ * `operationTransforms`, so the startup re-materialization folds with them once.
191
584
  */
192
- setSchema(schema: SchemaDefinition): Promise<void>;
585
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
193
586
  /** Schema used for materialized tables and server-side validation, if set. */
194
587
  getSchema(): SchemaDefinition | null;
588
+ /**
589
+ * Set the schema transforms the store folds with (RT-84). Operations are stored
590
+ * exactly as uploaded; the fold merges each operation as
591
+ * `operationSchemaView(op, schema.version, transforms)` reads it. Transforms are part
592
+ * of the fold plan fingerprint: a change re-folds every record (once). The sync
593
+ * server calls this with its `operationTransforms` at construction.
594
+ */
595
+ setOperationTransforms?(transforms: readonly OperationTransform[]): Promise<void>;
596
+ /** The schema transforms the store folds with (empty when none). */
597
+ getOperationTransforms?(): readonly OperationTransform[];
195
598
  /**
196
599
  * Get all records from a materialized collection.
197
600
  * When schema is set, reads directly from the collection table (O(1) indexed).
@@ -303,6 +706,68 @@ interface ApplyServerOperationResult {
303
706
  appliedOperations: Operation[];
304
707
  /** Rejection reason when the operation was not applied */
305
708
  rejection?: OperationRejection;
709
+ /**
710
+ * Side effects of an applied delete that were NOT derived because
711
+ * {@link ApplyServerOperationOptions.isAuthoredSideEffect} claimed them (RT-69). The
712
+ * caller must derive each one with {@link deriveServerSideEffects} unless the
713
+ * author's own copy is stored.
714
+ */
715
+ deferredSideEffects?: SideEffectOp[];
716
+ }
717
+ /**
718
+ * Decides whether an untrusted writer may cause one referential side effect of its
719
+ * delete. `effect` is the side effect shaped as the operation the server would
720
+ * write; `stored` is the affected record as stored now (including soft-deleted).
721
+ */
722
+ type SideEffectAuthorizer = (effect: Operation, stored: MaterializedRecord | null) => UplinkAuthorizationResult;
723
+ /** Options for {@link applyServerOperation}. */
724
+ interface ApplyServerOperationOptions {
725
+ /**
726
+ * Uplink authorization for an untrusted writer, re-evaluated by the store inside
727
+ * its apply critical section against the row as stored at commit time (see
728
+ * `ApplyRemoteOptions.authorize`). A refusal becomes a non-retriable rejection and
729
+ * nothing is written.
730
+ */
731
+ authorize?: ApplyRemoteOptions['authorize'];
732
+ /**
733
+ * Authorization of the referential side effects (cascade delete, set-null) an
734
+ * untrusted writer's delete would cause (RT-10). Every side effect is checked
735
+ * against the writer's own scope BEFORE the delete is applied; if any falls
736
+ * outside it, the whole delete is refused and nothing is written.
737
+ *
738
+ * Design: integrity is judged on ALL children, in every tenant (a restrict or a
739
+ * cross-tenant cascade is never silently skipped, which would leave dangling
740
+ * references), but the writer learns nothing about children it cannot see: a
741
+ * restrict violation and an unauthorized side effect are refused with the same
742
+ * generic `RESTRICTED` rejection, with no ids and no counts. Setting `authorize`
743
+ * alone also switches restrict refusals to the generic form.
744
+ */
745
+ authorizeSideEffect?: SideEffectAuthorizer;
746
+ /**
747
+ * The writer is a legacy client that does not reserve sequence numbers (RT-37): a
748
+ * different operation under a held `(nodeId, sequenceNumber)` is stored as a legacy
749
+ * pair instead of being refused with `SEQUENCE_CONFLICT`. Applies to the primary
750
+ * operation only; server-originated side effects are always enforced.
751
+ */
752
+ legacySequenceWriter?: boolean;
753
+ /** See `ApplyRemoteOptions.onLegacySequencePair`; for the primary operation. */
754
+ onLegacySequencePair?: ApplyRemoteOptions['onLegacySequencePair'];
755
+ /**
756
+ * True for a referential side effect of this delete that the writer authored itself
757
+ * (its own cascade or set-null, later in the same upload, RT-69). Such an effect is
758
+ * not derived now but returned in `deferredSideEffects`, so the log stores one copy
759
+ * per child instead of the author's and the server's. Effects it does not claim (a
760
+ * child the author did not know, a legacy client that authors no cascades) are
761
+ * derived as before.
762
+ */
763
+ isAuthoredSideEffect?: (effect: SideEffectOp) => boolean;
764
+ /**
765
+ * The operation as the server schema reads it (transforms at fold time, RT-84), when
766
+ * the caller already computed it. Shape, constraint and referential checks judge the
767
+ * view; the store keeps `op` exactly as given and folds the same view. Default: the
768
+ * store's own view (`serverOperationView`).
769
+ */
770
+ view?: Operation;
306
771
  }
307
772
 
308
773
  /**
@@ -318,6 +783,13 @@ interface ServerMetricsSnapshot {
318
783
  uniqueOperationsReceived: number;
319
784
  duplicateOperationsReceived: number;
320
785
  rejectedOperations: number;
786
+ /**
787
+ * Operations from protocol-1 (beta.12) clients stored with an id that could not be
788
+ * verified (RT-71). Non-zero only while beta.12 clients write `undefined` members.
789
+ */
790
+ unverifiedLegacyOperations: number;
791
+ /** Uploads refused for reusing a stored operation id with other content (RT-77). */
792
+ forgedDuplicates: number;
321
793
  operationsSent: number;
322
794
  bytesReceived: number;
323
795
  bytesSent: number;
@@ -352,6 +824,8 @@ declare class ServerMetricsCollector {
352
824
  private uniqueOperationsReceived;
353
825
  private duplicateOperationsReceived;
354
826
  private rejectedOperations;
827
+ private unverifiedLegacyOperations;
828
+ private forgedDuplicates;
355
829
  private operationsSent;
356
830
  private bytesReceived;
357
831
  private bytesSent;
@@ -376,6 +850,10 @@ declare class ServerMetricsCollector {
376
850
  }): void;
377
851
  /** Record operations sent to a client. */
378
852
  recordSent(sessionId: string, count: number, byteSize: number): void;
853
+ /** Record an operation stored with an unverified legacy id (RT-71). */
854
+ recordUnverifiedLegacyOperation(): void;
855
+ /** Record an upload refused for reusing a stored id with other content (RT-77). */
856
+ recordForgedDuplicate(): void;
379
857
  /** Record an error. */
380
858
  recordError(): void;
381
859
  /** Set the schema version. */
@@ -440,6 +918,210 @@ declare function createSilentLogger(): Logger;
440
918
  */
441
919
  declare function createDefaultLogger(): Logger;
442
920
 
921
+ /**
922
+ * Resolves a blob chunk by content hash from server-side storage. Optional: when
923
+ * provided, the server can answer chunk requests directly from its own store
924
+ * (central-store deployment). When absent, the server acts as a pure relay,
925
+ * forwarding chunk requests to peer sessions and their responses back.
926
+ */
927
+ type ResolveBlobChunk = (hash: string) => Promise<Uint8Array | null>;
928
+
929
+ /** Input to {@link BlobAccessIndex.authorizeReference}. */
930
+ interface BlobReferenceRequest {
931
+ /** The content hash (blob, manifest or chunk) the writer wants to reference. */
932
+ hash: string;
933
+ /** The writer's download scope, or undefined for an unscoped writer. */
934
+ scopes: ScopeMap | undefined;
935
+ /**
936
+ * Stable ownership key of the writer: the user id for a signed-in principal, the
937
+ * node-claim owner for an anonymous device, a `kora:route:` key for a scoped route.
938
+ */
939
+ owner: string;
940
+ /** Hashes the written record already references as stored (unchanged references). */
941
+ alreadyReferenced?: ReadonlySet<string>;
942
+ }
943
+ /**
944
+ * Answers "does a live record inside this download scope reference this blob
945
+ * hash?" for the blob relay (RT-1), and "may this writer reference this hash?" at
946
+ * ingest (RT-11).
947
+ *
948
+ * **A content hash is not a secret.** Hashes of well-known content can be computed
949
+ * by anyone, and they travel in records, manifests and logs. So knowing a hash never
950
+ * grants access: a write may reference a hash only when the writer can already read
951
+ * a record that references it, or has proven possession of the bytes (pushed them).
952
+ * Only in peer-relay mode (no central store, so nothing can be pre-claimed and then
953
+ * read back from the server) may the first writer of a hash nobody references or
954
+ * owns claim it by reference alone.
955
+ *
956
+ * A hash is referenced in a scope when a live (not deleted) record in scope has a
957
+ * blob field whose `BlobRef.manifestHash` (or, for a bare reference without a
958
+ * 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
959
+ * the manifest (so a crafted manifest listing someone else's chunk hashes exposes
960
+ * nothing). Manifests are read through `resolveBlobChunk` (central store) or learned
961
+ * from manifest bytes verified in transit.
962
+ *
963
+ * Cost: the referenced set of a scope is built by a scoped query over every
964
+ * collection that declares a blob field (O(records in scope with blobs)). It is
965
+ * cached per scope, rebuilt after a write to one of the blob collections it scanned
966
+ * ({@link invalidate}) or after `ttlMs` (writes through another server instance),
967
+ * and concurrent lookups for one scope share a single rebuild (RT-17). A full hash
968
+ * index in the store would make this O(1); this is the interim, correctness-first
969
+ * form.
970
+ */
971
+ declare class BlobAccessIndex {
972
+ private readonly store;
973
+ private readonly resolveBlobChunk;
974
+ private readonly ttlMs;
975
+ private readonly byScope;
976
+ /** Rebuilds in progress per scope key, shared by concurrent lookups (RT-17). */
977
+ private readonly inflight;
978
+ /** manifestHash -> chunk hashes it lists (only from bytes verified against the hash). */
979
+ private readonly manifestChunks;
980
+ /** chunkHash -> manifests listing it. */
981
+ private readonly chunkManifests;
982
+ /** Ownership fallback for stores without blob-owner persistence. */
983
+ private readonly memoryOwners;
984
+ private globalGeneration;
985
+ private readonly collectionGeneration;
986
+ constructor(store: ServerStore, resolveBlobChunk: ResolveBlobChunk | null, ttlMs?: number);
987
+ /**
988
+ * Forget cached scope sets because records changed. With `collections`, only sets
989
+ * built from those collections are dropped (a write to a collection without blob
990
+ * fields drops nothing); without, every set is dropped (for example writes made
991
+ * through another server instance).
992
+ */
993
+ invalidate(collections?: Iterable<string>): void;
994
+ /**
995
+ * True when a live record inside `scopes` references `hash`.
996
+ *
997
+ * @param scopes - The session's download scope, or undefined for unscoped
998
+ * @param hash - The requested content hash
999
+ */
1000
+ isReferenced(scopes: ScopeMap | undefined, hash: string): Promise<boolean>;
1001
+ /**
1002
+ * Decide whether a writer may reference `hash` in a blob field (RT-11). Allowed
1003
+ * when the record already referenced it, when a live record inside the writer's
1004
+ * download scope references it, when the writer owns it (pushed the bytes, or
1005
+ * claimed it first), or when nobody references, owns or stores it yet: then the
1006
+ * writer claims it, atomically, so a later forger cannot.
1007
+ */
1008
+ authorizeReference(request: BlobReferenceRequest): Promise<boolean>;
1009
+ /** True when `owner` pushed (or first claimed) the bytes behind `hash`. */
1010
+ isOwnedBy(hash: string, owner: string): Promise<boolean>;
1011
+ /**
1012
+ * Record that `owner` pushed bytes verified to hash to `hash` (proof of
1013
+ * possession), and learn the chunk list when the bytes are a manifest.
1014
+ */
1015
+ recordPush(hash: string, bytes: Uint8Array, owner: string): Promise<void>;
1016
+ /**
1017
+ * When `bytes` are a manifest, true only if the pusher may reference every chunk
1018
+ * it lists (the same rule as {@link authorizeReference}); true for non-manifest
1019
+ * bytes. A manifest listing someone else's chunks is refused before it is stored.
1020
+ */
1021
+ authorizeManifestPush(bytes: Uint8Array, scopes: ScopeMap | undefined, owner: string): Promise<boolean>;
1022
+ /**
1023
+ * Learn a manifest's chunk list from bytes already verified to hash to `hash`.
1024
+ * Bytes that are not a manifest are ignored. Unowned chunks of an owned manifest
1025
+ * are claimed for the manifest's owner, so a manifest crafted later cannot adopt
1026
+ * them.
1027
+ */
1028
+ observeVerifiedBytes(hash: string, bytes: Uint8Array): void;
1029
+ private claimChunksForManifestOwner;
1030
+ private currentStamp;
1031
+ private isCurrent;
1032
+ private blobCollections;
1033
+ private directHashes;
1034
+ private build;
1035
+ /** Read unknown manifests from the central store, verifying them against their hash. */
1036
+ private loadManifests;
1037
+ private ownersOf;
1038
+ private addOwner;
1039
+ private claim;
1040
+ private rememberMemoryOwner;
1041
+ private rememberManifest;
1042
+ private forgetManifest;
1043
+ }
1044
+
1045
+ /**
1046
+ * Minimal interface for a ws.WebSocket instance.
1047
+ * Allows dependency injection for testing without importing ws directly.
1048
+ */
1049
+ interface WsWebSocket {
1050
+ readyState: number;
1051
+ send(data: string | Uint8Array, callback?: (err?: Error) => void): void;
1052
+ close(code?: number, reason?: string): void;
1053
+ on(event: string, listener: (...args: unknown[]) => void): void;
1054
+ removeAllListeners(): void;
1055
+ /** Send a ping frame (ws). Without it, no liveness probing happens. */
1056
+ ping?(): void;
1057
+ /** Destroy the socket at once, emitting `close` (ws). Falls back to `close()`. */
1058
+ terminate?(): void;
1059
+ /** Bytes queued and not yet written to the network (ws). */
1060
+ readonly bufferedAmount?: number;
1061
+ }
1062
+ /**
1063
+ * Options for WsServerTransport.
1064
+ */
1065
+ interface WsServerTransportOptions {
1066
+ /** Message serializer. Defaults to JsonMessageSerializer. */
1067
+ serializer?: MessageSerializer;
1068
+ /**
1069
+ * Interval between WebSocket pings, in ms (SRV-6). A connection that leaves
1070
+ * `maxMissedPongs` pings in a row unanswered (and sends nothing else meanwhile) is
1071
+ * terminated, which runs Kora's normal close path. Defaults to 25 seconds; 0
1072
+ * disables probing. Keep it at 15 seconds or more on mobile networks.
1073
+ */
1074
+ heartbeatIntervalMs?: number;
1075
+ /** Unanswered pings before the connection is terminated. Defaults to 2. */
1076
+ maxMissedPongs?: number;
1077
+ /**
1078
+ * Bytes that may be queued for this client before it is disconnected as a slow
1079
+ * consumer. Defaults to 32 MiB; 0 disables the ceiling.
1080
+ */
1081
+ maxBufferedBytes?: number;
1082
+ }
1083
+ /**
1084
+ * Server-side transport wrapping a ws.WebSocket connection.
1085
+ * Created for each incoming client connection.
1086
+ *
1087
+ * It probes liveness with WebSocket pings (both the standalone server and
1088
+ * `createProductionServer` create their connections through it), so a half-open
1089
+ * connection, a peer that vanished without a FIN, is detected and closed instead of
1090
+ * holding a session forever.
1091
+ */
1092
+ declare class WsServerTransport implements ServerTransport {
1093
+ private readonly ws;
1094
+ private readonly serializer;
1095
+ private messageHandler;
1096
+ private closeHandler;
1097
+ private errorHandler;
1098
+ private readonly maxMissedPongs;
1099
+ private readonly maxBufferedBytes;
1100
+ private heartbeatTimer;
1101
+ private missedPongs;
1102
+ private terminated;
1103
+ constructor(ws: WsWebSocket, options?: WsServerTransportOptions);
1104
+ send(message: SyncMessage): void;
1105
+ onMessage(handler: ServerMessageHandler): void;
1106
+ onClose(handler: ServerCloseHandler): void;
1107
+ onError(handler: ServerErrorHandler): void;
1108
+ isConnected(): boolean;
1109
+ close(code?: number, reason?: string): void;
1110
+ bufferedAmount(): number;
1111
+ /**
1112
+ * Ping every `intervalMs`; terminate once `maxMissedPongs` pings in a row went
1113
+ * unanswered. A pong or any inbound message proves the peer alive.
1114
+ */
1115
+ private startHeartbeat;
1116
+ private stopHeartbeat;
1117
+ /**
1118
+ * Drop the connection at once. ws emits `close` from `terminate()`, which runs
1119
+ * Kora's normal close path (session, relays and side channels are released).
1120
+ */
1121
+ private terminate;
1122
+ private setupListeners;
1123
+ }
1124
+
443
1125
  /**
444
1126
  * Minimal interface for a ws.WebSocketServer instance.
445
1127
  * Allows dependency injection for testing without importing ws directly.
@@ -458,6 +1140,8 @@ type WsServerConstructor = new (options: {
458
1140
  port?: number;
459
1141
  host?: string;
460
1142
  path?: string;
1143
+ maxPayload?: number;
1144
+ perMessageDeflate?: false | Record<string, unknown>;
461
1145
  }) => WsServerLike;
462
1146
  /**
463
1147
  * Self-hosted sync server. Accepts WebSocket connections from clients,
@@ -480,22 +1164,74 @@ declare class KoraSyncServer {
480
1164
  private readonly schemaVersion;
481
1165
  private readonly supportedSchemaVersions;
482
1166
  private readonly operationTransforms;
1167
+ /** The store's re-fold for the configured transforms (RT-84), awaited by start(). */
1168
+ private storeTransformsReady;
483
1169
  private readonly port;
484
1170
  private readonly host;
485
1171
  private readonly path;
486
1172
  private readonly logger;
487
1173
  private readonly metrics;
488
1174
  private readonly awarenessRelay;
1175
+ /** Reads the records presence cursors name, for every session (F16). */
1176
+ private readonly presenceRecords;
1177
+ /** The record each session's current presence cursor names (F16). */
1178
+ private readonly presenceTargets;
1179
+ /** The sessions whose presence cursor names each record, by record key. */
1180
+ private readonly presenceTargetsByKey;
1181
+ /**
1182
+ * Audience re-decisions running, one per record: a write while one runs marks it
1183
+ * dirty, so it reads again and the last decision applied is always from a read that
1184
+ * began after the last write (never an older read finishing late).
1185
+ */
1186
+ private readonly presenceRefreshes;
489
1187
  private readonly yjsDocRelay;
490
1188
  private readonly blobChunkRelay;
1189
+ /** Wrapped encryption key records (ENC-1, D4b), persisted in the server store. */
1190
+ private readonly encryptionKeys;
1191
+ /** Which blob hashes each download scope may obtain (RT-1). */
1192
+ private readonly blobAccess;
491
1193
  private readonly persistBlobChunk;
492
1194
  private readonly maxOperationBytes;
493
1195
  private readonly maxOpsPerMinute;
1196
+ /** Per-principal ingest budget per minute; 0 when disabled. */
1197
+ private readonly maxOpsPerMinutePerUser;
1198
+ private readonly allowLegacyAnonymousClaims;
1199
+ private readonly deviceNodeHandover;
1200
+ private readonly unscopedSharing;
1201
+ private readonly anonymousClaimTtlMs;
1202
+ private readonly maxOpsPerBatch;
1203
+ private readonly maxScopePredicateValues;
1204
+ private readonly maxMessageBytes;
1205
+ private readonly heartbeatIntervalMs;
1206
+ private readonly appHeartbeatIntervalMs;
1207
+ private readonly encryptionPolicy;
1208
+ private readonly handshakeTimeoutMs;
1209
+ private readonly maxBufferedBytes;
1210
+ private readonly deliveryHighWaterBytes;
1211
+ private readonly perMessageDeflate;
1212
+ /**
1213
+ * Ingest rate limiters by device node id, shared by that node's sessions so a
1214
+ * reconnect does not reset the budget (SRV-6). Entries idle past the window are
1215
+ * dropped by the background tick; insertion order doubles as LRU order.
1216
+ */
1217
+ private readonly rateLimiters;
1218
+ /**
1219
+ * Ingest budgets by authenticated principal, shared by every node of that user, so
1220
+ * minting node ids does not multiply the per-node budget. Expired like the node map.
1221
+ */
1222
+ private readonly userRateLimiters;
1223
+ private readonly blobLimits;
494
1224
  private readonly validateOperation;
495
1225
  private readonly koraContext;
496
1226
  private readonly sessions;
497
- private readonly httpClients;
498
- private readonly httpSessionToClient;
1227
+ /**
1228
+ * HTTP long-poll sessions by their server-issued, high-entropy id (RT-2). Each is
1229
+ * bound to the principal that opened it; every request must re-authenticate as it.
1230
+ */
1231
+ private readonly httpSessions;
1232
+ /** Internal ClientSession id -> HTTP session id, for cleanup on close. */
1233
+ private readonly httpSessionIdBySession;
1234
+ private readonly httpSessionIdleTimeoutMs;
499
1235
  private readonly serverVersion;
500
1236
  private wsServer;
501
1237
  private running;
@@ -508,6 +1244,17 @@ declare class KoraSyncServer {
508
1244
  private deliveryPollTimer;
509
1245
  private lastObservedDeliverySequence;
510
1246
  private deliveryPollInFlight;
1247
+ /**
1248
+ * Re-validates every live session's credential against the auth provider, so a
1249
+ * revocation persisted by ANOTHER server instance (whose in-process revocation
1250
+ * listeners never reach this one) still ends the session here (RT-18).
1251
+ */
1252
+ private readonly sessionRevalidationIntervalMs;
1253
+ private sessionRevalidationTimer;
1254
+ private lastSessionRevalidationAtMs;
1255
+ private sessionRevalidationInFlight;
1256
+ /** Unsubscribes from the auth provider's revocation feed (AUTH-11). */
1257
+ private revocationUnsubscribe;
511
1258
  /**
512
1259
  * Relay operations a client never acknowledged before it disconnected, buffered by
513
1260
  * client node id so they can be redelivered on its next connection. Bounded per
@@ -517,19 +1264,142 @@ declare class KoraSyncServer {
517
1264
  private static readonly MAX_ORPHANED_RELAY_OPS_PER_NODE;
518
1265
  private static readonly ORPHANED_RELAY_TTL_MS;
519
1266
  constructor(config: KoraSyncServerConfig);
1267
+ /**
1268
+ * Follow the auth provider's revocation feed, when it has one, so revoking a
1269
+ * device or user ends its live sessions without any wiring by the app.
1270
+ */
1271
+ private ensureRevocationSubscribed;
1272
+ /**
1273
+ * End live sync sessions whose credential was revoked (AUTH-11).
1274
+ *
1275
+ * Each matching session receives a retriable `AUTH_REVOKED` (or `AUTH_EXPIRED`)
1276
+ * error and is closed. The client refreshes its credentials and re-handshakes,
1277
+ * so the server authenticates it again: a still-valid device reconnects, a
1278
+ * revoked one is refused. Sessions still in their handshake are refused as soon
1279
+ * as their principal is known.
1280
+ *
1281
+ * `createKoraAuthServer().bindSyncServer(server)` calls this on device revoke,
1282
+ * sign-out, password reset or change, and admin revoke. A provider exposing
1283
+ * `onRevoke` (the built-in one does) is followed automatically.
1284
+ *
1285
+ * @param filter - `userId` ends every session of that user; with `deviceId`,
1286
+ * only that device's sessions. At least one of the two is required.
1287
+ * @returns The number of established sessions that were closed
1288
+ *
1289
+ * @example
1290
+ * ```typescript
1291
+ * server.terminateSessions({ userId: 'u1', deviceId: 'laptop' })
1292
+ * ```
1293
+ */
1294
+ terminateSessions(filter: TerminateSessionsFilter): number;
1295
+ /**
1296
+ * Admin release of a device node id (RT-5). The next principal to handshake with
1297
+ * this node id claims it, even when the node already has operation history.
1298
+ *
1299
+ * Use it to hand over operation history written before node claims existed (an
1300
+ * upgrade from beta.12, where nobody may adopt such a node until it is released),
1301
+ * or to reassign a lost device's node id. Live sessions on that node id are ended
1302
+ * with a retriable `NODE_RELEASED` error so the handover starts clean.
1303
+ *
1304
+ * @param nodeId - The device node id to release
1305
+ * @returns True when the node had a claim or history to release; false when it
1306
+ * was unknown or the store does not support node claims
1307
+ *
1308
+ * @example
1309
+ * ```typescript
1310
+ * await server.releaseNodeClaim('0190a1b2-...')
1311
+ * ```
1312
+ */
1313
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
520
1314
  private ensureBackgroundTimersStarted;
1315
+ /**
1316
+ * Re-validate the credential of every live session with the auth provider and end
1317
+ * the sessions whose credential is no longer accepted (RT-18).
1318
+ *
1319
+ * Revocations are persisted by the auth stores (token, device and per-user
1320
+ * cut-offs), but `onRevoke` listeners only run in the process that handled the
1321
+ * revocation. Every instance therefore re-checks its own live sessions
1322
+ * periodically (`sessionRevalidationIntervalMs`, default 30 seconds, also driven by
1323
+ * the delivery poll tick) by authenticating the session's credential again. A
1324
+ * session whose credential is refused, or now resolves to another principal, gets
1325
+ * a retriable `AUTH_REVOKED` (or `AUTH_EXPIRED`) and is closed. A provider error
1326
+ * (for example the auth database is unreachable) ends nothing: it is logged and the
1327
+ * next pass retries, so an outage does not disconnect every client.
1328
+ *
1329
+ * @returns The number of sessions ended by this pass
1330
+ *
1331
+ * @example
1332
+ * ```typescript
1333
+ * // After revoking through another instance's admin API:
1334
+ * await server.revalidateSessions()
1335
+ * ```
1336
+ */
1337
+ revalidateSessions(): Promise<number>;
1338
+ /**
1339
+ * Re-resolve one user's grant on every live session of that user now, instead of
1340
+ * waiting for the next revalidation pass (`sessionRevalidationIntervalMs`). Call it
1341
+ * after a membership change the auth provider's grant depends on: an invitation
1342
+ * accepted, a collaborator removed, a role changed.
1343
+ *
1344
+ * Each of the user's sessions re-authenticates its credential with the provider. A
1345
+ * session whose resolved download or upload scope changed ends with a retriable
1346
+ * `SCOPE_CHANGED`; its client reconnects at once and receives the new grant. A
1347
+ * narrowed grant applies the client's `scopeExit` policy at that handshake
1348
+ * (`'retract'` hides the rows that left the scope), and the device's unsynced writes
1349
+ * outside the new upload scope are refused. A session still in its handshake
1350
+ * re-checks as soon as it is established, so a grant read before the change never
1351
+ * outlives this call. Sessions whose grant did not change are kept.
1352
+ *
1353
+ * Only this process's sessions are refreshed. Other instances apply the change at
1354
+ * their next revalidation pass, or call this too (for example from a pub/sub
1355
+ * message).
1356
+ *
1357
+ * @param userId - The user whose grant changed (the auth provider's `userId`)
1358
+ * @returns The number of sessions this call ended
1359
+ *
1360
+ * @example
1361
+ * ```typescript
1362
+ * await removeCollaborator(documentId, bobId)
1363
+ * await server.refreshScopes(bobId)
1364
+ * ```
1365
+ */
1366
+ refreshScopes(userId: string): Promise<number>;
1367
+ /** Run {@link revalidateSessions} from the delivery poll tick once the interval elapsed. */
1368
+ private maybeRevalidateSessions;
521
1369
  /**
522
1370
  * Retransmit relay batches that connected clients have not acknowledged within
523
1371
  * `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
524
1372
  * already-applied operation is harmless (clients dedup by content-addressed id).
525
1373
  */
526
1374
  retransmitPendingRelays(staleMs?: number): void;
1375
+ /**
1376
+ * The ingest rate limiter of a device node, created on first use and shared by
1377
+ * every session of that node, so reconnecting does not buy a fresh budget (SRV-6).
1378
+ */
1379
+ private rateLimiterFor;
1380
+ /**
1381
+ * Forget rate limiters of nodes with no live session whose window has long
1382
+ * passed: their budget would be full again anyway.
1383
+ */
1384
+ private expireIdleRateLimiters;
1385
+ /** Close HTTP long-poll sessions that sent no request within the idle timeout. */
1386
+ private expireIdleHttpSessions;
527
1387
  /**
528
1388
  * Check whether the authoritative delivery log advanced without passing through
529
1389
  * this server instance, then wake delivery-watermark sessions to scan from their
530
1390
  * own acknowledged cursors. Session-level filtering remains the only fan-out path.
531
1391
  */
532
1392
  pollDeliveryLog(): Promise<void>;
1393
+ /**
1394
+ * Invalidate cached blob access sets for the collections written between two
1395
+ * delivery frontiers. Falls back to dropping every set when the gap is large.
1396
+ */
1397
+ private invalidateBlobAccessSince;
1398
+ /**
1399
+ * The blob reference authority shared by every session and route (RT-11).
1400
+ * @internal Used by the route context; not part of the public API.
1401
+ */
1402
+ getBlobAccessIndex(): BlobAccessIndex;
533
1403
  /**
534
1404
  * Buffer relay operations a disconnected client never acknowledged, keyed by its
535
1405
  * node id, deduped by operation id and bounded by count. Redelivered when the
@@ -572,19 +1442,50 @@ declare class KoraSyncServer {
572
1442
  /**
573
1443
  * Handle one HTTP sync request for a long-polling client.
574
1444
  *
575
- * A stable `clientId` identifies the logical connection across requests.
1445
+ * The POST that opens a session (the handshake, sent without `sessionId`) creates
1446
+ * it and answers with a server-issued, high-entropy session id in the
1447
+ * `x-kora-session` response header. Every later request must name that id.
1448
+ *
1449
+ * With an auth provider, EVERY request is authenticated from its `authorization`
1450
+ * header and must resolve to the same principal (user, device and anonymity) as
1451
+ * the request that opened the session and as the session's handshake, so knowing
1452
+ * a session id alone grants nothing (RT-2). Sessions with no request for
1453
+ * `httpSessionIdleTimeoutMs` are closed.
1454
+ *
1455
+ * Responses: 202 (POST accepted), 200/204/304 (GET), 400 (malformed), 401
1456
+ * (credential missing or invalid), 403 (credential of another principal), 404
1457
+ * (unknown or expired session), 405 (method), 410 (session closed).
576
1458
  */
577
1459
  handleHttpRequest(request: HttpSyncRequest): Promise<HttpSyncResponse>;
578
1460
  /**
579
- * Handle an incoming client connection (attach mode).
580
- * Creates a new ClientSession for the transport.
581
- *
582
- * @param transport - The server transport for the new connection
583
- * @returns The session ID
1461
+ * Authenticate one HTTP request. Returns the principal identity, null when the
1462
+ * server has no real auth provider, or 'unauthorized'.
584
1463
  */
585
- handleConnection(transport: ServerTransport): string;
1464
+ private authenticateHttpRequest;
586
1465
  /**
587
- * Get the current server status.
1466
+ * True when a request's principal is the one that opened the session and, once
1467
+ * the handshake authenticated, the session's own principal.
1468
+ */
1469
+ private httpPrincipalMatches;
1470
+ /**
1471
+ * Handle an accepted WebSocket (attach mode with `ws`): wraps it in a
1472
+ * {@link WsServerTransport} configured like the standalone server's (serializer,
1473
+ * ping/pong liveness probing, send-buffer ceiling) and starts a session.
1474
+ *
1475
+ * @param ws - A `ws` WebSocket from `WebSocketServer.handleUpgrade` or `connection`
1476
+ * @returns The session ID
1477
+ */
1478
+ handleWebSocket(ws: WsWebSocket): string;
1479
+ /**
1480
+ * Handle an incoming client connection (attach mode).
1481
+ * Creates a new ClientSession for the transport.
1482
+ *
1483
+ * @param transport - The server transport for the new connection
1484
+ * @returns The session ID
1485
+ */
1486
+ handleConnection(transport: ServerTransport): string;
1487
+ /**
1488
+ * Get the current server status.
588
1489
  */
589
1490
  getStatus(): Promise<ServerStatus>;
590
1491
  /**
@@ -599,9 +1500,10 @@ declare class KoraSyncServer {
599
1500
  * client only receives the operation if it falls within that client's scope.
600
1501
  *
601
1502
  * @param op - A fully-formed, server-originated operation to apply
1503
+ * @param options - Optional in-store authorization (used by scoped routes)
602
1504
  * @returns The apply result, including any server-generated side-effect ops
603
1505
  */
604
- applyLocalOperation(op: Operation): Promise<ApplyServerOperationResult>;
1506
+ applyLocalOperation(op: Operation, options?: ApplyServerOperationOptions): Promise<ApplyServerOperationResult>;
605
1507
  /**
606
1508
  * Relay already-applied, server-originated operations to every connected client.
607
1509
  * Used by the conditional route apply, which commits its operations atomically
@@ -626,11 +1528,65 @@ declare class KoraSyncServer {
626
1528
  * Get the number of currently connected clients.
627
1529
  */
628
1530
  getConnectionCount(): number;
1531
+ /**
1532
+ * Node ids whose operations win `merge('server-authoritative')` fields in the fold
1533
+ * (W7): the store's own node id, which authors every server-originated operation
1534
+ * (side effects, constraint corrections, route writes), plus any extras the store
1535
+ * was configured with (`authoritativeNodeIds` store option). This is the one source
1536
+ * of truth: every session advertises exactly this list in the handshake, so clients
1537
+ * fold with the same authority as the server's stores.
1538
+ */
1539
+ get authoritativeNodeIds(): string[];
1540
+ /**
1541
+ * Blob access policy (RT-1): true when the session is streaming and a live record
1542
+ * inside its download scope references the hash. Gates the central store.
1543
+ */
1544
+ private sessionReferencesBlob;
1545
+ /** True when a session other than `exceptSessionId` is connected as `nodeId`. */
1546
+ private isNodeLive;
1547
+ /**
1548
+ * Central-store read policy (RT-1, RT-25): the session owns the hash (it pushed
1549
+ * the bytes) or a live record inside its download scope references it.
1550
+ */
1551
+ private sessionMayReadStoredBlob;
1552
+ /**
1553
+ * Blob access policy (RT-1): a request may be forwarded to a peer that shares the
1554
+ * requester's exact download scope (the same tenant view, as for presence), which
1555
+ * keeps manifests handed over out of band working; across scopes, only when both
1556
+ * scopes reference the hash, so neither the hash nor the bytes cross a tenant.
1557
+ */
1558
+ private mayForwardBlobRequest;
629
1559
  private handleRelay;
630
1560
  private handleSessionClose;
631
1561
  private handleAwarenessRelay;
1562
+ /** Point a session's presence at a record (or at none), keeping the index in step. */
1563
+ private setPresenceTarget;
1564
+ /**
1565
+ * Records changed (`operations`, or anything when null): re-decide the audience of
1566
+ * every presence cursor on one of them (F16). A record that moved out of a session's
1567
+ * grant takes the presence on it out of that session, and one that moved in brings
1568
+ * it, without waiting for the sender's next update; catch-ups use the new audience.
1569
+ * Every write is noted first, so a presence read that began before it is never
1570
+ * taken as current.
1571
+ */
1572
+ private refreshPresence;
1573
+ /** Re-decide the presence on one record, coalesced with a re-decision already running. */
1574
+ private schedulePresenceRefresh;
1575
+ private runPresenceRefresh;
1576
+ /**
1577
+ * Who may see a presence state (F16). A state whose cursor names a record reaches
1578
+ * exactly the sessions whose download scope contains that record, the rule of the
1579
+ * Yjs doc channel, and only when the sender may read the record too: collaborators
1580
+ * with different grants see each other on the documents they share, and nobody
1581
+ * sees presence on a record outside their grant. A malformed cursor reaches
1582
+ * nobody. A state without a cursor names no record: it reaches only sessions of
1583
+ * the same presence partition (identical download scope; anonymous sessions are
1584
+ * each their own partition).
1585
+ */
1586
+ private awarenessAudience;
632
1587
  private handleYjsDocRelay;
633
- private getOrCreateHttpClient;
1588
+ /** Open a new HTTP long-poll session bound to `principal`, under a fresh random id. */
1589
+ private openHttpSession;
634
1590
  }
635
1591
 
636
1592
  /**
@@ -820,6 +1776,19 @@ interface AuthContext {
820
1776
  uplinkScopes?: Record<string, Record<string, unknown>>;
821
1777
  /** Arbitrary metadata about the authenticated user */
822
1778
  metadata?: Record<string, unknown>;
1779
+ /**
1780
+ * True for an unauthenticated (anonymous) principal, such as the fallback of
1781
+ * `MixedAuthProvider`. Its `userId` is not stable across connections, so node-id
1782
+ * claims are keyed by the shared anonymous owner instead: an anonymous device can
1783
+ * reconnect with its node id, but can never take a signed-in user's node id.
1784
+ */
1785
+ anonymous?: boolean;
1786
+ /**
1787
+ * When the credential that authenticated this session expires (ms since
1788
+ * epoch). A session must not outlive its credential: the sync server can
1789
+ * close it with a retriable AUTH_EXPIRED so the client refreshes (AUTH-11).
1790
+ */
1791
+ expiresAt?: number;
823
1792
  }
824
1793
  /**
825
1794
  * Interface for authenticating incoming client connections.
@@ -832,6 +1801,33 @@ interface AuthProvider {
832
1801
  * @returns AuthContext if valid, null if rejected
833
1802
  */
834
1803
  authenticate(token: string): Promise<AuthContext | null>;
1804
+ /**
1805
+ * Optional revocation feed. When present, `KoraSyncServer` subscribes on
1806
+ * construction and calls {@link KoraSyncServer.terminateSessions} for every
1807
+ * event, so a revoked device or user loses its live sync sessions immediately
1808
+ * (AUTH-11). The built-in `createKoraAuthServer().auth` provides it.
1809
+ *
1810
+ * @returns An unsubscribe function
1811
+ */
1812
+ onRevoke?(listener: (event: SessionRevocation) => void | Promise<void>): () => void;
1813
+ }
1814
+ /**
1815
+ * Which live sync sessions a credential revocation ends. `userId` alone ends
1816
+ * every session of that user; with `deviceId` only that device's sessions
1817
+ * (matched against `AuthContext.metadata.deviceId`).
1818
+ */
1819
+ interface SessionRevocation {
1820
+ userId?: string;
1821
+ deviceId?: string;
1822
+ }
1823
+ /** Options for {@link KoraSyncServer.terminateSessions}. */
1824
+ interface TerminateSessionsFilter extends SessionRevocation {
1825
+ /**
1826
+ * Error code sent to the client before the session closes. Both are retriable:
1827
+ * the client refreshes its credentials and re-handshakes, and the server then
1828
+ * decides again. Defaults to `'AUTH_REVOKED'`.
1829
+ */
1830
+ code?: 'AUTH_REVOKED' | 'AUTH_EXPIRED';
835
1831
  }
836
1832
  /**
837
1833
  * Configuration for creating a KoraSyncServer.
@@ -849,8 +1845,60 @@ interface KoraSyncServerConfig {
849
1845
  serializer?: MessageSerializer;
850
1846
  /** Event emitter for DevTools integration */
851
1847
  emitter?: KoraEventEmitter;
852
- /** Maximum concurrent client connections. 0 = unlimited. Defaults to 0. */
1848
+ /**
1849
+ * Maximum concurrent client connections (sessions, including ones still
1850
+ * handshaking). A connection over the limit gets a retriable `MAX_CONNECTIONS` error
1851
+ * and is closed. 0 = unlimited. Defaults to 10,000.
1852
+ */
853
1853
  maxConnections?: number;
1854
+ /**
1855
+ * Interval between WebSocket pings, in ms (SRV-6, LMS #12). A connection that
1856
+ * leaves two pings in a row unanswered is terminated, which ends its session.
1857
+ * Applies to the standalone server and `createProductionServer`. Defaults to 25
1858
+ * seconds; 0 disables probing.
1859
+ */
1860
+ heartbeatIntervalMs?: number;
1861
+ /**
1862
+ * Interval of the application-level `heartbeat` message sent to clients that
1863
+ * advertise support for it (browsers cannot see WebSocket pings), in ms. A client
1864
+ * that hears nothing for about 2.5 intervals reconnects. Defaults to 25 seconds; 0
1865
+ * disables it.
1866
+ */
1867
+ appHeartbeatIntervalMs?: number;
1868
+ /**
1869
+ * End-to-end encryption policy (protocol v2, ENC-3). With `required: true` an
1870
+ * uploaded plaintext operation that carries data is refused non-retriably
1871
+ * (`PLAINTEXT_REJECTED`), unless `allowPlaintextMigration` opens a migration window.
1872
+ * Encrypted operations (envelope v2) are always stored opaquely, never transformed.
1873
+ */
1874
+ encryption?: {
1875
+ required: boolean;
1876
+ allowPlaintextMigration?: boolean;
1877
+ };
1878
+ /**
1879
+ * Time a new connection has to send its handshake, in ms. Defaults to 10 seconds;
1880
+ * 0 disables the deadline.
1881
+ */
1882
+ handshakeTimeoutMs?: number;
1883
+ /**
1884
+ * Bytes that may wait unsent for one client (WebSocket send buffer, or the queue of
1885
+ * an HTTP long-poll client that stopped polling) before it is disconnected as a slow
1886
+ * consumer. Defaults to 32 MiB; 0 disables the ceiling. The delivery stream pauses
1887
+ * well before this (`deliveryHighWaterBytes`).
1888
+ */
1889
+ maxBufferedBytes?: number;
1890
+ /**
1891
+ * Queued outbound bytes above which a client's delivery stream pauses until the
1892
+ * client drains them (backpressure). Defaults to 1 MiB.
1893
+ */
1894
+ deliveryHighWaterBytes?: number;
1895
+ /**
1896
+ * WebSocket permessage-deflate compression. `true` (the default) compresses messages
1897
+ * of 1 KiB or more without keeping a compression context between messages, which
1898
+ * cuts sync payloads several-fold on 2G/3G links at a bounded memory cost per
1899
+ * connection. `false` disables it; an object is passed to `ws` as is.
1900
+ */
1901
+ perMessageDeflate?: boolean | Record<string, unknown>;
854
1902
  /** Maximum operations per sync batch. Defaults to 100. */
855
1903
  batchSize?: number;
856
1904
  /**
@@ -878,10 +1926,13 @@ interface KoraSyncServerConfig {
878
1926
  max: number;
879
1927
  };
880
1928
  /**
881
- * Transform accepted legacy client operations into the server schema version
882
- * before validation and materialization. Required when
883
- * `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
884
- * shapes changed across versions.
1929
+ * Schema transforms from older client schema versions to the server schema version.
1930
+ * Required when `supportedSchemaVersions.min` is lower than `schemaVersion` and
1931
+ * operation shapes changed across versions. They run at fold time (RT-84): every
1932
+ * operation is stored and delivered exactly as uploaded; authorization, validators
1933
+ * and the store's fold use its transformed view. The server hands this list to its
1934
+ * store (`setOperationTransforms`); pass it to `store.setSchema` too to fold with it
1935
+ * from the first start. Transforms must be pure and deterministic.
885
1936
  */
886
1937
  operationTransforms?: OperationTransform[];
887
1938
  /** WebSocket path (standalone mode). Defaults to '/'. */
@@ -909,6 +1960,32 @@ interface KoraSyncServerConfig {
909
1960
  * `@korajs/store` derives both from a `ContentAddressedBlobStore`.
910
1961
  */
911
1962
  persistBlobChunk?: (hash: string, bytes: Uint8Array) => Promise<void> | void;
1963
+ /**
1964
+ * Limits on the blob side channel. `maxChunkBytes` (default 1 MiB) caps one
1965
+ * pushed chunk or manifest, `maxBytesPerSession` (default 256 MiB) caps the total
1966
+ * a session may push for central persistence, `maxPendingRequestsPerSession`
1967
+ * (default 256) and `pendingRequestTtlMs` (default 60s) bound the chunk requests
1968
+ * the relay remembers per session.
1969
+ */
1970
+ blobLimits?: {
1971
+ maxChunkBytes?: number;
1972
+ maxBytesPerSession?: number;
1973
+ maxPendingRequestsPerSession?: number;
1974
+ pendingRequestTtlMs?: number;
1975
+ /**
1976
+ * Blob chunk requests one session may make per minute, a budget separate from
1977
+ * `maxOpsPerMinute` (one request per chunk, so a large blob makes many). Over
1978
+ * it, a request is answered with a retriable `throttled` response the client
1979
+ * backs off on (RT-24). Defaults to 6000.
1980
+ */
1981
+ maxRequestsPerMinute?: number;
1982
+ };
1983
+ /**
1984
+ * Largest WebSocket message the standalone server (and `createProductionServer`)
1985
+ * accepts, in bytes. Larger frames are refused by the socket layer before they are
1986
+ * buffered. Defaults to 32 MiB (the `ws` library default is 100 MiB).
1987
+ */
1988
+ maxMessageBytes?: number;
912
1989
  /**
913
1990
  * Maximum serialized byte size of a single client operation accepted at sync
914
1991
  * ingest. Operations larger than this are rejected before materialization.
@@ -917,11 +1994,95 @@ interface KoraSyncServerConfig {
917
1994
  */
918
1995
  maxOperationBytes?: number;
919
1996
  /**
920
- * Maximum operations accepted per connected client per minute (sliding window).
921
- * Operations beyond the limit are rejected until the window resets. Defaults to
922
- * 600. Set once here to enforce one rate cap across every connected client.
1997
+ * Maximum operations accepted per device node per minute (fixed window). The budget
1998
+ * belongs to the node id (bound to its principal when auth is configured), not to
1999
+ * the connection, so reconnecting does not reset it (SRV-6). Operations beyond the
2000
+ * limit get a retriable `RATE_LIMIT` until the window resets. Defaults to 600.
923
2001
  */
924
2002
  maxOpsPerMinute?: number;
2003
+ /**
2004
+ * Maximum operations accepted per authenticated principal per minute, across all of
2005
+ * its device nodes, on top of the per-node `maxOpsPerMinute`. Without it a user could
2006
+ * multiply the per-node budget by minting node ids. Keyed by the provider's user id;
2007
+ * anonymous and unauthenticated sessions (no stable principal) have only the per-node
2008
+ * budget. Defaults to 4 x `maxOpsPerMinute` (2400), so one busy device never meets it
2009
+ * and a user's devices share it. `0` disables the per-user budget.
2010
+ */
2011
+ maxOpsPerMinutePerUser?: number;
2012
+ /**
2013
+ * Largest operation batch accepted from a client in one message. A larger batch is
2014
+ * refused whole with `BATCH_TOO_LARGE` before the server decodes it or reads the
2015
+ * store, so one message cannot buy unbounded work. Defaults to 1000 (the client
2016
+ * sends batches of 100 by default).
2017
+ */
2018
+ maxOpsPerBatch?: number;
2019
+ /**
2020
+ * Most values one `$in` scope predicate may hold, in either direction. A grant
2021
+ * with more is refused at handshake with `SCOPE_PREDICATE_LIMIT`, so an auth
2022
+ * provider bug cannot hand one session an unbounded predicate that every delivery
2023
+ * and revalidation then evaluates. Defaults to 100. See the production server
2024
+ * guide for the measured cost of larger values before raising it.
2025
+ */
2026
+ maxScopePredicateValues?: number;
2027
+ /**
2028
+ * Accept anonymous devices whose node claim predates confirmed claims (RT-21):
2029
+ * nodes whose operation history predates node claims (a database written by a
2030
+ * beta.12 or older server, which recorded no claims; RT-91), nodes held by the
2031
+ * pre-release shared anonymous owner, and provisional claims that expired without
2032
+ * the device ever confirming its node token (beta.12 and older clients have no
2033
+ * token support). Accepted with a deprecation warning and re-issued a token.
2034
+ * Defaults to `true` for 1.0.0-beta.13; the default flips to `false` in the next
2035
+ * release, after which such devices rotate to a fresh node id instead (beta.12
2036
+ * clients cannot rotate and stay refused).
2037
+ *
2038
+ * With `MixedAuthProvider`, a node with pre-claims history may also be a signed-in
2039
+ * user's beta.12 device: an anonymous device can take such a node, as it could on
2040
+ * beta.12. Set this to `false` if that matters more than keeping anonymous beta.12
2041
+ * devices syncing.
2042
+ */
2043
+ allowLegacyAnonymousClaims?: boolean;
2044
+ /**
2045
+ * Automatic device handover after an upgrade from beta.12 (F1). When a signed-in
2046
+ * device presents a node id that has operation history but no owner (beta.12
2047
+ * recorded no node claims) or that an administrator released, and the node id
2048
+ * equals the device id the auth provider verified for that user
2049
+ * (`AuthContext.metadata.deviceId`, set by the built-in `KoraAuthProvider` from the
2050
+ * token), the node is claimed for that user in one atomic step instead of being
2051
+ * refused `NODE_ID_CLAIMED`. A node another principal owns is never taken.
2052
+ * Defaults to `true`. A custom auth provider must set `metadata.deviceId` only to
2053
+ * a device id it verified as registered to the user; set this to `false` if yours
2054
+ * cannot guarantee that.
2055
+ */
2056
+ deviceNodeHandover?: boolean;
2057
+ /**
2058
+ * What to do when a signed-in session's grant restricts no collection, so every user
2059
+ * syncs every other user's data (F4). That happens when the auth provider returns
2060
+ * a claims grant (the built-in provider's default) and the schema declares no sync
2061
+ * rule binding it, or when a provider returns no scopes. `'warn'` (default) logs it
2062
+ * once per provider; `'allow'` silences it for apps whose users all share one data
2063
+ * set; `'refuse'` refuses such handshakes with `UNSCOPED_SHARING_REFUSED`.
2064
+ * Anonymous sessions are not judged: their grant is the app's own choice.
2065
+ */
2066
+ unscopedSharing?: 'warn' | 'allow' | 'refuse';
2067
+ /**
2068
+ * How long an anonymous device's provisional node claim may stay unconfirmed and
2069
+ * still be re-issued to a device that presents no token (a handshake response lost
2070
+ * in transit), in milliseconds (RT-21). Defaults to 24 hours.
2071
+ */
2072
+ anonymousClaimTtlMs?: number;
2073
+ /**
2074
+ * How often every live session's credential is re-validated with the auth
2075
+ * provider, in milliseconds (RT-18). A revocation persisted by another server
2076
+ * instance ends the session here within this interval. Defaults to 30 seconds; 0
2077
+ * disables it (sessions then end only through this process's `onRevoke` feed,
2078
+ * `terminateSessions`, or credential expiry).
2079
+ */
2080
+ sessionRevalidationIntervalMs?: number;
2081
+ /**
2082
+ * How long an HTTP long-poll session may go without any request before the server
2083
+ * closes it, in milliseconds. Defaults to 2 minutes; 0 disables expiry.
2084
+ */
2085
+ httpSessionIdleTimeoutMs?: number;
925
2086
  /**
926
2087
  * Adjudicate untrusted client operations before they become authoritative.
927
2088
  *
@@ -937,12 +2098,25 @@ interface KoraSyncServerConfig {
937
2098
  }
938
2099
  /**
939
2100
  * Request envelope for the server-side HTTP sync endpoint.
2101
+ *
2102
+ * Map it from your HTTP framework: `sessionId` from the `x-kora-session` header and
2103
+ * `authorization` from the `Authorization` header, on every request.
940
2104
  */
941
2105
  interface HttpSyncRequest {
942
- /** Stable client identifier for binding HTTP requests to a server session */
943
- clientId: string;
944
2106
  /** HTTP method */
945
2107
  method: 'GET' | 'POST';
2108
+ /**
2109
+ * The server-issued session id (`x-kora-session` request header). Absent only on
2110
+ * the POST that opens a session (the handshake); the response to that POST
2111
+ * carries the new id in its `x-kora-session` header. Never chosen by the client.
2112
+ */
2113
+ sessionId?: string;
2114
+ /**
2115
+ * The raw `Authorization` header (`Bearer <token>`). With an auth provider
2116
+ * configured, EVERY request is authenticated and must resolve to the same
2117
+ * principal and device as the session it names (RT-2).
2118
+ */
2119
+ authorization?: string;
946
2120
  /** Optional raw request payload for POST */
947
2121
  body?: string | Uint8Array;
948
2122
  /** Value of the Content-Type header for POST payloads */
@@ -955,7 +2129,7 @@ interface HttpSyncRequest {
955
2129
  */
956
2130
  interface HttpSyncResponse {
957
2131
  /** HTTP status code */
958
- status: 200 | 202 | 204 | 304 | 400 | 405 | 410;
2132
+ status: 200 | 202 | 204 | 304 | 400 | 401 | 403 | 404 | 405 | 410;
959
2133
  /** Optional raw response payload */
960
2134
  body?: string | Uint8Array;
961
2135
  /** Optional response headers */
@@ -1002,6 +2176,20 @@ interface ServerStatus {
1002
2176
  errorCount: number;
1003
2177
  }
1004
2178
 
2179
+ /**
2180
+ * Which reverse proxies in front of the server are trusted to report the client
2181
+ * address in `X-Forwarded-For`.
2182
+ *
2183
+ * - `number`: trust that many hops, counting the socket peer as the first. `1` means
2184
+ * "one proxy (for example a load balancer) sits directly in front of the server".
2185
+ * - `string[]`: trust peers whose address is in one of these IPs or CIDR ranges
2186
+ * (IPv4 and IPv6, for example `['10.0.0.0/8', '::1']`).
2187
+ *
2188
+ * When unset, `X-Forwarded-For` is ignored and the socket address is the client
2189
+ * address, so a directly connected client cannot choose its own address.
2190
+ */
2191
+ type TrustProxySetting = number | string[];
2192
+
1005
2193
  interface HttpPollResponse {
1006
2194
  status: 200 | 204 | 304 | 410;
1007
2195
  body?: string | Uint8Array;
@@ -1021,56 +2209,176 @@ declare class HttpServerTransport implements ServerTransport {
1021
2209
  private connected;
1022
2210
  private nextSequence;
1023
2211
  private readonly queue;
1024
- constructor(serializer: MessageSerializer);
2212
+ private queuedBytes;
2213
+ private readonly maxQueuedBytes;
2214
+ /**
2215
+ * @param serializer - Message serializer
2216
+ * @param options - `maxQueuedBytes`: close the session once this many bytes wait
2217
+ * unpolled (default 32 MiB; 0 disables the ceiling)
2218
+ */
2219
+ constructor(serializer: MessageSerializer, options?: {
2220
+ maxQueuedBytes?: number;
2221
+ });
1025
2222
  send(message: _korajs_sync.SyncMessage): void;
2223
+ /** Bytes queued for the client and not yet polled (delivery-stream backpressure). */
2224
+ bufferedAmount(): number;
1026
2225
  onMessage(handler: ServerMessageHandler): void;
1027
2226
  onClose(handler: ServerCloseHandler): void;
1028
2227
  onError(handler: ServerErrorHandler): void;
1029
2228
  isConnected(): boolean;
2229
+ /**
2230
+ * Close the session. Messages already queued (typically the final error that
2231
+ * explains the close, for example a refused handshake) stay pollable, so a
2232
+ * long-polling client learns why, exactly as a WebSocket client receives the error
2233
+ * frame before the close; once drained, polls answer 410.
2234
+ */
1030
2235
  close(code?: number, reason?: string): void;
2236
+ /** True while messages queued before the close are still waiting to be polled. */
2237
+ hasPending(): boolean;
1031
2238
  receive(payload: string | Uint8Array): void;
1032
2239
  poll(ifNoneMatch?: string): HttpPollResponse;
1033
2240
  private makeEtag;
1034
2241
  }
1035
2242
 
1036
- /**
1037
- * Minimal interface for a ws.WebSocket instance.
1038
- * Allows dependency injection for testing without importing ws directly.
1039
- */
1040
- interface WsWebSocket {
1041
- readyState: number;
1042
- send(data: string | Uint8Array, callback?: (err?: Error) => void): void;
1043
- close(code?: number, reason?: string): void;
1044
- on(event: string, listener: (...args: unknown[]) => void): void;
1045
- removeAllListeners(): void;
2243
+ /** One read of a record for a presence decision. */
2244
+ interface PresenceRecordRead {
2245
+ /** The stored row (soft-deleted rows included), or null when the server holds none */
2246
+ stored: MaterializedRecord | null;
2247
+ /**
2248
+ * The reader's clock when the read began. A write that touched the record after
2249
+ * it ({@link PresenceRecords.touchedSince}) means the row may predate that write.
2250
+ */
2251
+ asOf: number;
1046
2252
  }
1047
2253
  /**
1048
- * Options for WsServerTransport.
2254
+ * Reads the records presence cursors name, for every session of one sync server (F16).
2255
+ *
2256
+ * Presence decides who may see a cursor from the record it names, so the row must be
2257
+ * the store's latest, never a copy another code path holds (a session's delivery
2258
+ * pass caches rows for the whole chunk it filters). Every write the server commits
2259
+ * {@link touch}es the records it wrote; a read is current when no touch of its record
2260
+ * came after it began. Reads of one record are shared while current (many cursors on
2261
+ * one busy document cost one read per write burst, not one per cursor per write), a
2262
+ * read is never shared across a touch, and reads are bounded in number at once.
2263
+ *
2264
+ * Presence is ephemeral and nothing here is persisted.
1049
2265
  */
1050
- interface WsServerTransportOptions {
1051
- /** Message serializer. Defaults to JsonMessageSerializer. */
1052
- serializer?: MessageSerializer;
2266
+ declare class PresenceRecords {
2267
+ private readonly store;
2268
+ private readonly now;
2269
+ private clock;
2270
+ /** The clock value of each tracked record's last touch, oldest first. */
2271
+ private readonly lastTouch;
2272
+ /** Touches forgotten to bound memory count as having happened at this clock. */
2273
+ private forgottenTouchesAt;
2274
+ private readonly inFlight;
2275
+ private readonly cache;
2276
+ private running;
2277
+ private readonly waiting;
2278
+ constructor(store: ServerStore, now?: () => number);
2279
+ /**
2280
+ * Record that writes to these records committed: reads that began before are no
2281
+ * longer current. Null means any record may have changed.
2282
+ *
2283
+ * @param keys - Record keys from {@link presenceRecordKey}, or null for every record
2284
+ */
2285
+ touch(keys: Iterable<string> | null): void;
2286
+ /**
2287
+ * True when a write touched the record after a read that began at `asOf`.
2288
+ *
2289
+ * @param key - Record key from {@link presenceRecordKey}
2290
+ * @param asOf - The read's {@link PresenceRecordRead.asOf}
2291
+ */
2292
+ touchedSince(key: string, asOf: number): boolean;
2293
+ /**
2294
+ * A current read of the record that is still fresh enough to reuse, without a store
2295
+ * read; undefined when the record must be read.
2296
+ *
2297
+ * @param collection - The record's collection
2298
+ * @param recordId - The record's id
2299
+ */
2300
+ peek(collection: string, recordId: string): PresenceRecordRead | undefined;
2301
+ /**
2302
+ * Read the record from the store, joining a read of it already running when no
2303
+ * write touched the record since that read began. Rejects when the store read fails.
2304
+ *
2305
+ * @param collection - The record's collection
2306
+ * @param recordId - The record's id
2307
+ */
2308
+ read(collection: string, recordId: string): Promise<PresenceRecordRead>;
2309
+ /** Forget every read and touch (the server stopped). */
2310
+ clear(): void;
2311
+ private readStore;
2312
+ private forget;
2313
+ }
2314
+
2315
+ /** Outcome of one key-service message. */
2316
+ interface KeyServiceOutcome {
2317
+ response: EncryptionKeyResponseMessage;
2318
+ /** The record written, when the message was a successful write (to push to siblings). */
2319
+ written: WrappedKeyRecord | null;
1053
2320
  }
1054
2321
  /**
1055
- * Server-side transport wrapping a ws.WebSocket connection.
1056
- * Created for each incoming client connection.
2322
+ * The sync server's key service (ENC-1, decision D4b). Stores each owner's wrapped key
2323
+ * record and serves it back. It never sees a passphrase or a data key: a record is
2324
+ * salt, KDF parameters, AES-GCM-wrapped data keys, key ids and versions.
2325
+ *
2326
+ * Rules it enforces:
2327
+ * - **Owner from the session only.** The owner is the session's authenticated
2328
+ * principal (or the shared anonymous owner on a server without auth); a message
2329
+ * cannot name a user, so no session reads or writes another user's record.
2330
+ * - **Compare-and-set.** A write names the revision it replaces; a concurrent write
2331
+ * loses with `conflict` and the current record, so two devices that both create a
2332
+ * first key converge on one.
2333
+ * - **History is append-only.** A write must raise the revision, keep the ring id and
2334
+ * every key version (same key id), so no device can make old operations unreadable
2335
+ * for the others. A first write (or a re-upload after the record was lost) keeps the
2336
+ * revision it carries, since devices refuse revisions below the one they accepted.
2337
+ * - **Lost records are visible.** A fetch with no record reports key ids of the owner's
2338
+ * stored encrypted operations, so a new device waits for one holding the ring.
2339
+ *
2340
+ * It cannot check the record MAC or that wraps open (only a holder of the ring's master
2341
+ * key can): devices authenticate every record themselves and act on nothing else. A
2342
+ * principal can still overwrite its own record with garbage; devices refuse it and
2343
+ * keep their keys.
1057
2344
  */
1058
- declare class WsServerTransport implements ServerTransport {
1059
- private readonly ws;
1060
- private readonly serializer;
1061
- private messageHandler;
1062
- private closeHandler;
1063
- private errorHandler;
1064
- constructor(ws: WsWebSocket, options?: WsServerTransportOptions);
1065
- send(message: SyncMessage): void;
1066
- onMessage(handler: ServerMessageHandler): void;
1067
- onClose(handler: ServerCloseHandler): void;
1068
- onError(handler: ServerErrorHandler): void;
1069
- isConnected(): boolean;
1070
- close(code?: number, reason?: string): void;
1071
- private setupListeners;
2345
+ declare class EncryptionKeyService {
2346
+ private readonly store;
2347
+ constructor(store: ServerStore);
2348
+ /** Whether the store can persist key records (all built-in stores can). */
2349
+ isSupported(): boolean;
2350
+ /**
2351
+ * Answer one key-service message for `owner`.
2352
+ *
2353
+ * @param owner - The session's owner key, or null when the session may not hold keys
2354
+ * @param message - A request or a write
2355
+ */
2356
+ handle(owner: string | null, message: EncryptionKeyRequestMessage | EncryptionKeyPutMessage): Promise<KeyServiceOutcome>;
2357
+ /**
2358
+ * Key ids of the owner's stored encrypted operations (a sample). Reported only while
2359
+ * no record exists: then non-empty means the record was lost, and a new device must
2360
+ * wait for one that holds the ring instead of starting another (RT-104).
2361
+ */
2362
+ private knownKeyIds;
2363
+ private read;
2364
+ }
2365
+
2366
+ /** What a session charges uploaded operations to. */
2367
+ interface IngestRateLimiter {
2368
+ /** Units allowed per window (the tightest applicable budget). */
2369
+ readonly limit: number;
2370
+ /** Charge `count` units; false when a budget is exceeded (the units are refused). */
2371
+ allow(count?: number): boolean;
2372
+ /** Milliseconds until a refused unit may be retried. */
2373
+ retryAfterMs(): number;
1072
2374
  }
1073
2375
 
2376
+ /**
2377
+ * Session-ending error codes, all retriable. `AUTH_REVOKED` / `AUTH_EXPIRED`: the
2378
+ * client refreshes its credential and re-handshakes. `SCOPE_CHANGED` (RT-26): the
2379
+ * principal's grant changed; the client simply reconnects and gets the new scope.
2380
+ */
2381
+ type SessionTerminationCode = 'AUTH_REVOKED' | 'AUTH_EXPIRED' | 'SCOPE_CHANGED';
1074
2382
  /**
1075
2383
  * Possible states for a client session.
1076
2384
  */
@@ -1082,11 +2390,27 @@ type RelayCallback = (sourceSessionId: string, operations: Operation[]) => void;
1082
2390
  /**
1083
2391
  * Callback invoked when a session receives an awareness update to relay to other sessions.
1084
2392
  */
1085
- type AwarenessRelayCallback = (sourceSessionId: string, message: AwarenessUpdateMessage) => void;
2393
+ type AwarenessRelayCallback = (sourceSessionId: string, message: AwarenessUpdateMessage, cursorTarget?: AwarenessCursorTarget) => void;
2394
+ /**
2395
+ * The record an awareness state's cursor names, as the sending session resolved it
2396
+ * from the store. `stored` is null for a record the server does not hold. A cursor
2397
+ * that is not a `{ collection, recordId }` pair of strings is `{ invalid: true }`;
2398
+ * a state without a cursor has no target.
2399
+ */
2400
+ type AwarenessCursorTarget = {
2401
+ invalid?: false;
2402
+ collection: string;
2403
+ recordId: string;
2404
+ stored: MaterializedRecord | null;
2405
+ /** When the read of `stored` began, on the server's presence reader clock. */
2406
+ asOf?: number;
2407
+ } | {
2408
+ invalid: true;
2409
+ };
1086
2410
  /**
1087
2411
  * Callback invoked when a session receives a Yjs doc channel update to relay.
1088
2412
  */
1089
- type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage) => void;
2413
+ type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage, storedRecord: MaterializedRecord | null) => void;
1090
2414
  /**
1091
2415
  * Callback invoked when a session receives a blob chunk request to route.
1092
2416
  */
@@ -1128,20 +2452,41 @@ interface ClientSessionOptions {
1128
2452
  min: number;
1129
2453
  max: number;
1130
2454
  };
1131
- /** Transform accepted legacy operations into the server schema before validation. */
2455
+ /** Schema transforms: judged views of older operations (transforms at fold time, RT-84). */
1132
2456
  operationTransforms?: OperationTransform[];
1133
2457
  /** Called when this session has operations to relay to other sessions */
1134
2458
  onRelay?: RelayCallback;
1135
2459
  /** Called when this session receives an awareness update to broadcast */
1136
2460
  onAwarenessUpdate?: AwarenessRelayCallback;
2461
+ /**
2462
+ * The reader of the records presence cursors name, shared by the server's sessions
2463
+ * (F16). Defaults to one of this session's own.
2464
+ */
2465
+ presenceRecords?: PresenceRecords;
1137
2466
  /** Called when this session receives a Yjs doc channel update to broadcast */
1138
2467
  onYjsDocUpdate?: YjsDocRelayCallback;
1139
2468
  /** Called when this session receives a blob chunk request to route */
1140
2469
  onBlobChunkRequest?: BlobChunkRequestCallback;
1141
2470
  /** Called when this session receives a blob chunk response to route back */
1142
2471
  onBlobChunkResponse?: BlobChunkResponseCallback;
2472
+ /** The key service storing wrapped encryption key records (ENC-1). */
2473
+ encryptionKeys?: EncryptionKeyService;
2474
+ /** Called after this session wrote a key record, to push it to the owner's other sessions. */
2475
+ onEncryptionKeyWritten?: (sessionId: string, owner: string, keyring: string, record: WrappedKeyRecord) => void;
2476
+ /** Key-service messages allowed per session per minute. Defaults to 60. */
2477
+ maxKeyRequestsPerMinute?: number;
1143
2478
  /** Persist a client-uploaded blob chunk centrally (keyed by content hash) */
1144
2479
  persistBlobChunk?: PersistBlobChunk;
2480
+ /**
2481
+ * Called once the session completes an accepted handshake and reaches streaming.
2482
+ * Side channels (Yjs doc, blob and awareness relays) register the session here,
2483
+ * never at connect time, so an unauthenticated connection is never on a relay.
2484
+ */
2485
+ onReady?: (sessionId: string) => void;
2486
+ /** Largest single blob chunk this session may push. Defaults to 1 MiB. */
2487
+ maxBlobChunkBytes?: number;
2488
+ /** Total blob bytes this session may push. Defaults to 256 MiB. */
2489
+ maxBlobBytesPerSession?: number;
1145
2490
  /** Called when this session closes */
1146
2491
  onClose?: (sessionId: string) => void;
1147
2492
  /**
@@ -1161,6 +2506,24 @@ interface ClientSessionOptions {
1161
2506
  maxOperationBytes?: number;
1162
2507
  /** Maximum operations accepted per minute for this session. Defaults to 600. */
1163
2508
  maxOpsPerMinute?: number;
2509
+ /**
2510
+ * Blob chunk requests accepted per minute, separate from the operation budget
2511
+ * (RT-24). Defaults to 6000.
2512
+ */
2513
+ maxBlobRequestsPerMinute?: number;
2514
+ /** Length of the blob request window in ms. One minute; shorter only in tests. */
2515
+ blobRequestWindowMs?: number;
2516
+ /**
2517
+ * Largest operation batch accepted in one message. A larger batch is refused whole
2518
+ * (`BATCH_TOO_LARGE`) before it is decoded or the store is read. Defaults to 1000.
2519
+ */
2520
+ maxOpsPerBatch?: number;
2521
+ /**
2522
+ * Most values one `$in` scope predicate may hold. A grant over the limit is
2523
+ * refused at handshake (`SCOPE_PREDICATE_LIMIT`). Defaults to
2524
+ * {@link DEFAULT_MAX_SCOPE_PREDICATE_VALUES} (100).
2525
+ */
2526
+ maxScopePredicateValues?: number;
1164
2527
  /**
1165
2528
  * Adjudicate untrusted client operations before materialization. When present,
1166
2529
  * each incoming operation is passed to this validator; a `reject` decision
@@ -1169,6 +2532,71 @@ interface ClientSessionOptions {
1169
2532
  validateOperation?: OperationValidator;
1170
2533
  /** Trusted data-plane context handed to the validator (read state, author derived ops). */
1171
2534
  koraContext?: ProductionHttpRouteContext;
2535
+ /**
2536
+ * Blob reference authority (RT-11): decides which content hashes this session may
2537
+ * reference in blob fields and records the bytes it pushes. Without it, blob
2538
+ * references are not checked at ingest.
2539
+ */
2540
+ blobAccess?: BlobAccessIndex;
2541
+ /** See `KoraSyncServerConfig.allowLegacyAnonymousClaims` (RT-21). Defaults to true. */
2542
+ allowLegacyAnonymousClaims?: boolean;
2543
+ /** See `KoraSyncServerConfig.deviceNodeHandover` (F1). Defaults to true. */
2544
+ deviceNodeHandover?: boolean;
2545
+ /** See `KoraSyncServerConfig.unscopedSharing` (F4). Defaults to `'warn'`. */
2546
+ unscopedSharing?: 'warn' | 'allow' | 'refuse';
2547
+ /** See `KoraSyncServerConfig.anonymousClaimTtlMs` (RT-21). Defaults to 24 hours. */
2548
+ anonymousClaimTtlMs?: number;
2549
+ /**
2550
+ * True when another live session (not `exceptSessionId`) is connected as `nodeId`.
2551
+ * A provisional anonymous claim is never re-issued away from a connected device.
2552
+ */
2553
+ isNodeLive?: (nodeId: string, exceptSessionId: string) => boolean;
2554
+ /**
2555
+ * Time a new connection has to deliver its handshake, in ms (SRV-6). A connection
2556
+ * that sends none is closed with `HANDSHAKE_TIMEOUT`. Defaults to 10 seconds; 0
2557
+ * disables the deadline.
2558
+ */
2559
+ handshakeTimeoutMs?: number;
2560
+ /**
2561
+ * Queued outbound bytes above which the delivery stream pauses until the client
2562
+ * drains its socket (transports that report `bufferedAmount`). Defaults to 1 MiB.
2563
+ */
2564
+ deliveryHighWaterBytes?: number;
2565
+ /**
2566
+ * The ingest rate limiter for a node, shared across that node's sessions so a
2567
+ * reconnect does not reset it (SRV-6). Called once the handshake binds the node,
2568
+ * with the authenticated principal's user id (null for anonymous and
2569
+ * unauthenticated sessions) so a per-user budget can be layered on top: a user
2570
+ * minting node ids must not multiply the per-node budget.
2571
+ * Without it the session keeps a private limiter.
2572
+ */
2573
+ rateLimiterFor?: (nodeId: string, principal: string | null) => IngestRateLimiter;
2574
+ /**
2575
+ * Interval of the application-level heartbeat sent to clients that advertised
2576
+ * support for it, in ms (LMS #12). The handshake response tells the client the
2577
+ * interval so it can declare the connection dead after missing two. 0 disables it.
2578
+ */
2579
+ appHeartbeatIntervalMs?: number;
2580
+ /**
2581
+ * Node ids whose operations this server authors (protocol v2). Sent to clients in
2582
+ * the handshake response (`authoritativeNodeIds`); only operations from these nodes
2583
+ * may carry server-authored metadata (`fieldVersions`, `foldState`). Defaults to the
2584
+ * ids the store folds with (`ServerStore.getAuthoritativeNodeIds`), which is what
2585
+ * `KoraSyncServer` always passes. Scope-entry operations (`kora:scope-entry`) are not
2586
+ * listed: they carry the server's fold state and are joined, never folded as writes.
2587
+ */
2588
+ authoritativeNodeIds?: readonly string[];
2589
+ /**
2590
+ * End-to-end encryption policy (protocol v2, ENC-3). With `required`, every uploaded
2591
+ * data-bearing operation must carry the encryption envelope; a plaintext one is
2592
+ * refused non-retriably (`PLAINTEXT_REJECTED`), unless `allowPlaintextMigration` is
2593
+ * set for a migration window. Without it, the server stores whatever it receives
2594
+ * (envelope operations always opaquely).
2595
+ */
2596
+ encryption?: {
2597
+ required: boolean;
2598
+ allowPlaintextMigration?: boolean;
2599
+ };
1172
2600
  }
1173
2601
  /**
1174
2602
  * Handles the sync protocol for a single connected client.
@@ -1188,6 +2616,31 @@ declare class ClientSession {
1188
2616
  private state;
1189
2617
  private clientNodeId;
1190
2618
  private authContext;
2619
+ /**
2620
+ * The context the auth provider returned, kept apart from {@link authContext}
2621
+ * (which an unauthenticated session also gets, keyed by node id) so revocation
2622
+ * only ever matches a verified principal.
2623
+ */
2624
+ private principal;
2625
+ /** Closes the session when its credential expires (AUTH-11). */
2626
+ private expiryTimer;
2627
+ /** Revocations that arrived while the handshake was still authenticating. */
2628
+ private readonly pendingRevocations;
2629
+ /** Node token issued by this handshake's first anonymous claim, sent once in the response (RT-12). */
2630
+ private issuedNodeToken;
2631
+ /** The node-claim owner this session holds its node id under (userId, or an anonymous device key). */
2632
+ private nodeOwnerKey;
2633
+ /** A provisional anonymous claim awaiting the device's confirmation (RT-21). */
2634
+ private pendingNodeClaim;
2635
+ private readonly allowLegacyAnonymousClaims;
2636
+ private readonly deviceNodeHandover;
2637
+ private readonly unscopedSharing;
2638
+ private readonly anonymousClaimTtlMs;
2639
+ private readonly isNodeLive;
2640
+ /** The credential presented at handshake, kept to re-validate the session (RT-18). */
2641
+ private credential;
2642
+ /** The scope the client asked for at handshake, kept to re-resolve scopes (RT-26). */
2643
+ private handshakeScope;
1191
2644
  private syncQuerySubsets;
1192
2645
  private scopeExitPolicy;
1193
2646
  private resumeDeltaCursor;
@@ -1200,23 +2653,69 @@ declare class ClientSession {
1200
2653
  private clientDeliveryWatermark;
1201
2654
  /**
1202
2655
  * The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
1203
- * watermark, as reported in acks). Incremental streaming pushes resume from here, not
1204
- * from the highest sequence sent, so a dropped or unapplied batch is always
1205
- * re-included by the next push (which re-scans from the acknowledged position). This
1206
- * is what makes streaming recovery independent of any bounded retransmit buffer: there
1207
- * is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
1208
- * client's reported watermark at handshake.
2656
+ * watermark, as reported in acks). A retransmission rewinds the send cursor to here,
2657
+ * so a dropped or unapplied batch is always re-included by a re-scan from the
2658
+ * acknowledged position: recovery never depends on a bounded retransmit buffer.
2659
+ * Seeded from the client's reported watermark at handshake.
1209
2660
  */
1210
2661
  private lastAckedDeliverySeq;
1211
2662
  /**
1212
- * Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
1213
- * this as its stale window so a slow client does not receive duplicate full-stream
1214
- * batches every server tick while the first batch is still being applied.
2663
+ * The delivery send cursor (SRV-3): the max of the last batch sent. Live pushes
2664
+ * chain from it (base = lastSent), so each operation goes out once per retransmit
2665
+ * epoch instead of the whole unacknowledged backlog on every write. Invariant:
2666
+ * lastAcked <= lastSent. It rewinds to lastAcked only when the outstanding delivery
2667
+ * went unacknowledged for the (backed-off) stale window.
2668
+ */
2669
+ private lastSentDeliverySeq;
2670
+ /** The highest batch max ever sent to this client (acks are capped by it). */
2671
+ private highestSentDeliverySeq;
2672
+ /**
2673
+ * Own-node operations at or below this delivery sequence are streamed to this
2674
+ * client; above it they are skipped (it uploaded them itself). Set at handshake: a
2675
+ * full resync (watermark 0) recovers the client's own history up to the frontier
2676
+ * at that moment; a resume excludes all of them.
1215
2677
  */
1216
- private lastDeliveryPushAttemptAtMs;
1217
- private outstandingDelivery;
1218
- /** Serializes incremental delivery pushes so their batches never interleave. */
2678
+ private ownOperationsIncludedThrough;
2679
+ /** When the delivery last made progress (an ack advanced, or a fresh send/rewind). */
2680
+ private lastDeliveryProgressAtMs;
2681
+ /** Consecutive rewinds without acknowledgment progress (drives the backoff, LMS #12). */
2682
+ private deliveryRewinds;
2683
+ /**
2684
+ * Sent, not yet acknowledged delivery batches in send order (their max and send
2685
+ * time), for round-trip samples. `resent` marks a re-sent range, which yields no
2686
+ * sample (Karn's rule: its ack cannot be matched to one transmission).
2687
+ */
2688
+ private readonly unackedBatches;
2689
+ private smoothedRttMs;
2690
+ private rttVarianceMs;
2691
+ /**
2692
+ * Retransmit timeout of the delivery stream (RFC 6298 style: smoothed round trip
2693
+ * plus four deviations, floored). An outstanding delivery with no acknowledgment
2694
+ * progress for this long (doubled per consecutive re-send) is re-sent from the
2695
+ * acknowledged position. Round trips include the client's apply time, so a slow
2696
+ * device earns a longer timeout instead of spurious re-sends.
2697
+ */
2698
+ private retransmitTimeoutMs;
2699
+ private retransmitTimer;
2700
+ /** A delivery push is queued but not started: further wake-ups coalesce into it. */
2701
+ private deliveryPushQueued;
2702
+ /** Serializes delivery pushes so their batches never interleave. */
1219
2703
  private deliveryPushChain;
2704
+ /**
2705
+ * Records prefetched for the delivery chunk being filtered (LMS #11: one batched
2706
+ * lookup per collection per chunk instead of one query per operation). Keyed
2707
+ * `collection\u0000id`; null marks a record known to be absent. Only set while a
2708
+ * chunk is filtered.
2709
+ */
2710
+ private readonly deliveryHighWaterBytes;
2711
+ private readonly handshakeTimeoutMs;
2712
+ private handshakeTimer;
2713
+ private handshakeReceived;
2714
+ private readonly rateLimiterFor;
2715
+ private readonly appHeartbeatIntervalMs;
2716
+ private appHeartbeatTimer;
2717
+ /** Last time anything was sent to the client (a heartbeat is skipped when recent). */
2718
+ private lastClientSendAtMs;
1220
2719
  /**
1221
2720
  * Relay batches sent to this client but not yet acknowledged, keyed by messageId.
1222
2721
  * The client acks every operation-batch it applies; on ack the entry is cleared.
@@ -1243,15 +2742,64 @@ declare class ClientSession {
1243
2742
  private readonly onYjsDocUpdate;
1244
2743
  private readonly onBlobChunkRequest;
1245
2744
  private readonly onBlobChunkResponse;
2745
+ private readonly encryptionKeys;
2746
+ private readonly onEncryptionKeyWritten;
2747
+ /** Budget of key-service messages (each may cost a store read and write). */
2748
+ private readonly keyRateLimiter;
1246
2749
  private readonly persistBlobChunk;
1247
2750
  private readonly onClose;
2751
+ private readonly onReady;
2752
+ private readonly maxBlobChunkBytes;
2753
+ private readonly maxBlobBytesPerSession;
2754
+ private blobBytesPushed;
1248
2755
  private readonly onOrphanedRelays;
1249
2756
  private readonly takeOrphanedRelays;
1250
2757
  private readonly maxOperationBytes;
1251
2758
  private readonly maxOpsPerMinute;
1252
- private readonly rateLimiter;
2759
+ private readonly maxOpsPerBatch;
2760
+ private readonly maxScopePredicateValues;
2761
+ /** {@link getScopePartitionKey}, cached for the scope map it was computed from. */
2762
+ private scopePartitionKeyCache;
2763
+ /** Store reads for presence cursors (F16), per minute. */
2764
+ private readonly presenceLookupLimiter;
2765
+ /** Reads the records presence cursors name (shared by the server's sessions). */
2766
+ private readonly presenceRecords;
2767
+ /** A {@link refreshScopes} request arrived during the handshake: re-check once streaming. */
2768
+ private scopeRefreshPending;
2769
+ private rateLimiter;
2770
+ /** Separate budget for blob chunk requests (RT-24). */
2771
+ private readonly blobRateLimiter;
2772
+ /** Operations refused by the rate limiter (RT-6), for diagnostics. */
2773
+ private rateLimitedOperations;
2774
+ /** Batches refused whole for exceeding {@link maxOpsPerBatch} (RT-6). */
2775
+ private rejectedBatches;
2776
+ /** Blob chunk requests answered "not held" because the session was over budget (RT-17). */
2777
+ private rateLimitedBlobRequests;
2778
+ /**
2779
+ * The client advertised the `sequenceReservation` handshake capability (RT-37): it
2780
+ * reserves sequence numbers in-transaction, so a second operation under a held
2781
+ * (node, sequence) is refused with SEQUENCE_CONFLICT. False for a legacy client
2782
+ * (Kora <= beta.12), whose duplicate pairs are stored instead.
2783
+ */
2784
+ private sequenceReservation;
2785
+ /** Legacy duplicate pairs this session stored (RT-37), for diagnostics. */
2786
+ private legacySequencePairs;
2787
+ /** Protocol version the client declared in its handshake (1 when absent). */
2788
+ private clientProtocolVersion;
2789
+ private readonly authoritativeNodeIds;
2790
+ /** Hash version an undeclared uploaded id was verified as (RT-64), by operation object. */
2791
+ private readonly matchedHashVersions;
2792
+ /** Uploads whose data is stored as the beta.12 writer held it (RT-71). */
2793
+ private readonly restoredLegacyData;
2794
+ /** Operations accepted with an unverified legacy (beta.12) id (RT-71). */
2795
+ private unverifiedLegacyOperations;
2796
+ private forgedDuplicates;
2797
+ private readonly encryptionPolicy;
2798
+ /** Uploaded operations refused because their id is not their content hash (CORE-1). */
2799
+ private invalidOperationIds;
1253
2800
  private readonly validateOperation;
1254
2801
  private readonly koraContext;
2802
+ private readonly blobAccess;
1255
2803
  constructor(options: ClientSessionOptions);
1256
2804
  /**
1257
2805
  * Start handling messages from the client transport.
@@ -1263,9 +2811,13 @@ declare class ClientSession {
1263
2811
  */
1264
2812
  relayOperations(operations: Operation[]): void;
1265
2813
  /**
1266
- * Push newly-available in-scope operations to a delivery-watermark client, resuming
1267
- * from the highest delivery sequence already sent. Pushes are serialized so their
1268
- * batches never interleave (which would break the base/max chain).
2814
+ * Push newly-available in-scope operations to a delivery-watermark client, chained
2815
+ * from the send cursor (SRV-3): only what was not sent yet goes out. `rewind`
2816
+ * first moves the cursor back to the acknowledged position (a retransmission), and
2817
+ * `initial` is the handshake stream (it always ends in a final batch, even an empty
2818
+ * one, so the client completes initial sync). Pushes are serialized so their batches
2819
+ * never interleave (which would break the base/max chain); wake-ups that arrive
2820
+ * while one is queued coalesce into it.
1269
2821
  */
1270
2822
  private pushDeliveryStream;
1271
2823
  private relayVisibleOperations;
@@ -1275,18 +2827,63 @@ declare class ClientSession {
1275
2827
  * Retransmit relay batches this client has not acknowledged within `staleMs`.
1276
2828
  * Called on a periodic tick by the server (and directly by tests). Redelivering an
1277
2829
  * already-applied op is harmless: the client dedups by content-addressed id.
2830
+ *
2831
+ * For a delivery-watermark client it re-sends an outstanding (sent, unacknowledged)
2832
+ * delivery from the acknowledged position, under the same backed-off window as the
2833
+ * retransmit timer; `staleMs` 0 re-sends at once (a deterministic trigger for tests).
1278
2834
  */
1279
2835
  retransmitPendingRelays(staleMs?: number): void;
1280
2836
  /**
1281
2837
  * Wake the durable delivery stream for clients that negotiated delivery
1282
- * watermarks. This is used both for dropped watermark batches and for operations
1283
- * appended by another server/store instance: the session always scans from its
1284
- * own acknowledged cursor and applies its visibility filter before sending.
2838
+ * watermarks: for operations appended by another server/store instance (sent from
2839
+ * the send cursor), and for a delivery that went unacknowledged (re-sent from the
2840
+ * acknowledged position once it made no progress for `staleMs`, backed off while it
2841
+ * stays stuck). `staleMs` 0 re-sends at once.
2842
+ *
2843
+ * @param staleMs - Base stale window before an unacknowledged delivery is re-sent
2844
+ * @param options - `serverFrontier`: the store's max delivery sequence, when known;
2845
+ * `trackStall`: emit `sync:delivery-stalled` after repeated re-sends
1285
2846
  */
1286
2847
  pushDeliveryStreamIfSupported(staleMs?: number, options?: {
1287
2848
  trackStall?: boolean;
1288
2849
  serverFrontier?: number;
1289
2850
  }): void;
2851
+ /** Book-keeping for one sent delivery batch ending at `max`. */
2852
+ private recordDeliverySent;
2853
+ /**
2854
+ * A delivery acknowledgment: advance the confirmed watermark (a retransmission
2855
+ * rewinds to it), take a round-trip sample, and reset the backoff. The watermark
2856
+ * never passes what was sent: a client cannot acknowledge what it never received.
2857
+ */
2858
+ private noteDeliveryAcked;
2859
+ /** Fold one round-trip sample into the retransmit timeout (RFC 6298). */
2860
+ private recordRoundTrip;
2861
+ /**
2862
+ * The current retransmit window: the timeout, which each re-send doubled (up to
2863
+ * five minutes) and which only a fresh round-trip sample brings back down.
2864
+ */
2865
+ private retransmitWindowMs;
2866
+ /**
2867
+ * (Re)arm the retransmit timer for the outstanding delivery: it fires once no
2868
+ * acknowledgment progress was made for the retransmit window.
2869
+ */
2870
+ private armRetransmitTimer;
2871
+ /**
2872
+ * No acknowledgment progress for the retransmit window: re-send from the
2873
+ * acknowledged position (the batch was dropped, or the client could not apply it),
2874
+ * unless the socket is still draining what was sent.
2875
+ */
2876
+ private onRetransmitTimeout;
2877
+ /** Re-send the outstanding delivery from the acknowledged position. */
2878
+ private rewindDelivery;
2879
+ /** Bytes queued on the transport and not yet written to the network (0 if unknown). */
2880
+ private transportBufferedAmount;
2881
+ /**
2882
+ * Wait until the transport's queued bytes drop to the high-water mark (SRV-5
2883
+ * backpressure), so a slow client never makes the server buffer the whole stream
2884
+ * in socket memory. Resolves early when the session closes.
2885
+ */
2886
+ private waitForSendWindow;
1290
2887
  /**
1291
2888
  * Close this session.
1292
2889
  */
@@ -1297,11 +2894,118 @@ declare class ClientSession {
1297
2894
  */
1298
2895
  private flushOrphanedRelays;
1299
2896
  close(reason?: string): void;
2897
+ /**
2898
+ * End this session if its verified principal matches a credential revocation
2899
+ * (AUTH-11): send a retriable `code` error so the client refreshes and
2900
+ * re-handshakes, then close. A session still authenticating remembers the
2901
+ * revocation and applies it as soon as its principal is known.
2902
+ *
2903
+ * @returns True when the session was closed by this call
2904
+ */
2905
+ terminateIfMatches(filter: SessionRevocation, code: SessionTerminationCode): boolean;
2906
+ /**
2907
+ * Re-authenticate the credential that opened this session (RT-18). The session is
2908
+ * ended with a retriable `AUTH_REVOKED` (`AUTH_EXPIRED` once past its expiry) when
2909
+ * the provider now refuses the credential or resolves it to another principal, for
2910
+ * example after a revocation persisted by another server instance.
2911
+ *
2912
+ * @returns `'terminated'` when this call ended the session, `'error'` when the
2913
+ * provider threw (nothing is ended; the caller retries later), else `'valid'`
2914
+ */
2915
+ revalidateCredential(): Promise<'valid' | 'terminated' | 'error'>;
2916
+ /**
2917
+ * Re-resolve this session's grant on request (`KoraSyncServer.refreshScopes`), for
2918
+ * the user named. A streaming session whose principal is that user re-authenticates
2919
+ * now and ends with a retriable `SCOPE_CHANGED` when its grant changed (or
2920
+ * `AUTH_REVOKED` when the credential is no longer accepted). A session still in its
2921
+ * handshake may have resolved its grant from membership read before the change, so
2922
+ * it re-checks as soon as it reaches streaming; a session whose principal is not
2923
+ * known yet does the same (the re-check is a no-op for a grant that did not change).
2924
+ *
2925
+ * @param userId - The user whose grant changed
2926
+ * @returns The revalidation outcome, `'deferred'` when the check waits for the
2927
+ * handshake, or `'skipped'` for another user's (or a closed) session
2928
+ */
2929
+ refreshScopes(userId: string): Promise<'valid' | 'terminated' | 'error' | 'deferred' | 'skipped'>;
2930
+ /** Run a re-check deferred by {@link refreshScopes} once the session streams. */
2931
+ private runDeferredScopeRefresh;
2932
+ /**
2933
+ * True when `context` (a fresh authentication of this session's principal)
2934
+ * resolves to download or upload scopes other than the ones this session holds.
2935
+ * A grant that no longer resolves at all counts as changed.
2936
+ */
2937
+ private scopesChangedFor;
2938
+ /**
2939
+ * End this session because an admin released its node id (RT-5). Retriable: a
2940
+ * client that still owns the node simply reconnects and claims it again.
2941
+ */
2942
+ endForNodeRelease(): void;
2943
+ private terminate;
2944
+ /** Clear every timer this session owns (expiry, handshake deadline, heartbeat). */
2945
+ private clearSessionTimers;
2946
+ /**
2947
+ * Start the application-level heartbeat for a client that advertised support for it
2948
+ * (LMS #12): a `heartbeat` message whenever nothing else was sent for an interval,
2949
+ * so the client can detect a dead connection it cannot see (browsers never surface
2950
+ * WebSocket pings).
2951
+ */
2952
+ private startAppHeartbeat;
2953
+ /**
2954
+ * Arm the expiry timer from `AuthContext.expiresAt`. Returns false when the
2955
+ * credential has already expired (the caller refuses the handshake).
2956
+ */
2957
+ private armExpiryTimer;
2958
+ private clearExpiryTimer;
1300
2959
  getState(): SessionState;
1301
2960
  getSessionId(): string;
1302
2961
  getClientNodeId(): string | null;
1303
2962
  getAuthContext(): AuthContext | null;
2963
+ /**
2964
+ * The principal the auth provider verified at handshake, or null before (or
2965
+ * without) authentication. Unlike {@link getAuthContext}, never a context
2966
+ * synthesized for an unauthenticated session.
2967
+ */
2968
+ getPrincipal(): AuthContext | null;
1304
2969
  isStreaming(): boolean;
2970
+ /**
2971
+ * Ingest refusals that happen before any store work (RT-6): operations refused by
2972
+ * the per-minute rate limiter and batches refused for exceeding the per-batch cap.
2973
+ */
2974
+ getIngestLimitCounts(): {
2975
+ rateLimitedOperations: number;
2976
+ rejectedBatches: number;
2977
+ rateLimitedBlobRequests: number;
2978
+ };
2979
+ /**
2980
+ * True when the client did not advertise the `sequenceReservation` capability
2981
+ * (RT-37): a legacy client (Kora <= beta.12) that may give two operations one
2982
+ * sequence number. Such a pair is stored and delivered, never refused with
2983
+ * SEQUENCE_CONFLICT. Meaningful once the handshake was accepted.
2984
+ */
2985
+ isLegacySequenceClient(): boolean;
2986
+ /** Legacy duplicate (node, sequence) pairs this session stored (RT-37). */
2987
+ getLegacySequencePairCount(): number;
2988
+ /**
2989
+ * True when a stored record is inside this session's download scope, so a side
2990
+ * channel (for example a Yjs doc update) about it may be delivered here. A record
2991
+ * not stored yet is judged by its id alone.
2992
+ */
2993
+ canReceiveRecord(collection: string, recordId: string, storedRecord: Record<string, unknown> | null): boolean;
2994
+ /** This session's download scope, or undefined when it is unscoped. */
2995
+ getDownlinkScopes(): ScopeMap | undefined;
2996
+ /**
2997
+ * A stable key for this session's download scope: sessions with the same key see
2998
+ * exactly the same data. Unscoped sessions share the key `"*"`. Cached per scope
2999
+ * map, since a large `$in` grant makes the key costly and the relays read it often.
3000
+ */
3001
+ getScopePartitionKey(): string;
3002
+ /**
3003
+ * Partition for presence without a cursor (a "who is online" state that names no
3004
+ * record): only sessions with the same key see each other's. Signed-in sessions
3005
+ * use their download scope key; every anonymous session is its own partition,
3006
+ * because sessions holding the same anonymous grant are unrelated people.
3007
+ */
3008
+ getPresencePartitionKey(): string;
1305
3009
  /**
1306
3010
  * Get the transport for this session.
1307
3011
  * Used by the awareness relay to send messages to this client.
@@ -1312,50 +3016,371 @@ declare class ClientSession {
1312
3016
  private sendToClient;
1313
3017
  private enqueueMessage;
1314
3018
  private handleMessageAsync;
3019
+ /**
3020
+ * The owner of this session's encryption key records (ENC-1), or null when it may
3021
+ * hold none: the authenticated user, or on a server without auth the one shared
3022
+ * anonymous owner. An anonymous principal of a mixed provider has no stable
3023
+ * identity, so it holds no keys.
3024
+ */
3025
+ getEncryptionKeyOwner(): string | null;
3026
+ /** Push a key record another session of the same owner wrote (rotation, new passphrase). */
3027
+ pushEncryptionKeyRecord(keyring: string, record: WrappedKeyRecord): void;
3028
+ private handleEncryptionKeyMessage;
1315
3029
  /**
1316
3030
  * Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
1317
3031
  * verified to hash to the declared hash before storing, so a corrupt or
1318
3032
  * mislabeled upload is rejected rather than served later as trusted content.
1319
3033
  */
1320
- private handleBlobChunkPush;
1321
- private handleMessageFailure;
1322
- private handleHandshake;
1323
- private handleOperationBatch;
1324
- private transformForServerSchema;
1325
- private sendDelta;
3034
+ private handleBlobChunkPush;
3035
+ /**
3036
+ * Claim (or re-claim) a node id for an anonymous device (RT-12, RT-21).
3037
+ *
3038
+ * A device presenting a token holds the node when the claim is (or can become)
3039
+ * `kora:anon-node:<hash(token)>`: its confirmed claim, a fresh claim, or its own
3040
+ * provisional claim, which presenting the token confirms.
3041
+ *
3042
+ * A device presenting no token gets a fresh token and a PROVISIONAL claim, which it
3043
+ * confirms by acknowledging the response with the token once saved (or by
3044
+ * presenting it next time). Until then the claim may be re-issued to a device that
3045
+ * again presents no token (the response was lost in transit), but only while no
3046
+ * session is connected as that node and before the claim expires. Legacy claims
3047
+ * (expired provisional ones, and nodes of the pre-release shared anonymous owner)
3048
+ * are re-issued only with `allowLegacyAnonymousClaims`, with a deprecation warning.
3049
+ */
3050
+ private claimAnonymousNode;
3051
+ /**
3052
+ * Automatic device handover after an upgrade from beta.12 (F1). A node with
3053
+ * history but no owner (written before node claims existed) or released by an
3054
+ * admin is claimed for the signed-in user when the node id IS the device id the
3055
+ * auth provider verified for this credential (`metadata.deviceId`; the built-in
3056
+ * provider takes it from the token's `dev` claim, which names a device registered
3057
+ * to that user). `@korajs/auth` clients use that device id as their node id and
3058
+ * cannot change it, so without this they stay refused until an operator binds the
3059
+ * node. The claim is one atomic store step; a node another principal owns is never
3060
+ * taken, and another user presenting the node id is still refused.
3061
+ *
3062
+ * The claim decides ownership from now on only. History written under the node on
3063
+ * beta.12 may include operations another user forged (beta.12 did not verify node
3064
+ * ids); the operation log integrity scan is the tool for auditing it.
3065
+ */
3066
+ private handOverDeviceNode;
3067
+ /**
3068
+ * The download grant the server itself gives this session, before the client's
3069
+ * handshake scope narrows it (F4). A client may narrow its own view, but that does
3070
+ * not isolate tenants: another client of the same user set can ask for everything.
3071
+ * `null` when the grant cannot be resolved here (the handshake already refused or
3072
+ * accepted it on the full path); then nothing is judged.
3073
+ */
3074
+ private grantWithoutHandshakeNarrowing;
3075
+ /** Who holds a refused node id, as `NODE_ID_CLAIMED` reports it (F9). */
3076
+ private nodeOwnershipOf;
3077
+ /** The deprecation warning for an anonymous device adopted under a legacy claim (RT-21, RT-91). */
3078
+ private warnLegacyAnonymousClaim;
3079
+ /**
3080
+ * The device proved it saved the node token issued by this handshake: make the
3081
+ * provisional claim permanent (RT-21). A mismatched token is ignored.
3082
+ */
3083
+ private confirmNodeClaim;
3084
+ /**
3085
+ * The download scope references are authorized against (RT-11, RT-13), or
3086
+ * undefined when they are not checked: without a real auth provider there is no
3087
+ * tenant boundary to protect.
3088
+ */
3089
+ private referenceScopes;
3090
+ /**
3091
+ * Stable blob-ownership key of this session's principal: the user id, or for an
3092
+ * anonymous device its node-claim owner (never shared by two anonymous devices).
3093
+ */
3094
+ getBlobOwnerKey(): string;
3095
+ /**
3096
+ * Partition key for peer-to-peer blob forwarding. Sessions with the same download
3097
+ * scope share a tenant view, except anonymous devices: every anonymous session has
3098
+ * the same grant but they are unrelated people, so each is its own partition.
3099
+ */
3100
+ getBlobPartitionKey(): string;
3101
+ /** Authorize foreign-key targets and blob references of an untrusted write. */
3102
+ private authorizeReferences;
3103
+ private handleMessageFailure;
3104
+ private handleHandshake;
3105
+ /**
3106
+ * The nodes whose operations after `fromDeliverySeq` this scoped session will
3107
+ * receive, for the handshake vector (RT-7). Scans the delivery log without holding
3108
+ * it (memory is O(nodes)), and judges each node only until one of its operations is
3109
+ * visible, so most operations cost one scope comparison and no record read.
3110
+ */
3111
+ private collectVisibleNodeIds;
3112
+ /**
3113
+ * The version vector as committed in the store (SRV-4). A store shared by several
3114
+ * instances reads it fresh; others serve their own (complete) vector.
3115
+ */
3116
+ private readServerVector;
3117
+ /**
3118
+ * Resolve this session's download and upload scopes from an auth context and the
3119
+ * scope the client asked for at handshake. Side-effect free, so revalidation can
3120
+ * recompute it and compare (RT-26). The legacy `scopes` contract remains shorthand
3121
+ * for both directions.
3122
+ */
3123
+ private computeSessionScopes;
3124
+ private handleOperationBatch;
3125
+ /**
3126
+ * For each delete of this session's node in an upload batch, the batch's later
3127
+ * operations of the same node that name it as a causal parent: candidates for the
3128
+ * author's own copies of its cascades and set-nulls (RT-69). Indexed by the delete's
3129
+ * id, in the server's schema (field names as the server derives effects).
3130
+ */
3131
+ private indexAuthoredSideEffectCopies;
3132
+ /**
3133
+ * The referential effects of a delete that arrived again as a stored duplicate and
3134
+ * are still undone (RT-73): effects the author covers later in this batch are
3135
+ * deferred like a fresh delete's (derived after the batch unless a copy is stored);
3136
+ * the rest are derived now. Derived ids are deterministic, so a concurrent retry or
3137
+ * another instance stores the same operations.
3138
+ *
3139
+ * @returns The derived operations (to relay)
3140
+ */
3141
+ private resumeStoredDeleteEffects;
3142
+ /**
3143
+ * Derive the server's copy of each deferred side effect unless one of the author's
3144
+ * copies is stored (applied or already held). A store with the batch lookup is asked
3145
+ * again, so a copy stored by a concurrent session counts too.
3146
+ */
3147
+ private deriveUncoveredSideEffects;
3148
+ /** {@link ServerStore.findStoredOperations} by id; empty without it or on a failed read. */
3149
+ private findStoredOperationIds;
3150
+ /**
3151
+ * The operation as the server schema reads it (transforms at fold time, RT-84): the
3152
+ * view authorization, validators, constraint checks and scope filters judge, and the
3153
+ * one the server stores fold (core `operationSchemaView`, same transforms). Never
3154
+ * stored: the store keeps the operation as uploaded. An envelope operation is opaque
3155
+ * to the server (NEW-ENC-1) and judged as is; devices transform after decryption.
3156
+ */
3157
+ private schemaView;
3158
+ /**
3159
+ * Integrity checks on an uploaded operation before anything else judges it:
3160
+ * - a version-2 plaintext id must be the content hash (`INVALID_OPERATION_ID`);
3161
+ * - an unknown declared hash version is refused the same way;
3162
+ * - with required encryption, a data-bearing plaintext operation is refused
3163
+ * (`PLAINTEXT_REJECTED`) unless the plaintext migration window is open.
3164
+ *
3165
+ * @returns null when the operation passes, or the refusal
3166
+ */
3167
+ private checkUploadIntegrity;
3168
+ /**
3169
+ * Whether an undeclared version-1 id that does not verify is accepted unverified
3170
+ * (RT-71): only from a protocol-1 session (Kora <= beta.12), and only for the
3171
+ * session's own node.
3172
+ */
3173
+ private acceptsUnverifiedLegacyId;
3174
+ /** Log, count and emit an operation accepted with an unverified legacy id (RT-71). */
3175
+ private reportUnverifiedLegacyOperation;
3176
+ /** Operations this session accepted with an unverified legacy id (RT-71). */
3177
+ getUnverifiedLegacyOperationCount(): number;
3178
+ /**
3179
+ * The operation as the server stores it: an undeclared plaintext id this server
3180
+ * verified declares the version it matched (1, or 2 when the declaration was lost),
3181
+ * so receivers verify it too (RT-64). An absent version is left only on ids nobody
3182
+ * could verify (protocol-1 encrypted payloads, server-side transforms).
3183
+ */
3184
+ private declareVerifiedHashVersion;
3185
+ /**
3186
+ * True for a node id that is (or was) the server's own: the `kora:` namespace, the
3187
+ * store's node id, and every id the server advertises or folds as authoritative
3188
+ * (other instances, legacy server node ids, configured extras). No device may use one
3189
+ * (RT-61), whether or not it has history yet.
3190
+ */
3191
+ private isServerAuthorNodeId;
3192
+ /**
3193
+ * The authoritative ids a handshake advertises (RT-75): only explicit ones (legacy
3194
+ * server node ids, configured extras). Every `kora:server:` id is authoritative by
3195
+ * prefix on every replica, and listing instance ids would make the list differ per
3196
+ * instance and per start, which devices would have to treat as news.
3197
+ */
3198
+ /** The handshake's explicit revocations (RT-81), when the deployment has any. */
3199
+ private advertisedRevocations;
3200
+ private advertisedAuthoritativeNodeIds;
3201
+ /** Node ids this server authors operations under (protocol v2 handshake response). */
3202
+ private serverAuthoritativeNodeIds;
3203
+ /**
3204
+ * A protocol-1 client (Kora <= beta.12) is served for one release (beta.13) with a
3205
+ * deprecation warning: its operation ids are version-1 hashes and its encrypted
3206
+ * payloads have no envelope binding.
3207
+ */
3208
+ private warnLegacyProtocol;
3209
+ /** The in-scope operations a version-vector client is missing (not yet sent). */
3210
+ private collectDeltaOperations;
3211
+ /** Send a collected version-vector delta in paginated, cursor-carrying batches. */
3212
+ private sendCollectedDelta;
1326
3213
  /**
1327
- * Send the gap-free server->client delivery stream, resuming just after
3214
+ * Stream the gap-free server->client delivery stream, resuming just after
1328
3215
  * `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
1329
3216
  * (commit order), scope-filtered for this session, and sent in batches that chain:
1330
3217
  * each batch's `baseDeliverySequence` equals the previous batch's
1331
3218
  * `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
1332
3219
  * applies a batch only when its watermark equals the base and advances the
1333
3220
  * watermark to the max, so a dropped batch stalls the watermark and is recovered by
1334
- * the next handshake resend. Because delivery-sequence order respects causal order
1335
- * (a dependency is always committed, and thus sequenced, before its dependent), no
1336
- * topological sort is needed and the client never defers on a missing dependency
1337
- * that is itself in this stream.
3221
+ * a re-send from the acknowledged position. Because delivery-sequence order respects
3222
+ * causal order (a dependency is always committed, and thus sequenced, before its
3223
+ * dependent), no topological sort is needed.
3224
+ *
3225
+ * Batches go out as each scan chunk is filtered (SRV-5): the server holds at most
3226
+ * one scan chunk plus two batches per stream, never the whole backlog, and the
3227
+ * first batch reaches the client after one chunk. One full batch is held back so
3228
+ * the stream's last batch can be marked `isFinal` (one-batch lookahead). The final
3229
+ * batch carries the highest sequence scanned, not merely the last in-scope one, so
3230
+ * an out-of-scope tail is not re-scanned. Every sent batch advances the send cursor
3231
+ * ({@link lastSentDeliverySeq}). Between batches the stream waits for the client to
3232
+ * drain its socket (backpressure).
1338
3233
  *
1339
- * The final batch advances the watermark to the highest delivery sequence scanned,
1340
- * not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
1341
- * next reconnect. Returns the number of operations actually sent.
3234
+ * @param fromDeliverySeq - The chain base: the client's watermark or the send cursor
3235
+ * @param finalizeWhenEmpty - Send an empty final batch even when nothing new was
3236
+ * scanned (the handshake stream, so the client completes initial sync)
3237
+ */
3238
+ private streamDelivery;
3239
+ /**
3240
+ * Filter one scan chunk of the delivery log down to what this session receives:
3241
+ * visible operations (each preceded by its scope-entry insert, RT-19) and
3242
+ * retractions. The records the decisions need are fetched for the whole chunk at
3243
+ * once (LMS #11), one batched read per collection instead of one query per op.
1342
3244
  */
1343
- private sendDeliveryStream;
3245
+ private filterDeliveryChunk;
3246
+ /**
3247
+ * Read, in one batched query per collection, every record the visibility decisions
3248
+ * for these operations may look up, when the store supports batched reads. Returns
3249
+ * null (per-operation lookups) otherwise. Over-fetching is harmless: the chunk
3250
+ * bounds it.
3251
+ */
3252
+ private prefetchRecordsFor;
1344
3253
  /** Build, track, and send one chained delivery-stream batch. */
1345
3254
  private sendDeliveryBatch;
3255
+ /** The scope snapshots of these operations, when the store keeps them (RT-14). */
3256
+ private scopeSnapshotsFor;
3257
+ /**
3258
+ * Download visibility of one operation. With a scope snapshot (RT-14) the
3259
+ * operation is judged on the record's scope values right after it was applied, so
3260
+ * history written while a record belonged to someone else stays hidden after an
3261
+ * ownership transfer, and a previous owner still receives its own history. Query
3262
+ * subsets (a client-chosen narrowing, not an authorization) keep using the current
3263
+ * row. Operations without a snapshot (legacy rows, custom stores) fall back to the
3264
+ * current row plus `op.data`.
3265
+ */
1346
3266
  private operationVisibleToClient;
1347
- /** Upload authorization is independent from the client's downloaded/query view. */
1348
- private operationAllowedFromClient;
3267
+ /**
3268
+ * Where the store already holds each of `operations` (by id), in one store read.
3269
+ * Null when the store cannot answer by id (a custom store without
3270
+ * `findStoredOperations`, or the read failed): callers then fall back to a
3271
+ * per-operation (node, sequence) lookup and the store's own dedup at apply.
3272
+ */
3273
+ private findStoredOperations;
3274
+ /**
3275
+ * True when the batch lookup alone shows `op` is already stored and may be
3276
+ * acknowledged as a duplicate with no further store work (RT-31, RT-39): this
3277
+ * device's own operation stored under its node (its sequence repair may have
3278
+ * renumbered it, keeping the id), or any other operation stored under exactly its
3279
+ * (node, sequence, id), such as one the server itself delivered and the client
3280
+ * echoed back. Never true for a store without the batch lookup.
3281
+ */
3282
+ private isStoredDuplicate;
3283
+ /**
3284
+ * The operation the store holds under `op`'s id (at the node and sequence the batch
3285
+ * lookup found), or null when it cannot be read (the upload is then judged like any
3286
+ * other: verified, validated, charged, and deduplicated by the store at apply).
3287
+ */
3288
+ private loadStoredOperation;
3289
+ /**
3290
+ * Refuse an upload that reuses a stored operation's id with different content (RT-77):
3291
+ * terminal, nothing recorded or derived, logged and emitted as tampering. An honest
3292
+ * client never does this (ids are content hashes), so the rejection only reaches a
3293
+ * forger. No resolution is recorded: the id belongs to the stored operation.
3294
+ */
3295
+ private refuseForgedDuplicate;
3296
+ /** Uploads refused for reusing a stored id with other content (RT-77). */
3297
+ getForgedDuplicateCount(): number;
3298
+ /**
3299
+ * The recorded resolutions of the batch's own-node operations that the lookup did
3300
+ * not find stored (RT-43, RT-47). Asked only for this session's own node, so the
3301
+ * answer never reveals another device's (or tenant's) refusals.
3302
+ */
3303
+ private findResolutions;
3304
+ /** The highest resolved sequence of `nodeId` (0 without the store method or on error). */
3305
+ private resolvedThrough;
3306
+ /**
3307
+ * Durably record how this session's own operation was resolved without being stored
3308
+ * under its sequence. Awaited before the answer is sent: a failure propagates (the
3309
+ * batch fails and is not acknowledged), so the client never acts on a resolution the
3310
+ * server could forget.
3311
+ */
3312
+ private recordResolution;
3313
+ /**
3314
+ * Refuse an operation for good (non-retriable): remember the refusal (RT-47), then
3315
+ * tell the client. A resubmission of the same id is answered with this rejection
3316
+ * without being judged again.
3317
+ */
3318
+ private refuseTerminally;
3319
+ /**
3320
+ * An own operation acknowledged as a duplicate of the copy stored under ANOTHER
3321
+ * sequence (the client's sequence repair renumbered it, keeping its id): record the
3322
+ * submitted sequence as resolved, so the handshake does not read it as lost (RT-43).
3323
+ */
3324
+ private noteStoredElsewhere;
3325
+ /** Refuse the rest of a batch for the per-minute ingest budget (retriable). */
3326
+ private sendRateLimited;
3327
+ /**
3328
+ * True when the store already holds exactly this operation (same node, sequence and
3329
+ * id). Answered from the batch lookup when the store supports it.
3330
+ */
3331
+ private isStoredOperation;
3332
+ /**
3333
+ * The scopes this session may write under. Fails closed: with auth configured but
3334
+ * no accepted authentication, the session may write nothing (an empty scope map),
3335
+ * never everything.
3336
+ */
3337
+ private uplinkScopes;
3338
+ /**
3339
+ * Upload authorization, independent from the client's downloaded/query view. The
3340
+ * stored row (including a soft-deleted one) is always loaded when scopes apply and
3341
+ * is authoritative; client-supplied previousData is never consulted.
3342
+ */
3343
+ private authorizeClientOperation;
3344
+ /**
3345
+ * True when this update moved the record out of the session's scope, judged on the
3346
+ * store's own before/after values (RT-15), never on the writer's previousData. An
3347
+ * operation without a snapshot never produces a retraction (fail quiet: the
3348
+ * client keeps what it already had, and no other tenant's record id leaks).
3349
+ */
1349
3350
  private scopeRetractionFor;
3351
+ /**
3352
+ * The scope-entry operation to send before `op` when `op` moved an existing record
3353
+ * into this session's download scope (RT-19), or null. Judged on the store's own
3354
+ * pre/post snapshot; built from the record's CURRENT row, and only while that row
3355
+ * is live and still inside the scope (a record that has since left again, or was
3356
+ * deleted, gets no entry: its later operations retract or delete it anyway).
3357
+ * Operations without a snapshot (legacy rows, custom stores) never produce one.
3358
+ */
3359
+ private scopeEntryFor;
1350
3360
  /**
1351
3361
  * Read a record's current field values from the materialized store for scope /
1352
3362
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
1353
3363
  * carries no fields) is still judged against the record's actual scope. Returns
1354
3364
  * undefined when the record cannot be read (never materialized, or no schema).
3365
+ * `rows` are the rows one delivery pass prefetched for its own decisions.
1355
3366
  */
1356
3367
  private lookupRecordFields;
3368
+ /**
3369
+ * Hand an awareness update to the server's relay, with the record its cursor
3370
+ * names, read from the store, so the relay can deliver it only to sessions whose
3371
+ * download scope contains that record (the rule of the Yjs doc channel).
3372
+ * Awareness is purely ephemeral: nothing is persisted.
3373
+ */
1357
3374
  private handleAwarenessUpdate;
3375
+ /**
3376
+ * A Yjs doc-channel update is a write to a richtext field: the sender must be
3377
+ * allowed to write the stored record (same rule as operation uploads). The stored
3378
+ * row is handed to the relay so delivery can be limited to sessions whose download
3379
+ * scope contains the record.
3380
+ */
1358
3381
  private handleYjsDocUpdate;
3382
+ /** A legacy client's duplicate pair was stored (RT-37): count it and log it. */
3383
+ private recordLegacySequencePair;
1359
3384
  private sendError;
1360
3385
  /**
1361
3386
  * Reject one specific client operation, tied to its id, so the submitter can
@@ -1364,10 +3389,57 @@ declare class ClientSession {
1364
3389
  * replica ever sees it.
1365
3390
  */
1366
3391
  private sendOperationRejected;
1367
- private setSerializerWireFormat;
1368
3392
  private handleTransportClose;
1369
3393
  }
1370
3394
 
3395
+ /** {@link findJsonStringValues} was given a page or sample size it cannot use. */
3396
+ declare class InvalidDiagnosticOptionsError extends KoraError {
3397
+ constructor(option: string, value: unknown);
3398
+ }
3399
+ /** Rows of one json/object field whose stored value is a string that itself holds JSON. */
3400
+ interface JsonStringValueReport {
3401
+ collection: string;
3402
+ field: string;
3403
+ /** How many live rows hold such a string. */
3404
+ count: number;
3405
+ /** Ids of the first such rows, to inspect. */
3406
+ sampleIds: string[];
3407
+ }
3408
+ /** Options for {@link findJsonStringValues}. */
3409
+ interface FindJsonStringValuesOptions {
3410
+ /** Ids reported per field. Defaults to 10. */
3411
+ sampleSize?: number;
3412
+ /** Rows read per page. Defaults to 1,000. */
3413
+ pageSize?: number;
3414
+ }
3415
+ /**
3416
+ * Find `t.json()` / `t.object()` fields whose stored value is a JSON-encoded string
3417
+ * (`'{"a":1}'` instead of `{ a: 1 }`), after an upgrade from a server older than
3418
+ * 1.0.0-beta.13 (F14).
3419
+ *
3420
+ * Servers before beta.13 decoded one JSON-string layer of such values when they built
3421
+ * rows; since beta.13 the rows hold exactly what the operations wrote. A client that
3422
+ * wrote a json field as a string (or double-encoded it) therefore reads back a string
3423
+ * where the old server returned an object. Kora does not rewrite these values: a string
3424
+ * is also a legitimate json value, so no rule can tell a real string from an encoded
3425
+ * object. Fix them with server-authored updates (`server.kora.apply`) once you know
3426
+ * which shape your app expects.
3427
+ *
3428
+ * @param store - The server store, with its schema set
3429
+ * @param options - Sample and page sizes
3430
+ * @returns One report per field that holds at least one such value
3431
+ * @throws {InvalidDiagnosticOptionsError} When `pageSize` is not a positive integer or
3432
+ * `sampleSize` not a non-negative one
3433
+ *
3434
+ * @example
3435
+ * ```typescript
3436
+ * for (const report of await findJsonStringValues(store)) {
3437
+ * console.warn(`${report.collection}.${report.field}: ${report.count} rows`, report.sampleIds)
3438
+ * }
3439
+ * ```
3440
+ */
3441
+ declare function findJsonStringValues(store: ServerStore, options?: FindJsonStringValuesOptions): Promise<JsonStringValueReport[]>;
3442
+
1371
3443
  /**
1372
3444
  * Configuration for the production server that serves both
1373
3445
  * static files and WebSocket sync on a single port.
@@ -1406,6 +3478,32 @@ interface ProductionServerConfig {
1406
3478
  * backupToken.
1407
3479
  */
1408
3480
  operationalAuth?: ProductionOperationalAuth;
3481
+ /**
3482
+ * Reverse proxies trusted to report the client address in `X-Forwarded-For`,
3483
+ * which becomes `request.ip` (the key `@korajs/auth` rate-limits sign-in by).
3484
+ * A hop count (`1` for a single load balancer in front) or a list of trusted
3485
+ * proxy IPs / CIDR ranges. When unset, the header is ignored and `request.ip`
3486
+ * is the socket address, so a client cannot pick its own rate-limit bucket.
3487
+ */
3488
+ trustProxy?: TrustProxySetting;
3489
+ /**
3490
+ * Largest request body a custom HTTP route (`httpRoutes`) accepts, in bytes. A
3491
+ * larger body is refused with 413 before it is buffered (SRV-6), so an
3492
+ * unauthenticated client cannot make the server hold arbitrary amounts of memory.
3493
+ * Defaults to 1 MiB.
3494
+ */
3495
+ maxRequestBodyBytes?: number;
3496
+ /**
3497
+ * Largest backup accepted by `/__kora/backup/import`, in bytes. Defaults to 256 MiB.
3498
+ */
3499
+ maxBackupBytes?: number;
3500
+ /**
3501
+ * Which requests for a missing path get the app shell (`index.html`). `'navigation'`
3502
+ * (default): browser navigations only, so a missing API path is a real 404.
3503
+ * `'extensionless'`: also any path without a file extension, for a service worker
3504
+ * that warms app routes with a plain `fetch(url)`. Never under `/assets/`.
3505
+ */
3506
+ spaFallback?: 'navigation' | 'extensionless';
1409
3507
  }
1410
3508
  interface ProductionOperationalAuth {
1411
3509
  /** Protects /__kora, /__kora/status, and /__kora/events. */
@@ -1414,6 +3512,14 @@ interface ProductionOperationalAuth {
1414
3512
  metricsToken?: string;
1415
3513
  /** Protects /__kora/backup/*. Falls back to adminToken when omitted. */
1416
3514
  backupToken?: string;
3515
+ /**
3516
+ * With `NODE_ENV=production`, an endpoint group whose token is unset is disabled
3517
+ * (`403 OPERATIONAL_ENDPOINT_DISABLED`): backups export or replace every user's data,
3518
+ * and the dashboard lists connected devices. Set this to `true` to serve such groups
3519
+ * without a token anyway (for example behind a network you control). Outside
3520
+ * production, groups without a token stay public, with a startup warning.
3521
+ */
3522
+ allowPublic?: boolean;
1417
3523
  }
1418
3524
  interface ProductionHttpRouteRequest {
1419
3525
  method: string;
@@ -1444,7 +3550,10 @@ interface ProductionHttpRoute {
1444
3550
  * A production server handle returned by createProductionServer.
1445
3551
  */
1446
3552
  interface ProductionServer {
1447
- /** Start listening. Returns the URL the server is available at. */
3553
+ /**
3554
+ * Start listening. Returns the URL the server is available at, with the port it
3555
+ * actually bound (pass `port: 0` to let the OS pick a free one).
3556
+ */
1448
3557
  start(): Promise<string>;
1449
3558
  /** Stop the server gracefully. */
1450
3559
  stop(): Promise<void>;
@@ -1471,6 +3580,25 @@ interface ProductionServer {
1471
3580
  * @returns Every live blob reference the server can currently see.
1472
3581
  */
1473
3582
  getLiveBlobRefs(): Promise<BlobRef[]>;
3583
+ /**
3584
+ * Re-resolve one user's grant on their live sync sessions now. Call it after a
3585
+ * membership change (an invitation accepted, a collaborator removed): a session
3586
+ * whose scope changed ends with a retriable `SCOPE_CHANGED` and its client
3587
+ * reconnects with the new grant, applying its `scopeExit` policy. See
3588
+ * `KoraSyncServer.refreshScopes`.
3589
+ *
3590
+ * @param userId - The user whose grant changed
3591
+ * @returns The number of sessions ended
3592
+ */
3593
+ refreshScopes(userId: string): Promise<number>;
3594
+ /**
3595
+ * Re-validate every live sync session's credential and grant now (the pass that
3596
+ * otherwise runs every `sessionRevalidationIntervalMs`). See
3597
+ * `KoraSyncServer.revalidateSessions`.
3598
+ *
3599
+ * @returns The number of sessions ended
3600
+ */
3601
+ revalidateSessions(): Promise<number>;
1474
3602
  }
1475
3603
  /**
1476
3604
  * Creates a production server that serves both static files and WebSocket sync
@@ -1496,6 +3624,10 @@ interface TokenAuthProviderOptions {
1496
3624
  /**
1497
3625
  * Validate a token and return an AuthContext if valid, or null if rejected.
1498
3626
  * This is where you implement your auth logic (JWT verification, database lookup, etc.).
3627
+ *
3628
+ * Return `scopes` to grant sync access explicitly. When the returned context
3629
+ * has no scopes, schema-scoped collections are bound to `{ userId }` and any
3630
+ * collection scoped by another key is denied.
1499
3631
  */
1500
3632
  validate: (token: string) => Promise<AuthContext | null>;
1501
3633
  }
@@ -1528,11 +3660,13 @@ interface TokenValidator {
1528
3660
  sub: string;
1529
3661
  dev: string;
1530
3662
  type: string;
3663
+ exp?: number;
1531
3664
  } | null;
1532
3665
  validateTokenWithRevocation?(token: string): Promise<{
1533
3666
  sub: string;
1534
3667
  dev: string;
1535
3668
  type: string;
3669
+ exp?: number;
1536
3670
  } | null>;
1537
3671
  }
1538
3672
  /**
@@ -1575,8 +3709,15 @@ interface KoraAuthProviderOptions {
1575
3709
  */
1576
3710
  deviceTracker?: DeviceToucher;
1577
3711
  /**
1578
- * Optional scope resolver. Called with the user ID to determine
1579
- * which collections/records the user can sync.
3712
+ * Optional scope resolver. Called with the verified user ID to determine
3713
+ * which collections/records the user can sync. Its result is the complete
3714
+ * server grant: a collection it omits is not visible, and the client
3715
+ * handshake can only narrow it. Use `claimScopes({ userId, orgId })` from
3716
+ * `@korajs/core` to bind every schema-scoped collection from verified values.
3717
+ *
3718
+ * When omitted, the grant binds every schema-scoped collection from
3719
+ * `{ userId: <verified sub> }`; collections scoped by any other key are denied
3720
+ * until you supply it.
1580
3721
  */
1581
3722
  resolveScopes?: (userId: string) => Promise<Record<string, Record<string, unknown>>>;
1582
3723
  }
@@ -1630,7 +3771,9 @@ interface MixedAuthProviderOptions {
1630
3771
  */
1631
3772
  primary: AuthProvider;
1632
3773
  /**
1633
- * Scopes to apply to anonymous connections.
3774
+ * Scopes to apply to anonymous connections. This is the complete anonymous
3775
+ * grant: anonymous sessions can sync exactly these collections, and the
3776
+ * client handshake can only narrow them, never add one.
1634
3777
  * Each key is a collection name; the value is a filter object
1635
3778
  * (use `{}` for unrestricted access to that collection).
1636
3779
  *
@@ -1654,10 +3797,10 @@ interface MixedAuthProviderOptions {
1654
3797
  /**
1655
3798
  * Auth provider that supports both authenticated and anonymous connections.
1656
3799
  *
1657
- * When a client connects with a valid token, the primary auth provider
1658
- * handles authentication normally. When a client connects without a token
1659
- * (or with an invalid one), the connection is accepted as anonymous with
1660
- * restricted sync scopes.
3800
+ * When a client connects with a token, the primary auth provider decides:
3801
+ * a valid token authenticates normally and an invalid, expired or revoked one
3802
+ * is rejected (the client refreshes and reconnects). Only a client that sends
3803
+ * no token at all is accepted as anonymous, with exactly `anonymousScopes`.
1661
3804
  *
1662
3805
  * This is the recommended pattern for apps that need public data access
1663
3806
  * alongside authenticated users — for example, a form builder where
@@ -1698,9 +3841,24 @@ declare class MixedAuthProvider implements AuthProvider {
1698
3841
  private readonly anonymousPrefix;
1699
3842
  private anonymousCounter;
1700
3843
  constructor(options: MixedAuthProviderOptions);
1701
- authenticate(token: string): Promise<AuthContext>;
3844
+ /** The primary provider's revocation feed, when it has one. */
3845
+ onRevoke?: AuthProvider['onRevoke'];
3846
+ authenticate(token: string): Promise<AuthContext | null>;
1702
3847
  }
1703
3848
 
3849
+ /** Options for {@link MemoryServerStore}. */
3850
+ interface MemoryServerStoreOptions {
3851
+ /**
3852
+ * Node ids, besides this store's own, whose operations win
3853
+ * `merge('server-authoritative')` fields (legacy server node ids). Every
3854
+ * `kora:server:` node id is authoritative without being listed.
3855
+ */
3856
+ authoritativeNodeIds?: string[];
3857
+ /** Revoked explicit authorities (see `ServerIdentityOptions.revokedAuthoritativeNodeIds`). */
3858
+ revokedAuthoritativeNodeIds?: string[];
3859
+ /** Instance id within the deployment (see `ServerIdentityOptions.instanceId`). */
3860
+ instanceId?: string;
3861
+ }
1704
3862
  /**
1705
3863
  * In-memory server store for testing and quick prototyping.
1706
3864
  * Not suitable for production — data does not survive process restart.
@@ -1712,6 +3870,13 @@ declare class MemoryServerStore implements ServerStore {
1712
3870
  private readonly nodeId;
1713
3871
  private readonly operations;
1714
3872
  private readonly operationIndex;
3873
+ /** (nodeId, sequenceNumber) -> the id of the operation holding it (W3 step 4). */
3874
+ /** (node, sequence) -> ids holding it, in store order (several only for a legacy pair). */
3875
+ private readonly operationIdsBySequence;
3876
+ /** Node -> sequences held by more than one operation (legacy pairs, RT-48). */
3877
+ private readonly pairSequencesByNode;
3878
+ /** Operation id -> how it was resolved without being stored (RT-43, RT-47). */
3879
+ private readonly resolutions;
1715
3880
  private readonly versionVector;
1716
3881
  /**
1717
3882
  * Server-assigned delivery sequence per operation id. Single-process, so a plain
@@ -1722,24 +3887,118 @@ declare class MemoryServerStore implements ServerStore {
1722
3887
  private readonly deliverySeqByOpId;
1723
3888
  private deliverySeqCounter;
1724
3889
  private schema;
3890
+ /** Node id -> the authenticated principal that claimed it (see claimNode). */
3891
+ private readonly nodeOwners;
3892
+ /** Operation id -> scope snapshot captured at apply time (RT-14). */
3893
+ private readonly scopeSnapshots;
3894
+ /** Fields the current snapshots were captured with (RT-20). */
3895
+ private snapshotFingerprint;
3896
+ /** Blob content hash -> owners that pushed or first claimed it (RT-11). */
3897
+ private readonly blobOwners;
3898
+ /** Wrapped encryption key records (ENC-1): owner + keyring -> record JSON and revision. */
3899
+ private readonly encryptionKeyRecords;
1725
3900
  /** Materialized records: collection -> recordId -> record data */
1726
3901
  private readonly materializedRecords;
3902
+ /** Fold state per materialized record (W7): rows are projected from it. */
3903
+ private readonly foldStates;
3904
+ /** Every operation of a record, in store (delivery) order. */
3905
+ private readonly recordOps;
3906
+ private readonly authoritativeNodeIds;
3907
+ /** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
3908
+ private readonly explicitAuthorities;
3909
+ /** Explicit authority over time: revoked and ever-held ids (RT-81). */
3910
+ private readonly authorityHistory;
3911
+ private foldOptions;
3912
+ /** Schema transforms the fold applies (transforms at fold time, RT-84). */
3913
+ private operationTransforms;
1727
3914
  private closed;
1728
- constructor(nodeId?: string);
3915
+ /**
3916
+ * Sequence-enforcement epoch (see SEQUENCE_ENFORCEMENT_EPOCH_KEY). A memory store
3917
+ * starts empty under this release, so 0: every holder was stored under enforcement.
3918
+ * A replace-mode backup import moves it to the restored log's end.
3919
+ */
3920
+ private sequenceEpoch;
3921
+ /** Derivation secret of server-derived ids (RT-64). Per process: nothing persists. */
3922
+ private readonly derivationSecret;
3923
+ /**
3924
+ * @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
3925
+ * `kora:server:` id is used verbatim. Leave unset: the store authors under
3926
+ * `kora:server:<deployment>:<instance>` (a fresh deployment per process, since a
3927
+ * memory store persists nothing).
3928
+ * @param options - Extra authoritative ids and the instance id
3929
+ */
3930
+ constructor(nodeId?: string, options?: MemoryServerStoreOptions);
3931
+ deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
3932
+ getAuthoritativeNodeIds(): string[];
3933
+ /** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
3934
+ getRevokedAuthoritativeNodeIds(): string[];
3935
+ /**
3936
+ * Every explicit id the deployment ever held authoritative, revoked ones included
3937
+ * (RT-81): never accepted as a device node id.
3938
+ */
3939
+ getEverAuthoritativeNodeIds(): string[];
1729
3940
  getVersionVector(): VersionVector;
1730
3941
  getNodeId(): string;
1731
3942
  getSchema(): SchemaDefinition | null;
1732
- setSchema(schema: SchemaDefinition): Promise<void>;
1733
- applyRemoteOperation(op: Operation): Promise<ApplyResult>;
3943
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
3944
+ getOperationTransforms(): readonly OperationTransform[];
3945
+ setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
3946
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
3947
+ private addSequenceHolder;
3948
+ getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
3949
+ recordOperationResolution(resolution: OperationResolution): Promise<void>;
3950
+ findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
3951
+ deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
3952
+ getResolvedThrough(nodeId: string): Promise<number>;
3953
+ findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
3954
+ getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
3955
+ getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
3956
+ getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
3957
+ getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
3958
+ getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
3959
+ previewOperation(op: Operation): Promise<MaterializedRecord | null>;
3960
+ /**
3961
+ * The record's fold state: the stored one for a materialized collection, else
3962
+ * folded from the record's operations (a collection outside the schema).
3963
+ */
3964
+ private readFoldState;
3965
+ private addRecordOp;
3966
+ recordBlobOwner(hash: string, owner: string): Promise<void>;
3967
+ getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
3968
+ claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
3969
+ /** Scope values of a live record before a write; null when absent or deleted. */
3970
+ private scopeValuesBefore;
3971
+ /**
3972
+ * Rebuild missing scope snapshots from the log (migration from a store without
3973
+ * them, or a replace-mode backup import), replaying each record in commit order.
3974
+ */
3975
+ private backfillScopeSnapshots;
1734
3976
  getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
1735
3977
  getOperationCount(): Promise<number>;
1736
3978
  getMaxDeliverySequence(): Promise<number>;
1737
3979
  getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
1738
3980
  materializeCollection(collection: string): Promise<MaterializedRecord[]>;
3981
+ getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
3982
+ findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
1739
3983
  queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
1740
3984
  findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
1741
3985
  countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
1742
3986
  close(): Promise<void>;
3987
+ getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
3988
+ putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
3989
+ listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
3990
+ getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
3991
+ claimNode(nodeId: string, userId: string): Promise<boolean>;
3992
+ claimUnownedNode(nodeId: string, userId: string): Promise<boolean>;
3993
+ getNodeClaimOwner(nodeId: string): Promise<string | null>;
3994
+ replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
3995
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
3996
+ /**
3997
+ * The record as currently stored, including a soft-deleted one (whose last field
3998
+ * values are kept), or null when it was never written. Without a materialized
3999
+ * table, the last known values are replayed from the operation log.
4000
+ */
4001
+ private readStoredRow;
1743
4002
  /**
1744
4003
  * Wipes all in-memory state. For tests and E2E isolation only.
1745
4004
  */
@@ -1753,7 +4012,16 @@ declare class MemoryServerStore implements ServerStore {
1753
4012
  * Get all stored operations (for test assertions).
1754
4013
  */
1755
4014
  getAllOperations(): Operation[];
1756
- private rebuildMaterializedRecord;
4015
+ /**
4016
+ * Merge one just-appended operation into its record's fold state and project the
4017
+ * row: O(fields the operation touches), independent of the record's history
4018
+ * (SRV-7). Falls back to a full re-fold only when the stored state cannot take the
4019
+ * operation (a field's fold kind changed with the schema).
4020
+ */
4021
+ private mergeIntoRecord;
4022
+ /** Persist a record's fold state and project it onto its materialized row. */
4023
+ private writeRecord;
4024
+ /** Re-fold every record of every materialized collection (schema set, backup restore). */
1757
4025
  private backfillAllCollections;
1758
4026
  private materializeFromOps;
1759
4027
  private assertOpen;
@@ -1761,6 +4029,123 @@ declare class MemoryServerStore implements ServerStore {
1761
4029
  private assertCollection;
1762
4030
  }
1763
4031
 
4032
+ /**
4033
+ * Server materialization through the one deterministic fold (W7 Stage B2).
4034
+ *
4035
+ * Every server store (memory, SQLite, Postgres) keeps, per materialized record, the
4036
+ * record's serialized {@link FoldState} next to its collection row. Applying an
4037
+ * operation is "append, then `mergeOp`": O(fields the operation touches), never a
4038
+ * replay of the record's history (SRV-7). The collection row is a pure projection of
4039
+ * the fold state ({@link projectFoldState}), so the server's rows are exactly what a
4040
+ * client that holds the same operations materializes (SRV-1).
4041
+ *
4042
+ * The fold is pure JavaScript: ordering uses HLC stamps compared in JS, never a
4043
+ * database collation (the Phase 1 COLLATE "C" concern disappears for materialization).
4044
+ *
4045
+ * Old helpers (`replayOperationsForRecord`, `mergeArraySet`) are no longer used to
4046
+ * materialize; they stay exported from core for legacy comparison only.
4047
+ */
4048
+
4049
+ /** What a store's startup re-materialization did (W7 step 7). */
4050
+ interface FoldMigrationReport {
4051
+ /** False when no schema was set (nothing is materialized). */
4052
+ ran: boolean;
4053
+ /** Records re-folded and rewritten. */
4054
+ records: number;
4055
+ /**
4056
+ * Records that own quarantined operations (see `ServerLogIntegrityReport`): their log
4057
+ * is incomplete, so their pre-fold rows were kept as a snapshot base with the
4058
+ * remaining operations folded onto it (RT-70), never re-folded from the log alone.
4059
+ */
4060
+ skippedUnclean: number;
4061
+ /** True when the fold plan (field kinds, strategies, resolvers) changed: every record was re-folded. */
4062
+ fullRefold: boolean;
4063
+ }
4064
+
4065
+ /** A row the scan quarantined. */
4066
+ interface ServerLogIntegrityRow {
4067
+ operationId: string;
4068
+ problem: string;
4069
+ detail: string;
4070
+ }
4071
+ /** Result of the server scan. */
4072
+ interface ServerLogIntegrityReport {
4073
+ /** Rows checked by this scan (0 when it already ran on this database). */
4074
+ checkedRows: number;
4075
+ /** Rows this scan moved to the quarantine table. */
4076
+ quarantined: ServerLogIntegrityRow[];
4077
+ /** False when the one-time scan had already run (only the quarantine is reported). */
4078
+ ran: boolean;
4079
+ /** Every row in the quarantine table. */
4080
+ totalQuarantined: number;
4081
+ }
4082
+
4083
+ /**
4084
+ * Stable server identity (RT-61, RT-62, RT-64).
4085
+ *
4086
+ * Every operation the server authors (route writes, cascades, set-nulls, constraint
4087
+ * corrections) is authored by a node id in the reserved namespace
4088
+ * `kora:server:<deploymentId>:<instanceId>`:
4089
+ *
4090
+ * - `deploymentId` is generated once per database and persisted in `kora_server_meta`,
4091
+ * so every instance that shares the database (and every restart) belongs to one
4092
+ * deployment.
4093
+ * - `instanceId` is distinct per running instance, so two instances never author the
4094
+ * same `(nodeId, sequenceNumber)`: SQLite persists one (a SQLite database has one
4095
+ * writer process), Postgres allocates a fresh one from a counter in the database on
4096
+ * every start unless the operator configures a stable `instanceId` per instance.
4097
+ *
4098
+ * Authority (`merge('server-authoritative')`) is the prefix rule: any operation whose
4099
+ * node id starts with {@link SERVER_NODE_PREFIX} is authoritative, on the server and on
4100
+ * every device. Devices can never use a `kora:` node id (the session refuses it at
4101
+ * handshake). Server node ids written before this release (random per-process ids, or
4102
+ * an id the operator configured) are recorded once, at the first start of this release,
4103
+ * in a persisted list of legacy authoritative ids that the handshake keeps advertising,
4104
+ * so earlier server decisions keep winning everywhere.
4105
+ *
4106
+ * Server-derived operation ids (cascades, corrections) are keyed with a persisted,
4107
+ * deployment-wide secret (HMAC-SHA-256), so a writer cannot predict them and occupy one
4108
+ * first; every instance of the deployment derives the same id for the same effect.
4109
+ */
4110
+
4111
+ /** Prefix of every node id the server authors under. */
4112
+ declare const SERVER_NODE_PREFIX = "kora:server:";
4113
+ /** Thrown for an invalid server identity configuration. */
4114
+ declare class ServerIdentityError extends KoraError {
4115
+ constructor(message: string, context?: Record<string, unknown>);
4116
+ }
4117
+ /** Identity options every server store accepts. */
4118
+ interface ServerIdentityOptions {
4119
+ /**
4120
+ * Deprecated. A plain id (for example one set before beta.13) is no longer the id the
4121
+ * server authors under: it is recorded as a legacy authoritative id, so operations
4122
+ * already stored under it keep their authority, and no device may use it. An id in
4123
+ * the `kora:server:` namespace is used verbatim (it must then be unique per instance).
4124
+ */
4125
+ nodeId?: string;
4126
+ /**
4127
+ * Stable id of this instance within the deployment (`[A-Za-z0-9._-]`, at most 64
4128
+ * characters). Must be distinct per running instance. SQLite persists one on its own;
4129
+ * Postgres allocates a new one per start when it is not configured.
4130
+ */
4131
+ instanceId?: string;
4132
+ /**
4133
+ * Extra node ids whose operations win `merge('server-authoritative')` fields. Every id
4134
+ * listed here once stays authoritative (devices keep it too) until it is revoked with
4135
+ * {@link revokedAuthoritativeNodeIds}: removing it from this list does not revoke it.
4136
+ */
4137
+ authoritativeNodeIds?: string[];
4138
+ /**
4139
+ * Explicit authoritative node ids to revoke (RT-81): they stop being authoritative on
4140
+ * the server, the handshake tells every device to drop them (devices re-fold), and they
4141
+ * are still never accepted as device node ids. Revocation is permanent (persisted); an
4142
+ * id may not be both listed in `authoritativeNodeIds` and revoked.
4143
+ */
4144
+ revokedAuthoritativeNodeIds?: string[];
4145
+ }
4146
+ /** True for a node id the server authors under (current or any instance of any deployment). */
4147
+ declare function isServerNodeId(nodeId: string): boolean;
4148
+
1764
4149
  /**
1765
4150
  * PostgreSQL-backed server store using Drizzle ORM.
1766
4151
  * All reads and writes go through Drizzle's typed query builder.
@@ -1769,7 +4154,18 @@ declare class MemoryServerStore implements ServerStore {
1769
4154
  * collection tables for efficient indexed queries (dual-write).
1770
4155
  */
1771
4156
  declare class PostgresServerStore implements ServerStore {
1772
- private readonly nodeId;
4157
+ /** This instance's server node id; loaded from the database during startup (RT-62). */
4158
+ private loadedNodeId;
4159
+ private readonly configuredIdentity;
4160
+ /** Derivation secret of server-derived ids, shared by the deployment (RT-64). */
4161
+ private derivationSecret;
4162
+ /** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
4163
+ private explicitAuthorities;
4164
+ /** Explicit authority over time: revoked and ever-held ids (RT-81). */
4165
+ private authorityHistory;
4166
+ /** Other `kora:server:` nodes with stored operations (advertised for older clients). */
4167
+ /** Records owning quarantined operations: folded onto their kept rows (RT-70). */
4168
+ private quarantine;
1773
4169
  private readonly db;
1774
4170
  private readonly versionVector;
1775
4171
  private readonly ready;
@@ -1783,6 +4179,9 @@ declare class PostgresServerStore implements ServerStore {
1783
4179
  * null after a backup import so it re-seeds from the restored version vector.
1784
4180
  */
1785
4181
  private sequenceCounter;
4182
+ /** See {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}; fixed per database at first start. */
4183
+ private sequenceEpoch;
4184
+ private logIntegrity;
1786
4185
  /**
1787
4186
  * Time source for HLC timestamps on server-originated operations. Injectable so a
1788
4187
  * test can freeze wall-clock time and prove the conditional-apply ordering holds
@@ -1790,7 +4189,54 @@ declare class PostgresServerStore implements ServerStore {
1790
4189
  * exist to make correct). Defaults to the system clock in production.
1791
4190
  */
1792
4191
  private readonly timeSource;
1793
- constructor(db: PostgresJsDatabase, nodeId?: string, timeSource?: TimeSource);
4192
+ private foldOptions;
4193
+ /** Schema transforms the fold applies (transforms at fold time, RT-84). */
4194
+ private operationTransforms;
4195
+ private foldMigration;
4196
+ /**
4197
+ * @param db - The Drizzle database
4198
+ * @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
4199
+ * `kora:server:` id is used verbatim (it must then be unique per instance). Leave
4200
+ * unset: the store authors under `kora:server:<deployment>:<instance>`, with the
4201
+ * deployment persisted in the database and a distinct instance per process (RT-62).
4202
+ * @param timeSource - Clock for server-originated operations (tests)
4203
+ * @param options - Extra authoritative ids and a stable instance id
4204
+ */
4205
+ constructor(db: PostgresJsDatabase, nodeId?: string, timeSource?: TimeSource, options?: Omit<ServerIdentityOptions, 'nodeId'>);
4206
+ /** Resolves once the store's tables and identity are loaded. */
4207
+ whenReady(): Promise<void>;
4208
+ /**
4209
+ * This store's node id and the legacy and configured ids (explicit authorities).
4210
+ * Other `kora:server:` instances are not listed: the prefix rule makes every one of
4211
+ * them authoritative, and a list that grew by one instance id per start would make
4212
+ * every device re-fold after each deploy (RT-75). Handshakes advertise only the
4213
+ * explicit (non-prefixed) ids.
4214
+ */
4215
+ getAuthoritativeNodeIds(): string[];
4216
+ /** Legacy and configured authorities (beside the `kora:server:` namespace). */
4217
+ getLegacyAuthoritativeNodeIds(): string[];
4218
+ /** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
4219
+ getRevokedAuthoritativeNodeIds(): string[];
4220
+ /**
4221
+ * Every explicit id the deployment ever held authoritative, revoked ones included
4222
+ * (RT-81): never accepted as a device node id.
4223
+ */
4224
+ getEverAuthoritativeNodeIds(): string[];
4225
+ deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
4226
+ private get nodeId();
4227
+ /** Remember another `kora:server:` node that authored stored operations. */
4228
+ /**
4229
+ * Load (creating on first start) the persisted server identity under an advisory
4230
+ * lock: the deployment id and derivation secret (one per database, shared by every
4231
+ * instance), a fresh instance id from the database counter (unless configured), and
4232
+ * the legacy authoritative ids. At the first start of this release, every node id
4233
+ * whose operations hold an authority class in a stored fold state (a server decision
4234
+ * folded under an earlier, per-process server node id) is recorded as a legacy
4235
+ * authority, before any re-fold could drop that class.
4236
+ */
4237
+ private loadIdentity;
4238
+ /** What the last startup re-materialization did (W7 step 7). */
4239
+ getFoldMigrationReport(): FoldMigrationReport;
1794
4240
  getVersionVector(): VersionVector;
1795
4241
  /**
1796
4242
  * Atomically reserve this node's next server-operation sequence number. The
@@ -1800,8 +4246,51 @@ declare class PostgresServerStore implements ServerStore {
1800
4246
  reserveSequenceNumber(): number;
1801
4247
  getNodeId(): string;
1802
4248
  getSchema(): SchemaDefinition | null;
1803
- setSchema(schema: SchemaDefinition): Promise<void>;
1804
- applyRemoteOperation(op: Operation): Promise<ApplyResult>;
4249
+ /**
4250
+ * One-time migration (RT-101): drop the enum `CHECK` constraints (and any `NOT NULL`
4251
+ * on a schema field) that beta.12 and earlier DDL put on collection tables, in one
4252
+ * transaction. A check is dropped when it parses as an enum check (one column against
4253
+ * string literals, in every form Postgres normalizes `IN (...)` to, the single-value
4254
+ * `col = 'x'` included, RT-108) on an enum field of the schema, or as the multi-value
4255
+ * `= ANY (ARRAY[...])` shape on another non-internal column; checks added by hand are
4256
+ * kept. Idempotent and resumable: a later start finds nothing to drop.
4257
+ */
4258
+ private relaxValueDomainConstraints;
4259
+ /**
4260
+ * Throws {@link OperationTransformCoverageError} when a schema version in the stored
4261
+ * log has no transform path to `version` (RT-103): those operations would fold as
4262
+ * absent, silently erasing them from their records.
4263
+ */
4264
+ private assertTransformCoverage;
4265
+ getOperationTransforms(): readonly OperationTransform[];
4266
+ setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
4267
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
4268
+ /**
4269
+ * One-time migration of each collection table to the lossless text encoding (RT-65):
4270
+ * rows written before it that contain U+FFFF (the escape introducer) are re-encoded,
4271
+ * so the decoder returns them unchanged. Per table, under an advisory lock, recorded
4272
+ * in `kora_server_meta`.
4273
+ */
4274
+ private migrateTextCodec;
4275
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
4276
+ /**
4277
+ * The version vector as committed in `sync_state`, shared by every instance
4278
+ * (SRV-4). Also refreshes this instance's cache, which `getVersionVector()` serves.
4279
+ */
4280
+ readVersionVector(): Promise<VersionVector>;
4281
+ /**
4282
+ * Insert one operation row inside an append transaction that already holds the
4283
+ * delivery-counter lock. Returns `'duplicate'` (writing nothing) when an operation
4284
+ * with the same id is stored; throws {@link SequenceConflictError} when a DIFFERENT
4285
+ * operation stored after the sequence-enforcement epoch holds its (node, sequence)
4286
+ * and the writer reserves its sequences (W3 step 4); a legacy holder or a legacy
4287
+ * writer (RT-37) is accepted and the row is stored unflagged. Otherwise returns the
4288
+ * verdict the row was stored under. The partial unique index over sole-holder rows
4289
+ * backs the check against a writer that bypasses the counter lock.
4290
+ */
4291
+ private insertOperationRow;
4292
+ /** Advance `sync_state` (the persisted version vector) for an appended operation. */
4293
+ private advanceSyncState;
1805
4294
  /**
1806
4295
  * Conditionally apply operations atomically, serialized across server instances
1807
4296
  * on the target record via a transaction-scoped advisory lock. The lock makes
@@ -1813,7 +4302,37 @@ declare class PostgresServerStore implements ServerStore {
1813
4302
  getOperationCount(): Promise<number>;
1814
4303
  getMaxDeliverySequence(): Promise<number>;
1815
4304
  getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
4305
+ getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
4306
+ getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
4307
+ getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
4308
+ getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
4309
+ getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
4310
+ previewOperation(op: Operation): Promise<MaterializedRecord | null>;
4311
+ recordBlobOwner(hash: string, owner: string): Promise<void>;
4312
+ getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
4313
+ claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
4314
+ /**
4315
+ * Scope values of a record as stored, for an operation's scope snapshot. With
4316
+ * `includeDeleted` false a soft-deleted row counts as absent; with true it keeps
4317
+ * its last values (a delete's post-image).
4318
+ */
4319
+ private readScopeValues;
4320
+ /** Persist the scope snapshot of a just-applied operation (RT-14). */
4321
+ private writeScopeSnapshot;
4322
+ /**
4323
+ * Rebuild missing scope snapshots from the log (migration from a database written
4324
+ * before snapshots existed), replaying each affected record in commit order.
4325
+ */
4326
+ private backfillScopeSnapshots;
1816
4327
  materializeCollection(collection: string): Promise<MaterializedRecord[]>;
4328
+ getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
4329
+ getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
4330
+ recordOperationResolution(resolution: OperationResolution): Promise<void>;
4331
+ findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
4332
+ deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
4333
+ getResolvedThrough(nodeId: string): Promise<number>;
4334
+ findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
4335
+ findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
1817
4336
  queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
1818
4337
  findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
1819
4338
  countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
@@ -1830,28 +4349,106 @@ declare class PostgresServerStore implements ServerStore {
1830
4349
  * write so newly built operations sort strictly after it.
1831
4350
  */
1832
4351
  private latestOperationTimestamp;
4352
+ getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
4353
+ putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
4354
+ listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
4355
+ getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
4356
+ claimNode(nodeId: string, userId: string): Promise<boolean>;
4357
+ claimUnownedNode(nodeId: string, userId: string): Promise<boolean>;
4358
+ getNodeClaimOwner(nodeId: string): Promise<string | null>;
4359
+ replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
4360
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
4361
+ /**
4362
+ * The record as currently stored, including a soft-deleted one (whose last field
4363
+ * values are kept), or null when it was never written. Without a materialized
4364
+ * table, the last known values are replayed from the operation log.
4365
+ */
4366
+ private readStoredRow;
4367
+ /**
4368
+ * Merge a just-appended operation (stored under `deliverySeq`) into its record's
4369
+ * fold state and project the row, inside the append transaction. O(fields the
4370
+ * operation touches): the state is read by primary key and the freshness check is
4371
+ * one index probe (SRV-7).
4372
+ *
4373
+ * Concurrency: every append on every instance holds the delivery-counter row lock
4374
+ * until commit (see nextDeliverySeq), and applyConditional takes its per-record
4375
+ * advisory lock before it, so this read-merge-write of one record's state is
4376
+ * serialized across instances; the `FOR UPDATE` on the state row is defense in
4377
+ * depth. A record whose log grew without this state (an older release during a
4378
+ * rolling upgrade) has its missing tail merged first; an unreadable, newer-than-log
4379
+ * or plan-incompatible state is re-folded from the log.
4380
+ */
4381
+ private mergeIntoRecord;
4382
+ /**
4383
+ * The fold state of a record that owns quarantined operations (RT-70): its stored
4384
+ * state, or its kept row as a snapshot base, with every remaining operation of the
4385
+ * record merged on top (`rebuildFromSnapshot`). Nothing is written.
4386
+ */
4387
+ private rebuildAffected;
4388
+ /** True when the record's operation at delivery sequence `seq` has id `opId`. */
4389
+ private coversStoredOperation;
4390
+ /**
4391
+ * The record's current fold state for a read: the stored one when it covers the
4392
+ * record's whole log, else folded from the log (nothing is written).
4393
+ */
4394
+ private readFoldState;
4395
+ /** A record's operations stored after `afterDeliverySeq`, in delivery order. */
4396
+ private readRecordOperations;
1833
4397
  /**
1834
- * Rebuild a single record in the materialized collection table by replaying
1835
- * all operations for that record.
4398
+ * Persist a record's fold state (covering the log up to `coveredSeq`) and project
4399
+ * it onto the materialized row.
1836
4400
  */
1837
- private rebuildMaterializedRecord;
4401
+ private writeFoldedRecord;
1838
4402
  /**
1839
4403
  * UPSERT a record into the materialized collection table.
1840
4404
  */
1841
4405
  private upsertMaterializedRecord;
1842
4406
  /**
1843
- * Backfill all materialized collection tables from the existing operation log.
4407
+ * {@link writeFoldedRecord} for a batch of records of one collection, in a handful
4408
+ * of multi-row statements (the migration's write path). Rows are chunked so a
4409
+ * statement never nears the 65,535 bind-parameter limit, however wide the collection.
4410
+ */
4411
+ private writeFoldedRecords;
4412
+ /**
4413
+ * Re-materialization migration (W7 step 7), run by setSchema and after a
4414
+ * replace-mode restore: every record whose fold state is missing, stale or built
4415
+ * under a different fold plan is re-folded from its log; nothing else is touched.
4416
+ * See the SQLite store's `rematerialize` for the rules (a record owning quarantined
4417
+ * operations folds onto its kept row, RT-70; idempotent; resumable).
4418
+ *
4419
+ * Postgres specifics: records are paged in batches of
4420
+ * {@link FOLD_MIGRATION_BATCH} through the (collection, record_id, delivery_seq)
4421
+ * index, and every batch is one transaction that first takes the delivery-counter
4422
+ * row lock. Appends on every instance take that lock too, so a batch reads each
4423
+ * record's complete log and writes its state while no append to it can commit: a
4424
+ * live write on another instance during a rolling deploy is never overwritten by a
4425
+ * stale re-fold (NEW-SRV-5), it either committed before the batch (and is folded)
4426
+ * or commits after it (and is merged into the batch's state).
1844
4427
  */
1845
- private backfillAllCollections;
1846
4428
  /**
1847
- * Backfill a single collection's materialized table from operations.
4429
+ * Write the beta.12 clears the ids prove into the stored bodies, once per database
4430
+ * (RT-85, see `provenLegacyClears`), and mark their records' fold states stale.
4431
+ * Idempotent: concurrent first starts write the same bodies.
1848
4432
  */
1849
- private backfillCollection;
4433
+ private canonicalizeLegacyBodies;
4434
+ private rematerialize;
1850
4435
  private buildSelectQuery;
1851
4436
  private buildWhereClause;
1852
4437
  private deserializeRow;
1853
4438
  private materializeFromOpsLog;
1854
4439
  private initialize;
4440
+ /**
4441
+ * The startup log-integrity scan's result (W8 step 0): rows that could not be read
4442
+ * back into an operation are moved to `operations_quarantine` once per database,
4443
+ * before any fold reads them.
4444
+ */
4445
+ getLogIntegrityReport(): Promise<ServerLogIntegrityReport>;
4446
+ /**
4447
+ * One-time scan under the setup advisory lock (concurrent starts run it once). Keyset
4448
+ * pages by id, so a large log is read in bounded chunks; like the delivery-sequence
4449
+ * backfill it runs on the first start of this release only.
4450
+ */
4451
+ private scanLogIntegrityOnce;
1855
4452
  private ensureTables;
1856
4453
  /**
1857
4454
  * Reserve the next delivery sequence inside an open append transaction. The
@@ -1859,6 +4456,11 @@ declare class PostgresServerStore implements ServerStore {
1859
4456
  * serializing assignment into commit order (see the counter table comment).
1860
4457
  */
1861
4458
  private nextDeliverySeq;
4459
+ /**
4460
+ * @param soleHolder - Nothing else held the (node, sequence) at insert: the row is
4461
+ * flagged `seq_unique` and covered by {@link NODE_SEQ_UNIQUE_INDEX}. False for a
4462
+ * legacy pair's second operation and for restored backup rows.
4463
+ */
1862
4464
  private serializeOperation;
1863
4465
  private deserializeOperation;
1864
4466
  private assertOpen;
@@ -1870,8 +4472,14 @@ declare class PostgresServerStore implements ServerStore {
1870
4472
  */
1871
4473
  declare function createPostgresServerStore(options: {
1872
4474
  connectionString: string;
1873
- nodeId?: string;
1874
- }): Promise<PostgresServerStore>;
4475
+ } & ServerIdentityOptions): Promise<PostgresServerStore>;
4476
+ /**
4477
+ * The Postgres server store could not start: the database is unreachable, refused the
4478
+ * credentials, or its tables could not be created. `cause` is the driver's error.
4479
+ */
4480
+ declare class ServerStoreUnavailableError extends KoraError {
4481
+ constructor(connectionString: string, cause: unknown);
4482
+ }
1875
4483
 
1876
4484
  /**
1877
4485
  * SQLite-backed server store using Drizzle ORM.
@@ -1886,46 +4494,207 @@ declare class SqliteServerStore implements ServerStore {
1886
4494
  private readonly db;
1887
4495
  private schema;
1888
4496
  private closed;
1889
- constructor(db: BetterSQLite3Database, nodeId?: string);
4497
+ /** See {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}; fixed per database at first start. */
4498
+ private sequenceEpoch;
4499
+ private logIntegrity;
4500
+ /** Derivation secret of server-derived ids, shared by the deployment (RT-64). */
4501
+ private readonly derivationSecret;
4502
+ /** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
4503
+ private readonly explicitAuthorities;
4504
+ /** Explicit authority over time: revoked and ever-held ids (RT-81). */
4505
+ private readonly authorityHistory;
4506
+ /** Other `kora:server:` nodes with stored operations (advertised for older clients). */
4507
+ /** Records owning quarantined operations: folded onto their kept rows (RT-70). */
4508
+ private quarantine;
4509
+ private foldOptions;
4510
+ /** Schema transforms the fold applies (transforms at fold time, RT-84). */
4511
+ private operationTransforms;
4512
+ private foldMigration;
4513
+ /**
4514
+ * @param db - The Drizzle database
4515
+ * @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
4516
+ * `kora:server:` id is used verbatim. Leave unset: the store authors under
4517
+ * `kora:server:<deployment>:<instance>`, both persisted in the database (RT-62).
4518
+ * @param options - Extra authoritative ids and the instance id
4519
+ */
4520
+ constructor(db: BetterSQLite3Database, nodeId?: string, options?: Omit<ServerIdentityOptions, 'nodeId'>);
4521
+ /**
4522
+ * This store's node id and the legacy and configured ids (explicit authorities).
4523
+ * Other `kora:server:` instances are not listed: the prefix rule makes every one of
4524
+ * them authoritative, and a list that grew by one instance id per start would make
4525
+ * every device re-fold after each deploy (RT-75). Handshakes advertise only the
4526
+ * explicit (non-prefixed) ids.
4527
+ */
4528
+ getAuthoritativeNodeIds(): string[];
4529
+ /** Legacy and configured authorities (beside the `kora:server:` namespace). */
4530
+ getLegacyAuthoritativeNodeIds(): string[];
4531
+ /** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
4532
+ getRevokedAuthoritativeNodeIds(): string[];
4533
+ /**
4534
+ * Every explicit id the deployment ever held authoritative, revoked ones included
4535
+ * (RT-81): never accepted as a device node id.
4536
+ */
4537
+ getEverAuthoritativeNodeIds(): string[];
4538
+ deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
4539
+ /** Remember another `kora:server:` node that authored stored operations. */
4540
+ /**
4541
+ * Load (creating on first start) the persisted server identity: the deployment id,
4542
+ * the derivation secret, this database's instance id and the legacy authoritative
4543
+ * ids. At the first start of this release, every node id whose operations hold an
4544
+ * authority class in a stored fold state (a server decision folded under an earlier,
4545
+ * per-process server node id) is recorded as a legacy authority, before any re-fold
4546
+ * could drop that class.
4547
+ */
4548
+ private loadIdentity;
4549
+ /** What the last startup re-materialization did (W7 step 7). */
4550
+ getFoldMigrationReport(): FoldMigrationReport;
1890
4551
  getVersionVector(): VersionVector;
1891
4552
  getNodeId(): string;
1892
4553
  getSchema(): SchemaDefinition | null;
1893
- setSchema(schema: SchemaDefinition): Promise<void>;
1894
- applyRemoteOperation(op: Operation): Promise<ApplyResult>;
4554
+ setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
4555
+ /**
4556
+ * One-time migration (RT-101): rebuild collection tables that still carry an enum
4557
+ * `CHECK` (or `NOT NULL` on a schema field), in one transaction, keeping rows, indexes
4558
+ * and triggers. Idempotent (a relaxed table is left alone) and resumable (an
4559
+ * interrupted rebuild rolls back and runs again at the next start).
4560
+ */
4561
+ private relaxValueDomainConstraints;
4562
+ /**
4563
+ * Throws {@link OperationTransformCoverageError} when a schema version in the stored
4564
+ * log has no transform path to `version` (RT-103): those operations would fold as
4565
+ * absent, silently erasing them from their records.
4566
+ */
4567
+ private assertTransformCoverage;
4568
+ getOperationTransforms(): readonly OperationTransform[];
4569
+ setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
4570
+ applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
4571
+ private applyInTransaction;
1895
4572
  getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
1896
4573
  getOperationCount(): Promise<number>;
1897
4574
  getMaxDeliverySequence(): Promise<number>;
1898
4575
  getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
4576
+ getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
4577
+ getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
4578
+ getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
4579
+ getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
4580
+ getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
4581
+ previewOperation(op: Operation): Promise<MaterializedRecord | null>;
4582
+ recordBlobOwner(hash: string, owner: string): Promise<void>;
4583
+ getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
4584
+ claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
4585
+ /**
4586
+ * Scope values of a record as stored, for an operation's scope snapshot. With
4587
+ * `includeDeleted` false a soft-deleted row counts as absent (the pre-image of a
4588
+ * write to a deleted record); with true it keeps its last values (a delete's
4589
+ * post-image).
4590
+ */
4591
+ private readScopeValues;
4592
+ /**
4593
+ * Rebuild missing scope snapshots from the log (migration from a database written
4594
+ * before snapshots existed), replaying each affected record in commit order.
4595
+ */
4596
+ private backfillScopeSnapshots;
1899
4597
  materializeCollection(collection: string): Promise<MaterializedRecord[]>;
4598
+ getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
4599
+ getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
4600
+ recordOperationResolution(resolution: OperationResolution): Promise<void>;
4601
+ findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
4602
+ deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
4603
+ getResolvedThrough(nodeId: string): Promise<number>;
4604
+ findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
4605
+ findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
1900
4606
  queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
1901
4607
  findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
1902
4608
  countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
1903
4609
  close(): Promise<void>;
4610
+ getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
4611
+ putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
4612
+ listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
4613
+ getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
4614
+ claimNode(nodeId: string, userId: string): Promise<boolean>;
4615
+ claimUnownedNode(nodeId: string, userId: string): Promise<boolean>;
4616
+ getNodeClaimOwner(nodeId: string): Promise<string | null>;
4617
+ replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
4618
+ releaseNodeClaim(nodeId: string): Promise<boolean>;
4619
+ /**
4620
+ * The record as currently stored, including a soft-deleted one (whose last field
4621
+ * values are kept), or null when it was never written. Without a materialized
4622
+ * table, the last known values are replayed from the operation log.
4623
+ */
4624
+ private readStoredRow;
4625
+ /**
4626
+ * The fold state of a record that owns quarantined operations (RT-70): its stored
4627
+ * state, or its kept row as a snapshot base, with every remaining operation of the
4628
+ * record merged on top (`rebuildFromSnapshot`). Nothing is written.
4629
+ */
4630
+ private rebuildAffected;
1904
4631
  exportBackup(): Promise<Uint8Array>;
1905
4632
  importBackup(data: Uint8Array, merge?: boolean): Promise<{
1906
4633
  operationsRestored: number;
1907
4634
  success: boolean;
1908
4635
  }>;
1909
4636
  /**
1910
- * Rebuild a single record in the materialized collection table by replaying
1911
- * all operations for that record. Called within the applyRemoteOperation
1912
- * transaction for atomic dual-write.
4637
+ * Merge a just-appended operation (stored under `deliverySeq`) into its record's
4638
+ * fold state and project the row, inside the append transaction. O(fields the
4639
+ * operation touches): the stored state is read by primary key and the freshness
4640
+ * check is one index probe, never a scan of the record's history (SRV-7).
4641
+ *
4642
+ * The stored state records the highest delivery sequence it covers. If another
4643
+ * writer appended operations to this record without folding them (an older release
4644
+ * during a rolling upgrade), the missing tail is merged first; if the stored state
4645
+ * is unreadable, newer than the log, or built under an incompatible field kind,
4646
+ * the record is re-folded from its log.
4647
+ */
4648
+ private mergeIntoRecord;
4649
+ /** True when the record's operation at delivery sequence `seq` has id `opId`. */
4650
+ private coversStoredOperation;
4651
+ /**
4652
+ * The record's current fold state for a read: the stored one when it covers the
4653
+ * record's whole log, else folded from the log (nothing is written).
4654
+ */
4655
+ private readFoldState;
4656
+ /** A record's operations stored after `afterDeliverySeq`, in delivery order. */
4657
+ private readRecordOperations;
4658
+ /**
4659
+ * Persist a record's fold state (covering the log up to `coveredSeq`) and project
4660
+ * it onto the materialized row.
1913
4661
  */
1914
- private rebuildMaterializedRecord;
4662
+ private writeFoldedRecord;
1915
4663
  /**
1916
4664
  * UPSERT a record into the materialized collection table.
1917
4665
  * Uses INSERT ... ON CONFLICT (id) DO UPDATE SET for atomic upsert.
1918
4666
  */
1919
4667
  private upsertMaterializedRecord;
1920
4668
  /**
1921
- * Backfill all materialized collection tables from the existing operation log.
1922
- * Called when setSchema() is invoked and operations already exist.
4669
+ * Re-materialization migration (W7 step 7), run by setSchema and after a
4670
+ * replace-mode restore. Every record whose fold state is missing, stale (it covers
4671
+ * less, or more, of the log than is stored) or built under a different fold plan
4672
+ * (field kind, merge strategy, resolver) is re-folded from its log and its row
4673
+ * rewritten; nothing else is touched, so a restart on an up-to-date database does
4674
+ * one aggregate read.
4675
+ *
4676
+ * - Runs only on a log the W8b integrity scan reports clean. With quarantined rows,
4677
+ * rows materialized before the fold existed are kept as they are (re-folding a
4678
+ * log that lost operations would bake the loss in) and the skip is logged loudly;
4679
+ * such a record is folded from its remaining log on its next write. Records with
4680
+ * no row yet, and fold states this release already wrote, are still processed.
4681
+ * - Idempotent and resumable: each batch commits on its own, and staleness is
4682
+ * re-derived from the tables at every start, so a crash resumes where it stopped.
4683
+ * A plan change first marks every stored state stale (one statement), then
4684
+ * records the new fingerprint, so it is resumable too.
1923
4685
  */
1924
- private backfillAllCollections;
1925
4686
  /**
1926
- * Backfill a single collection's materialized table from operations.
4687
+ * One-time migration of each collection table to the lossless text encoding (RT-65):
4688
+ * rows written before it whose raw-string columns contain U+FFFF (the escape
4689
+ * introducer) are re-encoded, so the decoder returns them unchanged.
4690
+ */
4691
+ private migrateTextCodec;
4692
+ /**
4693
+ * Write the beta.12 clears the ids prove into the stored bodies, once per database
4694
+ * (RT-85, see `provenLegacyClears`), and mark their records' fold states stale.
1927
4695
  */
1928
- private backfillCollection;
4696
+ private canonicalizeLegacyBodies;
4697
+ private rematerialize;
1929
4698
  private buildSelectQuery;
1930
4699
  private buildWhereClause;
1931
4700
  private deserializeRow;
@@ -1934,6 +4703,29 @@ declare class SqliteServerStore implements ServerStore {
1934
4703
  * Create the operations and sync_state tables if they don't exist.
1935
4704
  */
1936
4705
  private ensureTables;
4706
+ /**
4707
+ * The startup log-integrity scan's result (W8 step 0): rows that could not be read
4708
+ * back into an operation are moved to `operations_quarantine` once per database,
4709
+ * before any fold reads them.
4710
+ */
4711
+ getLogIntegrityReport(): ServerLogIntegrityReport;
4712
+ private scanLogIntegrityOnce;
4713
+ /**
4714
+ * Index the legacy pairs already in the log once (history written before the
4715
+ * `sequence_pairs` table existed); appends maintain it from then on.
4716
+ */
4717
+ private backfillSequencePairs;
4718
+ /**
4719
+ * Record the sequence-enforcement epoch on the first start of this release (the log's
4720
+ * highest delivery sequence then; see {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}) and
4721
+ * back the append check with the partial unique index over sole-holder rows
4722
+ * ({@link NODE_SEQ_UNIQUE_INDEX}), which therefore always exists: a legacy duplicate
4723
+ * pair is stored unflagged. Replaces the earlier indexes (a full one, created only
4724
+ * when the log had no duplicates, and one over the rows past the epoch, which a
4725
+ * legacy client's pair would violate). Runs in one write transaction, so stores
4726
+ * sharing a file agree on the epoch.
4727
+ */
4728
+ private ensureSequenceEnforcement;
1937
4729
  /**
1938
4730
  * Assign delivery sequences to any operations written before the column existed,
1939
4731
  * then seed the durable counter from the current max. Ordered by
@@ -1947,6 +4739,11 @@ declare class SqliteServerStore implements ServerStore {
1947
4739
  * allocation shared by every SqliteServerStore instance using the same file.
1948
4740
  */
1949
4741
  private nextDeliverySeq;
4742
+ /**
4743
+ * @param soleHolder - Nothing else held the (node, sequence) at insert: the row is
4744
+ * flagged `seq_unique` and covered by {@link NODE_SEQ_UNIQUE_INDEX}. False for a
4745
+ * legacy pair's second operation and for restored backup rows.
4746
+ */
1950
4747
  private serializeOperation;
1951
4748
  private deserializeOperation;
1952
4749
  private assertOpen;
@@ -1959,7 +4756,9 @@ declare class SqliteServerStore implements ServerStore {
1959
4756
  *
1960
4757
  * @param options - Configuration options
1961
4758
  * @param options.filename - Path to SQLite database file. Defaults to ':memory:' for testing.
1962
- * @param options.nodeId - Server node ID. Auto-generated if not provided.
4759
+ * @param options.nodeId - Deprecated (a plain id becomes a legacy authoritative id). Leave
4760
+ * unset: the server authors under the persisted `kora:server:<deployment>:<instance>`.
4761
+ * @param options.instanceId - Optional stable instance id
1963
4762
  * @returns A ready-to-use SqliteServerStore
1964
4763
  *
1965
4764
  * @example
@@ -1976,8 +4775,7 @@ declare class SqliteServerStore implements ServerStore {
1976
4775
  */
1977
4776
  declare function createSqliteServerStore(options: {
1978
4777
  filename?: string;
1979
- nodeId?: string;
1980
- }): SqliteServerStore;
4778
+ } & ServerIdentityOptions): SqliteServerStore;
1981
4779
 
1982
4780
  /**
1983
4781
  * Auth provider that accepts all connections.
@@ -1988,38 +4786,187 @@ declare class NoAuthProvider implements AuthProvider {
1988
4786
  authenticate(_token: string): Promise<AuthContext>;
1989
4787
  }
1990
4788
 
4789
+ /**
4790
+ * Thrown when a sync scope predicate value is `undefined` or `null` (also inside a
4791
+ * `$in` list). Such a value matches every record that lacks the field, so a grant
4792
+ * built from a failed lookup (for example `{ orgId: user.orgId }` with no org)
4793
+ * would expose every unowned record. Kora refuses it instead (fail closed).
4794
+ */
4795
+ declare class InvalidScopePredicateError extends KoraError {
4796
+ readonly collection: string;
4797
+ readonly field: string;
4798
+ constructor(collection: string, field: string);
4799
+ }
4800
+ /** Thrown when a `$in` scope predicate is malformed or larger than the configured limit. */
4801
+ declare class ScopePredicateLimitError extends KoraError {
4802
+ constructor(message: string, context: Record<string, unknown>);
4803
+ }
4804
+
4805
+ interface ResolveSessionScopesOptions {
4806
+ /** Scope map sent by the client during handshake. It can only NARROW the grant. */
4807
+ handshakeScope?: ScopeMap$1;
4808
+ /**
4809
+ * The server grant from the auth provider. Its collection set is authoritative:
4810
+ * a collection it does not list is not visible, whatever the handshake asks for.
4811
+ * It may carry verified claim values under `$claims` (see `claimScopes` in
4812
+ * `@korajs/core`), which are bound to every schema-scoped collection.
4813
+ */
4814
+ authScopes?: ScopeMap$1;
4815
+ /**
4816
+ * Verified, server-derived scope values (for example `{ userId: sub }`). Treated
4817
+ * exactly like `$claims` in `authScopes`. Never pass client-supplied values here.
4818
+ */
4819
+ scopeValues?: Record<string, unknown>;
4820
+ /**
4821
+ * True when an auth provider authenticated this session. An authenticated
4822
+ * session whose provider returned no grant at all is denied every
4823
+ * schema-scoped collection instead of trusting the client handshake.
4824
+ *
4825
+ * Leave it false only for servers without an auth provider (or with
4826
+ * `NoAuthProvider`), where the handshake is a client-side filter, not a grant.
4827
+ * @default false
4828
+ */
4829
+ authenticated?: boolean;
4830
+ /**
4831
+ * What to do when a schema-scoped collection's bindings cannot all be resolved
4832
+ * from the verified claims. `'deny'` hides the collection (fail closed);
4833
+ * `'throw'` raises {@link ScopeRequiredError} so the caller can reject the
4834
+ * handshake with `SCOPE_REQUIRED`.
4835
+ * @default 'deny'
4836
+ */
4837
+ onUnresolved?: 'deny' | 'throw';
4838
+ }
4839
+ /** A schema-scoped collection the session was denied because a binding was unresolved. */
4840
+ interface DeniedScopeCollection {
4841
+ collection: string;
4842
+ /** Scope binding keys (for example `orgId`) that had no verified value. */
4843
+ missingKeys: string[];
4844
+ }
4845
+ /** Detailed result of {@link resolveSessionScopeGrant}. */
4846
+ interface ResolvedSessionScopeGrant {
4847
+ /**
4848
+ * Effective scope map. `undefined` means "no scoping" and is only ever returned
4849
+ * for unauthenticated servers without a grant. An empty object means "nothing
4850
+ * visible".
4851
+ */
4852
+ scopes: ScopeMap$1 | undefined;
4853
+ /** Collections denied because their scope bindings could not be resolved. */
4854
+ denied: DeniedScopeCollection[];
4855
+ }
4856
+ /**
4857
+ * Thrown (with `onUnresolved: 'throw'`) when an authenticated session cannot be
4858
+ * bound to a schema-scoped collection because the server grant lacks a value.
4859
+ */
4860
+ declare class ScopeRequiredError extends KoraError {
4861
+ readonly denied: DeniedScopeCollection[];
4862
+ constructor(denied: DeniedScopeCollection[]);
4863
+ }
4864
+ /**
4865
+ * Resolve the effective per-session sync scope map.
4866
+ *
4867
+ * The result is the INTERSECTION of the server grant and the client handshake:
4868
+ * - The collection set is the grant's collection set. A handshake can never add
4869
+ * a collection.
4870
+ * - Per collection, handshake predicates only narrow: grant fields win, a
4871
+ * handshake field on an unconstrained field is added, and for an `$in` grant a
4872
+ * handshake value is used only when it is a subset of the granted values.
4873
+ * - Schema-scoped collections bound from verified claims fail closed: an
4874
+ * unresolved binding denies the collection instead of producing `{}`.
4875
+ *
4876
+ * Invariant: the effective scope is a subset of the server grant, and no
4877
+ * schema-scoped collection resolves to `{}` from claims for an authenticated
4878
+ * principal.
4879
+ *
4880
+ * @returns The effective scope map; `undefined` only for unauthenticated servers
4881
+ * with neither a grant nor a handshake scope
4882
+ */
4883
+ declare function resolveSessionScopes(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ScopeMap$1 | undefined;
4884
+ /**
4885
+ * Same as {@link resolveSessionScopes}, but also reports which collections were
4886
+ * denied for unresolved bindings, so a caller can surface `SCOPE_REQUIRED`.
4887
+ */
4888
+ declare function resolveSessionScopeGrant(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ResolvedSessionScopeGrant;
4889
+
4890
+ /**
4891
+ * Decides whether one client's current awareness state may reach another session.
4892
+ * Called with the receiving session's id; returning false keeps the state from it.
4893
+ * The sync server builds one per update from the record named in the state's cursor.
4894
+ */
4895
+ type AwarenessAudience = (targetSessionId: string) => boolean;
1991
4896
  /**
1992
4897
  * Server-side awareness relay. Broadcasts ephemeral awareness states
1993
4898
  * (cursor positions, user presence) between connected clients.
1994
4899
  *
1995
- * Awareness data is never persisted. The relay simply forwards awareness
1996
- * updates from one client to all other connected clients, and broadcasts
1997
- * removal notifications when a client disconnects.
4900
+ * Awareness data is never persisted. Each session's awareness `clientId` is bound on
4901
+ * registration: a session may only publish (or clear) its own state, never another
4902
+ * client's. A state reaches exactly the sessions its audience admits (the sync server
4903
+ * admits sessions whose download scope contains the record named in the cursor), or,
4904
+ * for an update without an audience, the clients of the same partition.
1998
4905
  */
1999
4906
  declare class AwarenessRelay {
2000
4907
  private readonly clients;
2001
4908
  /**
2002
- * Register a client for awareness broadcasting.
4909
+ * Register a client for awareness broadcasting. A session registers once; later
4910
+ * calls for an already-registered session are ignored, so a client cannot rebind
4911
+ * itself to another client's id.
4912
+ *
4913
+ * The new client is sent every existing state it may see (catch-up): a state with
4914
+ * an audience when the audience admits the new session, otherwise a state of the
4915
+ * same partition.
2003
4916
  *
2004
4917
  * @param sessionId - Unique session identifier
2005
4918
  * @param clientId - Client-assigned awareness ID
2006
4919
  * @param transport - Transport for sending messages to this client
4920
+ * @param partition - Presence partition for states relayed without an audience
4921
+ */
4922
+ addClient(sessionId: string, clientId: number, transport: ServerTransport, partition?: string): void;
4923
+ /**
4924
+ * True when the session is registered with the relay.
4925
+ *
4926
+ * @param sessionId - Session to check
2007
4927
  */
2008
- addClient(sessionId: string, clientId: number, transport: ServerTransport): void;
4928
+ hasClient(sessionId: string): boolean;
2009
4929
  /**
2010
- * Remove a client and broadcast its removal to all remaining clients.
4930
+ * Remove a client and send its removal to every client that was shown its state.
2011
4931
  *
2012
4932
  * @param sessionId - Session ID of the disconnecting client
2013
4933
  */
2014
4934
  removeClient(sessionId: string): void;
2015
4935
  /**
2016
- * Handle an incoming awareness update from a client.
2017
- * Stores the state and relays to all other connected clients.
4936
+ * True when {@link handleUpdate} would take this update: the session is registered,
4937
+ * the update is stamped with its bound `clientId` and carries that client's entry.
4938
+ *
4939
+ * @param sessionId - Session ID of the sending client
4940
+ * @param message - The awareness update message
4941
+ */
4942
+ accepts(sessionId: string, message: AwarenessUpdateMessage): boolean;
4943
+ /**
4944
+ * Handle an incoming awareness update from a registered client.
4945
+ *
4946
+ * Only the sender's own entry is accepted: the message must be stamped with the
4947
+ * sender's bound `clientId`, and only `states[clientId]` is stored and relayed.
4948
+ * Entries for other clients are dropped, so a sender cannot overwrite or remove
4949
+ * anyone else's presence.
4950
+ *
4951
+ * The state is sent to every other client the audience admits (without an
4952
+ * audience: every client of the sender's partition). A client that was shown an
4953
+ * earlier state of the sender but is not admitted now receives a removal.
2018
4954
  *
2019
4955
  * @param sessionId - Session ID of the sending client
2020
4956
  * @param message - The awareness update message
4957
+ * @param audience - Who may see this state; omitted means the sender's partition
4958
+ */
4959
+ handleUpdate(sessionId: string, message: AwarenessUpdateMessage, audience?: AwarenessAudience): void;
4960
+ /**
4961
+ * Re-decide who may see a client's current state after the record its cursor names
4962
+ * changed (F16): it moved into or out of a session's grant. Sessions that may no
4963
+ * longer see the state get a removal; sessions that may see it now and were not
4964
+ * shown it get the state. Later catch-ups use the new audience.
4965
+ *
4966
+ * @param sessionId - The client whose audience changed
4967
+ * @param audience - The new audience
2021
4968
  */
2022
- handleUpdate(sessionId: string, message: AwarenessUpdateMessage): void;
4969
+ updateAudience(sessionId: string, audience: AwarenessAudience): void;
2023
4970
  /**
2024
4971
  * Get the number of registered awareness clients.
2025
4972
  */
@@ -2028,7 +4975,9 @@ declare class AwarenessRelay {
2028
4975
  * Remove all clients and clear all state.
2029
4976
  */
2030
4977
  clear(): void;
2031
- private broadcastExcept;
4978
+ /** True when `target` may see `source`'s current state. Fails closed. */
4979
+ private mayReceive;
4980
+ private removalMessage;
2032
4981
  }
2033
4982
 
2034
4983
  /**
@@ -2048,4 +4997,4 @@ declare class AwarenessRelay {
2048
4997
  */
2049
4998
  declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
2050
4999
 
2051
- 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 };
5000
+ export { type ApplyRemoteOptions, type AuthContext, type AuthProvider, type AwarenessAudience, type AwarenessCursorTarget, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, DEFAULT_MAX_SCOPE_PREDICATE_VALUES, type DeniedScopeCollection, type FindJsonStringValuesOptions, type FoldMigrationReport, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, InvalidDiagnosticOptionsError, InvalidScopePredicateError, type JsonStringValueReport, 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, findJsonStringValues, isRetriableRejection, isServerNodeId, normalizeScopeMap, operationMatchesScopes, resolveSessionScopeGrant, resolveSessionScopes };