@korajs/server 1.0.0-beta.13 → 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
@@ -402,6 +402,17 @@ interface ServerStore extends SyncStore {
402
402
  * for custom stores; the built-in stores persist the claim.
403
403
  */
404
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>;
405
416
  /**
406
417
  * Admin release of a node id (RT-5): the next principal to claim it takes it
407
418
  * over, even when the node has operation history. Returns true when the node had
@@ -1161,6 +1172,18 @@ declare class KoraSyncServer {
1161
1172
  private readonly logger;
1162
1173
  private readonly metrics;
1163
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;
1164
1187
  private readonly yjsDocRelay;
1165
1188
  private readonly blobChunkRelay;
1166
1189
  /** Wrapped encryption key records (ENC-1, D4b), persisted in the server store. */
@@ -1173,8 +1196,11 @@ declare class KoraSyncServer {
1173
1196
  /** Per-principal ingest budget per minute; 0 when disabled. */
1174
1197
  private readonly maxOpsPerMinutePerUser;
1175
1198
  private readonly allowLegacyAnonymousClaims;
1199
+ private readonly deviceNodeHandover;
1200
+ private readonly unscopedSharing;
1176
1201
  private readonly anonymousClaimTtlMs;
1177
1202
  private readonly maxOpsPerBatch;
1203
+ private readonly maxScopePredicateValues;
1178
1204
  private readonly maxMessageBytes;
1179
1205
  private readonly heartbeatIntervalMs;
1180
1206
  private readonly appHeartbeatIntervalMs;
@@ -1309,6 +1335,35 @@ declare class KoraSyncServer {
1309
1335
  * ```
1310
1336
  */
1311
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>;
1312
1367
  /** Run {@link revalidateSessions} from the delivery poll tick once the interval elapsed. */
1313
1368
  private maybeRevalidateSessions;
1314
1369
  /**
@@ -1504,6 +1559,31 @@ declare class KoraSyncServer {
1504
1559
  private handleRelay;
1505
1560
  private handleSessionClose;
1506
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;
1507
1587
  private handleYjsDocRelay;
1508
1588
  /** Open a new HTTP long-poll session bound to `principal`, under a fresh random id. */
1509
1589
  private openHttpSession;
@@ -1936,6 +2016,14 @@ interface KoraSyncServerConfig {
1936
2016
  * sends batches of 100 by default).
1937
2017
  */
1938
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;
1939
2027
  /**
1940
2028
  * Accept anonymous devices whose node claim predates confirmed claims (RT-21):
1941
2029
  * nodes whose operation history predates node claims (a database written by a
@@ -1953,6 +2041,29 @@ interface KoraSyncServerConfig {
1953
2041
  * devices syncing.
1954
2042
  */
1955
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';
1956
2067
  /**
1957
2068
  * How long an anonymous device's provisional node claim may stay unconfirmed and
1958
2069
  * still be re-issued to a device that presents no token (a handshake response lost
@@ -2129,6 +2240,78 @@ declare class HttpServerTransport implements ServerTransport {
2129
2240
  private makeEtag;
2130
2241
  }
2131
2242
 
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;
2252
+ }
2253
+ /**
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.
2265
+ */
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
+
2132
2315
  /** Outcome of one key-service message. */
2133
2316
  interface KeyServiceOutcome {
2134
2317
  response: EncryptionKeyResponseMessage;
@@ -2207,7 +2390,23 @@ type RelayCallback = (sourceSessionId: string, operations: Operation[]) => void;
2207
2390
  /**
2208
2391
  * Callback invoked when a session receives an awareness update to relay to other sessions.
2209
2392
  */
2210
- 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
+ };
2211
2410
  /**
2212
2411
  * Callback invoked when a session receives a Yjs doc channel update to relay.
2213
2412
  */
@@ -2259,6 +2458,11 @@ interface ClientSessionOptions {
2259
2458
  onRelay?: RelayCallback;
2260
2459
  /** Called when this session receives an awareness update to broadcast */
2261
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;
2262
2466
  /** Called when this session receives a Yjs doc channel update to broadcast */
2263
2467
  onYjsDocUpdate?: YjsDocRelayCallback;
2264
2468
  /** Called when this session receives a blob chunk request to route */
@@ -2314,6 +2518,12 @@ interface ClientSessionOptions {
2314
2518
  * (`BATCH_TOO_LARGE`) before it is decoded or the store is read. Defaults to 1000.
2315
2519
  */
2316
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;
2317
2527
  /**
2318
2528
  * Adjudicate untrusted client operations before materialization. When present,
2319
2529
  * each incoming operation is passed to this validator; a `reject` decision
@@ -2330,6 +2540,10 @@ interface ClientSessionOptions {
2330
2540
  blobAccess?: BlobAccessIndex;
2331
2541
  /** See `KoraSyncServerConfig.allowLegacyAnonymousClaims` (RT-21). Defaults to true. */
2332
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';
2333
2547
  /** See `KoraSyncServerConfig.anonymousClaimTtlMs` (RT-21). Defaults to 24 hours. */
2334
2548
  anonymousClaimTtlMs?: number;
2335
2549
  /**
@@ -2419,6 +2633,8 @@ declare class ClientSession {
2419
2633
  /** A provisional anonymous claim awaiting the device's confirmation (RT-21). */
2420
2634
  private pendingNodeClaim;
2421
2635
  private readonly allowLegacyAnonymousClaims;
2636
+ private readonly deviceNodeHandover;
2637
+ private readonly unscopedSharing;
2422
2638
  private readonly anonymousClaimTtlMs;
2423
2639
  private readonly isNodeLive;
2424
2640
  /** The credential presented at handshake, kept to re-validate the session (RT-18). */
@@ -2491,7 +2707,6 @@ declare class ClientSession {
2491
2707
  * `collection\u0000id`; null marks a record known to be absent. Only set while a
2492
2708
  * chunk is filtered.
2493
2709
  */
2494
- private recordLookupCache;
2495
2710
  private readonly deliveryHighWaterBytes;
2496
2711
  private readonly handshakeTimeoutMs;
2497
2712
  private handshakeTimer;
@@ -2542,6 +2757,15 @@ declare class ClientSession {
2542
2757
  private readonly maxOperationBytes;
2543
2758
  private readonly maxOpsPerMinute;
2544
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;
2545
2769
  private rateLimiter;
2546
2770
  /** Separate budget for blob chunk requests (RT-24). */
2547
2771
  private readonly blobRateLimiter;
@@ -2689,6 +2913,22 @@ declare class ClientSession {
2689
2913
  * provider threw (nothing is ended; the caller retries later), else `'valid'`
2690
2914
  */
2691
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;
2692
2932
  /**
2693
2933
  * True when `context` (a fresh authentication of this session's principal)
2694
2934
  * resolves to download or upload scopes other than the ones this session holds.
@@ -2751,14 +2991,21 @@ declare class ClientSession {
2751
2991
  * not stored yet is judged by its id alone.
2752
2992
  */
2753
2993
  canReceiveRecord(collection: string, recordId: string, storedRecord: Record<string, unknown> | null): boolean;
2754
- /**
2755
- * A stable key for this session's download scope. Presence (awareness) is only
2756
- * relayed between sessions that share exactly the same key, so it never crosses a
2757
- * tenant boundary. Unscoped sessions share the key `"*"`.
2758
- */
2759
2994
  /** This session's download scope, or undefined when it is unscoped. */
2760
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
+ */
2761
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;
2762
3009
  /**
2763
3010
  * Get the transport for this session.
2764
3011
  * Used by the awareness relay to send messages to this client.
@@ -2801,6 +3048,32 @@ declare class ClientSession {
2801
3048
  * are re-issued only with `allowLegacyAnonymousClaims`, with a deprecation warning.
2802
3049
  */
2803
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;
2804
3077
  /** The deprecation warning for an anonymous device adopted under a legacy claim (RT-21, RT-91). */
2805
3078
  private warnLegacyAnonymousClaim;
2806
3079
  /**
@@ -3089,8 +3362,15 @@ declare class ClientSession {
3089
3362
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
3090
3363
  * carries no fields) is still judged against the record's actual scope. Returns
3091
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.
3092
3366
  */
3093
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
+ */
3094
3374
  private handleAwarenessUpdate;
3095
3375
  /**
3096
3376
  * A Yjs doc-channel update is a write to a richtext field: the sender must be
@@ -3112,6 +3392,54 @@ declare class ClientSession {
3112
3392
  private handleTransportClose;
3113
3393
  }
3114
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
+
3115
3443
  /**
3116
3444
  * Configuration for the production server that serves both
3117
3445
  * static files and WebSocket sync on a single port.
@@ -3169,6 +3497,13 @@ interface ProductionServerConfig {
3169
3497
  * Largest backup accepted by `/__kora/backup/import`, in bytes. Defaults to 256 MiB.
3170
3498
  */
3171
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';
3172
3507
  }
3173
3508
  interface ProductionOperationalAuth {
3174
3509
  /** Protects /__kora, /__kora/status, and /__kora/events. */
@@ -3177,6 +3512,14 @@ interface ProductionOperationalAuth {
3177
3512
  metricsToken?: string;
3178
3513
  /** Protects /__kora/backup/*. Falls back to adminToken when omitted. */
3179
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;
3180
3523
  }
3181
3524
  interface ProductionHttpRouteRequest {
3182
3525
  method: string;
@@ -3237,6 +3580,25 @@ interface ProductionServer {
3237
3580
  * @returns Every live blob reference the server can currently see.
3238
3581
  */
3239
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>;
3240
3602
  }
3241
3603
  /**
3242
3604
  * Creates a production server that serves both static files and WebSocket sync
@@ -3627,6 +3989,7 @@ declare class MemoryServerStore implements ServerStore {
3627
3989
  listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
3628
3990
  getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
3629
3991
  claimNode(nodeId: string, userId: string): Promise<boolean>;
3992
+ claimUnownedNode(nodeId: string, userId: string): Promise<boolean>;
3630
3993
  getNodeClaimOwner(nodeId: string): Promise<string | null>;
3631
3994
  replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
3632
3995
  releaseNodeClaim(nodeId: string): Promise<boolean>;
@@ -3991,6 +4354,7 @@ declare class PostgresServerStore implements ServerStore {
3991
4354
  listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
3992
4355
  getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
3993
4356
  claimNode(nodeId: string, userId: string): Promise<boolean>;
4357
+ claimUnownedNode(nodeId: string, userId: string): Promise<boolean>;
3994
4358
  getNodeClaimOwner(nodeId: string): Promise<string | null>;
3995
4359
  replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
3996
4360
  releaseNodeClaim(nodeId: string): Promise<boolean>;
@@ -4248,6 +4612,7 @@ declare class SqliteServerStore implements ServerStore {
4248
4612
  listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
4249
4613
  getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
4250
4614
  claimNode(nodeId: string, userId: string): Promise<boolean>;
4615
+ claimUnownedNode(nodeId: string, userId: string): Promise<boolean>;
4251
4616
  getNodeClaimOwner(nodeId: string): Promise<string | null>;
4252
4617
  replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
4253
4618
  releaseNodeClaim(nodeId: string): Promise<boolean>;
@@ -4522,13 +4887,21 @@ declare function resolveSessionScopes(schema: SchemaDefinition | null, options:
4522
4887
  */
4523
4888
  declare function resolveSessionScopeGrant(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ResolvedSessionScopeGrant;
4524
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;
4525
4896
  /**
4526
4897
  * Server-side awareness relay. Broadcasts ephemeral awareness states
4527
4898
  * (cursor positions, user presence) between connected clients.
4528
4899
  *
4529
4900
  * Awareness data is never persisted. Each session's awareness `clientId` is bound on
4530
4901
  * registration: a session may only publish (or clear) its own state, never another
4531
- * client's, and its updates reach only clients in the same partition.
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.
4532
4905
  */
4533
4906
  declare class AwarenessRelay {
4534
4907
  private readonly clients;
@@ -4537,10 +4910,14 @@ declare class AwarenessRelay {
4537
4910
  * calls for an already-registered session are ignored, so a client cannot rebind
4538
4911
  * itself to another client's id.
4539
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.
4916
+ *
4540
4917
  * @param sessionId - Unique session identifier
4541
4918
  * @param clientId - Client-assigned awareness ID
4542
4919
  * @param transport - Transport for sending messages to this client
4543
- * @param partition - Presence partition (clients only see their own partition)
4920
+ * @param partition - Presence partition for states relayed without an audience
4544
4921
  */
4545
4922
  addClient(sessionId: string, clientId: number, transport: ServerTransport, partition?: string): void;
4546
4923
  /**
@@ -4550,23 +4927,46 @@ declare class AwarenessRelay {
4550
4927
  */
4551
4928
  hasClient(sessionId: string): boolean;
4552
4929
  /**
4553
- * Remove a client and broadcast its removal to the remaining clients in its partition.
4930
+ * Remove a client and send its removal to every client that was shown its state.
4554
4931
  *
4555
4932
  * @param sessionId - Session ID of the disconnecting client
4556
4933
  */
4557
4934
  removeClient(sessionId: string): void;
4935
+ /**
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;
4558
4943
  /**
4559
4944
  * Handle an incoming awareness update from a registered client.
4560
4945
  *
4561
4946
  * Only the sender's own entry is accepted: the message must be stamped with the
4562
4947
  * sender's bound `clientId`, and only `states[clientId]` is stored and relayed.
4563
4948
  * Entries for other clients are dropped, so a sender cannot overwrite or remove
4564
- * anyone else's presence. The sanitized update reaches only the sender's partition.
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.
4565
4954
  *
4566
4955
  * @param sessionId - Session ID of the sending client
4567
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
4568
4968
  */
4569
- handleUpdate(sessionId: string, message: AwarenessUpdateMessage): void;
4969
+ updateAudience(sessionId: string, audience: AwarenessAudience): void;
4570
4970
  /**
4571
4971
  * Get the number of registered awareness clients.
4572
4972
  */
@@ -4575,7 +4975,9 @@ declare class AwarenessRelay {
4575
4975
  * Remove all clients and clear all state.
4576
4976
  */
4577
4977
  clear(): void;
4578
- private broadcastExcept;
4978
+ /** True when `target` may see `source`'s current state. Fails closed. */
4979
+ private mayReceive;
4980
+ private removalMessage;
4579
4981
  }
4580
4982
 
4581
4983
  /**
@@ -4595,4 +4997,4 @@ declare class AwarenessRelay {
4595
4997
  */
4596
4998
  declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
4597
4999
 
4598
- export { type ApplyRemoteOptions, type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, DEFAULT_MAX_SCOPE_PREDICATE_VALUES, type DeniedScopeCollection, type FoldMigrationReport, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, InvalidScopePredicateError, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, type MemoryServerStoreOptions, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type ResolveSessionScopesOptions, type ResolvedSessionScopeGrant, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, SERVER_NODE_PREFIX, ScopePredicateLimitError, ScopeRequiredError, ServerCloseHandler, ServerErrorHandler, ServerIdentityError, type ServerIdentityOptions, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerSchemaOptions, type ServerStatus, type ServerStore, ServerStoreUnavailableError, ServerTransport, type SessionRevocation, type SessionState, type SessionTerminationCode, SqliteServerStore, type StoredOperationKey, type TerminateSessionsFilter, TokenAuthProvider, type TokenAuthProviderOptions, type TrustProxySetting, type UplinkAuthorizationCode, UplinkAuthorizationError, type UplinkAuthorizationResult, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, authorizeUplinkWrite, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection, isServerNodeId, normalizeScopeMap, operationMatchesScopes, resolveSessionScopeGrant, resolveSessionScopes };
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 };