@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/{chunk-IRRXJ7YN.js → chunk-UKWG5T6J.js} +1 -1
- package/dist/chunk-UKWG5T6J.js.map +1 -0
- package/dist/index.cjs +1203 -379
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +416 -14
- package/dist/index.d.ts +416 -14
- package/dist/index.js +1001 -181
- package/dist/index.js.map +1 -1
- package/dist/{server-backup-WBIYHWYQ.js → server-backup-KITNPMZJ.js} +2 -2
- package/package.json +5 -4
- package/dist/chunk-IRRXJ7YN.js.map +0 -1
- /package/dist/{server-backup-WBIYHWYQ.js.map → server-backup-KITNPMZJ.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -17,7 +17,72 @@ import {
|
|
|
17
17
|
judgeSequenceHolders,
|
|
18
18
|
reportLegacyPair,
|
|
19
19
|
validateIngestedOperation
|
|
20
|
-
} from "./chunk-
|
|
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
|
|
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
|
|
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
|
|
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])).
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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 =
|
|
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 &&
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
8104
|
+
function contentKey(op, version2) {
|
|
7835
8105
|
let body;
|
|
7836
8106
|
try {
|
|
7837
8107
|
body = canonicalizeOperationBody(
|
|
7838
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
8104
|
-
|
|
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 !
|
|
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
|
-
|
|
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
|
|
9483
|
+
if (!this.allowLegacyAnonymousClaims) return { ok: false };
|
|
9139
9484
|
if (this.isNodeLive?.(nodeId, this.sessionId) === true) return { ok: false };
|
|
9140
|
-
if (
|
|
9141
|
-
|
|
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
|
|
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.
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
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
|
-
|
|
9806
|
+
if (this.unscopedSharing !== "allow" && this.auth && sharesEveryUsersData(
|
|
9390
9807
|
this.auth,
|
|
9391
|
-
|
|
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
|
|
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
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
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 =
|
|
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
|
|
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
|
|
11227
|
+
return rows2[0];
|
|
10801
11228
|
} catch {
|
|
10802
11229
|
return void 0;
|
|
10803
11230
|
}
|
|
10804
11231
|
}
|
|
10805
|
-
|
|
10806
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
10933
|
-
if (client
|
|
10934
|
-
|
|
10935
|
-
|
|
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
|
|
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
|
-
|
|
10967
|
-
|
|
10968
|
-
|
|
10969
|
-
|
|
10970
|
-
|
|
10971
|
-
|
|
10972
|
-
|
|
10973
|
-
|
|
10974
|
-
|
|
10975
|
-
|
|
10976
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
11019
|
-
|
|
11020
|
-
|
|
11021
|
-
|
|
11022
|
-
|
|
11023
|
-
|
|
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(
|
|
11140
|
-
this.schemaVersion =
|
|
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
|
-
//
|
|
12000
|
-
//
|
|
12001
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
13825
|
+
session.getPresencePartitionKey()
|
|
13172
13826
|
);
|
|
13173
13827
|
}
|
|
13174
|
-
this.awarenessRelay.
|
|
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
|
|
14256
|
+
const version2 = fileVersionKey(stats);
|
|
13464
14257
|
const cached = digests.get(path);
|
|
13465
|
-
if (cached && cached.version ===
|
|
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 &&
|
|
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
|
-
|
|
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)
|
|
13799
|
-
|
|
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
|
|
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${
|
|
15019
|
+
<p class="subtitle">v${version2} · <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,
|