@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.js CHANGED
@@ -17,7 +17,72 @@ import {
17
17
  judgeSequenceHolders,
18
18
  reportLegacyPair,
19
19
  validateIngestedOperation
20
- } from "./chunk-IRRXJ7YN.js";
20
+ } from "./chunk-UKWG5T6J.js";
21
+
22
+ // src/diagnostics/json-string-values.ts
23
+ import { KoraError } from "@korajs/core";
24
+ var InvalidDiagnosticOptionsError = class extends KoraError {
25
+ constructor(option, value) {
26
+ super(
27
+ `findJsonStringValues: ${option} must be a ${option === "pageSize" ? "positive" : "non-negative"} integer, got ${String(value)}.`,
28
+ "INVALID_DIAGNOSTIC_OPTIONS",
29
+ { option, value }
30
+ );
31
+ this.name = "InvalidDiagnosticOptionsError";
32
+ }
33
+ };
34
+ async function findJsonStringValues(store, options = {}) {
35
+ const schema = store.getSchema();
36
+ if (!schema) return [];
37
+ const sampleSize = options.sampleSize ?? 10;
38
+ const pageSize = options.pageSize ?? 1e3;
39
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
40
+ throw new InvalidDiagnosticOptionsError("pageSize", pageSize);
41
+ }
42
+ if (!Number.isInteger(sampleSize) || sampleSize < 0) {
43
+ throw new InvalidDiagnosticOptionsError("sampleSize", sampleSize);
44
+ }
45
+ const reports = [];
46
+ for (const [collection, definition] of Object.entries(schema.collections)) {
47
+ const fields = Object.entries(definition.fields).filter(([, descriptor]) => descriptor.kind === "json" || descriptor.kind === "object").map(([name]) => name);
48
+ if (fields.length === 0) continue;
49
+ const found = /* @__PURE__ */ new Map();
50
+ for (let offset = 0; ; offset += pageSize) {
51
+ const rows = await store.queryCollection(collection, {
52
+ orderBy: "id",
53
+ limit: pageSize,
54
+ offset
55
+ });
56
+ for (const row of rows) {
57
+ for (const field of fields) {
58
+ if (!isEncodedJson(row[field])) continue;
59
+ let report = found.get(field);
60
+ if (!report) {
61
+ report = { collection, field, count: 0, sampleIds: [] };
62
+ found.set(field, report);
63
+ }
64
+ report.count += 1;
65
+ if (report.sampleIds.length < sampleSize) report.sampleIds.push(String(row.id));
66
+ }
67
+ }
68
+ if (rows.length < pageSize) break;
69
+ }
70
+ reports.push(...found.values());
71
+ }
72
+ return reports;
73
+ }
74
+ function isEncodedJson(value) {
75
+ if (typeof value !== "string") return false;
76
+ const trimmed = value.trim();
77
+ const first = trimmed[0];
78
+ if (first !== "{" && first !== "[" && first !== '"') return false;
79
+ try {
80
+ JSON.parse(trimmed);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
21
86
 
22
87
  // src/logging/structured-logger.ts
23
88
  function defaultJsonSerializer(entry) {
@@ -85,7 +150,7 @@ function isRetriableRejection(code) {
85
150
  import { HybridLogicalClock as HybridLogicalClock2, assertOperationTransformCoverage } from "@korajs/core";
86
151
 
87
152
  // src/scopes/server-scope-filter.ts
88
- import { KoraError as KoraError2 } from "@korajs/core";
153
+ import { KoraError as KoraError3 } from "@korajs/core";
89
154
  import {
90
155
  buildScopeSnapshot,
91
156
  matchesScopePredicate,
@@ -93,8 +158,8 @@ import {
93
158
  } from "@korajs/sync/internal";
94
159
 
95
160
  // src/scopes/scope-predicate-errors.ts
96
- import { KoraError } from "@korajs/core";
97
- var InvalidScopePredicateError = class extends KoraError {
161
+ import { KoraError as KoraError2 } from "@korajs/core";
162
+ var InvalidScopePredicateError = class extends KoraError2 {
98
163
  constructor(collection, field) {
99
164
  super(
100
165
  `Sync scope predicate for "${collection}.${field}" is undefined or null. A missing value would match every record without "${field}", so the grant is refused.`,
@@ -112,7 +177,7 @@ var InvalidScopePredicateError = class extends KoraError {
112
177
  collection;
113
178
  field;
114
179
  };
115
- var ScopePredicateLimitError = class extends KoraError {
180
+ var ScopePredicateLimitError = class extends KoraError2 {
116
181
  constructor(message, context) {
117
182
  super(message, "SCOPE_PREDICATE_LIMIT", context);
118
183
  this.name = "ScopePredicateLimitError";
@@ -151,14 +216,18 @@ function normalizeScopeMap(scopes, maxValues = DEFAULT_MAX_SCOPE_PREDICATE_VALUE
151
216
  collection,
152
217
  field
153
218
  });
154
- const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).values()];
219
+ const unique = [...new Map(values.map((value) => [stableValueKey(value), value])).entries()];
155
220
  if (unique.length > maxValues)
156
221
  throw new ScopePredicateLimitError(
157
222
  `Scope predicate for ${collection}.${field} exceeds the ${maxValues}-value limit`,
158
223
  { collection, field, maxValues }
159
224
  );
160
225
  predicate[field] = {
161
- $in: unique.sort((a, b) => stableValueKey(a).localeCompare(stableValueKey(b)))
226
+ // Frozen: matchers may cache a set of a large list (F17), which is only
227
+ // sound for a list that cannot change.
228
+ $in: Object.freeze(
229
+ unique.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, value]) => value)
230
+ )
162
231
  };
163
232
  } else predicate[field] = expected;
164
233
  }
@@ -229,7 +298,7 @@ function missingScopeFields(op, scopes) {
229
298
  const snapshot = buildScopeSnapshot(op);
230
299
  return Object.keys(collectionScope).filter((field) => !(field in snapshot));
231
300
  }
232
- var UplinkAuthorizationError = class extends KoraError2 {
301
+ var UplinkAuthorizationError = class extends KoraError3 {
233
302
  constructor(rejectionCode, message, context) {
234
303
  super(message, rejectionCode, context);
235
304
  this.rejectionCode = rejectionCode;
@@ -506,7 +575,7 @@ import {
506
575
  import { mergeRichtext } from "@korajs/merge";
507
576
 
508
577
  // src/store/server-identity.ts
509
- import { KoraError as KoraError3, generateUUIDv7 } from "@korajs/core";
578
+ import { KoraError as KoraError4, generateUUIDv7 } from "@korajs/core";
510
579
  var SERVER_NODE_PREFIX = "kora:server:";
511
580
  var RESERVED_NODE_PREFIX = "kora:";
512
581
  var SERVER_DEPLOYMENT_ID_KEY = "server_deployment_id";
@@ -519,7 +588,7 @@ var SERVER_REVOKED_AUTHORITY_KEY = "server_revoked_authoritative_node_ids";
519
588
  var SERVER_LEGACY_SCAN_KEY = "server_legacy_authority_scan_v1";
520
589
  var INSTANCE_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
521
590
  var SERVER_DERIVED_DOMAIN = "kora/server-derived/v1";
522
- var ServerIdentityError = class extends KoraError3 {
591
+ var ServerIdentityError = class extends KoraError4 {
523
592
  constructor(message, context) {
524
593
  super(message, "INVALID_SERVER_IDENTITY", context);
525
594
  this.name = "ServerIdentityError";
@@ -1501,6 +1570,16 @@ var MemoryServerStore = class {
1501
1570
  }
1502
1571
  return owner === userId;
1503
1572
  }
1573
+ async claimUnownedNode(nodeId, userId) {
1574
+ this.assertOpen();
1575
+ if (userId === RELEASED_NODE_OWNER) return false;
1576
+ const owner = this.nodeOwners.get(nodeId);
1577
+ if (owner === void 0 || owner === RELEASED_NODE_OWNER) {
1578
+ this.nodeOwners.set(nodeId, userId);
1579
+ return true;
1580
+ }
1581
+ return owner === userId;
1582
+ }
1504
1583
  async getNodeClaimOwner(nodeId) {
1505
1584
  this.assertOpen();
1506
1585
  return this.nodeOwners.get(nodeId) ?? null;
@@ -1558,7 +1637,7 @@ var MemoryServerStore = class {
1558
1637
  }
1559
1638
  async exportBackup() {
1560
1639
  this.assertOpen();
1561
- const { buildServerBackup } = await import("./server-backup-WBIYHWYQ.js");
1640
+ const { buildServerBackup } = await import("./server-backup-KITNPMZJ.js");
1562
1641
  return buildServerBackup(
1563
1642
  this.nodeId,
1564
1643
  this.operations,
@@ -1568,7 +1647,7 @@ var MemoryServerStore = class {
1568
1647
  }
1569
1648
  async importBackup(data, merge) {
1570
1649
  this.assertOpen();
1571
- const { mergeBackupOperations, parseServerBackup, restoreBackupKeyRecords } = await import("./server-backup-WBIYHWYQ.js");
1650
+ const { mergeBackupOperations, parseServerBackup, restoreBackupKeyRecords } = await import("./server-backup-KITNPMZJ.js");
1572
1651
  const { operations: operations2, versionVector, keyRecords } = parseServerBackup(data);
1573
1652
  await restoreBackupKeyRecords(this, keyRecords, merge === true);
1574
1653
  if (merge) {
@@ -1721,7 +1800,7 @@ function keyRecordKey(owner, keyring) {
1721
1800
  // src/store/postgres-server-store.ts
1722
1801
  import {
1723
1802
  HybridLogicalClock as HybridLogicalClock4,
1724
- KoraError as KoraError4,
1803
+ KoraError as KoraError5,
1725
1804
  assertOperationTransformCoverage as assertOperationTransformCoverage2,
1726
1805
  quoteIdent as quoteIdent2
1727
1806
  } from "@korajs/core";
@@ -2360,14 +2439,14 @@ var PostgresServerStore = class {
2360
2439
  * log has no transform path to `version` (RT-103): those operations would fold as
2361
2440
  * absent, silently erasing them from their records.
2362
2441
  */
2363
- async assertTransformCoverage(version, transforms) {
2442
+ async assertTransformCoverage(version2, transforms) {
2364
2443
  if (transforms.length === 0) return;
2365
2444
  const rows = await this.db.execute(
2366
2445
  sql`SELECT DISTINCT schema_version AS v FROM operations`
2367
2446
  );
2368
2447
  assertOperationTransformCoverage2(
2369
2448
  rows.map((row) => Number(row.v)),
2370
- version,
2449
+ version2,
2371
2450
  transforms,
2372
2451
  "Postgres server store"
2373
2452
  );
@@ -3054,7 +3133,7 @@ var PostgresServerStore = class {
3054
3133
  async exportBackup() {
3055
3134
  this.assertOpen();
3056
3135
  await this.ready;
3057
- const { buildServerBackup } = await import("./server-backup-WBIYHWYQ.js");
3136
+ const { buildServerBackup } = await import("./server-backup-KITNPMZJ.js");
3058
3137
  const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.deliverySeq));
3059
3138
  const operations2 = rows.map((row) => this.deserializeOperation(row));
3060
3139
  return buildServerBackup(
@@ -3067,7 +3146,7 @@ var PostgresServerStore = class {
3067
3146
  async importBackup(data, merge) {
3068
3147
  this.assertOpen();
3069
3148
  await this.ready;
3070
- const { mergeBackupOperations, parseServerBackup, restoreBackupKeyRecords } = await import("./server-backup-WBIYHWYQ.js");
3149
+ const { mergeBackupOperations, parseServerBackup, restoreBackupKeyRecords } = await import("./server-backup-KITNPMZJ.js");
3071
3150
  const { operations: operations2, versionVector, keyRecords } = parseServerBackup(data);
3072
3151
  await restoreBackupKeyRecords(this, keyRecords, merge === true);
3073
3152
  if (merge) {
@@ -3215,6 +3294,21 @@ var PostgresServerStore = class {
3215
3294
  );
3216
3295
  return rows[0]?.user_id === userId;
3217
3296
  }
3297
+ async claimUnownedNode(nodeId, userId) {
3298
+ this.assertOpen();
3299
+ await this.ready;
3300
+ if (userId === RELEASED_NODE_OWNER) return false;
3301
+ await this.db.execute(
3302
+ sql`INSERT INTO node_claims (node_id, user_id, claimed_at)
3303
+ VALUES (${nodeId}, ${userId}, ${Date.now()})
3304
+ ON CONFLICT (node_id) DO UPDATE SET user_id = EXCLUDED.user_id, claimed_at = EXCLUDED.claimed_at
3305
+ WHERE node_claims.user_id = ${RELEASED_NODE_OWNER}`
3306
+ );
3307
+ const rows = await this.db.execute(
3308
+ sql`SELECT user_id FROM node_claims WHERE node_id = ${nodeId} LIMIT 1`
3309
+ );
3310
+ return rows[0]?.user_id === userId;
3311
+ }
3218
3312
  async getNodeClaimOwner(nodeId) {
3219
3313
  this.assertOpen();
3220
3314
  await this.ready;
@@ -4199,7 +4293,7 @@ async function createPostgresServerStore(options) {
4199
4293
  }
4200
4294
  return store;
4201
4295
  }
4202
- var ServerStoreUnavailableError = class extends KoraError4 {
4296
+ var ServerStoreUnavailableError = class extends KoraError5 {
4203
4297
  constructor(connectionString, cause) {
4204
4298
  const reason = cause instanceof Error ? cause.message : String(cause);
4205
4299
  super(
@@ -4256,7 +4350,9 @@ var POSTGRES_DRIVER_SPECIFIERS = [
4256
4350
  ];
4257
4351
 
4258
4352
  // src/store/sqlite-server-store.ts
4353
+ import { mkdirSync } from "fs";
4259
4354
  import { createRequire } from "module";
4355
+ import { dirname } from "path";
4260
4356
  import { assertOperationTransformCoverage as assertOperationTransformCoverage3, quoteIdent as quoteIdent3 } from "@korajs/core";
4261
4357
  import { planSqliteConstraintRelaxation } from "@korajs/core/internal";
4262
4358
  import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, gt as gt2, sql as sql2 } from "drizzle-orm";
@@ -4597,14 +4693,14 @@ var SqliteServerStore = class {
4597
4693
  * log has no transform path to `version` (RT-103): those operations would fold as
4598
4694
  * absent, silently erasing them from their records.
4599
4695
  */
4600
- assertTransformCoverage(version, transforms) {
4696
+ assertTransformCoverage(version2, transforms) {
4601
4697
  if (transforms.length === 0) return;
4602
4698
  const rows = this.db.all(
4603
4699
  sql2`SELECT DISTINCT schema_version AS v FROM operations`
4604
4700
  );
4605
4701
  assertOperationTransformCoverage3(
4606
4702
  rows.map((row) => Number(row.v)),
4607
- version,
4703
+ version2,
4608
4704
  transforms,
4609
4705
  "SQLite server store"
4610
4706
  );
@@ -5092,6 +5188,20 @@ var SqliteServerStore = class {
5092
5188
  );
5093
5189
  return rows[0]?.user_id === userId;
5094
5190
  }
5191
+ async claimUnownedNode(nodeId, userId) {
5192
+ this.assertOpen();
5193
+ if (userId === RELEASED_NODE_OWNER) return false;
5194
+ this.db.run(
5195
+ sql2`INSERT INTO node_claims (node_id, user_id, claimed_at)
5196
+ VALUES (${nodeId}, ${userId}, ${Date.now()})
5197
+ ON CONFLICT (node_id) DO UPDATE SET user_id = excluded.user_id, claimed_at = excluded.claimed_at
5198
+ WHERE node_claims.user_id = ${RELEASED_NODE_OWNER}`
5199
+ );
5200
+ const rows = this.db.all(
5201
+ sql2`SELECT user_id FROM node_claims WHERE node_id = ${nodeId} LIMIT 1`
5202
+ );
5203
+ return rows[0]?.user_id === userId;
5204
+ }
5095
5205
  async getNodeClaimOwner(nodeId) {
5096
5206
  this.assertOpen();
5097
5207
  const rows = this.db.all(
@@ -5181,7 +5291,7 @@ var SqliteServerStore = class {
5181
5291
  }
5182
5292
  async exportBackup() {
5183
5293
  this.assertOpen();
5184
- const { buildServerBackup } = await import("./server-backup-WBIYHWYQ.js");
5294
+ const { buildServerBackup } = await import("./server-backup-KITNPMZJ.js");
5185
5295
  const rows = this.db.select().from(operations).orderBy(asc2(operations.deliverySeq)).all();
5186
5296
  const deserialized = rows.map((row) => this.deserializeOperation(row));
5187
5297
  const vv = this.getVersionVector();
@@ -5189,7 +5299,7 @@ var SqliteServerStore = class {
5189
5299
  }
5190
5300
  async importBackup(data, merge) {
5191
5301
  this.assertOpen();
5192
- const { mergeBackupOperations, parseServerBackup, restoreBackupKeyRecords } = await import("./server-backup-WBIYHWYQ.js");
5302
+ const { mergeBackupOperations, parseServerBackup, restoreBackupKeyRecords } = await import("./server-backup-KITNPMZJ.js");
5193
5303
  const { operations: ops, versionVector, keyRecords } = parseServerBackup(data);
5194
5304
  await restoreBackupKeyRecords(this, keyRecords, merge === true);
5195
5305
  if (merge) {
@@ -6094,6 +6204,7 @@ function createSqliteServerStore(options) {
6094
6204
  const Database = esmRequire("better-sqlite3");
6095
6205
  const { drizzle } = esmRequire("drizzle-orm/better-sqlite3");
6096
6206
  const filename = options.filename ?? ":memory:";
6207
+ ensureDatabaseDirectory(filename);
6097
6208
  const sqlite = new Database(filename);
6098
6209
  sqlite.pragma("journal_mode = WAL");
6099
6210
  const db = drizzle(sqlite);
@@ -6103,6 +6214,10 @@ function createSqliteServerStore(options) {
6103
6214
  ...options.instanceId !== void 0 ? { instanceId: options.instanceId } : {}
6104
6215
  });
6105
6216
  }
6217
+ function ensureDatabaseDirectory(filename) {
6218
+ if (filename === "" || filename === ":memory:" || filename.startsWith("file:")) return;
6219
+ mkdirSync(dirname(filename), { recursive: true });
6220
+ }
6106
6221
 
6107
6222
  // src/transport/http-server-transport.ts
6108
6223
  import { SyncError } from "@korajs/core";
@@ -6475,8 +6590,11 @@ function chooseWinner(members, constraint) {
6475
6590
  }
6476
6591
  return sorted[0];
6477
6592
  }
6593
+ function groupKeys(constraint) {
6594
+ return [.../* @__PURE__ */ new Set([...constraint.fields, ...Object.keys(constraint.where ?? {})])];
6595
+ }
6478
6596
  function groupWhere(constraint, row) {
6479
- const where = constraint.type === "capacity" ? { ...constraint.where } : {};
6597
+ const where = { ...constraint.where ?? {} };
6480
6598
  for (const field of constraint.fields) {
6481
6599
  const value = row[field];
6482
6600
  if (value === null || value === void 0) return null;
@@ -6485,6 +6603,7 @@ function groupWhere(constraint, row) {
6485
6603
  return where;
6486
6604
  }
6487
6605
  async function undoConstrainedWrite(store, schema, collection, constraintIndex, constraint, loser, winner) {
6606
+ const keys = groupKeys(constraint);
6488
6607
  const ops = await store.getRecordOperations?.(collection, loser.row.id) ?? [];
6489
6608
  const state = await store.getRecordFoldState?.(collection, loser.row.id);
6490
6609
  const newest = stampTimestamp(state?.u ?? null) ?? loser.key;
@@ -6495,12 +6614,14 @@ async function undoConstrainedWrite(store, schema, collection, constraintIndex,
6495
6614
  ops.filter((stored) => {
6496
6615
  if (HybridLogicalClock5.compare(stored.timestamp, loser.key) !== 0) return false;
6497
6616
  const op = operationSchemaView2(stored, schema.version, transforms);
6498
- return op !== null && op.data !== null && constraint.fields.some((field) => op.data !== null && field in op.data);
6617
+ return op !== null && op.data !== null && keys.some((field) => op.data !== null && field in op.data);
6499
6618
  }).map((op) => op.id)
6500
6619
  );
6501
6620
  const without = foldRecord2(ops, schema, { exclude: losing, transforms }).state;
6502
6621
  const previous = without && isFoldStateLive2(without) ? materialize2(without) : null;
6503
- const collides = previous !== null && constraint.fields.every((field) => sameValue(previous[field], winner.row[field]));
6622
+ const collides = previous !== null && constraint.fields.every((field) => sameValue(previous[field], winner.row[field])) && Object.entries(constraint.where ?? {}).every(
6623
+ ([field, value]) => sameValue(previous[field], value)
6624
+ );
6504
6625
  if (previous === null || collides) {
6505
6626
  return {
6506
6627
  parent,
@@ -6515,10 +6636,17 @@ async function undoConstrainedWrite(store, schema, collection, constraintIndex,
6515
6636
  }
6516
6637
  const data = {};
6517
6638
  const previousData = {};
6518
- for (const field of constraint.fields) {
6639
+ for (const field of keys) {
6640
+ if (sameValue(previous[field], loser.row[field])) continue;
6519
6641
  data[field] = previous[field] ?? null;
6520
6642
  previousData[field] = loser.row[field] ?? null;
6521
6643
  }
6644
+ if (Object.keys(data).length === 0) {
6645
+ for (const field of constraint.fields) {
6646
+ data[field] = previous[field] ?? null;
6647
+ previousData[field] = loser.row[field] ?? null;
6648
+ }
6649
+ }
6522
6650
  return {
6523
6651
  parent,
6524
6652
  rule,
@@ -6537,7 +6665,7 @@ async function uniqueAndCapacityCorrections(store, schema, op) {
6537
6665
  const definition = schema.collections[op.collection];
6538
6666
  if (!definition || op.type === "delete") return [];
6539
6667
  const crossRecord = definition.constraints.filter(
6540
- (constraint) => (constraint.type === "unique" || constraint.type === "capacity") && (op.type === "insert" || constraint.fields.some((field) => op.data !== null && field in op.data))
6668
+ (constraint) => (constraint.type === "unique" || constraint.type === "capacity") && (op.type === "insert" || groupKeys(constraint).some((field) => op.data !== null && field in op.data))
6541
6669
  );
6542
6670
  if (crossRecord.length === 0 || !store.getRecordFieldVersions) return [];
6543
6671
  const row = await store.findRecord(op.collection, op.recordId);
@@ -6563,7 +6691,7 @@ async function uniqueAndCapacityCorrections(store, schema, op) {
6563
6691
  members.push({
6564
6692
  row: candidate,
6565
6693
  versions,
6566
- key: constrainedVersion(versions, constraint.fields)
6694
+ key: constrainedVersion(versions, groupKeys(constraint))
6567
6695
  });
6568
6696
  }
6569
6697
  if (members.length < 2) continue;
@@ -7126,6 +7254,148 @@ var NoAuthProvider = class {
7126
7254
  }
7127
7255
  };
7128
7256
 
7257
+ // src/awareness/presence-records.ts
7258
+ var CACHE_TTL_MS = 1e3;
7259
+ var MAX_TRACKED_RECORDS = 1e4;
7260
+ var MAX_CONCURRENT_READS = 16;
7261
+ var PresenceRecords = class {
7262
+ constructor(store, now = Date.now) {
7263
+ this.store = store;
7264
+ this.now = now;
7265
+ }
7266
+ store;
7267
+ now;
7268
+ clock = 0;
7269
+ /** The clock value of each tracked record's last touch, oldest first. */
7270
+ lastTouch = /* @__PURE__ */ new Map();
7271
+ /** Touches forgotten to bound memory count as having happened at this clock. */
7272
+ forgottenTouchesAt = 0;
7273
+ inFlight = /* @__PURE__ */ new Map();
7274
+ cache = /* @__PURE__ */ new Map();
7275
+ running = 0;
7276
+ waiting = [];
7277
+ /**
7278
+ * Record that writes to these records committed: reads that began before are no
7279
+ * longer current. Null means any record may have changed.
7280
+ *
7281
+ * @param keys - Record keys from {@link presenceRecordKey}, or null for every record
7282
+ */
7283
+ touch(keys) {
7284
+ this.clock += 1;
7285
+ if (keys === null) {
7286
+ this.forgottenTouchesAt = this.clock;
7287
+ this.lastTouch.clear();
7288
+ this.cache.clear();
7289
+ return;
7290
+ }
7291
+ for (const key of keys) {
7292
+ this.lastTouch.delete(key);
7293
+ this.lastTouch.set(key, this.clock);
7294
+ this.cache.delete(key);
7295
+ }
7296
+ this.forget(this.lastTouch, (value) => {
7297
+ this.forgottenTouchesAt = Math.max(this.forgottenTouchesAt, value);
7298
+ });
7299
+ }
7300
+ /**
7301
+ * True when a write touched the record after a read that began at `asOf`.
7302
+ *
7303
+ * @param key - Record key from {@link presenceRecordKey}
7304
+ * @param asOf - The read's {@link PresenceRecordRead.asOf}
7305
+ */
7306
+ touchedSince(key, asOf) {
7307
+ return (this.lastTouch.get(key) ?? this.forgottenTouchesAt) > asOf;
7308
+ }
7309
+ /**
7310
+ * A current read of the record that is still fresh enough to reuse, without a store
7311
+ * read; undefined when the record must be read.
7312
+ *
7313
+ * @param collection - The record's collection
7314
+ * @param recordId - The record's id
7315
+ */
7316
+ peek(collection, recordId) {
7317
+ const key = presenceRecordKey(collection, recordId);
7318
+ const cached = this.cache.get(key);
7319
+ if (!cached) return void 0;
7320
+ if (this.now() - cached.atMs >= CACHE_TTL_MS || this.touchedSince(key, cached.startedAt)) {
7321
+ this.cache.delete(key);
7322
+ return void 0;
7323
+ }
7324
+ return { stored: cached.stored, asOf: cached.startedAt };
7325
+ }
7326
+ /**
7327
+ * Read the record from the store, joining a read of it already running when no
7328
+ * write touched the record since that read began. Rejects when the store read fails.
7329
+ *
7330
+ * @param collection - The record's collection
7331
+ * @param recordId - The record's id
7332
+ */
7333
+ async read(collection, recordId) {
7334
+ const key = presenceRecordKey(collection, recordId);
7335
+ let entry = this.inFlight.get(key);
7336
+ if (!entry || this.touchedSince(key, entry.startedAt)) {
7337
+ const startedAt = this.clock;
7338
+ const created = {
7339
+ startedAt,
7340
+ promise: this.readStore(collection, recordId)
7341
+ };
7342
+ entry = created;
7343
+ this.inFlight.set(key, created);
7344
+ void created.promise.then(
7345
+ (stored2) => {
7346
+ if (this.inFlight.get(key) === created) this.inFlight.delete(key);
7347
+ if (this.touchedSince(key, startedAt)) return;
7348
+ this.cache.delete(key);
7349
+ this.cache.set(key, { stored: stored2, startedAt, atMs: this.now() });
7350
+ this.forget(this.cache, () => {
7351
+ });
7352
+ },
7353
+ () => {
7354
+ if (this.inFlight.get(key) === created) this.inFlight.delete(key);
7355
+ }
7356
+ );
7357
+ }
7358
+ const stored = await entry.promise;
7359
+ return { stored, asOf: entry.startedAt };
7360
+ }
7361
+ /** Forget every read and touch (the server stopped). */
7362
+ clear() {
7363
+ this.touch(null);
7364
+ this.inFlight.clear();
7365
+ }
7366
+ async readStore(collection, recordId) {
7367
+ if (this.running < MAX_CONCURRENT_READS) {
7368
+ this.running += 1;
7369
+ } else {
7370
+ await new Promise((resolve2) => this.waiting.push(resolve2));
7371
+ }
7372
+ try {
7373
+ const rows = await this.store.queryCollection(collection, {
7374
+ where: { id: recordId },
7375
+ includeDeleted: true,
7376
+ limit: 1
7377
+ });
7378
+ return rows[0] ?? null;
7379
+ } finally {
7380
+ const next = this.waiting.shift();
7381
+ if (next) next();
7382
+ else this.running -= 1;
7383
+ }
7384
+ }
7385
+ forget(map, onForget) {
7386
+ while (map.size > MAX_TRACKED_RECORDS) {
7387
+ const oldest = map.keys().next();
7388
+ if (oldest.done) return;
7389
+ const value = map.get(oldest.value);
7390
+ map.delete(oldest.value);
7391
+ if (value !== void 0) onForget(value);
7392
+ }
7393
+ }
7394
+ };
7395
+ function presenceRecordKey(collection, recordId) {
7396
+ return `${collection}\0${recordId}`;
7397
+ }
7398
+
7129
7399
  // src/encryption/key-record-service.ts
7130
7400
  import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
7131
7401
  import {
@@ -7663,14 +7933,14 @@ function asRecord2(value) {
7663
7933
 
7664
7934
  // src/scopes/resolve-session-scopes.ts
7665
7935
  import {
7666
- KoraError as KoraError5,
7936
+ KoraError as KoraError6,
7667
7937
  SCOPE_CLAIMS_KEY,
7668
7938
  getCollectionScopeBindings,
7669
7939
  getScopeClaims,
7670
7940
  hasSchemaSyncRules,
7671
7941
  isCollectionSyncScoped
7672
7942
  } from "@korajs/core";
7673
- var ScopeRequiredError = class extends KoraError5 {
7943
+ var ScopeRequiredError = class extends KoraError6 {
7674
7944
  constructor(denied) {
7675
7945
  super(
7676
7946
  `Sync scope required: ${denied.map((d) => `"${d.collection}" needs ${d.missingKeys.map((k) => `"${k}"`).join(", ")}`).join("; ")}. The server could not derive these values from the verified token.`,
@@ -7831,11 +8101,11 @@ function isSameStoredOperation(upload, stored) {
7831
8101
  const b = contentKey(stored, storedVersion);
7832
8102
  return a !== null && a === b;
7833
8103
  }
7834
- function contentKey(op, version) {
8104
+ function contentKey(op, version2) {
7835
8105
  let body;
7836
8106
  try {
7837
8107
  body = canonicalizeOperationBody(
7838
- version === 1 ? canonicalizeLegacyOperation2({ ...op, hashVersion: void 0 }) : op
8108
+ version2 === 1 ? canonicalizeLegacyOperation2({ ...op, hashVersion: void 0 }) : op
7839
8109
  );
7840
8110
  } catch {
7841
8111
  return null;
@@ -7851,7 +8121,7 @@ function contentKey(op, version) {
7851
8121
  atomicOps,
7852
8122
  sealed: op.encrypted !== void 0
7853
8123
  };
7854
- if (version === 2) {
8124
+ if (version2 === 2) {
7855
8125
  key.previousData = body.previousData;
7856
8126
  key.sequenceNumber = op.sequenceNumber;
7857
8127
  key.causalDeps = [...op.causalDeps].sort();
@@ -8071,24 +8341,24 @@ function revocationMatches(principal, filter) {
8071
8341
  return true;
8072
8342
  }
8073
8343
  var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
8074
- function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
8075
- if (!auth || auth instanceof NoAuthProvider) {
8076
- return;
8077
- }
8078
- if (resolvedScopes) {
8079
- return;
8080
- }
8081
- if (!schema || Object.keys(schema.collections).length === 0) {
8082
- return;
8083
- }
8344
+ function warnIfMultiTenantWithoutScopes(auth) {
8084
8345
  if (warnedUnscopedProviders.has(auth)) {
8085
8346
  return;
8086
8347
  }
8087
8348
  warnedUnscopedProviders.add(auth);
8088
8349
  console.warn(
8089
- "[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
8350
+ "[kora] An authenticated session resolved to no sync scopes: its grant restricts no collection, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes or scopeValues) and declare schema sync rules that bind them, to isolate tenants. If all signed-in users are meant to share the same data, set unscopedSharing: 'allow' in the sync server options to silence this; 'refuse' turns it into a handshake refusal."
8090
8351
  );
8091
8352
  }
8353
+ function sharesEveryUsersData(auth, principal, downlink, schema) {
8354
+ if (downlink === null) return false;
8355
+ if (!auth || auth instanceof NoAuthProvider) return false;
8356
+ if (!principal || principal.anonymous === true) return false;
8357
+ if (!schema || Object.keys(schema.collections).length === 0) return false;
8358
+ if (downlink === void 0) return true;
8359
+ const collections = Object.values(downlink);
8360
+ return collections.length > 0 && collections.every((predicate) => Object.keys(predicate).length === 0);
8361
+ }
8092
8362
  function stableStringify(value) {
8093
8363
  if (Array.isArray(value)) {
8094
8364
  return `[${value.map((item) => stableStringify(item)).join(",")}]`;
@@ -8100,10 +8370,16 @@ function stableStringify(value) {
8100
8370
  }
8101
8371
  return JSON.stringify(value);
8102
8372
  }
8103
- function sameScopeMap(a, b) {
8104
- const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value) : value;
8373
+ function sameNormalizedScopeMap(a, b) {
8374
+ if (a === b) return true;
8375
+ return stableStringify(a ?? null) === stableStringify(b ?? null);
8376
+ }
8377
+ function sameScopeMap(a, b, maxValues) {
8378
+ const normalize = (value) => value && typeof value === "object" ? normalizeScopeMap(value, maxValues) : value;
8105
8379
  return stableStringify(normalize(a) ?? null) === stableStringify(normalize(b) ?? null);
8106
8380
  }
8381
+ var MAX_PRESENCE_LOOKUPS_PER_MINUTE = 1200;
8382
+ var MAX_PRESENCE_REREADS = 2;
8107
8383
  var DEFAULT_MAX_BLOB_CHUNK_BYTES = 1024 * 1024;
8108
8384
  var DEFAULT_MAX_BLOB_BYTES_PER_SESSION = 256 * 1024 * 1024;
8109
8385
  var ClientSession = class _ClientSession {
@@ -8127,6 +8403,8 @@ var ClientSession = class _ClientSession {
8127
8403
  /** A provisional anonymous claim awaiting the device's confirmation (RT-21). */
8128
8404
  pendingNodeClaim = null;
8129
8405
  allowLegacyAnonymousClaims;
8406
+ deviceNodeHandover;
8407
+ unscopedSharing;
8130
8408
  anonymousClaimTtlMs;
8131
8409
  isNodeLive;
8132
8410
  /** The credential presented at handshake, kept to re-validate the session (RT-18). */
@@ -8199,7 +8477,6 @@ var ClientSession = class _ClientSession {
8199
8477
  * `collection\u0000id`; null marks a record known to be absent. Only set while a
8200
8478
  * chunk is filtered.
8201
8479
  */
8202
- recordLookupCache = null;
8203
8480
  deliveryHighWaterBytes;
8204
8481
  handshakeTimeoutMs;
8205
8482
  handshakeTimer = null;
@@ -8250,6 +8527,15 @@ var ClientSession = class _ClientSession {
8250
8527
  maxOperationBytes;
8251
8528
  maxOpsPerMinute;
8252
8529
  maxOpsPerBatch;
8530
+ maxScopePredicateValues;
8531
+ /** {@link getScopePartitionKey}, cached for the scope map it was computed from. */
8532
+ scopePartitionKeyCache = null;
8533
+ /** Store reads for presence cursors (F16), per minute. */
8534
+ presenceLookupLimiter = new SessionRateLimiter(MAX_PRESENCE_LOOKUPS_PER_MINUTE);
8535
+ /** Reads the records presence cursors name (shared by the server's sessions). */
8536
+ presenceRecords;
8537
+ /** A {@link refreshScopes} request arrived during the handshake: re-check once streaming. */
8538
+ scopeRefreshPending = false;
8253
8539
  rateLimiter;
8254
8540
  /** Separate budget for blob chunk requests (RT-24). */
8255
8541
  blobRateLimiter;
@@ -8302,6 +8588,7 @@ var ClientSession = class _ClientSession {
8302
8588
  this.operationTransforms = options.operationTransforms ?? [];
8303
8589
  this.onRelay = options.onRelay ?? null;
8304
8590
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
8591
+ this.presenceRecords = options.presenceRecords ?? new PresenceRecords(options.store);
8305
8592
  this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
8306
8593
  this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
8307
8594
  this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
@@ -8328,10 +8615,13 @@ var ClientSession = class _ClientSession {
8328
8615
  options.blobRequestWindowMs ?? 6e4
8329
8616
  );
8330
8617
  this.maxOpsPerBatch = options.maxOpsPerBatch ?? DEFAULT_MAX_OPS_PER_BATCH;
8618
+ this.maxScopePredicateValues = options.maxScopePredicateValues ?? DEFAULT_MAX_SCOPE_PREDICATE_VALUES;
8331
8619
  this.validateOperation = options.validateOperation ?? null;
8332
8620
  this.koraContext = options.koraContext ?? null;
8333
8621
  this.blobAccess = options.blobAccess ?? null;
8334
8622
  this.allowLegacyAnonymousClaims = options.allowLegacyAnonymousClaims ?? true;
8623
+ this.deviceNodeHandover = options.deviceNodeHandover ?? true;
8624
+ this.unscopedSharing = options.unscopedSharing ?? "warn";
8335
8625
  this.anonymousClaimTtlMs = options.anonymousClaimTtlMs ?? DEFAULT_ANONYMOUS_CLAIM_TTL_MS;
8336
8626
  this.isNodeLive = options.isNodeLive ?? null;
8337
8627
  this.deliveryHighWaterBytes = options.deliveryHighWaterBytes ?? DEFAULT_DELIVERY_HIGH_WATER_BYTES;
@@ -8731,6 +9021,44 @@ var ClientSession = class _ClientSession {
8731
9021
  this.terminate(expired ? "AUTH_EXPIRED" : "AUTH_REVOKED");
8732
9022
  return "terminated";
8733
9023
  }
9024
+ /**
9025
+ * Re-resolve this session's grant on request (`KoraSyncServer.refreshScopes`), for
9026
+ * the user named. A streaming session whose principal is that user re-authenticates
9027
+ * now and ends with a retriable `SCOPE_CHANGED` when its grant changed (or
9028
+ * `AUTH_REVOKED` when the credential is no longer accepted). A session still in its
9029
+ * handshake may have resolved its grant from membership read before the change, so
9030
+ * it re-checks as soon as it reaches streaming; a session whose principal is not
9031
+ * known yet does the same (the re-check is a no-op for a grant that did not change).
9032
+ *
9033
+ * @param userId - The user whose grant changed
9034
+ * @returns The revalidation outcome, `'deferred'` when the check waits for the
9035
+ * handshake, or `'skipped'` for another user's (or a closed) session
9036
+ */
9037
+ async refreshScopes(userId) {
9038
+ if (this.state === "closed") return "skipped";
9039
+ const principal = this.principal;
9040
+ if (principal && principal.userId !== userId) return "skipped";
9041
+ if (!principal || this.state !== "streaming" && this.state !== "syncing") {
9042
+ this.scopeRefreshPending = true;
9043
+ return "deferred";
9044
+ }
9045
+ return this.revalidateCredential();
9046
+ }
9047
+ /** Run a re-check deferred by {@link refreshScopes} once the session streams. */
9048
+ runDeferredScopeRefresh() {
9049
+ if (!this.scopeRefreshPending) return;
9050
+ this.scopeRefreshPending = false;
9051
+ void this.revalidateCredential().then((outcome) => {
9052
+ if (outcome !== "error") return;
9053
+ this.logger?.log({
9054
+ timestamp: Date.now(),
9055
+ level: "warn",
9056
+ event: "session.revalidation_failed",
9057
+ sessionId: this.sessionId,
9058
+ details: { reason: "deferred scope refresh" }
9059
+ });
9060
+ });
9061
+ }
8734
9062
  /**
8735
9063
  * True when `context` (a fresh authentication of this session's principal)
8736
9064
  * resolves to download or upload scopes other than the ones this session holds.
@@ -8740,7 +9068,7 @@ var ClientSession = class _ClientSession {
8740
9068
  if (this.state !== "streaming" && this.state !== "syncing") return false;
8741
9069
  const resolution = this.computeSessionScopes(context, this.handshakeScope);
8742
9070
  if (!resolution.ok) return true;
8743
- return !sameScopeMap(resolution.downlink, this.authContext?.downlinkScopes) || !sameScopeMap(resolution.uplink, this.authContext?.uplinkScopes);
9071
+ return !sameNormalizedScopeMap(resolution.downlink, this.authContext?.downlinkScopes) || !sameNormalizedScopeMap(resolution.uplink, this.authContext?.uplinkScopes);
8744
9072
  }
8745
9073
  /**
8746
9074
  * End this session because an admin released its node id (RT-5). Retriable: a
@@ -8883,18 +9211,35 @@ var ClientSession = class _ClientSession {
8883
9211
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
8884
9212
  return recordMatchesScopes(collection, { ...storedRecord ?? {}, id: recordId }, scopes);
8885
9213
  }
8886
- /**
8887
- * A stable key for this session's download scope. Presence (awareness) is only
8888
- * relayed between sessions that share exactly the same key, so it never crosses a
8889
- * tenant boundary. Unscoped sessions share the key `"*"`.
8890
- */
8891
9214
  /** This session's download scope, or undefined when it is unscoped. */
8892
9215
  getDownlinkScopes() {
8893
9216
  return this.authContext?.downlinkScopes ?? this.authContext?.scopes;
8894
9217
  }
9218
+ /**
9219
+ * A stable key for this session's download scope: sessions with the same key see
9220
+ * exactly the same data. Unscoped sessions share the key `"*"`. Cached per scope
9221
+ * map, since a large `$in` grant makes the key costly and the relays read it often.
9222
+ */
8895
9223
  getScopePartitionKey() {
8896
9224
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
8897
- return scopes ? stableStringify(normalizeScopeMap(scopes)) : "*";
9225
+ if (!scopes) return "*";
9226
+ if (this.scopePartitionKeyCache?.scopes !== scopes) {
9227
+ this.scopePartitionKeyCache = {
9228
+ scopes,
9229
+ key: stableStringify(normalizeScopeMap(scopes, this.maxScopePredicateValues))
9230
+ };
9231
+ }
9232
+ return this.scopePartitionKeyCache.key;
9233
+ }
9234
+ /**
9235
+ * Partition for presence without a cursor (a "who is online" state that names no
9236
+ * record): only sessions with the same key see each other's. Signed-in sessions
9237
+ * use their download scope key; every anonymous session is its own partition,
9238
+ * because sessions holding the same anonymous grant are unrelated people.
9239
+ */
9240
+ getPresencePartitionKey() {
9241
+ const key = this.getScopePartitionKey();
9242
+ return this.principal?.anonymous === true ? `anonymous:${this.sessionId}|${key}` : key;
8898
9243
  }
8899
9244
  /**
8900
9245
  * Get the transport for this session.
@@ -8959,7 +9304,7 @@ var ClientSession = class _ClientSession {
8959
9304
  case "heartbeat":
8960
9305
  break;
8961
9306
  case "awareness-update":
8962
- this.handleAwarenessUpdate(message);
9307
+ await this.handleAwarenessUpdate(message);
8963
9308
  break;
8964
9309
  case "yjs-doc-update":
8965
9310
  await this.handleYjsDocUpdate(message);
@@ -9135,10 +9480,15 @@ var ClientSession = class _ClientSession {
9135
9480
  if (!store.getNodeClaimOwner || !store.replaceNodeClaim) return { ok: false };
9136
9481
  const current = await store.getNodeClaimOwner(nodeId);
9137
9482
  if (current === null) {
9138
- if (!this.allowLegacyAnonymousClaims || !store.releaseNodeClaim) return { ok: false };
9483
+ if (!this.allowLegacyAnonymousClaims) return { ok: false };
9139
9484
  if (this.isNodeLive?.(nodeId, this.sessionId) === true) return { ok: false };
9140
- if (!await store.releaseNodeClaim(nodeId)) return { ok: false };
9141
- if (!await store.claimNode(nodeId, owner)) return { ok: false };
9485
+ if (store.claimUnownedNode) {
9486
+ if (!await store.claimUnownedNode(nodeId, owner)) return { ok: false };
9487
+ } else {
9488
+ if (!store.releaseNodeClaim) return { ok: false };
9489
+ if (!await store.releaseNodeClaim(nodeId)) return { ok: false };
9490
+ if (!await store.claimNode(nodeId, owner)) return { ok: false };
9491
+ }
9142
9492
  this.warnLegacyAnonymousClaim(nodeId);
9143
9493
  return issued;
9144
9494
  }
@@ -9160,6 +9510,68 @@ var ClientSession = class _ClientSession {
9160
9510
  if (legacy) this.warnLegacyAnonymousClaim(nodeId);
9161
9511
  return issued;
9162
9512
  }
9513
+ /**
9514
+ * Automatic device handover after an upgrade from beta.12 (F1). A node with
9515
+ * history but no owner (written before node claims existed) or released by an
9516
+ * admin is claimed for the signed-in user when the node id IS the device id the
9517
+ * auth provider verified for this credential (`metadata.deviceId`; the built-in
9518
+ * provider takes it from the token's `dev` claim, which names a device registered
9519
+ * to that user). `@korajs/auth` clients use that device id as their node id and
9520
+ * cannot change it, so without this they stay refused until an operator binds the
9521
+ * node. The claim is one atomic store step; a node another principal owns is never
9522
+ * taken, and another user presenting the node id is still refused.
9523
+ *
9524
+ * The claim decides ownership from now on only. History written under the node on
9525
+ * beta.12 may include operations another user forged (beta.12 did not verify node
9526
+ * ids); the operation log integrity scan is the tool for auditing it.
9527
+ */
9528
+ async handOverDeviceNode(nodeId, context) {
9529
+ if (!this.deviceNodeHandover || context.anonymous === true) return false;
9530
+ if (!this.store.claimUnownedNode) return false;
9531
+ const deviceId = context.metadata?.deviceId;
9532
+ if (typeof deviceId !== "string" || deviceId.length === 0 || deviceId !== nodeId) return false;
9533
+ const previousOwner = await this.store.getNodeClaimOwner?.(nodeId) ?? null;
9534
+ if (previousOwner !== null && previousOwner !== RELEASED_NODE_OWNER) return false;
9535
+ if (!await this.store.claimUnownedNode(nodeId, context.userId)) return false;
9536
+ this.logger?.log({
9537
+ timestamp: Date.now(),
9538
+ level: "info",
9539
+ event: "node_claim.handover",
9540
+ sessionId: this.sessionId,
9541
+ nodeId,
9542
+ details: {
9543
+ userId: context.userId,
9544
+ previousOwner: previousOwner === null ? "none (pre-claims history)" : "released",
9545
+ message: "A signed-in device took over its own node id, which had history but no owner (a database upgraded from beta.12, or a node an administrator released). The node id equals the device id the auth provider verified for this user."
9546
+ }
9547
+ });
9548
+ return true;
9549
+ }
9550
+ /**
9551
+ * The download grant the server itself gives this session, before the client's
9552
+ * handshake scope narrows it (F4). A client may narrow its own view, but that does
9553
+ * not isolate tenants: another client of the same user set can ask for everything.
9554
+ * `null` when the grant cannot be resolved here (the handshake already refused or
9555
+ * accepted it on the full path); then nothing is judged.
9556
+ */
9557
+ grantWithoutHandshakeNarrowing(downlinkAuthScopes, authenticated) {
9558
+ try {
9559
+ return resolveSessionScopes(this.store.getSchema(), {
9560
+ handshakeScope: void 0,
9561
+ authScopes: downlinkAuthScopes,
9562
+ authenticated,
9563
+ onUnresolved: "deny"
9564
+ });
9565
+ } catch {
9566
+ return null;
9567
+ }
9568
+ }
9569
+ /** Who holds a refused node id, as `NODE_ID_CLAIMED` reports it (F9). */
9570
+ async nodeOwnershipOf(nodeId) {
9571
+ if (!this.store.getNodeClaimOwner) return void 0;
9572
+ const owner = await this.store.getNodeClaimOwner(nodeId);
9573
+ return owner === null || owner === RELEASED_NODE_OWNER ? "unowned" : "other-principal";
9574
+ }
9163
9575
  /** The deprecation warning for an anonymous device adopted under a legacy claim (RT-21, RT-91). */
9164
9576
  warnLegacyAnonymousClaim(nodeId) {
9165
9577
  this.logger?.log({
@@ -9169,7 +9581,7 @@ var ClientSession = class _ClientSession {
9169
9581
  sessionId: this.sessionId,
9170
9582
  nodeId,
9171
9583
  details: {
9172
- message: "An anonymous device re-claimed a node id under a legacy claim (no confirmed node token, or history from before node claims). Upgrade the client; allowLegacyAnonymousClaims will default to false in the next release."
9584
+ message: "An anonymous device re-claimed a node id under a legacy claim (no confirmed node token, or history from before node claims). Upgrade the client; allowLegacyAnonymousClaims will default to false in a later release, announced in its release notes."
9173
9585
  }
9174
9586
  });
9175
9587
  }
@@ -9313,14 +9725,19 @@ var ClientSession = class _ClientSession {
9313
9725
  } else {
9314
9726
  this.nodeOwnerKey = context.userId;
9315
9727
  claimed = await this.store.claimNode(msg.nodeId, context.userId);
9728
+ if (!claimed) claimed = await this.handOverDeviceNode(msg.nodeId, context);
9316
9729
  }
9317
9730
  if (!claimed) {
9318
9731
  this.issuedNodeToken = null;
9319
- this.sendError(
9320
- "NODE_ID_CLAIMED",
9321
- `Node id "${msg.nodeId}" belongs to another principal, or has operation history with no recorded owner (an administrator can release it with KoraSyncServer.releaseNodeClaim). Use a fresh node id per signed-in user.`,
9322
- false
9323
- );
9732
+ const nodeOwnership = context.anonymous === true ? void 0 : await this.nodeOwnershipOf(msg.nodeId);
9733
+ this.sendToClient({
9734
+ type: "error",
9735
+ messageId: generateUUIDv73(),
9736
+ code: "NODE_ID_CLAIMED",
9737
+ message: `Node id "${msg.nodeId}" belongs to another principal, or has operation history with no recorded owner (an administrator can release it with KoraSyncServer.releaseNodeClaim). Use a fresh node id per signed-in user.`,
9738
+ retriable: false,
9739
+ ...nodeOwnership ? { nodeOwnership } : {}
9740
+ });
9324
9741
  this.close("node id claimed by another user");
9325
9742
  return;
9326
9743
  }
@@ -9386,11 +9803,23 @@ var ClientSession = class _ClientSession {
9386
9803
  } = this.authContext;
9387
9804
  this.authContext = rest;
9388
9805
  }
9389
- warnIfMultiTenantWithoutScopes(
9806
+ if (this.unscopedSharing !== "allow" && this.auth && sharesEveryUsersData(
9390
9807
  this.auth,
9391
- authenticated ? downlinkAuthScopes : resolvedDownlinkScopes,
9808
+ this.principal,
9809
+ this.grantWithoutHandshakeNarrowing(downlinkAuthScopes, authenticated),
9392
9810
  this.store.getSchema()
9393
- );
9811
+ )) {
9812
+ if (this.unscopedSharing === "refuse") {
9813
+ this.sendError(
9814
+ "UNSCOPED_SHARING_REFUSED",
9815
+ "This server refuses signed-in sessions whose grant restricts no collection (unscopedSharing: 'refuse'). Return per-user sync scopes from the auth provider.",
9816
+ false
9817
+ );
9818
+ this.close("unscoped sharing refused");
9819
+ return;
9820
+ }
9821
+ warnIfMultiTenantWithoutScopes(this.auth);
9822
+ }
9394
9823
  if (msg.syncQueries && msg.syncQueries.length > 0) {
9395
9824
  this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
9396
9825
  } else {
@@ -9398,7 +9827,7 @@ var ClientSession = class _ClientSession {
9398
9827
  }
9399
9828
  this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
9400
9829
  this.clientDeliveryWatermark = msg.lastDeliverySequence ?? null;
9401
- if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes)) {
9830
+ if (this.clientDeliveryWatermark !== null && !sameScopeMap(msg.syncScope, this.authContext?.downlinkScopes, this.maxScopePredicateValues)) {
9402
9831
  const acceptedWatermark = msg.acceptedScopeWatermark;
9403
9832
  const resumable = typeof msg.acceptedScopeKey === "string" && typeof acceptedWatermark === "number" && Number.isSafeInteger(acceptedWatermark) && acceptedWatermark >= 0 && msg.acceptedScopeKey === scopeViewKey(this.authContext?.downlinkScopes);
9404
9833
  this.clientDeliveryWatermark = resumable ? acceptedWatermark : 0;
@@ -9522,6 +9951,7 @@ var ClientSession = class _ClientSession {
9522
9951
  }
9523
9952
  this.onReady?.(this.sessionId);
9524
9953
  if (heartbeat) this.startAppHeartbeat();
9954
+ this.runDeferredScopeRefresh();
9525
9955
  if (this.takeOrphanedRelays && this.clientNodeId) {
9526
9956
  const buffered = this.takeOrphanedRelays(this.clientNodeId);
9527
9957
  if (buffered.length > 0) {
@@ -9628,8 +10058,8 @@ var ClientSession = class _ClientSession {
9628
10058
  };
9629
10059
  }
9630
10060
  try {
9631
- const downlink = rawDownlink ? normalizeScopeMap(rawDownlink) : directionalScopesConfigured ? {} : void 0;
9632
- const uplink = rawUplink ? normalizeScopeMap(rawUplink) : directionalScopesConfigured ? {} : void 0;
10061
+ const downlink = rawDownlink ? normalizeScopeMap(rawDownlink, this.maxScopePredicateValues) : directionalScopesConfigured ? {} : void 0;
10062
+ const uplink = rawUplink !== void 0 && rawUplink === rawDownlink ? downlink : rawUplink ? normalizeScopeMap(rawUplink, this.maxScopePredicateValues) : directionalScopesConfigured ? {} : void 0;
9633
10063
  return { ok: true, downlink, uplink, downlinkAuthScopes, authenticated };
9634
10064
  } catch (error) {
9635
10065
  return {
@@ -10190,7 +10620,7 @@ var ClientSession = class _ClientSession {
10190
10620
  * payloads have no envelope binding.
10191
10621
  */
10192
10622
  warnLegacyProtocol(nodeId) {
10193
- const message = `Client node "${nodeId}" speaks sync protocol ${String(this.clientProtocolVersion)}; this server speaks ${String(SYNC_PROTOCOL_VERSION)}. Protocol 1 clients (Kora <= beta.12) are accepted in beta.13 only and will be refused by the next release. Upgrade the client.`;
10623
+ const message = `Client node "${nodeId}" speaks sync protocol ${String(this.clientProtocolVersion)}; this server speaks ${String(SYNC_PROTOCOL_VERSION)}. Protocol 1 clients (Kora <= beta.12) are deprecated and will be refused by a later release, announced in its release notes. Upgrade the client.`;
10194
10624
  this.logger?.log({
10195
10625
  timestamp: Date.now(),
10196
10626
  level: "warn",
@@ -10381,26 +10811,22 @@ var ClientSession = class _ClientSession {
10381
10811
  !(clientNodeId !== null && delivered.operation.nodeId === clientNodeId && delivered.deliverySequence > this.ownOperationsIncludedThrough)
10382
10812
  )
10383
10813
  );
10384
- this.recordLookupCache = await this.prefetchRecordsFor(candidates);
10385
- try {
10386
- for (const delivered of candidates) {
10387
- const snapshot = delivered.scopeSnapshot ?? null;
10388
- if (await this.operationVisibleToClient(delivered.operation, snapshot)) {
10389
- const entry = await this.scopeEntryFor(delivered.operation, snapshot);
10390
- if (entry) {
10391
- deliverable.push({
10392
- operation: entry,
10393
- deliverySequence: delivered.deliverySequence,
10394
- scopeEntry: true
10395
- });
10396
- }
10397
- deliverable.push(delivered);
10398
- } else if (await this.scopeRetractionFor(delivered.operation, snapshot)) {
10399
- deliverable.push({ ...delivered, retraction: true });
10814
+ const rows = await this.prefetchRecordsFor(candidates);
10815
+ for (const delivered of candidates) {
10816
+ const snapshot = delivered.scopeSnapshot ?? null;
10817
+ if (await this.operationVisibleToClient(delivered.operation, snapshot, rows)) {
10818
+ const entry = await this.scopeEntryFor(delivered.operation, snapshot, rows);
10819
+ if (entry) {
10820
+ deliverable.push({
10821
+ operation: entry,
10822
+ deliverySequence: delivered.deliverySequence,
10823
+ scopeEntry: true
10824
+ });
10400
10825
  }
10826
+ deliverable.push(delivered);
10827
+ } else if (await this.scopeRetractionFor(delivered.operation, snapshot, rows)) {
10828
+ deliverable.push({ ...delivered, retraction: true });
10401
10829
  }
10402
- } finally {
10403
- this.recordLookupCache = null;
10404
10830
  }
10405
10831
  return deliverable;
10406
10832
  }
@@ -10485,7 +10911,7 @@ var ClientSession = class _ClientSession {
10485
10911
  * row. Operations without a snapshot (legacy rows, custom stores) fall back to the
10486
10912
  * current row plus `op.data`.
10487
10913
  */
10488
- async operationVisibleToClient(stored, snapshot = null) {
10914
+ async operationVisibleToClient(stored, snapshot = null, rows = null) {
10489
10915
  const judged = this.schemaView(stored);
10490
10916
  const op = judged.ok ? judged.op : stored;
10491
10917
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
@@ -10494,18 +10920,18 @@ var ClientSession = class _ClientSession {
10494
10920
  let current;
10495
10921
  let post = snapshot.post;
10496
10922
  if (snapshotLacksScopeFields(op.collection, { pre: null, post }, scopes)) {
10497
- current = await this.lookupRecordFields(op.collection, op.recordId);
10923
+ current = await this.lookupRecordFields(op.collection, op.recordId, rows);
10498
10924
  post = snapshotValuesWithFallback(op.collection, post, scopes, current);
10499
10925
  }
10500
10926
  if (!post || !recordMatchesScopes(op.collection, { ...post, id: op.recordId }, scopes)) {
10501
10927
  return false;
10502
10928
  }
10503
10929
  if (subsets.length === 0) return true;
10504
- current ??= await this.lookupRecordFields(op.collection, op.recordId);
10930
+ current ??= await this.lookupRecordFields(op.collection, op.recordId, rows);
10505
10931
  return operationMatchesQuerySubsets(op, subsets, current);
10506
10932
  }
10507
10933
  const needsBackfill = missingScopeFields(op, scopes).length > 0 || subsets !== void 0 && subsets.length > 0;
10508
- const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
10934
+ const fullRecord = needsBackfill ? await this.lookupRecordFields(op.collection, op.recordId, rows) : void 0;
10509
10935
  if (!operationMatchesScopes(op, scopes, fullRecord)) {
10510
10936
  return false;
10511
10937
  }
@@ -10731,11 +11157,11 @@ var ClientSession = class _ClientSession {
10731
11157
  * operation without a snapshot never produces a retraction (fail quiet: the
10732
11158
  * client keeps what it already had, and no other tenant's record id leaks).
10733
11159
  */
10734
- async scopeRetractionFor(op, snapshot = null) {
11160
+ async scopeRetractionFor(op, snapshot = null, rows = null) {
10735
11161
  if (this.scopeExitPolicy !== "retract") return false;
10736
11162
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
10737
11163
  if (!scopes || !snapshot) return false;
10738
- const current = snapshotLacksScopeFields(op.collection, snapshot, scopes) ? await this.lookupRecordFields(op.collection, op.recordId) : void 0;
11164
+ const current = snapshotLacksScopeFields(op.collection, snapshot, scopes) ? await this.lookupRecordFields(op.collection, op.recordId, rows) : void 0;
10739
11165
  return snapshotExitsScopes(op, snapshot, scopes, current);
10740
11166
  }
10741
11167
  /**
@@ -10746,14 +11172,14 @@ var ClientSession = class _ClientSession {
10746
11172
  * deleted, gets no entry: its later operations retract or delete it anyway).
10747
11173
  * Operations without a snapshot (legacy rows, custom stores) never produce one.
10748
11174
  */
10749
- async scopeEntryFor(op, snapshot) {
11175
+ async scopeEntryFor(op, snapshot, rows = null) {
10750
11176
  const scopes = this.authContext?.downlinkScopes ?? this.authContext?.scopes;
10751
11177
  const schema = this.store.getSchema();
10752
11178
  if (!scopes || !snapshot || !schema || op.type !== "update") return null;
10753
11179
  if (!snapshotLacksScopeFields(op.collection, snapshot, scopes) && !snapshotEntersScopes(op, snapshot, scopes)) {
10754
11180
  return null;
10755
11181
  }
10756
- const current = await this.lookupRecordFields(op.collection, op.recordId);
11182
+ const current = await this.lookupRecordFields(op.collection, op.recordId, rows);
10757
11183
  if (!current || current._deleted === 1 || current._deleted === true) return null;
10758
11184
  if (!snapshotEntersScopes(op, snapshot, scopes, current)) return null;
10759
11185
  if (!recordMatchesScopes(op.collection, { ...current, id: op.recordId }, scopes)) return null;
@@ -10787,23 +11213,65 @@ var ClientSession = class _ClientSession {
10787
11213
  * query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
10788
11214
  * carries no fields) is still judged against the record's actual scope. Returns
10789
11215
  * undefined when the record cannot be read (never materialized, or no schema).
11216
+ * `rows` are the rows one delivery pass prefetched for its own decisions.
10790
11217
  */
10791
- async lookupRecordFields(collection, recordId) {
10792
- const cached = this.recordLookupCache?.get(recordCacheKey(collection, recordId));
11218
+ async lookupRecordFields(collection, recordId, rows = null) {
11219
+ const cached = rows?.get(recordCacheKey(collection, recordId));
10793
11220
  if (cached !== void 0) return cached ?? void 0;
10794
11221
  try {
10795
- const rows = await this.store.queryCollection(collection, {
11222
+ const rows2 = await this.store.queryCollection(collection, {
10796
11223
  where: { id: recordId },
10797
11224
  includeDeleted: true,
10798
11225
  limit: 1
10799
11226
  });
10800
- return rows[0];
11227
+ return rows2[0];
10801
11228
  } catch {
10802
11229
  return void 0;
10803
11230
  }
10804
11231
  }
10805
- handleAwarenessUpdate(msg) {
10806
- this.onAwarenessUpdate?.(this.sessionId, msg);
11232
+ /**
11233
+ * Hand an awareness update to the server's relay, with the record its cursor
11234
+ * names, read from the store, so the relay can deliver it only to sessions whose
11235
+ * download scope contains that record (the rule of the Yjs doc channel).
11236
+ * Awareness is purely ephemeral: nothing is persisted.
11237
+ */
11238
+ async handleAwarenessUpdate(msg) {
11239
+ if (!this.onAwarenessUpdate) return;
11240
+ const state = msg.states[String(msg.clientId)];
11241
+ const cursor = state ? state.cursor : void 0;
11242
+ if (cursor === void 0 || cursor === null) {
11243
+ this.onAwarenessUpdate(this.sessionId, msg);
11244
+ return;
11245
+ }
11246
+ const collection = cursor.collection;
11247
+ const recordId = cursor.recordId;
11248
+ if (typeof cursor !== "object" || typeof collection !== "string" || typeof recordId !== "string" || !isStorableIdentifier(collection) || !isStorableIdentifier(recordId)) {
11249
+ this.onAwarenessUpdate(this.sessionId, msg, { invalid: true });
11250
+ return;
11251
+ }
11252
+ let read = this.presenceRecords.peek(collection, recordId);
11253
+ if (read === void 0) {
11254
+ if (!this.presenceLookupLimiter.allow(1)) {
11255
+ this.onAwarenessUpdate(this.sessionId, msg, { invalid: true });
11256
+ return;
11257
+ }
11258
+ try {
11259
+ read = await this.presenceRecords.read(collection, recordId);
11260
+ const key = presenceRecordKey(collection, recordId);
11261
+ for (let attempt = 0; attempt < MAX_PRESENCE_REREADS && this.presenceRecords.touchedSince(key, read.asOf); attempt++) {
11262
+ read = await this.presenceRecords.read(collection, recordId);
11263
+ }
11264
+ } catch {
11265
+ this.onAwarenessUpdate(this.sessionId, msg, { invalid: true });
11266
+ return;
11267
+ }
11268
+ }
11269
+ this.onAwarenessUpdate(this.sessionId, msg, {
11270
+ collection,
11271
+ recordId,
11272
+ stored: read.stored,
11273
+ asOf: read.asOf
11274
+ });
10807
11275
  }
10808
11276
  /**
10809
11277
  * A Yjs doc-channel update is a write to a richtext field: the sender must be
@@ -10897,10 +11365,13 @@ function operationIdentifiersStorable(op) {
10897
11365
  }
10898
11366
 
10899
11367
  // src/server/kora-sync-server.ts
10900
- import { KoraError as KoraError6, SyncError as SyncError4, generateUUIDv7 as generateUUIDv76, isBlobRef as isBlobRef2 } from "@korajs/core";
11368
+ import { KoraError as KoraError7, SyncError as SyncError4, generateUUIDv7 as generateUUIDv76, isBlobRef as isBlobRef2 } from "@korajs/core";
10901
11369
  import { SimpleEventEmitter } from "@korajs/core/internal";
10902
11370
  import { HTTP_SYNC_SESSION_HEADER, JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
10903
11371
 
11372
+ // package.json
11373
+ var version = "1.0.0-beta.14";
11374
+
10904
11375
  // src/awareness/awareness-relay.ts
10905
11376
  import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
10906
11377
  var DEFAULT_PARTITION = "*";
@@ -10911,29 +11382,36 @@ var AwarenessRelay = class {
10911
11382
  * calls for an already-registered session are ignored, so a client cannot rebind
10912
11383
  * itself to another client's id.
10913
11384
  *
11385
+ * The new client is sent every existing state it may see (catch-up): a state with
11386
+ * an audience when the audience admits the new session, otherwise a state of the
11387
+ * same partition.
11388
+ *
10914
11389
  * @param sessionId - Unique session identifier
10915
11390
  * @param clientId - Client-assigned awareness ID
10916
11391
  * @param transport - Transport for sending messages to this client
10917
- * @param partition - Presence partition (clients only see their own partition)
11392
+ * @param partition - Presence partition for states relayed without an audience
10918
11393
  */
10919
11394
  addClient(sessionId, clientId, transport, partition = DEFAULT_PARTITION) {
10920
11395
  if (this.clients.has(sessionId)) return;
10921
- this.clients.set(sessionId, {
11396
+ const joining = {
10922
11397
  sessionId,
10923
11398
  clientId,
10924
11399
  transport,
10925
11400
  state: null,
10926
- partition
10927
- });
11401
+ partition,
11402
+ audience: null,
11403
+ deliveredTo: /* @__PURE__ */ new Set()
11404
+ };
11405
+ this.clients.set(sessionId, joining);
10928
11406
  const existingStates = {};
10929
11407
  let hasStates = false;
10930
11408
  for (const [, client] of this.clients) {
10931
11409
  if (client.sessionId === sessionId) continue;
10932
- if (client.partition !== partition) continue;
10933
- if (client.state) {
10934
- existingStates[String(client.clientId)] = client.state;
10935
- hasStates = true;
10936
- }
11410
+ if (!client.state) continue;
11411
+ if (!this.mayReceive(client, joining)) continue;
11412
+ existingStates[String(client.clientId)] = client.state;
11413
+ client.deliveredTo.add(sessionId);
11414
+ hasStates = true;
10937
11415
  }
10938
11416
  if (hasStates) {
10939
11417
  const catchUpMsg = {
@@ -10955,7 +11433,7 @@ var AwarenessRelay = class {
10955
11433
  return this.clients.has(sessionId);
10956
11434
  }
10957
11435
  /**
10958
- * Remove a client and broadcast its removal to the remaining clients in its partition.
11436
+ * Remove a client and send its removal to every client that was shown its state.
10959
11437
  *
10960
11438
  * @param sessionId - Session ID of the disconnecting client
10961
11439
  */
@@ -10963,17 +11441,26 @@ var AwarenessRelay = class {
10963
11441
  const client = this.clients.get(sessionId);
10964
11442
  if (!client) return;
10965
11443
  this.clients.delete(sessionId);
10966
- if (client.state === null) return;
10967
- const removalStates = {
10968
- [String(client.clientId)]: null
10969
- };
10970
- const msg = {
10971
- type: "awareness-update",
10972
- messageId: generateUUIDv74(),
10973
- clientId: client.clientId,
10974
- states: removalStates
10975
- };
10976
- this.broadcastExcept(sessionId, client.partition, msg);
11444
+ for (const [, other] of this.clients) other.deliveredTo.delete(sessionId);
11445
+ if (client.deliveredTo.size === 0) return;
11446
+ const msg = this.removalMessage(client);
11447
+ for (const targetId of client.deliveredTo) {
11448
+ const target = this.clients.get(targetId);
11449
+ if (target?.transport.isConnected()) target.transport.send(msg);
11450
+ }
11451
+ client.deliveredTo.clear();
11452
+ }
11453
+ /**
11454
+ * True when {@link handleUpdate} would take this update: the session is registered,
11455
+ * the update is stamped with its bound `clientId` and carries that client's entry.
11456
+ *
11457
+ * @param sessionId - Session ID of the sending client
11458
+ * @param message - The awareness update message
11459
+ */
11460
+ accepts(sessionId, message) {
11461
+ const sender = this.clients.get(sessionId);
11462
+ if (!sender || message.clientId !== sender.clientId) return false;
11463
+ return String(sender.clientId) in message.states;
10977
11464
  }
10978
11465
  /**
10979
11466
  * Handle an incoming awareness update from a registered client.
@@ -10981,12 +11468,17 @@ var AwarenessRelay = class {
10981
11468
  * Only the sender's own entry is accepted: the message must be stamped with the
10982
11469
  * sender's bound `clientId`, and only `states[clientId]` is stored and relayed.
10983
11470
  * Entries for other clients are dropped, so a sender cannot overwrite or remove
10984
- * anyone else's presence. The sanitized update reaches only the sender's partition.
11471
+ * anyone else's presence.
11472
+ *
11473
+ * The state is sent to every other client the audience admits (without an
11474
+ * audience: every client of the sender's partition). A client that was shown an
11475
+ * earlier state of the sender but is not admitted now receives a removal.
10985
11476
  *
10986
11477
  * @param sessionId - Session ID of the sending client
10987
11478
  * @param message - The awareness update message
11479
+ * @param audience - Who may see this state; omitted means the sender's partition
10988
11480
  */
10989
- handleUpdate(sessionId, message) {
11481
+ handleUpdate(sessionId, message, audience) {
10990
11482
  const sender = this.clients.get(sessionId);
10991
11483
  if (!sender) return;
10992
11484
  if (message.clientId !== sender.clientId) return;
@@ -10994,13 +11486,66 @@ var AwarenessRelay = class {
10994
11486
  if (!(key in message.states)) return;
10995
11487
  const senderState = message.states[key] ?? null;
10996
11488
  sender.state = senderState;
11489
+ sender.audience = audience ?? null;
10997
11490
  const sanitized = {
10998
11491
  type: "awareness-update",
10999
11492
  messageId: message.messageId,
11000
11493
  clientId: sender.clientId,
11001
11494
  states: { [key]: senderState }
11002
11495
  };
11003
- this.broadcastExcept(sessionId, sender.partition, sanitized);
11496
+ let removal = null;
11497
+ for (const [, client] of this.clients) {
11498
+ if (client.sessionId === sessionId) continue;
11499
+ const visible = senderState !== null && this.mayReceive(sender, client);
11500
+ if (visible) {
11501
+ if (!client.transport.isConnected()) continue;
11502
+ client.transport.send(sanitized);
11503
+ sender.deliveredTo.add(client.sessionId);
11504
+ } else if (sender.deliveredTo.delete(client.sessionId)) {
11505
+ if (!client.transport.isConnected()) continue;
11506
+ removal ??= senderState === null ? sanitized : this.removalMessage(sender);
11507
+ client.transport.send(removal);
11508
+ }
11509
+ }
11510
+ }
11511
+ /**
11512
+ * Re-decide who may see a client's current state after the record its cursor names
11513
+ * changed (F16): it moved into or out of a session's grant. Sessions that may no
11514
+ * longer see the state get a removal; sessions that may see it now and were not
11515
+ * shown it get the state. Later catch-ups use the new audience.
11516
+ *
11517
+ * @param sessionId - The client whose audience changed
11518
+ * @param audience - The new audience
11519
+ */
11520
+ updateAudience(sessionId, audience) {
11521
+ const sender = this.clients.get(sessionId);
11522
+ if (!sender) return;
11523
+ sender.audience = audience;
11524
+ if (sender.state === null) return;
11525
+ const key = String(sender.clientId);
11526
+ let current = null;
11527
+ let removal = null;
11528
+ for (const [, client] of this.clients) {
11529
+ if (client.sessionId === sessionId) continue;
11530
+ const visible = this.mayReceive(sender, client);
11531
+ const shown = sender.deliveredTo.has(client.sessionId);
11532
+ if (visible && !shown) {
11533
+ if (!client.transport.isConnected()) continue;
11534
+ current ??= {
11535
+ type: "awareness-update",
11536
+ messageId: generateUUIDv74(),
11537
+ clientId: sender.clientId,
11538
+ states: { [key]: sender.state }
11539
+ };
11540
+ client.transport.send(current);
11541
+ sender.deliveredTo.add(client.sessionId);
11542
+ } else if (!visible && shown) {
11543
+ sender.deliveredTo.delete(client.sessionId);
11544
+ if (!client.transport.isConnected()) continue;
11545
+ removal ??= this.removalMessage(sender);
11546
+ client.transport.send(removal);
11547
+ }
11548
+ }
11004
11549
  }
11005
11550
  /**
11006
11551
  * Get the number of registered awareness clients.
@@ -11015,14 +11560,23 @@ var AwarenessRelay = class {
11015
11560
  this.clients.clear();
11016
11561
  }
11017
11562
  // --- Private ---
11018
- broadcastExcept(excludeSessionId, partition, message) {
11019
- for (const [, client] of this.clients) {
11020
- if (client.sessionId === excludeSessionId) continue;
11021
- if (client.partition !== partition) continue;
11022
- if (!client.transport.isConnected()) continue;
11023
- client.transport.send(message);
11563
+ /** True when `target` may see `source`'s current state. Fails closed. */
11564
+ mayReceive(source, target) {
11565
+ if (source.audience === null) return source.partition === target.partition;
11566
+ try {
11567
+ return source.audience(target.sessionId);
11568
+ } catch {
11569
+ return false;
11024
11570
  }
11025
11571
  }
11572
+ removalMessage(client) {
11573
+ return {
11574
+ type: "awareness-update",
11575
+ messageId: generateUUIDv74(),
11576
+ clientId: client.clientId,
11577
+ states: { [String(client.clientId)]: null }
11578
+ };
11579
+ }
11026
11580
  };
11027
11581
 
11028
11582
  // src/diagnostics/server-metrics-collector.ts
@@ -11136,8 +11690,8 @@ var ServerMetricsCollector = class {
11136
11690
  this.errorCount++;
11137
11691
  }
11138
11692
  /** Set the schema version. */
11139
- setSchemaVersion(version) {
11140
- this.schemaVersion = version;
11693
+ setSchemaVersion(version2) {
11694
+ this.schemaVersion = version2;
11141
11695
  }
11142
11696
  /** Return a full snapshot of current metrics. */
11143
11697
  getSnapshot(totalOperations) {
@@ -11951,6 +12505,18 @@ var KoraSyncServer = class _KoraSyncServer {
11951
12505
  logger;
11952
12506
  metrics;
11953
12507
  awarenessRelay = new AwarenessRelay();
12508
+ /** Reads the records presence cursors name, for every session (F16). */
12509
+ presenceRecords;
12510
+ /** The record each session's current presence cursor names (F16). */
12511
+ presenceTargets = /* @__PURE__ */ new Map();
12512
+ /** The sessions whose presence cursor names each record, by record key. */
12513
+ presenceTargetsByKey = /* @__PURE__ */ new Map();
12514
+ /**
12515
+ * Audience re-decisions running, one per record: a write while one runs marks it
12516
+ * dirty, so it reads again and the last decision applied is always from a read that
12517
+ * began after the last write (never an older read finishing late).
12518
+ */
12519
+ presenceRefreshes = /* @__PURE__ */ new Map();
11954
12520
  yjsDocRelay = new YjsDocRelay();
11955
12521
  blobChunkRelay;
11956
12522
  /** Wrapped encryption key records (ENC-1, D4b), persisted in the server store. */
@@ -11963,8 +12529,11 @@ var KoraSyncServer = class _KoraSyncServer {
11963
12529
  /** Per-principal ingest budget per minute; 0 when disabled. */
11964
12530
  maxOpsPerMinutePerUser;
11965
12531
  allowLegacyAnonymousClaims;
12532
+ deviceNodeHandover;
12533
+ unscopedSharing;
11966
12534
  anonymousClaimTtlMs;
11967
12535
  maxOpsPerBatch;
12536
+ maxScopePredicateValues;
11968
12537
  maxMessageBytes;
11969
12538
  heartbeatIntervalMs;
11970
12539
  appHeartbeatIntervalMs;
@@ -11996,9 +12565,10 @@ var KoraSyncServer = class _KoraSyncServer {
11996
12565
  /** Internal ClientSession id -> HTTP session id, for cleanup on close. */
11997
12566
  httpSessionIdBySession = /* @__PURE__ */ new Map();
11998
12567
  httpSessionIdleTimeoutMs;
11999
- // Informational value reported by getStatus(). Keep in sync with the
12000
- // @korajs/server package version on release; it is not used for protocol negotiation.
12001
- serverVersion = "1.0.0-beta.0";
12568
+ // The @korajs/server package version, reported by getStatus() and /health so
12569
+ // operators can tell which release is deployed. Read from package.json (inlined by
12570
+ // the build), never hand-maintained. Not used for protocol negotiation.
12571
+ serverVersion = version;
12002
12572
  wsServer = null;
12003
12573
  running = false;
12004
12574
  /**
@@ -12031,6 +12601,7 @@ var KoraSyncServer = class _KoraSyncServer {
12031
12601
  static ORPHANED_RELAY_TTL_MS = 5 * 6e4;
12032
12602
  constructor(config) {
12033
12603
  this.store = config.store;
12604
+ this.presenceRecords = new PresenceRecords(config.store);
12034
12605
  const storeSchemaVersion = this.store.getSchema()?.version;
12035
12606
  if (config.schemaVersion !== void 0 && storeSchemaVersion !== void 0 && config.schemaVersion !== storeSchemaVersion) {
12036
12607
  throw new SyncError4("Sync schema version does not match the configured store schema.", {
@@ -12124,6 +12695,8 @@ var KoraSyncServer = class _KoraSyncServer {
12124
12695
  }
12125
12696
  this.maxOpsPerMinutePerUser = config.maxOpsPerMinutePerUser ?? (config.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE) * DEFAULT_USER_BUDGET_MULTIPLIER;
12126
12697
  this.allowLegacyAnonymousClaims = config.allowLegacyAnonymousClaims;
12698
+ this.deviceNodeHandover = config.deviceNodeHandover;
12699
+ this.unscopedSharing = config.unscopedSharing;
12127
12700
  this.anonymousClaimTtlMs = config.anonymousClaimTtlMs;
12128
12701
  if (config.maxOpsPerBatch !== void 0 && (!Number.isInteger(config.maxOpsPerBatch) || config.maxOpsPerBatch < 1)) {
12129
12702
  throw new SyncError4("maxOpsPerBatch must be a positive integer", {
@@ -12131,6 +12704,12 @@ var KoraSyncServer = class _KoraSyncServer {
12131
12704
  });
12132
12705
  }
12133
12706
  this.maxOpsPerBatch = config.maxOpsPerBatch;
12707
+ if (config.maxScopePredicateValues !== void 0 && (!Number.isInteger(config.maxScopePredicateValues) || config.maxScopePredicateValues < 1)) {
12708
+ throw new SyncError4("maxScopePredicateValues must be a positive integer", {
12709
+ maxScopePredicateValues: config.maxScopePredicateValues
12710
+ });
12711
+ }
12712
+ this.maxScopePredicateValues = config.maxScopePredicateValues;
12134
12713
  this.validateOperation = config.validateOperation;
12135
12714
  this.koraContext = createRouteContext(this, this.store);
12136
12715
  if (!this.emitter) {
@@ -12175,7 +12754,7 @@ var KoraSyncServer = class _KoraSyncServer {
12175
12754
  */
12176
12755
  terminateSessions(filter) {
12177
12756
  if (filter.userId === void 0 && filter.deviceId === void 0) {
12178
- throw new KoraError6(
12757
+ throw new KoraError7(
12179
12758
  "terminateSessions needs a userId or a deviceId; refusing to end every session.",
12180
12759
  "INVALID_TERMINATE_FILTER",
12181
12760
  { fix: "Pass { userId } to end a user, or { userId, deviceId } to end one device." }
@@ -12313,6 +12892,65 @@ var KoraSyncServer = class _KoraSyncServer {
12313
12892
  });
12314
12893
  return run;
12315
12894
  }
12895
+ /**
12896
+ * Re-resolve one user's grant on every live session of that user now, instead of
12897
+ * waiting for the next revalidation pass (`sessionRevalidationIntervalMs`). Call it
12898
+ * after a membership change the auth provider's grant depends on: an invitation
12899
+ * accepted, a collaborator removed, a role changed.
12900
+ *
12901
+ * Each of the user's sessions re-authenticates its credential with the provider. A
12902
+ * session whose resolved download or upload scope changed ends with a retriable
12903
+ * `SCOPE_CHANGED`; its client reconnects at once and receives the new grant. A
12904
+ * narrowed grant applies the client's `scopeExit` policy at that handshake
12905
+ * (`'retract'` hides the rows that left the scope), and the device's unsynced writes
12906
+ * outside the new upload scope are refused. A session still in its handshake
12907
+ * re-checks as soon as it is established, so a grant read before the change never
12908
+ * outlives this call. Sessions whose grant did not change are kept.
12909
+ *
12910
+ * Only this process's sessions are refreshed. Other instances apply the change at
12911
+ * their next revalidation pass, or call this too (for example from a pub/sub
12912
+ * message).
12913
+ *
12914
+ * @param userId - The user whose grant changed (the auth provider's `userId`)
12915
+ * @returns The number of sessions this call ended
12916
+ *
12917
+ * @example
12918
+ * ```typescript
12919
+ * await removeCollaborator(documentId, bobId)
12920
+ * await server.refreshScopes(bobId)
12921
+ * ```
12922
+ */
12923
+ async refreshScopes(userId) {
12924
+ if (typeof userId !== "string" || userId.length === 0) {
12925
+ throw new KoraError7(
12926
+ "refreshScopes needs the user id whose grant changed.",
12927
+ "INVALID_REFRESH_SCOPES_USER",
12928
+ { fix: "Pass the userId your auth provider returns for that user." }
12929
+ );
12930
+ }
12931
+ let terminated = 0;
12932
+ for (const session of [...this.sessions.values()]) {
12933
+ const outcome = await session.refreshScopes(userId);
12934
+ if (outcome === "terminated") terminated++;
12935
+ if (outcome === "error") {
12936
+ this.logger.log({
12937
+ timestamp: Date.now(),
12938
+ level: "warn",
12939
+ event: "session.revalidation_failed",
12940
+ sessionId: session.getSessionId(),
12941
+ details: { reason: "refreshScopes", userId }
12942
+ });
12943
+ }
12944
+ }
12945
+ this.logger.log({
12946
+ timestamp: Date.now(),
12947
+ level: "info",
12948
+ event: "sessions.scopes_refreshed",
12949
+ count: terminated,
12950
+ details: { userId }
12951
+ });
12952
+ return terminated;
12953
+ }
12316
12954
  /** Run {@link revalidateSessions} from the delivery poll tick once the interval elapsed. */
12317
12955
  maybeRevalidateSessions(now = Date.now()) {
12318
12956
  if (this.sessionRevalidationIntervalMs <= 0) return;
@@ -12418,13 +13056,17 @@ var KoraSyncServer = class _KoraSyncServer {
12418
13056
  const MAX_SCANNED = 1e3;
12419
13057
  if (to - from > MAX_SCANNED) {
12420
13058
  this.blobAccess.invalidate();
13059
+ this.refreshPresence(null);
12421
13060
  return;
12422
13061
  }
12423
13062
  try {
12424
13063
  const delivered = await this.store.getOperationsAfterDelivery(from, MAX_SCANNED);
12425
- this.blobAccess.invalidate(collectionsOf(delivered.map((d) => d.operation)));
13064
+ const operations2 = delivered.map((d) => d.operation);
13065
+ this.blobAccess.invalidate(collectionsOf(operations2));
13066
+ this.refreshPresence(operations2);
12426
13067
  } catch {
12427
13068
  this.blobAccess.invalidate();
13069
+ this.refreshPresence(null);
12428
13070
  }
12429
13071
  }
12430
13072
  /**
@@ -12640,6 +13282,10 @@ var KoraSyncServer = class _KoraSyncServer {
12640
13282
  this.revocationUnsubscribe?.();
12641
13283
  this.revocationUnsubscribe = null;
12642
13284
  this.awarenessRelay.clear();
13285
+ this.presenceTargets.clear();
13286
+ this.presenceTargetsByKey.clear();
13287
+ this.presenceRefreshes.clear();
13288
+ this.presenceRecords.clear();
12643
13289
  this.yjsDocRelay.clear();
12644
13290
  this.blobChunkRelay.clear();
12645
13291
  for (const session of this.sessions.values()) {
@@ -12904,9 +13550,10 @@ var KoraSyncServer = class _KoraSyncServer {
12904
13550
  onRelay: (sourceSessionId, operations2) => {
12905
13551
  this.handleRelay(sourceSessionId, operations2);
12906
13552
  },
12907
- onAwarenessUpdate: (sourceSessionId, message) => {
12908
- this.handleAwarenessRelay(sourceSessionId, message);
13553
+ onAwarenessUpdate: (sourceSessionId, message, cursorTarget) => {
13554
+ this.handleAwarenessRelay(sourceSessionId, message, cursorTarget);
12909
13555
  },
13556
+ presenceRecords: this.presenceRecords,
12910
13557
  onYjsDocUpdate: (sourceSessionId, message, storedRecord) => {
12911
13558
  this.handleYjsDocRelay(sourceSessionId, message, storedRecord);
12912
13559
  },
@@ -12939,8 +13586,11 @@ var KoraSyncServer = class _KoraSyncServer {
12939
13586
  ...this.maxOpsPerMinute !== void 0 ? { maxOpsPerMinute: this.maxOpsPerMinute } : {},
12940
13587
  ...this.blobLimits.maxRequestsPerMinute !== void 0 ? { maxBlobRequestsPerMinute: this.blobLimits.maxRequestsPerMinute } : {},
12941
13588
  ...this.maxOpsPerBatch !== void 0 ? { maxOpsPerBatch: this.maxOpsPerBatch } : {},
13589
+ ...this.maxScopePredicateValues !== void 0 ? { maxScopePredicateValues: this.maxScopePredicateValues } : {},
12942
13590
  ...this.validateOperation ? { validateOperation: this.validateOperation, koraContext: this.koraContext } : {},
12943
13591
  blobAccess: this.blobAccess,
13592
+ ...this.deviceNodeHandover !== void 0 ? { deviceNodeHandover: this.deviceNodeHandover } : {},
13593
+ ...this.unscopedSharing !== void 0 ? { unscopedSharing: this.unscopedSharing } : {},
12944
13594
  ...this.allowLegacyAnonymousClaims !== void 0 ? { allowLegacyAnonymousClaims: this.allowLegacyAnonymousClaims } : {},
12945
13595
  ...this.anonymousClaimTtlMs !== void 0 ? { anonymousClaimTtlMs: this.anonymousClaimTtlMs } : {},
12946
13596
  isNodeLive: (nodeId, exceptSessionId) => this.isNodeLive(nodeId, exceptSessionId),
@@ -13010,6 +13660,7 @@ var KoraSyncServer = class _KoraSyncServer {
13010
13660
  const result = await applyServerOperation(this.store, op, void 0, options);
13011
13661
  if (result.result === "applied" && result.appliedOperations.length > 0) {
13012
13662
  this.blobAccess.invalidate(collectionsOf(result.appliedOperations));
13663
+ this.refreshPresence(result.appliedOperations);
13013
13664
  for (const session of this.sessions.values()) {
13014
13665
  session.relayOperations(result.appliedOperations);
13015
13666
  }
@@ -13029,6 +13680,7 @@ var KoraSyncServer = class _KoraSyncServer {
13029
13680
  return;
13030
13681
  }
13031
13682
  this.blobAccess.invalidate(collectionsOf(operations2));
13683
+ this.refreshPresence(operations2);
13032
13684
  for (const session of this.sessions.values()) {
13033
13685
  session.relayOperations(operations2);
13034
13686
  }
@@ -13125,6 +13777,7 @@ var KoraSyncServer = class _KoraSyncServer {
13125
13777
  }
13126
13778
  handleRelay(sourceSessionId, operations2) {
13127
13779
  this.blobAccess.invalidate(collectionsOf(operations2));
13780
+ this.refreshPresence(operations2);
13128
13781
  const targetCount = this.sessions.size - 1;
13129
13782
  const byteSize = estimateOperationByteSize(operations2);
13130
13783
  this.metrics.recordSent(
@@ -13149,6 +13802,7 @@ var KoraSyncServer = class _KoraSyncServer {
13149
13802
  handleSessionClose(sessionId) {
13150
13803
  this.metrics.recordDisconnection(sessionId);
13151
13804
  this.awarenessRelay.removeClient(sessionId);
13805
+ this.setPresenceTarget(sessionId, null);
13152
13806
  this.yjsDocRelay.removeClient(sessionId);
13153
13807
  this.blobChunkRelay.removeClient(sessionId);
13154
13808
  this.sessions.delete(sessionId);
@@ -13160,7 +13814,7 @@ var KoraSyncServer = class _KoraSyncServer {
13160
13814
  }
13161
13815
  }
13162
13816
  }
13163
- handleAwarenessRelay(sourceSessionId, message) {
13817
+ handleAwarenessRelay(sourceSessionId, message, cursorTarget) {
13164
13818
  const session = this.sessions.get(sourceSessionId);
13165
13819
  if (!session || !session.isStreaming()) return;
13166
13820
  if (!this.awarenessRelay.hasClient(sourceSessionId)) {
@@ -13168,10 +13822,143 @@ var KoraSyncServer = class _KoraSyncServer {
13168
13822
  sourceSessionId,
13169
13823
  message.clientId,
13170
13824
  session.getTransport(),
13171
- session.getScopePartitionKey()
13825
+ session.getPresencePartitionKey()
13172
13826
  );
13173
13827
  }
13174
- this.awarenessRelay.handleUpdate(sourceSessionId, message);
13828
+ if (!this.awarenessRelay.accepts(sourceSessionId, message)) return;
13829
+ let audience = this.awarenessAudience(session, cursorTarget);
13830
+ if (cursorTarget !== void 0 && cursorTarget.invalid !== true) {
13831
+ const key = presenceRecordKey(cursorTarget.collection, cursorTarget.recordId);
13832
+ this.setPresenceTarget(sourceSessionId, {
13833
+ collection: cursorTarget.collection,
13834
+ recordId: cursorTarget.recordId,
13835
+ key
13836
+ });
13837
+ if (cursorTarget.asOf !== void 0 && this.presenceRecords.touchedSince(key, cursorTarget.asOf)) {
13838
+ audience = () => false;
13839
+ this.schedulePresenceRefresh(key);
13840
+ }
13841
+ } else {
13842
+ this.setPresenceTarget(sourceSessionId, null);
13843
+ }
13844
+ this.awarenessRelay.handleUpdate(sourceSessionId, message, audience);
13845
+ }
13846
+ /** Point a session's presence at a record (or at none), keeping the index in step. */
13847
+ setPresenceTarget(sessionId, target) {
13848
+ const previous = this.presenceTargets.get(sessionId);
13849
+ if (previous) {
13850
+ const sessions2 = this.presenceTargetsByKey.get(previous.key);
13851
+ sessions2?.delete(sessionId);
13852
+ if (sessions2?.size === 0) this.presenceTargetsByKey.delete(previous.key);
13853
+ }
13854
+ if (target === null) {
13855
+ this.presenceTargets.delete(sessionId);
13856
+ return;
13857
+ }
13858
+ this.presenceTargets.set(sessionId, target);
13859
+ let sessions = this.presenceTargetsByKey.get(target.key);
13860
+ if (!sessions) {
13861
+ sessions = /* @__PURE__ */ new Set();
13862
+ this.presenceTargetsByKey.set(target.key, sessions);
13863
+ }
13864
+ sessions.add(sessionId);
13865
+ }
13866
+ /**
13867
+ * Records changed (`operations`, or anything when null): re-decide the audience of
13868
+ * every presence cursor on one of them (F16). A record that moved out of a session's
13869
+ * grant takes the presence on it out of that session, and one that moved in brings
13870
+ * it, without waiting for the sender's next update; catch-ups use the new audience.
13871
+ * Every write is noted first, so a presence read that began before it is never
13872
+ * taken as current.
13873
+ */
13874
+ refreshPresence(operations2) {
13875
+ if (operations2 === null) {
13876
+ this.presenceRecords.touch(null);
13877
+ for (const key of this.presenceTargetsByKey.keys()) this.schedulePresenceRefresh(key);
13878
+ return;
13879
+ }
13880
+ const keys = new Set(operations2.map((op) => presenceRecordKey(op.collection, op.recordId)));
13881
+ this.presenceRecords.touch(keys);
13882
+ for (const key of keys) {
13883
+ if (this.presenceTargetsByKey.has(key)) this.schedulePresenceRefresh(key);
13884
+ }
13885
+ }
13886
+ /** Re-decide the presence on one record, coalesced with a re-decision already running. */
13887
+ schedulePresenceRefresh(key) {
13888
+ const running = this.presenceRefreshes.get(key);
13889
+ if (running) {
13890
+ running.dirty = true;
13891
+ return;
13892
+ }
13893
+ const state = { dirty: true };
13894
+ this.presenceRefreshes.set(key, state);
13895
+ void this.runPresenceRefresh(key, state);
13896
+ }
13897
+ async runPresenceRefresh(key, state) {
13898
+ let staleReads = 0;
13899
+ try {
13900
+ while (state.dirty) {
13901
+ state.dirty = false;
13902
+ const sessions = this.presenceTargetsByKey.get(key);
13903
+ const first = sessions?.values().next();
13904
+ const target = first && !first.done ? this.presenceTargets.get(first.value) : void 0;
13905
+ if (!target) return;
13906
+ let stored;
13907
+ try {
13908
+ const read = await this.presenceRecords.read(target.collection, target.recordId);
13909
+ if (this.presenceRecords.touchedSince(key, read.asOf)) {
13910
+ state.dirty = true;
13911
+ if (staleReads < 2) {
13912
+ staleReads += 1;
13913
+ continue;
13914
+ }
13915
+ stored = void 0;
13916
+ } else {
13917
+ stored = read.stored;
13918
+ }
13919
+ } catch {
13920
+ stored = void 0;
13921
+ }
13922
+ staleReads = 0;
13923
+ for (const sessionId of this.presenceTargetsByKey.get(key) ?? []) {
13924
+ const session = this.sessions.get(sessionId);
13925
+ if (!session) continue;
13926
+ this.awarenessRelay.updateAudience(
13927
+ sessionId,
13928
+ stored === void 0 ? () => false : this.awarenessAudience(session, {
13929
+ collection: target.collection,
13930
+ recordId: target.recordId,
13931
+ stored
13932
+ })
13933
+ );
13934
+ }
13935
+ }
13936
+ } finally {
13937
+ if (this.presenceRefreshes.get(key) === state) this.presenceRefreshes.delete(key);
13938
+ }
13939
+ }
13940
+ /**
13941
+ * Who may see a presence state (F16). A state whose cursor names a record reaches
13942
+ * exactly the sessions whose download scope contains that record, the rule of the
13943
+ * Yjs doc channel, and only when the sender may read the record too: collaborators
13944
+ * with different grants see each other on the documents they share, and nobody
13945
+ * sees presence on a record outside their grant. A malformed cursor reaches
13946
+ * nobody. A state without a cursor names no record: it reaches only sessions of
13947
+ * the same presence partition (identical download scope; anonymous sessions are
13948
+ * each their own partition).
13949
+ */
13950
+ awarenessAudience(source, cursorTarget) {
13951
+ if (cursorTarget === void 0) {
13952
+ const partition = source.getPresencePartitionKey();
13953
+ return (targetSessionId) => {
13954
+ const target = this.sessions.get(targetSessionId);
13955
+ return target?.isStreaming() === true && target.getPresencePartitionKey() === partition;
13956
+ };
13957
+ }
13958
+ if (cursorTarget.invalid === true) return () => false;
13959
+ const { collection, recordId, stored } = cursorTarget;
13960
+ if (!source.canReceiveRecord(collection, recordId, stored)) return () => false;
13961
+ return (targetSessionId) => this.sessions.get(targetSessionId)?.canReceiveRecord(collection, recordId, stored) ?? false;
13175
13962
  }
13176
13963
  handleYjsDocRelay(sourceSessionId, message, storedRecord) {
13177
13964
  if (!this.sessions.has(sourceSessionId)) {
@@ -13453,16 +14240,22 @@ function fileVersionKey(stats) {
13453
14240
  return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`;
13454
14241
  }
13455
14242
  var DIGEST_CACHE_MAX_ENTRIES = 4096;
13456
- function createStaticFileHandler(staticDir) {
14243
+ function createStaticFileHandler(staticDir, options = {}) {
13457
14244
  const root = resolve(staticDir);
14245
+ const spaFallback = options.spaFallback ?? "navigation";
14246
+ const servesShell = (req, pathname) => {
14247
+ if (pathname.startsWith(ASSET_PREFIX)) return false;
14248
+ if (isNavigation(req)) return true;
14249
+ return spaFallback === "extensionless" && extname(pathname) === "";
14250
+ };
13458
14251
  const compressed = /* @__PURE__ */ new Map();
13459
14252
  let compressedBytes = 0;
13460
14253
  const digests = /* @__PURE__ */ new Map();
13461
14254
  const verifiedSiblings = /* @__PURE__ */ new Map();
13462
14255
  async function contentDigest(path, stats) {
13463
- const version = fileVersionKey(stats);
14256
+ const version2 = fileVersionKey(stats);
13464
14257
  const cached = digests.get(path);
13465
- if (cached && cached.version === version) return cached.digest;
14258
+ if (cached && cached.version === version2) return cached.digest;
13466
14259
  const hash = createHash("sha256");
13467
14260
  await new Promise((done, fail) => {
13468
14261
  const stream = createReadStream(path);
@@ -13476,7 +14269,7 @@ function createStaticFileHandler(staticDir) {
13476
14269
  const oldest = digests.keys().next().value;
13477
14270
  if (oldest !== void 0) digests.delete(oldest);
13478
14271
  }
13479
- digests.set(path, { version, digest });
14272
+ digests.set(path, { version: version2, digest });
13480
14273
  return digest;
13481
14274
  }
13482
14275
  function remember(key, body) {
@@ -13542,7 +14335,7 @@ function createStaticFileHandler(staticDir) {
13542
14335
  return;
13543
14336
  }
13544
14337
  let found = await locate(pathname);
13545
- if (!found && isNavigation(req) && !pathname.startsWith(ASSET_PREFIX)) {
14338
+ if (!found && servesShell(req, pathname)) {
13546
14339
  found = await locate("/index.html");
13547
14340
  }
13548
14341
  if (!found) {
@@ -13758,6 +14551,11 @@ function parseIpv6(address) {
13758
14551
 
13759
14552
  // src/server/production-server.ts
13760
14553
  var DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
14554
+ var OPERATIONAL_PATHS = {
14555
+ admin: "/__kora, /__kora/status, /__kora/events",
14556
+ metrics: "/__kora/metrics",
14557
+ backup: "/__kora/backup/*"
14558
+ };
13761
14559
  var DEFAULT_MAX_BACKUP_BYTES = 256 * 1024 * 1024;
13762
14560
  function createProductionServer(config) {
13763
14561
  const port = config.port ?? (Number(process.env.PORT) || 3001);
@@ -13793,10 +14591,27 @@ function createProductionServer(config) {
13793
14591
  }
13794
14592
  return null;
13795
14593
  }
13796
- function isOperationalRequestAllowed(req, kind) {
14594
+ const production = process.env.NODE_ENV === "production";
14595
+ const allowPublic = config.operationalAuth?.allowPublic === true;
14596
+ const OPERATIONAL_GROUPS = ["admin", "metrics", "backup"];
14597
+ const unprotectedGroups = OPERATIONAL_GROUPS.filter((kind) => !getOperationalToken(kind));
14598
+ function isOperationalRequestAllowed(req, res, kind) {
13797
14599
  const expected = getOperationalToken(kind);
13798
- if (!expected) return true;
13799
- return extractRequestToken(req) === expected;
14600
+ if (!expected) {
14601
+ if (!production || allowPublic) return true;
14602
+ res.writeHead(403, { "Content-Type": "application/json" });
14603
+ res.end(
14604
+ JSON.stringify({
14605
+ error: `This operational endpoint is disabled: no ${kind === "admin" ? "adminToken" : `${kind}Token or adminToken`} is configured and NODE_ENV is production.`,
14606
+ code: "OPERATIONAL_ENDPOINT_DISABLED",
14607
+ fix: "Set operationalAuth tokens in createProductionServer, or operationalAuth.allowPublic: true to serve it without one."
14608
+ })
14609
+ );
14610
+ return false;
14611
+ }
14612
+ if (extractRequestToken(req) === expected) return true;
14613
+ rejectUnauthorized(res);
14614
+ return false;
13800
14615
  }
13801
14616
  function rejectUnauthorized(res) {
13802
14617
  res.writeHead(401, {
@@ -13955,10 +14770,31 @@ function createProductionServer(config) {
13955
14770
  getLiveBlobRefs() {
13956
14771
  return syncServer.getLiveBlobRefs();
13957
14772
  },
14773
+ refreshScopes(userId) {
14774
+ return syncServer.refreshScopes(userId);
14775
+ },
14776
+ revalidateSessions() {
14777
+ return syncServer.revalidateSessions();
14778
+ },
13958
14779
  async start() {
14780
+ if (unprotectedGroups.length > 0) {
14781
+ const paths = unprotectedGroups.map((kind) => OPERATIONAL_PATHS[kind]).join(", ");
14782
+ syncServer.getLogger().log({
14783
+ timestamp: Date.now(),
14784
+ level: "warn",
14785
+ event: "server.operational_endpoints_unprotected",
14786
+ details: {
14787
+ groups: unprotectedGroups,
14788
+ disabled: production && !allowPublic,
14789
+ message: production && !allowPublic ? `Operational endpoints without a token are disabled (${paths}). Set operationalAuth.adminToken and backupToken to use them.` : `Operational endpoints without a token are PUBLIC (${paths}). Set operationalAuth.adminToken and backupToken before exposing this server.`
14790
+ }
14791
+ });
14792
+ }
13959
14793
  const { createServer } = await import("http");
13960
14794
  const { WebSocketServer } = await import("ws");
13961
- const serveStatic = createStaticFileHandler(staticDir);
14795
+ const serveStatic = createStaticFileHandler(staticDir, {
14796
+ ...config.spaFallback ? { spaFallback: config.spaFallback } : {}
14797
+ });
13962
14798
  httpServer = createServer(async (req, res) => {
13963
14799
  try {
13964
14800
  await handleRequest(req, res);
@@ -13992,29 +14828,20 @@ function createProductionServer(config) {
13992
14828
  return;
13993
14829
  }
13994
14830
  if (url.pathname === "/__kora/status") {
13995
- if (!isOperationalRequestAllowed(req, "admin")) {
13996
- rejectUnauthorized(res);
13997
- return;
13998
- }
14831
+ if (!isOperationalRequestAllowed(req, res, "admin")) return;
13999
14832
  const status = await syncServer.getStatus();
14000
14833
  res.writeHead(200, { "Content-Type": "application/json" });
14001
14834
  res.end(JSON.stringify(status, null, 2));
14002
14835
  return;
14003
14836
  }
14004
14837
  if (url.pathname === "/__kora/metrics") {
14005
- if (!isOperationalRequestAllowed(req, "metrics")) {
14006
- rejectUnauthorized(res);
14007
- return;
14008
- }
14838
+ if (!isOperationalRequestAllowed(req, res, "metrics")) return;
14009
14839
  res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
14010
14840
  res.end(formatPrometheusMetrics());
14011
14841
  return;
14012
14842
  }
14013
14843
  if (url.pathname === "/__kora/events") {
14014
- if (!isOperationalRequestAllowed(req, "admin")) {
14015
- rejectUnauthorized(res);
14016
- return;
14017
- }
14844
+ if (!isOperationalRequestAllowed(req, res, "admin")) return;
14018
14845
  res.writeHead(200, {
14019
14846
  "Content-Type": "text/event-stream",
14020
14847
  "Cache-Control": "no-cache",
@@ -14042,20 +14869,14 @@ data: ${JSON.stringify(s)}
14042
14869
  return;
14043
14870
  }
14044
14871
  if (url.pathname === "/__kora" || url.pathname === "/__kora/") {
14045
- if (!isOperationalRequestAllowed(req, "admin")) {
14046
- rejectUnauthorized(res);
14047
- return;
14048
- }
14872
+ if (!isOperationalRequestAllowed(req, res, "admin")) return;
14049
14873
  const status = await syncServer.getStatus();
14050
14874
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
14051
14875
  res.end(renderDashboardHtml(status));
14052
14876
  return;
14053
14877
  }
14054
14878
  if (url.pathname === "/__kora/backup/export" && req.method === "POST") {
14055
- if (!isOperationalRequestAllowed(req, "backup")) {
14056
- rejectUnauthorized(res);
14057
- return;
14058
- }
14879
+ if (!isOperationalRequestAllowed(req, res, "backup")) return;
14059
14880
  try {
14060
14881
  const backup = await config.store.exportBackup();
14061
14882
  res.writeHead(200, {
@@ -14071,10 +14892,7 @@ data: ${JSON.stringify(s)}
14071
14892
  return;
14072
14893
  }
14073
14894
  if (url.pathname === "/__kora/backup/import" && req.method === "POST") {
14074
- if (!isOperationalRequestAllowed(req, "backup")) {
14075
- rejectUnauthorized(res);
14076
- return;
14077
- }
14895
+ if (!isOperationalRequestAllowed(req, res, "backup")) return;
14078
14896
  try {
14079
14897
  const body = await readBodyBuffer(req, maxBackupBytes);
14080
14898
  if (body === null) {
@@ -14167,7 +14985,7 @@ data: ${JSON.stringify(s)}
14167
14985
  };
14168
14986
  }
14169
14987
  function renderDashboardHtml(status) {
14170
- const version = status.version;
14988
+ const version2 = status.version;
14171
14989
  const uptime = formatUptime(status.uptime);
14172
14990
  return `<!DOCTYPE html>
14173
14991
  <html lang="en">
@@ -14198,7 +15016,7 @@ function renderDashboardHtml(status) {
14198
15016
  </head>
14199
15017
  <body>
14200
15018
  <h1>Kora Sync Server</h1>
14201
- <p class="subtitle">v${version} &middot; <span class="status-dot running"></span>Running</p>
15019
+ <p class="subtitle">v${version2} &middot; <span class="status-dot running"></span>Running</p>
14202
15020
  <div class="grid">
14203
15021
  <div class="card"><div class="label">Uptime</div><div class="value">${uptime}</div></div>
14204
15022
  <div class="card"><div class="label">Connected Clients</div><div class="value" id="connectedClients">${status.connectedClients}</div></div>
@@ -14247,6 +15065,7 @@ export {
14247
15065
  ClientSession,
14248
15066
  DEFAULT_MAX_SCOPE_PREDICATE_VALUES,
14249
15067
  HttpServerTransport,
15068
+ InvalidDiagnosticOptionsError,
14250
15069
  InvalidScopePredicateError,
14251
15070
  KoraAuthProvider,
14252
15071
  KoraSyncServer,
@@ -14275,6 +15094,7 @@ export {
14275
15094
  createProductionServer,
14276
15095
  createSilentLogger,
14277
15096
  createSqliteServerStore,
15097
+ findJsonStringValues,
14278
15098
  isRetriableRejection,
14279
15099
  isServerNodeId,
14280
15100
  normalizeScopeMap,