tetherdb 0.1.0 → 0.1.1
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/CHANGELOG.md +17 -0
- package/README.md +52 -5
- package/dist/cli/index.cjs +17 -2
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +17 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/server/index.cjs +17 -2
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.js +17 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/server.d.cts +6 -0
- package/dist/server/server.d.ts +6 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/vite/index.cjs +2050 -0
- package/dist/vite/index.cjs.map +1 -0
- package/dist/vite/index.d.cts +43 -0
- package/dist/vite/index.d.ts +43 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +2013 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +23 -2
|
@@ -0,0 +1,2013 @@
|
|
|
1
|
+
// src/server/server.ts
|
|
2
|
+
import * as http from "http";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
|
|
5
|
+
// src/shared/path.ts
|
|
6
|
+
function normalizeBasePath(path3) {
|
|
7
|
+
if (path3 === "" || path3 === "/") return "";
|
|
8
|
+
if (path3.endsWith("/")) path3 = path3.slice(0, path3.length - 1);
|
|
9
|
+
if (!path3.startsWith("/")) path3 = `/${path3}`;
|
|
10
|
+
return path3 === "/" ? "" : path3;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/server/crypto.ts
|
|
14
|
+
import * as crypto from "crypto";
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
var DEFAULT_TOKEN_EXPIRES_IN = 7 * 24 * 60 * 60;
|
|
18
|
+
async function hashPassword(password) {
|
|
19
|
+
const salt = crypto.randomBytes(16).toString("hex");
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
crypto.scrypt(
|
|
22
|
+
password.normalize("NFKC"),
|
|
23
|
+
salt,
|
|
24
|
+
64,
|
|
25
|
+
SCRYPT_OPTIONS,
|
|
26
|
+
(err, derivedKey) => {
|
|
27
|
+
if (err) return reject(err);
|
|
28
|
+
resolve(`scrypt$${salt}$${derivedKey.toString("hex")}`);
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function verifyPasswordHash(password, storedHash) {
|
|
34
|
+
if (!storedHash || typeof storedHash !== "string") return false;
|
|
35
|
+
const parts = storedHash.split("$");
|
|
36
|
+
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
|
37
|
+
const [, salt, expectedHex] = parts;
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
crypto.scrypt(
|
|
40
|
+
password.normalize("NFKC"),
|
|
41
|
+
salt,
|
|
42
|
+
64,
|
|
43
|
+
SCRYPT_OPTIONS,
|
|
44
|
+
(err, derivedKey) => {
|
|
45
|
+
if (err) return resolve(false);
|
|
46
|
+
const expectedBuf = Buffer.from(expectedHex, "hex");
|
|
47
|
+
if (derivedKey.length !== expectedBuf.length) return resolve(false);
|
|
48
|
+
resolve(crypto.timingSafeEqual(derivedKey, expectedBuf));
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async function verifyDummyPasswordHash(password) {
|
|
54
|
+
if (!dummyPasswordHashPromise) {
|
|
55
|
+
dummyPasswordHashPromise = hashPassword("TetherDB:dummy_seed_password");
|
|
56
|
+
}
|
|
57
|
+
const dummyHash = await dummyPasswordHashPromise;
|
|
58
|
+
return verifyPasswordHash(password, dummyHash);
|
|
59
|
+
}
|
|
60
|
+
function createSessionToken(userId, username, secret, expiresInSeconds = DEFAULT_TOKEN_EXPIRES_IN) {
|
|
61
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + expiresInSeconds;
|
|
62
|
+
const payload = JSON.stringify({
|
|
63
|
+
userId,
|
|
64
|
+
username,
|
|
65
|
+
expiresAt
|
|
66
|
+
});
|
|
67
|
+
const payloadB64 = Buffer.from(payload, "utf-8").toString("base64url");
|
|
68
|
+
const signature = crypto.createHmac("sha256", secret).update(payloadB64).digest("base64url");
|
|
69
|
+
return `${payloadB64}.${signature}`;
|
|
70
|
+
}
|
|
71
|
+
function verifySessionToken(token, secret) {
|
|
72
|
+
if (!token || typeof token !== "string") return null;
|
|
73
|
+
const parts = token.split(".");
|
|
74
|
+
if (parts.length !== 2) return null;
|
|
75
|
+
const [payloadB64, signature] = parts;
|
|
76
|
+
const expectedSig = crypto.createHmac("sha256", secret).update(payloadB64).digest("base64url");
|
|
77
|
+
if (signature.length !== expectedSig.length || !crypto.timingSafeEqual(
|
|
78
|
+
Buffer.from(signature, "utf-8"),
|
|
79
|
+
Buffer.from(expectedSig, "utf-8")
|
|
80
|
+
)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const raw = Buffer.from(payloadB64, "base64url").toString("utf-8");
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.userId !== "string" || !parsed.userId || typeof parsed.username !== "string" || !parsed.username || typeof parsed.expiresAt !== "number" || !Number.isFinite(parsed.expiresAt) || parsed.expiresAt < Math.floor(Date.now() / 1e3)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
userId: parsed.userId,
|
|
91
|
+
username: parsed.username,
|
|
92
|
+
expiresAt: parsed.expiresAt
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
var SCRYPT_OPTIONS = {
|
|
99
|
+
N: process.env.NODE_ENV === "test" ? 512 : 16384,
|
|
100
|
+
r: 8,
|
|
101
|
+
p: 1,
|
|
102
|
+
maxmem: 32 * 1024 * 1024
|
|
103
|
+
};
|
|
104
|
+
var dummyPasswordHashPromise = null;
|
|
105
|
+
|
|
106
|
+
// src/server/errors.ts
|
|
107
|
+
var TetherServerError = class extends Error {
|
|
108
|
+
/** Error category code identifying the broad error type. */
|
|
109
|
+
code;
|
|
110
|
+
/**
|
|
111
|
+
* Initializes a new `TetherServerError`.
|
|
112
|
+
*
|
|
113
|
+
* @param code - The error category code.
|
|
114
|
+
* @param message - User-safe error description message.
|
|
115
|
+
*/
|
|
116
|
+
constructor(code, message) {
|
|
117
|
+
super(message ?? getDefaultServerErrorMessage(code));
|
|
118
|
+
this.name = "TetherServerError";
|
|
119
|
+
this.code = code;
|
|
120
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
function getDefaultServerErrorMessage(code) {
|
|
124
|
+
switch (code) {
|
|
125
|
+
case 0 /* InvalidInput */:
|
|
126
|
+
return "Invalid request parameter";
|
|
127
|
+
case 1 /* NotFound */:
|
|
128
|
+
return "Requested resource not found";
|
|
129
|
+
case 2 /* AlreadyExists */:
|
|
130
|
+
return "Resource already exists";
|
|
131
|
+
case 3 /* Unauthorized */:
|
|
132
|
+
return "Authentication required";
|
|
133
|
+
case 4 /* AuthenticationFailed */:
|
|
134
|
+
return "Authentication failed";
|
|
135
|
+
case 5 /* LimitExceeded */:
|
|
136
|
+
return "Request or resource limit exceeded";
|
|
137
|
+
case 6 /* ConfigurationError */:
|
|
138
|
+
return "Server configuration error";
|
|
139
|
+
case 7 /* NotSupported */:
|
|
140
|
+
return "Operation not supported";
|
|
141
|
+
case 8 /* InternalError */:
|
|
142
|
+
return "Internal server error";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/server/lock.ts
|
|
147
|
+
import * as fs2 from "fs";
|
|
148
|
+
import * as path2 from "path";
|
|
149
|
+
function isProcessAlive(pid) {
|
|
150
|
+
if (!pid || pid <= 0) return false;
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, 0);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const code = err.code;
|
|
156
|
+
return code === "EPERM";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function readServerLock(baseDir) {
|
|
160
|
+
const lockPath = path2.join(baseDir, "server.lock");
|
|
161
|
+
try {
|
|
162
|
+
if (!fs2.existsSync(lockPath)) return null;
|
|
163
|
+
const content = fs2.readFileSync(lockPath, "utf-8");
|
|
164
|
+
const info = JSON.parse(content);
|
|
165
|
+
if (typeof info.pid === "number" && isProcessAlive(info.pid)) {
|
|
166
|
+
return info;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function acquireServerLock(baseDir, details) {
|
|
174
|
+
fs2.mkdirSync(baseDir, { recursive: true });
|
|
175
|
+
const lockPath = path2.join(baseDir, "server.lock");
|
|
176
|
+
const existing = readServerLock(baseDir);
|
|
177
|
+
if (existing && existing.pid !== process.pid) {
|
|
178
|
+
throw new TetherServerError(
|
|
179
|
+
2 /* AlreadyExists */,
|
|
180
|
+
"A TetherDB server is already running on this data directory"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (fs2.existsSync(lockPath)) {
|
|
184
|
+
try {
|
|
185
|
+
fs2.unlinkSync(lockPath);
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const info = {
|
|
190
|
+
pid: process.pid,
|
|
191
|
+
port: details.port,
|
|
192
|
+
host: details.host,
|
|
193
|
+
backend: details.backend,
|
|
194
|
+
startedAt: Date.now()
|
|
195
|
+
};
|
|
196
|
+
fs2.writeFileSync(lockPath, JSON.stringify(info, null, 2), {
|
|
197
|
+
encoding: "utf-8",
|
|
198
|
+
mode: 384
|
|
199
|
+
});
|
|
200
|
+
let isReleased = false;
|
|
201
|
+
const release = () => {
|
|
202
|
+
if (isReleased) return;
|
|
203
|
+
isReleased = true;
|
|
204
|
+
try {
|
|
205
|
+
if (fs2.existsSync(lockPath)) {
|
|
206
|
+
const current = JSON.parse(
|
|
207
|
+
fs2.readFileSync(lockPath, "utf-8")
|
|
208
|
+
);
|
|
209
|
+
if (current.pid === process.pid) {
|
|
210
|
+
fs2.unlinkSync(lockPath);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
info,
|
|
218
|
+
lockPath,
|
|
219
|
+
release
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/server/rate-limiter.ts
|
|
224
|
+
var RateLimiter = class {
|
|
225
|
+
store = /* @__PURE__ */ new Map();
|
|
226
|
+
windowMs;
|
|
227
|
+
maxRequests;
|
|
228
|
+
maxFailures;
|
|
229
|
+
initialBackoffMs;
|
|
230
|
+
maxBackoffMs;
|
|
231
|
+
maxEntries;
|
|
232
|
+
/**
|
|
233
|
+
* Initializes a new RateLimiter instance.
|
|
234
|
+
*
|
|
235
|
+
* @param options - Configuration options for window size, request limits, and backoff.
|
|
236
|
+
*/
|
|
237
|
+
constructor(options = {}) {
|
|
238
|
+
this.windowMs = options.windowMs ?? 6e4;
|
|
239
|
+
this.maxRequests = options.maxRequests ?? 60;
|
|
240
|
+
this.maxFailures = options.maxFailures ?? 3;
|
|
241
|
+
this.initialBackoffMs = options.initialBackoffMs ?? 1e3;
|
|
242
|
+
this.maxBackoffMs = options.maxBackoffMs ?? 9e5;
|
|
243
|
+
this.maxEntries = options.maxEntries ?? 1e4;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Returns the current number of tracked keys in memory.
|
|
247
|
+
*/
|
|
248
|
+
get size() {
|
|
249
|
+
return this.store.size;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Checks whether the given key is currently rate limited or blocked by backoff cooldown.
|
|
253
|
+
*
|
|
254
|
+
* @param key - Identifier (e.g. IP address or username).
|
|
255
|
+
* @param now - Current timestamp in milliseconds (defaults to Date.now()).
|
|
256
|
+
* @returns `true` if requests for this key are limited; `false` otherwise.
|
|
257
|
+
*/
|
|
258
|
+
isLimited(key, now = Date.now()) {
|
|
259
|
+
const entry = this.store.get(key);
|
|
260
|
+
if (!entry) return false;
|
|
261
|
+
if (entry.blockedUntil > now) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
if (entry.resetAt <= now) {
|
|
265
|
+
this.store.delete(key);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return entry.count >= this.maxRequests;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Consumes one attempt for the given key if not currently limited.
|
|
272
|
+
*
|
|
273
|
+
* @param key - Identifier.
|
|
274
|
+
* @param now - Current timestamp in milliseconds.
|
|
275
|
+
* @returns `true` if request was allowed and consumed; `false` if rate limited.
|
|
276
|
+
*/
|
|
277
|
+
consume(key, now = Date.now()) {
|
|
278
|
+
if (this.isLimited(key, now)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const entry = this.store.get(key);
|
|
282
|
+
if (!entry || entry.resetAt <= now && entry.blockedUntil <= now) {
|
|
283
|
+
this.setEntry(
|
|
284
|
+
key,
|
|
285
|
+
{
|
|
286
|
+
count: 1,
|
|
287
|
+
resetAt: now + this.windowMs,
|
|
288
|
+
failures: 0,
|
|
289
|
+
blockedUntil: 0
|
|
290
|
+
},
|
|
291
|
+
now
|
|
292
|
+
);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
entry.count++;
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Records a failed attempt for the given key and applies progressive exponential backoff.
|
|
300
|
+
*
|
|
301
|
+
* @param key - Identifier.
|
|
302
|
+
* @param now - Current timestamp in milliseconds.
|
|
303
|
+
* @returns Cooldown duration in milliseconds if blocked, or 0 if under failure threshold.
|
|
304
|
+
*/
|
|
305
|
+
recordFailure(key, now = Date.now()) {
|
|
306
|
+
let entry = this.store.get(key);
|
|
307
|
+
if (!entry || entry.resetAt <= now && entry.blockedUntil <= now) {
|
|
308
|
+
entry = {
|
|
309
|
+
count: 1,
|
|
310
|
+
resetAt: now + this.windowMs,
|
|
311
|
+
failures: 0,
|
|
312
|
+
blockedUntil: 0
|
|
313
|
+
};
|
|
314
|
+
this.setEntry(key, entry, now);
|
|
315
|
+
}
|
|
316
|
+
entry.failures++;
|
|
317
|
+
if (entry.failures >= this.maxFailures) {
|
|
318
|
+
const exponent = entry.failures - this.maxFailures;
|
|
319
|
+
const backoff = Math.min(
|
|
320
|
+
this.initialBackoffMs * 2 ** exponent,
|
|
321
|
+
this.maxBackoffMs
|
|
322
|
+
);
|
|
323
|
+
entry.blockedUntil = now + backoff;
|
|
324
|
+
return backoff;
|
|
325
|
+
}
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Resets all failure counters and request tracking for the given key.
|
|
330
|
+
*
|
|
331
|
+
* @param key - Identifier to reset.
|
|
332
|
+
*/
|
|
333
|
+
reset(key) {
|
|
334
|
+
this.store.delete(key);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Clears all stored rate limit entries.
|
|
338
|
+
*/
|
|
339
|
+
clear() {
|
|
340
|
+
this.store.clear();
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Purges expired entries from the internal store.
|
|
344
|
+
*
|
|
345
|
+
* @param now - Current timestamp in milliseconds.
|
|
346
|
+
*/
|
|
347
|
+
cleanup(now = Date.now()) {
|
|
348
|
+
for (const [key, entry] of this.store.entries()) {
|
|
349
|
+
if (entry.resetAt <= now && entry.blockedUntil <= now) {
|
|
350
|
+
this.store.delete(key);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// -- Private Helpers --------------------------------------------------------
|
|
355
|
+
setEntry(key, entry, now) {
|
|
356
|
+
if (this.store.size >= this.maxEntries && !this.store.has(key)) {
|
|
357
|
+
this.cleanup(now);
|
|
358
|
+
if (this.store.size >= this.maxEntries) {
|
|
359
|
+
const oldestKey = this.store.keys().next().value;
|
|
360
|
+
if (oldestKey !== void 0) {
|
|
361
|
+
this.store.delete(oldestKey);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
this.store.set(key, entry);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// src/shared/clock.ts
|
|
370
|
+
function shouldOverwrite(incoming, existing) {
|
|
371
|
+
if (!existing) return true;
|
|
372
|
+
if (incoming.timestamp > existing.timestamp) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
if (incoming.timestamp < existing.timestamp) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const incomingClient = incoming.clientId ?? "";
|
|
379
|
+
const existingClient = existing.clientId ?? "";
|
|
380
|
+
return incomingClient >= existingClient;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/shared/types.ts
|
|
384
|
+
var PROTOCOL_VERSION = 1;
|
|
385
|
+
|
|
386
|
+
// src/server/validate.ts
|
|
387
|
+
var MIN_USERNAME_LENGTH = 4;
|
|
388
|
+
var MAX_USERNAME_LENGTH = 128;
|
|
389
|
+
var MIN_PASSWORD_LENGTH = 4;
|
|
390
|
+
var MAX_PASSWORD_LENGTH = 512;
|
|
391
|
+
var MAX_FUTURE_TIMESTAMP_DRIFT_MS = 5 * 60 * 1e3;
|
|
392
|
+
function validateTimestamp(timestamp, maxFutureDriftMs = MAX_FUTURE_TIMESTAMP_DRIFT_MS) {
|
|
393
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) {
|
|
394
|
+
throw new TetherServerError(
|
|
395
|
+
0 /* InvalidInput */,
|
|
396
|
+
"Invalid timestamp"
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (timestamp > Date.now() + maxFutureDriftMs) {
|
|
400
|
+
throw new TetherServerError(
|
|
401
|
+
0 /* InvalidInput */,
|
|
402
|
+
"Timestamp drift exceeds maximum allowable threshold"
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return timestamp;
|
|
406
|
+
}
|
|
407
|
+
function validateUserId(userId) {
|
|
408
|
+
return validateFilesystemSafe(userId, "user ID");
|
|
409
|
+
}
|
|
410
|
+
function validateAppId(appId) {
|
|
411
|
+
return validateFilesystemSafe(appId, "application ID");
|
|
412
|
+
}
|
|
413
|
+
function validateTableName(tableName) {
|
|
414
|
+
return validateFilesystemSafe(tableName, "table name");
|
|
415
|
+
}
|
|
416
|
+
function validateRecordId(id) {
|
|
417
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 512) {
|
|
418
|
+
throw new TetherServerError(
|
|
419
|
+
0 /* InvalidInput */,
|
|
420
|
+
"Invalid record ID"
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return id;
|
|
424
|
+
}
|
|
425
|
+
function normalizeUsername(username) {
|
|
426
|
+
return typeof username === "string" ? username.trim().toLowerCase() : "";
|
|
427
|
+
}
|
|
428
|
+
function validateUsername(username) {
|
|
429
|
+
if (typeof username !== "string") {
|
|
430
|
+
throw new TetherServerError(
|
|
431
|
+
0 /* InvalidInput */,
|
|
432
|
+
`Username must be between ${MIN_USERNAME_LENGTH} and ${MAX_USERNAME_LENGTH} characters`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
const normalized = normalizeUsername(username);
|
|
436
|
+
if (normalized.length < MIN_USERNAME_LENGTH || normalized.length > MAX_USERNAME_LENGTH) {
|
|
437
|
+
throw new TetherServerError(
|
|
438
|
+
0 /* InvalidInput */,
|
|
439
|
+
`Username must be between ${MIN_USERNAME_LENGTH} and ${MAX_USERNAME_LENGTH} characters`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return normalized;
|
|
443
|
+
}
|
|
444
|
+
function normalizePassword(password) {
|
|
445
|
+
return typeof password === "string" ? password.trim() : "";
|
|
446
|
+
}
|
|
447
|
+
function validatePassword(password) {
|
|
448
|
+
if (typeof password !== "string") {
|
|
449
|
+
throw new TetherServerError(
|
|
450
|
+
0 /* InvalidInput */,
|
|
451
|
+
"Password must be a valid non-empty string"
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const normalized = normalizePassword(password);
|
|
455
|
+
if (normalized.length < MIN_PASSWORD_LENGTH || normalized.length > MAX_PASSWORD_LENGTH) {
|
|
456
|
+
throw new TetherServerError(
|
|
457
|
+
0 /* InvalidInput */,
|
|
458
|
+
`Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return normalized;
|
|
462
|
+
}
|
|
463
|
+
function validateIdentifier(id, name = "identifier") {
|
|
464
|
+
if (typeof id !== "string" || !/^[a-zA-Z0-9_-]{2,128}$/.test(id)) {
|
|
465
|
+
throw new TetherServerError(
|
|
466
|
+
0 /* InvalidInput */,
|
|
467
|
+
`Invalid ${name}`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return id;
|
|
471
|
+
}
|
|
472
|
+
function calculateByteSize(value) {
|
|
473
|
+
if (value === null || value === void 0) return 0;
|
|
474
|
+
if (typeof value === "string") return Buffer.byteLength(value, "utf-8");
|
|
475
|
+
if (typeof value === "number") return 8;
|
|
476
|
+
if (typeof value === "boolean") return 4;
|
|
477
|
+
try {
|
|
478
|
+
return Buffer.byteLength(JSON.stringify(value), "utf-8");
|
|
479
|
+
} catch {
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function validateFilesystemSafe(id, name) {
|
|
484
|
+
if (typeof id !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(id)) {
|
|
485
|
+
throw new TetherServerError(
|
|
486
|
+
0 /* InvalidInput */,
|
|
487
|
+
`Invalid ${name}`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return id;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/server/storage/base/app.ts
|
|
494
|
+
var AppBaseStorage = class {
|
|
495
|
+
id;
|
|
496
|
+
constructor(id) {
|
|
497
|
+
this.id = id;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
function applyChangeToRecord(change, existing, seq) {
|
|
501
|
+
const isDeleted = change.op === "delete" /* Delete */;
|
|
502
|
+
const nextVersion = (existing?.version ?? 0) + 1;
|
|
503
|
+
const updatedRecord = {
|
|
504
|
+
id: change.id,
|
|
505
|
+
version: nextVersion,
|
|
506
|
+
timestamp: change.timestamp,
|
|
507
|
+
clientId: change.clientId,
|
|
508
|
+
deleted: isDeleted,
|
|
509
|
+
data: isDeleted ? null : change.data ?? null
|
|
510
|
+
};
|
|
511
|
+
const appliedChange = {
|
|
512
|
+
seq,
|
|
513
|
+
table: change.table,
|
|
514
|
+
id: change.id,
|
|
515
|
+
op: change.op,
|
|
516
|
+
version: nextVersion,
|
|
517
|
+
timestamp: change.timestamp,
|
|
518
|
+
clientId: change.clientId,
|
|
519
|
+
data: isDeleted ? void 0 : change.data
|
|
520
|
+
};
|
|
521
|
+
return { updatedRecord, appliedChange };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/server/storage/base/storage.ts
|
|
525
|
+
var BaseStorage = class {
|
|
526
|
+
options;
|
|
527
|
+
constructor(options) {
|
|
528
|
+
this.options = options;
|
|
529
|
+
}
|
|
530
|
+
/** Optional storage base directory if disk-backed. */
|
|
531
|
+
getBaseDir() {
|
|
532
|
+
return void 0;
|
|
533
|
+
}
|
|
534
|
+
async getUserByToken(token) {
|
|
535
|
+
const payload = verifySessionToken(token, this.secret);
|
|
536
|
+
if (!payload) return void 0;
|
|
537
|
+
return this.getUser(payload.userId);
|
|
538
|
+
}
|
|
539
|
+
async getStatus(appId) {
|
|
540
|
+
const users = await this.getUsers();
|
|
541
|
+
const allApps = await this.getApps();
|
|
542
|
+
const targetApps = filterTargetApps(allApps, appId);
|
|
543
|
+
const apps = await buildAppSummaries(targetApps);
|
|
544
|
+
const status = {
|
|
545
|
+
backend: this.backend,
|
|
546
|
+
usersCount: users.length,
|
|
547
|
+
appsCount: allApps.length,
|
|
548
|
+
apps
|
|
549
|
+
};
|
|
550
|
+
const baseDir = this.getBaseDir();
|
|
551
|
+
if (baseDir !== void 0) {
|
|
552
|
+
status.baseDir = baseDir;
|
|
553
|
+
}
|
|
554
|
+
return status;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
function filterTargetApps(allApps, appId) {
|
|
558
|
+
const targetApps = appId ? allApps.filter((a) => a.id === validateAppId(appId)) : allApps;
|
|
559
|
+
if (appId && targetApps.length === 0) {
|
|
560
|
+
throw new TetherServerError(
|
|
561
|
+
1 /* NotFound */,
|
|
562
|
+
`Application "${appId}" not found`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
return targetApps;
|
|
566
|
+
}
|
|
567
|
+
async function buildAppSummaries(apps) {
|
|
568
|
+
const appSummaries = [];
|
|
569
|
+
for (const app of apps) {
|
|
570
|
+
const tables = await app.getTables();
|
|
571
|
+
appSummaries.push({
|
|
572
|
+
id: app.id,
|
|
573
|
+
tables: tables.map((t) => t.name)
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return appSummaries;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/server/storage/base/table.ts
|
|
580
|
+
var TableBaseStorage = class {
|
|
581
|
+
name;
|
|
582
|
+
app;
|
|
583
|
+
constructor(name, app) {
|
|
584
|
+
this.name = name;
|
|
585
|
+
this.app = app;
|
|
586
|
+
}
|
|
587
|
+
/** Applies batch mutation changes to this table. */
|
|
588
|
+
async applyChanges(user, changes) {
|
|
589
|
+
return this.app.applyChanges(
|
|
590
|
+
user,
|
|
591
|
+
targetChangesForTable(this.name, this.app.id, changes)
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
function filterActiveRecords(tableName, records) {
|
|
596
|
+
const items = [];
|
|
597
|
+
for (const rec of records) {
|
|
598
|
+
if (!rec.deleted) {
|
|
599
|
+
items.push({
|
|
600
|
+
...rec,
|
|
601
|
+
table: tableName
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return items;
|
|
606
|
+
}
|
|
607
|
+
function targetChangesForTable(tableName, appId, changes) {
|
|
608
|
+
return changes.map((c) => ({
|
|
609
|
+
...c,
|
|
610
|
+
table: tableName,
|
|
611
|
+
appId
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/server/storage/base/user.ts
|
|
616
|
+
var UserBaseStorage = class {
|
|
617
|
+
id;
|
|
618
|
+
username;
|
|
619
|
+
createdAt;
|
|
620
|
+
constructor(id, username, createdAt) {
|
|
621
|
+
this.id = id;
|
|
622
|
+
this.username = username;
|
|
623
|
+
this.createdAt = createdAt;
|
|
624
|
+
}
|
|
625
|
+
/** Creates a signed session token for this user. */
|
|
626
|
+
async createToken(expiresInSeconds) {
|
|
627
|
+
return createSessionToken(
|
|
628
|
+
this.id,
|
|
629
|
+
this.username,
|
|
630
|
+
this.getSecret(),
|
|
631
|
+
expiresInSeconds
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
/** Verifies whether the session token is valid for this user. */
|
|
635
|
+
async verifyToken(token) {
|
|
636
|
+
const payload = verifySessionToken(token, this.getSecret());
|
|
637
|
+
return payload !== null && payload.userId === this.id;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
async function verifyUserPassword(password, passwordHash) {
|
|
641
|
+
if (!passwordHash) return false;
|
|
642
|
+
const normalized = normalizePassword(password);
|
|
643
|
+
if (!normalized) return false;
|
|
644
|
+
return verifyPasswordHash(normalized, passwordHash);
|
|
645
|
+
}
|
|
646
|
+
async function hashUserPassword(newPassword) {
|
|
647
|
+
const valid = validatePassword(newPassword);
|
|
648
|
+
return hashPassword(valid);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/server/storage/memory/table.ts
|
|
652
|
+
var TableMemoryStorage = class extends TableBaseStorage {
|
|
653
|
+
storage;
|
|
654
|
+
constructor(name, app, storage) {
|
|
655
|
+
super(name, app);
|
|
656
|
+
this.storage = storage;
|
|
657
|
+
}
|
|
658
|
+
async getRecord(user, id) {
|
|
659
|
+
const safeId = validateRecordId(id);
|
|
660
|
+
const userState = this.storage.getUserState(user.id, this.app.id);
|
|
661
|
+
const tableMap = userState.tables.get(this.name);
|
|
662
|
+
const record = tableMap?.get(safeId);
|
|
663
|
+
if (!record || record.deleted) {
|
|
664
|
+
return void 0;
|
|
665
|
+
}
|
|
666
|
+
return record;
|
|
667
|
+
}
|
|
668
|
+
async getAllRecords(user) {
|
|
669
|
+
const userState = this.storage.getUserState(user.id, this.app.id);
|
|
670
|
+
const tableMap = userState.tables.get(this.name);
|
|
671
|
+
return tableMap ? filterActiveRecords(this.name, tableMap.values()) : [];
|
|
672
|
+
}
|
|
673
|
+
async delete() {
|
|
674
|
+
return this.app.deleteTable(this.name);
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
// src/server/storage/memory/app.ts
|
|
679
|
+
var AppMemoryStorage = class extends AppBaseStorage {
|
|
680
|
+
tables = /* @__PURE__ */ new Map();
|
|
681
|
+
storage;
|
|
682
|
+
constructor(id, storage) {
|
|
683
|
+
super(id);
|
|
684
|
+
this.storage = storage;
|
|
685
|
+
}
|
|
686
|
+
async createTable(name) {
|
|
687
|
+
const safeName = validateTableName(name);
|
|
688
|
+
if (this.tables.has(safeName)) {
|
|
689
|
+
throw new TetherServerError(
|
|
690
|
+
2 /* AlreadyExists */,
|
|
691
|
+
"Table already exists in this application"
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
const table = new TableMemoryStorage(safeName, this, this.storage);
|
|
695
|
+
this.tables.set(safeName, table);
|
|
696
|
+
return table;
|
|
697
|
+
}
|
|
698
|
+
async getTable(name) {
|
|
699
|
+
const safeName = validateTableName(name);
|
|
700
|
+
return this.tables.get(safeName);
|
|
701
|
+
}
|
|
702
|
+
async getTables() {
|
|
703
|
+
return Array.from(this.tables.values());
|
|
704
|
+
}
|
|
705
|
+
async applyChanges(user, changes) {
|
|
706
|
+
const userState = this.storage.getUserState(user.id, this.id);
|
|
707
|
+
const maxRecords = this.storage.options.maxRecordsPerTable ?? 1e4;
|
|
708
|
+
const maxRecordSize = this.storage.options.maxRecordSizeBytes ?? 512 * 1024;
|
|
709
|
+
const maxChangelog = this.storage.options.maxChangelogEntries ?? 1e3;
|
|
710
|
+
for (const change of changes) {
|
|
711
|
+
const tableName = validateTableName(change.table);
|
|
712
|
+
validateRecordId(change.id);
|
|
713
|
+
validateTimestamp(change.timestamp);
|
|
714
|
+
if (!this.tables.has(tableName)) {
|
|
715
|
+
throw new TetherServerError(
|
|
716
|
+
1 /* NotFound */,
|
|
717
|
+
"Table not found"
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const payloadBytes = calculateByteSize(change.data);
|
|
721
|
+
if (payloadBytes > maxRecordSize) {
|
|
722
|
+
throw new TetherServerError(
|
|
723
|
+
5 /* LimitExceeded */,
|
|
724
|
+
"Record payload exceeds maximum allowed size"
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const stagedTables = /* @__PURE__ */ new Map();
|
|
729
|
+
const stagedApplied = [];
|
|
730
|
+
let stagedCurrentSeq = userState.currentSeq;
|
|
731
|
+
let stagedMinSeq = userState.minSeq;
|
|
732
|
+
for (const change of changes) {
|
|
733
|
+
const tableName = validateTableName(change.table);
|
|
734
|
+
const recordId = validateRecordId(change.id);
|
|
735
|
+
let tableMap = stagedTables.get(tableName);
|
|
736
|
+
if (!tableMap) {
|
|
737
|
+
const existingTableMap = userState.tables.get(tableName);
|
|
738
|
+
tableMap = new Map(existingTableMap);
|
|
739
|
+
stagedTables.set(tableName, tableMap);
|
|
740
|
+
}
|
|
741
|
+
if (change.op === "put" /* Put */ && !tableMap.has(recordId) && tableMap.size >= maxRecords) {
|
|
742
|
+
throw new TetherServerError(
|
|
743
|
+
5 /* LimitExceeded */,
|
|
744
|
+
"Table record limit reached"
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
const existing = tableMap.get(recordId);
|
|
748
|
+
const shouldApply = !existing || shouldOverwrite(change, existing);
|
|
749
|
+
if (shouldApply) {
|
|
750
|
+
stagedCurrentSeq++;
|
|
751
|
+
const assignedSeq = stagedCurrentSeq;
|
|
752
|
+
if (stagedMinSeq === 0) {
|
|
753
|
+
stagedMinSeq = 1;
|
|
754
|
+
}
|
|
755
|
+
const { updatedRecord, appliedChange } = applyChangeToRecord(
|
|
756
|
+
change,
|
|
757
|
+
existing,
|
|
758
|
+
assignedSeq
|
|
759
|
+
);
|
|
760
|
+
tableMap.set(recordId, updatedRecord);
|
|
761
|
+
stagedApplied.push(appliedChange);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
for (const [tableName, stagedMap] of stagedTables.entries()) {
|
|
765
|
+
userState.tables.set(tableName, stagedMap);
|
|
766
|
+
}
|
|
767
|
+
userState.currentSeq = stagedCurrentSeq;
|
|
768
|
+
userState.minSeq = stagedMinSeq;
|
|
769
|
+
userState.changelog.push(...stagedApplied);
|
|
770
|
+
if (userState.changelog.length > maxChangelog) {
|
|
771
|
+
const pruneCount = userState.changelog.length - maxChangelog;
|
|
772
|
+
userState.changelog.splice(0, pruneCount);
|
|
773
|
+
if (userState.changelog.length > 0) {
|
|
774
|
+
userState.minSeq = userState.changelog[0].seq;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return { applied: stagedApplied, newSeq: userState.currentSeq };
|
|
778
|
+
}
|
|
779
|
+
async getChangesSince(user, fromSeq) {
|
|
780
|
+
const userState = this.storage.getUserState(user.id, this.id);
|
|
781
|
+
const currentSeq = userState.currentSeq;
|
|
782
|
+
const minSeq = userState.minSeq;
|
|
783
|
+
if (fromSeq < minSeq && minSeq > 0 || fromSeq > currentSeq) {
|
|
784
|
+
return { changes: [], currentSeq, requiresSnapshot: true };
|
|
785
|
+
}
|
|
786
|
+
const changes = userState.changelog.filter((c) => c.seq > fromSeq);
|
|
787
|
+
return { changes, currentSeq, requiresSnapshot: false };
|
|
788
|
+
}
|
|
789
|
+
async getCurrentSeq(user) {
|
|
790
|
+
const userState = this.storage.getUserState(user.id, this.id);
|
|
791
|
+
return userState.currentSeq;
|
|
792
|
+
}
|
|
793
|
+
async delete() {
|
|
794
|
+
return this.storage.deleteApp(this.id);
|
|
795
|
+
}
|
|
796
|
+
deleteTable(name) {
|
|
797
|
+
const safeName = validateTableName(name);
|
|
798
|
+
const deleted = this.tables.delete(safeName);
|
|
799
|
+
this.storage.deleteTableInUserStates(this.id, safeName);
|
|
800
|
+
return deleted;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// src/server/storage/memory/storage.ts
|
|
805
|
+
import * as crypto2 from "crypto";
|
|
806
|
+
|
|
807
|
+
// src/server/storage/memory/user.ts
|
|
808
|
+
var UserMemoryStorage = class extends UserBaseStorage {
|
|
809
|
+
storage;
|
|
810
|
+
constructor(data, storage) {
|
|
811
|
+
super(data.id, data.username, data.createdAt);
|
|
812
|
+
this.storage = storage;
|
|
813
|
+
}
|
|
814
|
+
getSecret() {
|
|
815
|
+
return this.storage.secret;
|
|
816
|
+
}
|
|
817
|
+
getUserData() {
|
|
818
|
+
const data = this.storage.getUserData(this.id);
|
|
819
|
+
if (!data) {
|
|
820
|
+
throw new TetherServerError(
|
|
821
|
+
1 /* NotFound */,
|
|
822
|
+
"User not found"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
return data;
|
|
826
|
+
}
|
|
827
|
+
async verifyPassword(password) {
|
|
828
|
+
const data = this.getUserData();
|
|
829
|
+
return verifyUserPassword(password, data.passwordHash);
|
|
830
|
+
}
|
|
831
|
+
async changePassword(newPassword) {
|
|
832
|
+
const data = this.getUserData();
|
|
833
|
+
data.passwordHash = await hashUserPassword(newPassword);
|
|
834
|
+
}
|
|
835
|
+
async delete() {
|
|
836
|
+
return this.storage.deleteUser(this.id);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// src/server/storage/memory/storage.ts
|
|
841
|
+
var MemoryStorage = class extends BaseStorage {
|
|
842
|
+
backend = "memory";
|
|
843
|
+
apps = /* @__PURE__ */ new Map();
|
|
844
|
+
userStates = /* @__PURE__ */ new Map();
|
|
845
|
+
// key = `${appId}:${userId}`
|
|
846
|
+
users = /* @__PURE__ */ new Map();
|
|
847
|
+
// key = userId
|
|
848
|
+
usersByUsername = /* @__PURE__ */ new Map();
|
|
849
|
+
// username -> userId
|
|
850
|
+
secret;
|
|
851
|
+
options;
|
|
852
|
+
constructor(options = {}) {
|
|
853
|
+
super(options);
|
|
854
|
+
this.options = options;
|
|
855
|
+
this.secret = options.secret ?? crypto2.randomBytes(32).toString("hex");
|
|
856
|
+
}
|
|
857
|
+
getUserState(userId, appId) {
|
|
858
|
+
const safeAppId = validateAppId(appId);
|
|
859
|
+
const safeUserId = validateUserId(userId);
|
|
860
|
+
const key = `${safeAppId}:${safeUserId}`;
|
|
861
|
+
let state = this.userStates.get(key);
|
|
862
|
+
if (!state) {
|
|
863
|
+
state = {
|
|
864
|
+
currentSeq: 0,
|
|
865
|
+
minSeq: 0,
|
|
866
|
+
tables: /* @__PURE__ */ new Map(),
|
|
867
|
+
changelog: []
|
|
868
|
+
};
|
|
869
|
+
this.userStates.set(key, state);
|
|
870
|
+
}
|
|
871
|
+
return state;
|
|
872
|
+
}
|
|
873
|
+
deleteUserState(userId) {
|
|
874
|
+
const safeUserId = validateUserId(userId);
|
|
875
|
+
let deleted = false;
|
|
876
|
+
for (const key of Array.from(this.userStates.keys())) {
|
|
877
|
+
if (key.endsWith(`:${safeUserId}`)) {
|
|
878
|
+
this.userStates.delete(key);
|
|
879
|
+
deleted = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return deleted;
|
|
883
|
+
}
|
|
884
|
+
deleteAppUserStates(appId) {
|
|
885
|
+
const safeAppId = validateAppId(appId);
|
|
886
|
+
for (const key of Array.from(this.userStates.keys())) {
|
|
887
|
+
if (key.startsWith(`${safeAppId}:`)) {
|
|
888
|
+
this.userStates.delete(key);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
deleteTableInUserStates(appId, tableName) {
|
|
893
|
+
const safeAppId = validateAppId(appId);
|
|
894
|
+
for (const [key, state] of this.userStates.entries()) {
|
|
895
|
+
if (key.startsWith(`${safeAppId}:`)) {
|
|
896
|
+
state.tables.delete(tableName);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async createApp(id) {
|
|
901
|
+
const safeId = validateAppId(id);
|
|
902
|
+
if (this.apps.has(safeId)) {
|
|
903
|
+
throw new TetherServerError(
|
|
904
|
+
2 /* AlreadyExists */,
|
|
905
|
+
"Application already exists"
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
const app = new AppMemoryStorage(safeId, this);
|
|
909
|
+
this.apps.set(safeId, app);
|
|
910
|
+
return app;
|
|
911
|
+
}
|
|
912
|
+
async getApp(id) {
|
|
913
|
+
const safeId = validateAppId(id);
|
|
914
|
+
return this.apps.get(safeId);
|
|
915
|
+
}
|
|
916
|
+
async getApps() {
|
|
917
|
+
return Array.from(this.apps.values());
|
|
918
|
+
}
|
|
919
|
+
async createUser(username, password) {
|
|
920
|
+
const safeUsername = validateUsername(username);
|
|
921
|
+
const validPassword = validatePassword(password);
|
|
922
|
+
if (this.usersByUsername.has(safeUsername)) {
|
|
923
|
+
throw new TetherServerError(
|
|
924
|
+
2 /* AlreadyExists */,
|
|
925
|
+
"Username is already registered"
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
const userId = crypto2.randomUUID();
|
|
929
|
+
const passwordHash = await hashPassword(validPassword);
|
|
930
|
+
const userData = {
|
|
931
|
+
id: userId,
|
|
932
|
+
username: safeUsername,
|
|
933
|
+
passwordHash,
|
|
934
|
+
createdAt: Date.now()
|
|
935
|
+
};
|
|
936
|
+
this.users.set(userId, userData);
|
|
937
|
+
this.usersByUsername.set(safeUsername, userId);
|
|
938
|
+
return new UserMemoryStorage(userData, this);
|
|
939
|
+
}
|
|
940
|
+
getUserData(userId) {
|
|
941
|
+
return this.users.get(userId);
|
|
942
|
+
}
|
|
943
|
+
async getUser(id) {
|
|
944
|
+
const safeUserId = validateUserId(id);
|
|
945
|
+
const data = this.users.get(safeUserId);
|
|
946
|
+
if (data) {
|
|
947
|
+
return new UserMemoryStorage(data, this);
|
|
948
|
+
}
|
|
949
|
+
return void 0;
|
|
950
|
+
}
|
|
951
|
+
async getUserByUsername(username) {
|
|
952
|
+
const safeUsername = normalizeUsername(username);
|
|
953
|
+
if (!safeUsername) return void 0;
|
|
954
|
+
const userId = this.usersByUsername.get(safeUsername);
|
|
955
|
+
if (!userId) return void 0;
|
|
956
|
+
const data = this.users.get(userId);
|
|
957
|
+
if (data) {
|
|
958
|
+
return new UserMemoryStorage(data, this);
|
|
959
|
+
}
|
|
960
|
+
return void 0;
|
|
961
|
+
}
|
|
962
|
+
async getUsers() {
|
|
963
|
+
return Array.from(this.users.values()).map(
|
|
964
|
+
(data) => new UserMemoryStorage(data, this)
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
deleteUser(id) {
|
|
968
|
+
const safeUserId = validateUserId(id);
|
|
969
|
+
this.deleteUserState(safeUserId);
|
|
970
|
+
const data = this.users.get(safeUserId);
|
|
971
|
+
if (data) {
|
|
972
|
+
this.usersByUsername.delete(data.username);
|
|
973
|
+
this.users.delete(safeUserId);
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
deleteApp(id) {
|
|
979
|
+
const safeId = validateAppId(id);
|
|
980
|
+
this.deleteAppUserStates(safeId);
|
|
981
|
+
return this.apps.delete(safeId);
|
|
982
|
+
}
|
|
983
|
+
async checkpoint(appId) {
|
|
984
|
+
throw new TetherServerError(
|
|
985
|
+
7 /* NotSupported */,
|
|
986
|
+
`Checkpoint operation is not supported by memory storage${appId ? ` (app: ${appId})` : ""}`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
async vacuum(appId) {
|
|
990
|
+
throw new TetherServerError(
|
|
991
|
+
7 /* NotSupported */,
|
|
992
|
+
`Vacuum operation is not supported by memory storage${appId ? ` (app: ${appId})` : ""}`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
async prune(appId, keepCount) {
|
|
996
|
+
const keep = keepCount ?? this.options.maxChangelogEntries ?? 1e3;
|
|
997
|
+
const allApps = await this.getApps();
|
|
998
|
+
const targetApps = filterTargetApps(allApps, appId);
|
|
999
|
+
let totalPruned = 0;
|
|
1000
|
+
for (const app of targetApps) {
|
|
1001
|
+
for (const [key, state] of this.userStates.entries()) {
|
|
1002
|
+
if (key.startsWith(`${app.id}:`)) {
|
|
1003
|
+
if (state.changelog.length > keep) {
|
|
1004
|
+
const pruneCount = state.changelog.length - keep;
|
|
1005
|
+
state.changelog.splice(0, pruneCount);
|
|
1006
|
+
if (state.changelog.length > 0) {
|
|
1007
|
+
state.minSeq = state.changelog[0].seq;
|
|
1008
|
+
}
|
|
1009
|
+
totalPruned += pruneCount;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
action: "prune",
|
|
1016
|
+
backend: "memory",
|
|
1017
|
+
appId,
|
|
1018
|
+
affectedCount: totalPruned,
|
|
1019
|
+
message: `Prune completed successfully. Removed ${totalPruned} changelog record(s)`
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
async close() {
|
|
1023
|
+
this.apps.clear();
|
|
1024
|
+
this.userStates.clear();
|
|
1025
|
+
this.users.clear();
|
|
1026
|
+
this.usersByUsername.clear();
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// src/server/sync.ts
|
|
1031
|
+
var Sync = class {
|
|
1032
|
+
storage;
|
|
1033
|
+
maxConcurrentConnectionsPerUser;
|
|
1034
|
+
authTimeoutMs;
|
|
1035
|
+
rateLimiter;
|
|
1036
|
+
logger;
|
|
1037
|
+
userClients = /* @__PURE__ */ new Map();
|
|
1038
|
+
// key = `${appId}:${userId}`
|
|
1039
|
+
webSocketToClient = /* @__PURE__ */ new Map();
|
|
1040
|
+
pendingAuthTimers = /* @__PURE__ */ new Map();
|
|
1041
|
+
webSocketToIp = /* @__PURE__ */ new Map();
|
|
1042
|
+
/**
|
|
1043
|
+
* Initializes a new Sync coordinator instance.
|
|
1044
|
+
*
|
|
1045
|
+
* @param storage - Pluggable backend storage engine.
|
|
1046
|
+
* @param options - Configuration options for concurrency limits, auth timeout, and rate limiting.
|
|
1047
|
+
*/
|
|
1048
|
+
constructor(storage, options = {}) {
|
|
1049
|
+
this.storage = storage;
|
|
1050
|
+
this.maxConcurrentConnectionsPerUser = options.maxConcurrentConnectionsPerUser ?? 20;
|
|
1051
|
+
this.authTimeoutMs = options.authTimeoutMs ?? 1e4;
|
|
1052
|
+
this.rateLimiter = options.rateLimiter ?? null;
|
|
1053
|
+
this.logger = options.logger ?? null;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Total number of currently active authenticated WebSocket client connections.
|
|
1057
|
+
*/
|
|
1058
|
+
get connectedClientsCount() {
|
|
1059
|
+
return this.webSocketToClient.size;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Handles an incoming WebSocket connection, binding message, error, and disconnection events.
|
|
1063
|
+
*
|
|
1064
|
+
* @param webSocket - Active WebSocket connection.
|
|
1065
|
+
* @param clientIp - Remote client IP address.
|
|
1066
|
+
*/
|
|
1067
|
+
handleConnection(webSocket, clientIp = "127.0.0.1") {
|
|
1068
|
+
this.webSocketToIp.set(webSocket, clientIp);
|
|
1069
|
+
if (this.rateLimiter && !this.rateLimiter.consume(clientIp)) {
|
|
1070
|
+
this.send(webSocket, {
|
|
1071
|
+
type: "auth_error" /* AuthError */,
|
|
1072
|
+
message: "Too many connection attempts"
|
|
1073
|
+
});
|
|
1074
|
+
webSocket.close();
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (this.authTimeoutMs > 0) {
|
|
1078
|
+
const timer = setTimeout(() => {
|
|
1079
|
+
if (!this.webSocketToClient.has(webSocket)) {
|
|
1080
|
+
this.send(webSocket, {
|
|
1081
|
+
type: "auth_error" /* AuthError */,
|
|
1082
|
+
message: "Authentication timeout"
|
|
1083
|
+
});
|
|
1084
|
+
webSocket.close();
|
|
1085
|
+
}
|
|
1086
|
+
}, this.authTimeoutMs);
|
|
1087
|
+
this.pendingAuthTimers.set(webSocket, timer);
|
|
1088
|
+
}
|
|
1089
|
+
let messageQueue = Promise.resolve();
|
|
1090
|
+
webSocket.on("message", (data) => {
|
|
1091
|
+
const client = this.webSocketToClient.get(webSocket);
|
|
1092
|
+
const userContext = client ? ` (app: "${client.appId}", user: "${client.user.id}", client: "${client.clientId}")` : "";
|
|
1093
|
+
messageQueue = messageQueue.then(async () => {
|
|
1094
|
+
try {
|
|
1095
|
+
const raw = typeof data === "string" ? data : data.toString();
|
|
1096
|
+
const msg = JSON.parse(raw);
|
|
1097
|
+
await this.handleMessage(webSocket, msg);
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
const message = err instanceof Error ? err.message : "Unknown server error";
|
|
1100
|
+
this.logger?.error(
|
|
1101
|
+
`[TetherServer.Sync] Error processing WebSocket message${userContext}:`,
|
|
1102
|
+
err
|
|
1103
|
+
);
|
|
1104
|
+
this.send(webSocket, {
|
|
1105
|
+
type: "error" /* Error */,
|
|
1106
|
+
message
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}).catch((err) => {
|
|
1110
|
+
this.logger?.error(
|
|
1111
|
+
`[TetherServer.Sync] Unhandled error in message queue${userContext}:`,
|
|
1112
|
+
err
|
|
1113
|
+
);
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
webSocket.on("error", (err) => {
|
|
1117
|
+
const client = this.webSocketToClient.get(webSocket);
|
|
1118
|
+
const userContext = client ? ` (app: "${client.appId}", user: "${client.user.id}", client: "${client.clientId}")` : "";
|
|
1119
|
+
this.logger?.error(
|
|
1120
|
+
`[TetherServer.Sync] WebSocket connection error${userContext}:`,
|
|
1121
|
+
err
|
|
1122
|
+
);
|
|
1123
|
+
this.cleanupConnection(webSocket);
|
|
1124
|
+
});
|
|
1125
|
+
webSocket.on("close", () => {
|
|
1126
|
+
this.cleanupConnection(webSocket);
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Routes and executes incoming client protocol messages.
|
|
1131
|
+
*
|
|
1132
|
+
* @param webSocket - The connection that sent the message.
|
|
1133
|
+
* @param msg - Parsed client protocol message.
|
|
1134
|
+
*/
|
|
1135
|
+
async handleMessage(webSocket, msg) {
|
|
1136
|
+
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
|
|
1137
|
+
throw new TetherServerError(
|
|
1138
|
+
0 /* InvalidInput */,
|
|
1139
|
+
"Invalid message format"
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
switch (msg.type) {
|
|
1143
|
+
case "auth" /* Auth */:
|
|
1144
|
+
await this.handleAuthMessage(webSocket, msg);
|
|
1145
|
+
break;
|
|
1146
|
+
case "change_batch" /* ChangeBatch */:
|
|
1147
|
+
await this.handleChangeBatchMessage(webSocket, msg);
|
|
1148
|
+
break;
|
|
1149
|
+
case "ping" /* Ping */:
|
|
1150
|
+
this.handlePingMessage(webSocket);
|
|
1151
|
+
break;
|
|
1152
|
+
default:
|
|
1153
|
+
throw new TetherServerError(
|
|
1154
|
+
0 /* InvalidInput */,
|
|
1155
|
+
"Unsupported message type"
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
// -- Private Message Handlers ---------------------------------------------
|
|
1160
|
+
async handleAuthMessage(webSocket, msg) {
|
|
1161
|
+
const ip = this.webSocketToIp.get(webSocket) ?? "127.0.0.1";
|
|
1162
|
+
const authTimer = this.pendingAuthTimers.get(webSocket);
|
|
1163
|
+
if (authTimer) {
|
|
1164
|
+
clearTimeout(authTimer);
|
|
1165
|
+
this.pendingAuthTimers.delete(webSocket);
|
|
1166
|
+
}
|
|
1167
|
+
if (msg.protocolVersion !== PROTOCOL_VERSION) {
|
|
1168
|
+
this.rateLimiter?.recordFailure(ip);
|
|
1169
|
+
this.send(webSocket, {
|
|
1170
|
+
type: "auth_error" /* AuthError */,
|
|
1171
|
+
message: `Unsupported protocol version: expected ${PROTOCOL_VERSION}, got ${msg.protocolVersion}`
|
|
1172
|
+
});
|
|
1173
|
+
webSocket.close();
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (typeof msg.token !== "string" || !msg.token) {
|
|
1177
|
+
this.rateLimiter?.recordFailure(ip);
|
|
1178
|
+
this.send(webSocket, {
|
|
1179
|
+
type: "auth_error" /* AuthError */,
|
|
1180
|
+
message: "Missing or invalid authentication token"
|
|
1181
|
+
});
|
|
1182
|
+
webSocket.close();
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const user = await this.storage.getUserByToken(msg.token);
|
|
1186
|
+
if (!user) {
|
|
1187
|
+
this.rateLimiter?.recordFailure(ip);
|
|
1188
|
+
this.send(webSocket, {
|
|
1189
|
+
type: "auth_error" /* AuthError */,
|
|
1190
|
+
message: "Invalid or expired authentication token"
|
|
1191
|
+
});
|
|
1192
|
+
webSocket.close();
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (typeof msg.appId !== "string" || !msg.appId) {
|
|
1196
|
+
this.send(webSocket, {
|
|
1197
|
+
type: "auth_error" /* AuthError */,
|
|
1198
|
+
message: "Missing required field: appId"
|
|
1199
|
+
});
|
|
1200
|
+
webSocket.close();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const appId = validateAppId(msg.appId);
|
|
1204
|
+
const app = await this.storage.getApp(appId);
|
|
1205
|
+
if (!app) {
|
|
1206
|
+
this.send(webSocket, {
|
|
1207
|
+
type: "auth_error" /* AuthError */,
|
|
1208
|
+
message: "Application not found"
|
|
1209
|
+
});
|
|
1210
|
+
webSocket.close();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const channelKey = `${appId}:${user.id}`;
|
|
1214
|
+
let set = this.userClients.get(channelKey);
|
|
1215
|
+
if (!set) {
|
|
1216
|
+
set = /* @__PURE__ */ new Set();
|
|
1217
|
+
this.userClients.set(channelKey, set);
|
|
1218
|
+
}
|
|
1219
|
+
if (set.size >= this.maxConcurrentConnectionsPerUser) {
|
|
1220
|
+
this.send(webSocket, {
|
|
1221
|
+
type: "auth_error" /* AuthError */,
|
|
1222
|
+
message: "Maximum concurrent connections exceeded for this user"
|
|
1223
|
+
});
|
|
1224
|
+
webSocket.close();
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
this.rateLimiter?.reset(ip);
|
|
1228
|
+
const clientId = validateIdentifier(
|
|
1229
|
+
msg.clientId ?? "client_anon",
|
|
1230
|
+
"clientId"
|
|
1231
|
+
);
|
|
1232
|
+
const existingClient = this.webSocketToClient.get(webSocket);
|
|
1233
|
+
if (existingClient) {
|
|
1234
|
+
const oldChannelKey = `${existingClient.appId}:${existingClient.user.id}`;
|
|
1235
|
+
const oldSet = this.userClients.get(oldChannelKey);
|
|
1236
|
+
if (oldSet) {
|
|
1237
|
+
oldSet.delete(existingClient);
|
|
1238
|
+
if (oldSet.size === 0) {
|
|
1239
|
+
this.userClients.delete(oldChannelKey);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
this.webSocketToClient.delete(webSocket);
|
|
1243
|
+
}
|
|
1244
|
+
const client = {
|
|
1245
|
+
webSocket,
|
|
1246
|
+
clientId,
|
|
1247
|
+
user,
|
|
1248
|
+
appId
|
|
1249
|
+
};
|
|
1250
|
+
this.webSocketToClient.set(webSocket, client);
|
|
1251
|
+
set.add(client);
|
|
1252
|
+
const currentSeq = await app.getCurrentSeq(user);
|
|
1253
|
+
const refreshedToken = await user.createToken();
|
|
1254
|
+
this.send(webSocket, {
|
|
1255
|
+
type: "auth_success" /* AuthSuccess */,
|
|
1256
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1257
|
+
userId: user.id,
|
|
1258
|
+
currentSeq,
|
|
1259
|
+
token: refreshedToken
|
|
1260
|
+
});
|
|
1261
|
+
await this.performSync(client, msg.lastSyncSeq);
|
|
1262
|
+
}
|
|
1263
|
+
async handleChangeBatchMessage(webSocket, msg) {
|
|
1264
|
+
const client = this.webSocketToClient.get(webSocket);
|
|
1265
|
+
if (!client) {
|
|
1266
|
+
this.send(webSocket, {
|
|
1267
|
+
type: "auth_error" /* AuthError */,
|
|
1268
|
+
message: "Not authenticated"
|
|
1269
|
+
});
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (!Array.isArray(msg.changes)) {
|
|
1273
|
+
throw new TetherServerError(
|
|
1274
|
+
0 /* InvalidInput */,
|
|
1275
|
+
"Invalid change batch: changes must be an array"
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
const maxBatchSize = this.storage.options?.maxBatchSizeBytes ?? 5 * 1024 * 1024;
|
|
1279
|
+
const batchBytes = calculateByteSize(msg.changes);
|
|
1280
|
+
if (batchBytes > maxBatchSize) {
|
|
1281
|
+
throw new TetherServerError(
|
|
1282
|
+
5 /* LimitExceeded */,
|
|
1283
|
+
"Change batch exceeds maximum allowed size"
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
const batchId = validateIdentifier(msg.batchId, "batchId");
|
|
1287
|
+
const app = await this.storage.getApp(client.appId);
|
|
1288
|
+
if (!app) {
|
|
1289
|
+
throw new TetherServerError(
|
|
1290
|
+
1 /* NotFound */,
|
|
1291
|
+
"Application not found"
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
const { applied, newSeq } = await app.applyChanges(
|
|
1295
|
+
client.user,
|
|
1296
|
+
msg.changes
|
|
1297
|
+
);
|
|
1298
|
+
this.send(webSocket, {
|
|
1299
|
+
type: "change_ack" /* ChangeAck */,
|
|
1300
|
+
batchId,
|
|
1301
|
+
appliedSeq: newSeq
|
|
1302
|
+
});
|
|
1303
|
+
if (applied.length > 0) {
|
|
1304
|
+
this.broadcastToAppUser(client.appId, client.user.id, client.clientId, {
|
|
1305
|
+
type: "broadcast_changes" /* BroadcastChanges */,
|
|
1306
|
+
fromClientId: client.clientId,
|
|
1307
|
+
changes: applied,
|
|
1308
|
+
seq: newSeq
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
handlePingMessage(webSocket) {
|
|
1313
|
+
this.send(webSocket, {
|
|
1314
|
+
type: "pong" /* Pong */
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
// -- Private Helpers ------------------------------------------------------
|
|
1318
|
+
cleanupConnection(webSocket) {
|
|
1319
|
+
const authTimer = this.pendingAuthTimers.get(webSocket);
|
|
1320
|
+
if (authTimer) {
|
|
1321
|
+
clearTimeout(authTimer);
|
|
1322
|
+
this.pendingAuthTimers.delete(webSocket);
|
|
1323
|
+
}
|
|
1324
|
+
this.webSocketToIp.delete(webSocket);
|
|
1325
|
+
const client = this.webSocketToClient.get(webSocket);
|
|
1326
|
+
if (!client) return;
|
|
1327
|
+
this.webSocketToClient.delete(webSocket);
|
|
1328
|
+
const channelKey = `${client.appId}:${client.user.id}`;
|
|
1329
|
+
const set = this.userClients.get(channelKey);
|
|
1330
|
+
if (set) {
|
|
1331
|
+
set.delete(client);
|
|
1332
|
+
if (set.size === 0) {
|
|
1333
|
+
this.userClients.delete(channelKey);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
send(webSocket, msg) {
|
|
1338
|
+
if (webSocket.readyState === 1) {
|
|
1339
|
+
webSocket.send(JSON.stringify(msg));
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async performSync(client, lastSyncSeq) {
|
|
1343
|
+
const app = await this.storage.getApp(client.appId);
|
|
1344
|
+
if (!app) {
|
|
1345
|
+
throw new TetherServerError(
|
|
1346
|
+
1 /* NotFound */,
|
|
1347
|
+
"Application not found"
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
const seq = lastSyncSeq ?? 0;
|
|
1351
|
+
if (seq === 0) {
|
|
1352
|
+
const snapshot = await this.getAppSnapshot(app, client.user);
|
|
1353
|
+
const currentSeq = await app.getCurrentSeq(client.user);
|
|
1354
|
+
this.send(client.webSocket, {
|
|
1355
|
+
type: "sync_snapshot" /* SyncSnapshot */,
|
|
1356
|
+
seq: currentSeq,
|
|
1357
|
+
snapshot
|
|
1358
|
+
});
|
|
1359
|
+
} else {
|
|
1360
|
+
const { changes, currentSeq, requiresSnapshot } = await app.getChangesSince(client.user, seq);
|
|
1361
|
+
if (requiresSnapshot) {
|
|
1362
|
+
const snapshot = await this.getAppSnapshot(app, client.user);
|
|
1363
|
+
this.send(client.webSocket, {
|
|
1364
|
+
type: "sync_snapshot" /* SyncSnapshot */,
|
|
1365
|
+
seq: currentSeq,
|
|
1366
|
+
snapshot
|
|
1367
|
+
});
|
|
1368
|
+
} else {
|
|
1369
|
+
this.send(client.webSocket, {
|
|
1370
|
+
type: "sync_diff" /* SyncDiff */,
|
|
1371
|
+
fromSeq: seq,
|
|
1372
|
+
toSeq: currentSeq,
|
|
1373
|
+
changes
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
async getAppSnapshot(app, user) {
|
|
1379
|
+
const tables = await app.getTables();
|
|
1380
|
+
const snapshot = [];
|
|
1381
|
+
for (const table of tables) {
|
|
1382
|
+
const records = await table.getAllRecords(user);
|
|
1383
|
+
snapshot.push(...records);
|
|
1384
|
+
}
|
|
1385
|
+
return snapshot;
|
|
1386
|
+
}
|
|
1387
|
+
broadcastToAppUser(appId, userId, excludeClientId, msg) {
|
|
1388
|
+
const channelKey = `${appId}:${userId}`;
|
|
1389
|
+
const clients = this.userClients.get(channelKey);
|
|
1390
|
+
if (!clients) return;
|
|
1391
|
+
for (const client of clients) {
|
|
1392
|
+
if (client.clientId !== excludeClientId) {
|
|
1393
|
+
this.send(client.webSocket, msg);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// src/server/server.ts
|
|
1400
|
+
var TetherServer = class {
|
|
1401
|
+
/** Underlying storage engine for users, apps, and tables. */
|
|
1402
|
+
storage;
|
|
1403
|
+
/** Real-time synchronization connection and broadcast coordinator. */
|
|
1404
|
+
sync;
|
|
1405
|
+
/** Base path for HTTP REST endpoints. */
|
|
1406
|
+
basePath;
|
|
1407
|
+
/** Path for WebSocket upgrade requests. */
|
|
1408
|
+
webSocketPath;
|
|
1409
|
+
trustProxy;
|
|
1410
|
+
allowRegistration;
|
|
1411
|
+
corsConfig;
|
|
1412
|
+
logger;
|
|
1413
|
+
ipLoginLimiter;
|
|
1414
|
+
userLoginLimiter;
|
|
1415
|
+
ipRegisterLimiter;
|
|
1416
|
+
_httpServer = null;
|
|
1417
|
+
_webSocketServer = null;
|
|
1418
|
+
lockHandle = null;
|
|
1419
|
+
/**
|
|
1420
|
+
* Initializes a new TetherServer instance.
|
|
1421
|
+
*
|
|
1422
|
+
* @param options - Configuration options for storage, endpoints, and rate limiting.
|
|
1423
|
+
*/
|
|
1424
|
+
constructor(options = {}) {
|
|
1425
|
+
this.storage = options.storage ?? new MemoryStorage();
|
|
1426
|
+
this.basePath = normalizeBasePath(options.basePath ?? "");
|
|
1427
|
+
this.webSocketPath = options.webSocketPath ?? `${this.basePath}/sync`;
|
|
1428
|
+
this.allowRegistration = options.allowRegistration ?? true;
|
|
1429
|
+
this.trustProxy = options.trustProxy ?? false;
|
|
1430
|
+
this.corsConfig = options.cors === false ? null : typeof options.cors === "object" ? options.cors : {};
|
|
1431
|
+
this.logger = options.logger === false ? null : options.logger ?? console;
|
|
1432
|
+
const rateLimitConfig = options.rateLimiting ?? true;
|
|
1433
|
+
if (rateLimitConfig === false) {
|
|
1434
|
+
this.ipLoginLimiter = null;
|
|
1435
|
+
this.userLoginLimiter = null;
|
|
1436
|
+
this.ipRegisterLimiter = null;
|
|
1437
|
+
this.sync = new Sync(this.storage, {
|
|
1438
|
+
maxConcurrentConnectionsPerUser: 1e3,
|
|
1439
|
+
authTimeoutMs: 0,
|
|
1440
|
+
rateLimiter: null,
|
|
1441
|
+
logger: this.logger
|
|
1442
|
+
});
|
|
1443
|
+
} else {
|
|
1444
|
+
const opts = typeof rateLimitConfig === "object" ? rateLimitConfig : {};
|
|
1445
|
+
const windowMs = opts.windowMs ?? 6e4;
|
|
1446
|
+
const maxFailures = opts.maxFailures ?? 5;
|
|
1447
|
+
const initialBackoffMs = opts.initialBackoffMs ?? 1e3;
|
|
1448
|
+
const maxBackoffMs = opts.maxBackoffMs ?? 9e5;
|
|
1449
|
+
this.ipLoginLimiter = new RateLimiter({
|
|
1450
|
+
windowMs,
|
|
1451
|
+
maxRequests: opts.ipLoginMaxRequests ?? 100,
|
|
1452
|
+
maxFailures,
|
|
1453
|
+
initialBackoffMs,
|
|
1454
|
+
maxBackoffMs
|
|
1455
|
+
});
|
|
1456
|
+
this.userLoginLimiter = new RateLimiter({
|
|
1457
|
+
windowMs,
|
|
1458
|
+
maxRequests: opts.userLoginMaxRequests ?? 20,
|
|
1459
|
+
maxFailures,
|
|
1460
|
+
initialBackoffMs,
|
|
1461
|
+
maxBackoffMs
|
|
1462
|
+
});
|
|
1463
|
+
this.ipRegisterLimiter = new RateLimiter({
|
|
1464
|
+
windowMs,
|
|
1465
|
+
maxRequests: opts.ipRegisterMaxRequests ?? 100
|
|
1466
|
+
});
|
|
1467
|
+
const syncLimiter = new RateLimiter({
|
|
1468
|
+
windowMs,
|
|
1469
|
+
maxRequests: opts.ipSyncMaxRequests ?? 100,
|
|
1470
|
+
maxFailures,
|
|
1471
|
+
initialBackoffMs,
|
|
1472
|
+
maxBackoffMs
|
|
1473
|
+
});
|
|
1474
|
+
this.sync = new Sync(this.storage, {
|
|
1475
|
+
maxConcurrentConnectionsPerUser: opts.maxConcurrentConnectionsPerUser ?? 20,
|
|
1476
|
+
authTimeoutMs: opts.authTimeoutMs ?? 1e4,
|
|
1477
|
+
rateLimiter: syncLimiter,
|
|
1478
|
+
logger: this.logger
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Active Node.js HTTP server instance, or `null` if not listening.
|
|
1484
|
+
*/
|
|
1485
|
+
get httpServer() {
|
|
1486
|
+
return this._httpServer;
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Active WebSocketServer instance, or `null` if not listening.
|
|
1490
|
+
*/
|
|
1491
|
+
get webSocketServer() {
|
|
1492
|
+
return this._webSocketServer;
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Declares an application and its tables.
|
|
1496
|
+
* Registers the application and any declared tables if not already present.
|
|
1497
|
+
*
|
|
1498
|
+
* @param appId - Application identifier.
|
|
1499
|
+
* @param tables - Array of table names.
|
|
1500
|
+
*/
|
|
1501
|
+
async declareApp(appId, tables = []) {
|
|
1502
|
+
let app = await this.storage.getApp(appId);
|
|
1503
|
+
if (!app) {
|
|
1504
|
+
app = await this.storage.createApp(appId);
|
|
1505
|
+
}
|
|
1506
|
+
for (const table of tables) {
|
|
1507
|
+
const existing = await app.getTable(table);
|
|
1508
|
+
if (!existing) {
|
|
1509
|
+
await app.createTable(table);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Declares a user account with the specified username and password.
|
|
1515
|
+
* Creates the user if not already registered, or updates the existing user's password.
|
|
1516
|
+
*
|
|
1517
|
+
* @param username - Username for the account.
|
|
1518
|
+
* @param password - Plaintext password for the account.
|
|
1519
|
+
* @returns UserStorage handle for the declared user.
|
|
1520
|
+
*/
|
|
1521
|
+
async declareUser(username, password) {
|
|
1522
|
+
const user = await this.storage.getUserByUsername(username);
|
|
1523
|
+
if (user) {
|
|
1524
|
+
await user.changePassword(password);
|
|
1525
|
+
return user;
|
|
1526
|
+
}
|
|
1527
|
+
return this.storage.createUser(username, password);
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Attaches WebSocket synchronization handling to an existing HTTP server.
|
|
1531
|
+
*
|
|
1532
|
+
* @param server - The HTTP server instance to attach to.
|
|
1533
|
+
*/
|
|
1534
|
+
attach(server) {
|
|
1535
|
+
if (!this._webSocketServer) {
|
|
1536
|
+
this._webSocketServer = new WebSocketServer({
|
|
1537
|
+
noServer: true,
|
|
1538
|
+
perMessageDeflate: {
|
|
1539
|
+
zlibDeflateOptions: {
|
|
1540
|
+
level: 6,
|
|
1541
|
+
memLevel: 8
|
|
1542
|
+
},
|
|
1543
|
+
threshold: 1024,
|
|
1544
|
+
clientNoContextTakeover: true,
|
|
1545
|
+
serverNoContextTakeover: true
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
this._webSocketServer.on("connection", (ws, req) => {
|
|
1549
|
+
const ip = req ? this.getClientIp(req) : "127.0.0.1";
|
|
1550
|
+
this.sync.handleConnection(ws, ip);
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1554
|
+
const url = new URL(
|
|
1555
|
+
req.url ?? "",
|
|
1556
|
+
`http://${req.headers.host ?? "localhost"}`
|
|
1557
|
+
);
|
|
1558
|
+
if (url.pathname === this.webSocketPath) {
|
|
1559
|
+
this._webSocketServer?.handleUpgrade(req, socket, head, (ws) => {
|
|
1560
|
+
this._webSocketServer?.emit("connection", ws, req);
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Starts the HTTP and WebSocket server listening on the specified port and host.
|
|
1567
|
+
*
|
|
1568
|
+
* @param port - Port number to bind. Defaults to 8080.
|
|
1569
|
+
* @param host - Host interface to bind. Defaults to '0.0.0.0'.
|
|
1570
|
+
* @returns The active Node.js HTTP server instance.
|
|
1571
|
+
*/
|
|
1572
|
+
async listen(port = 8080, host = "0.0.0.0") {
|
|
1573
|
+
const storageBaseDir = this.storage.baseDir;
|
|
1574
|
+
const isMemory = this.storage.inMemory ?? this.storage instanceof MemoryStorage;
|
|
1575
|
+
if (storageBaseDir && !isMemory) {
|
|
1576
|
+
const status = await this.storage.getStatus();
|
|
1577
|
+
this.lockHandle = acquireServerLock(storageBaseDir, {
|
|
1578
|
+
port,
|
|
1579
|
+
host,
|
|
1580
|
+
backend: status.backend
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
return new Promise((resolve, reject) => {
|
|
1584
|
+
this._httpServer = http.createServer(async (req, res) => {
|
|
1585
|
+
const handled = await this.handleHttpRequest(req, res);
|
|
1586
|
+
if (!handled) {
|
|
1587
|
+
this.sendJson(res, 404, { error: "Not found" });
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
this.attach(this._httpServer);
|
|
1591
|
+
this._httpServer.listen(port, host, () => {
|
|
1592
|
+
if (this._httpServer) {
|
|
1593
|
+
const addr = this._httpServer.address();
|
|
1594
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1595
|
+
if (this.lockHandle && storageBaseDir && this.lockHandle.info.port !== actualPort) {
|
|
1596
|
+
this.lockHandle.release();
|
|
1597
|
+
this.lockHandle = acquireServerLock(storageBaseDir, {
|
|
1598
|
+
port: actualPort,
|
|
1599
|
+
host,
|
|
1600
|
+
backend: this.lockHandle.info.backend
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
resolve(this._httpServer);
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
this._httpServer.on("error", (err) => {
|
|
1607
|
+
if (this.lockHandle) {
|
|
1608
|
+
this.lockHandle.release();
|
|
1609
|
+
this.lockHandle = null;
|
|
1610
|
+
}
|
|
1611
|
+
reject(err);
|
|
1612
|
+
});
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Closes active HTTP server and WebSocket server listeners.
|
|
1617
|
+
*/
|
|
1618
|
+
async close() {
|
|
1619
|
+
if (this.lockHandle) {
|
|
1620
|
+
this.lockHandle.release();
|
|
1621
|
+
this.lockHandle = null;
|
|
1622
|
+
}
|
|
1623
|
+
return new Promise((resolve, reject) => {
|
|
1624
|
+
if (this._webSocketServer) {
|
|
1625
|
+
for (const client of this._webSocketServer.clients) {
|
|
1626
|
+
try {
|
|
1627
|
+
client.terminate();
|
|
1628
|
+
} catch {
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
this._webSocketServer.close();
|
|
1632
|
+
this._webSocketServer = null;
|
|
1633
|
+
}
|
|
1634
|
+
if (this._httpServer) {
|
|
1635
|
+
try {
|
|
1636
|
+
this._httpServer.closeAllConnections?.();
|
|
1637
|
+
} catch {
|
|
1638
|
+
}
|
|
1639
|
+
this._httpServer.close((err) => {
|
|
1640
|
+
this._httpServer = null;
|
|
1641
|
+
if (err) reject(err);
|
|
1642
|
+
else resolve();
|
|
1643
|
+
});
|
|
1644
|
+
} else {
|
|
1645
|
+
resolve();
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Creates a Connect- and Express-compatible HTTP middleware handler.
|
|
1651
|
+
*
|
|
1652
|
+
* @returns Middleware function `(req, res, next) => void`.
|
|
1653
|
+
*/
|
|
1654
|
+
createMiddleware() {
|
|
1655
|
+
return (req, res, next) => {
|
|
1656
|
+
this.handleHttpRequest(req, res).then(
|
|
1657
|
+
(handled) => {
|
|
1658
|
+
if (!handled) next();
|
|
1659
|
+
},
|
|
1660
|
+
(err) => {
|
|
1661
|
+
next(err);
|
|
1662
|
+
}
|
|
1663
|
+
);
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Handles incoming HTTP requests for authentication and discovery endpoints.
|
|
1668
|
+
*
|
|
1669
|
+
* @param req - Incoming HTTP request.
|
|
1670
|
+
* @param res - Server HTTP response.
|
|
1671
|
+
* @returns `true` if the request was handled by TetherDB; `false` if the path did not match.
|
|
1672
|
+
*/
|
|
1673
|
+
async handleHttpRequest(req, res) {
|
|
1674
|
+
const url = new URL(
|
|
1675
|
+
req.url ?? "/",
|
|
1676
|
+
`http://${req.headers.host ?? "localhost"}`
|
|
1677
|
+
);
|
|
1678
|
+
const method = req.method?.toUpperCase();
|
|
1679
|
+
if (method === "OPTIONS") {
|
|
1680
|
+
this.handleOptions(req, res);
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
if (method === "GET" && url.pathname === `${this.basePath}/health`) {
|
|
1685
|
+
this.handleHealth(req, res);
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
if (method === "GET" && url.pathname === `${this.basePath}/ready`) {
|
|
1689
|
+
await this.handleReady(req, res);
|
|
1690
|
+
return true;
|
|
1691
|
+
}
|
|
1692
|
+
if (method === "GET" && url.pathname === `${this.basePath}/metrics`) {
|
|
1693
|
+
await this.handleMetrics(req, res);
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
if (this.allowRegistration && method === "POST" && url.pathname === `${this.basePath}/auth/register`) {
|
|
1697
|
+
await this.handleRegister(req, res);
|
|
1698
|
+
return true;
|
|
1699
|
+
}
|
|
1700
|
+
if (method === "POST" && url.pathname === `${this.basePath}/auth/login`) {
|
|
1701
|
+
await this.handleLogin(req, res);
|
|
1702
|
+
return true;
|
|
1703
|
+
}
|
|
1704
|
+
return false;
|
|
1705
|
+
} catch (err) {
|
|
1706
|
+
const status = getHttpStatusForError(err);
|
|
1707
|
+
if (status >= 500) {
|
|
1708
|
+
this.logger?.error("Error handling HTTP request:", err);
|
|
1709
|
+
} else {
|
|
1710
|
+
this.logger?.debug("Client error handling HTTP request:", err);
|
|
1711
|
+
}
|
|
1712
|
+
const msg = err instanceof Error ? err.message : "Internal server error";
|
|
1713
|
+
this.sendJson(res, status, { error: msg }, req);
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
// -- Private Helpers ------------------------------------------------------
|
|
1718
|
+
getCorsHeaders(req) {
|
|
1719
|
+
if (!this.corsConfig) return {};
|
|
1720
|
+
const headers = {
|
|
1721
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
1722
|
+
"Access-Control-Allow-Headers": (this.corsConfig.allowedHeaders ?? ["Content-Type", "Authorization"]).join(", ")
|
|
1723
|
+
};
|
|
1724
|
+
if (this.corsConfig.exposedHeaders && this.corsConfig.exposedHeaders.length > 0) {
|
|
1725
|
+
headers["Access-Control-Expose-Headers"] = this.corsConfig.exposedHeaders.join(", ");
|
|
1726
|
+
}
|
|
1727
|
+
if (this.corsConfig.maxAge !== void 0) {
|
|
1728
|
+
headers["Access-Control-Max-Age"] = String(this.corsConfig.maxAge);
|
|
1729
|
+
}
|
|
1730
|
+
const reqOrigin = req?.headers.origin;
|
|
1731
|
+
const origin = this.corsConfig.origin ?? "*";
|
|
1732
|
+
if (origin === "*") {
|
|
1733
|
+
if (this.corsConfig.credentials) {
|
|
1734
|
+
if (reqOrigin) {
|
|
1735
|
+
headers["Access-Control-Allow-Origin"] = reqOrigin;
|
|
1736
|
+
headers.Vary = "Origin";
|
|
1737
|
+
}
|
|
1738
|
+
} else {
|
|
1739
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
1740
|
+
}
|
|
1741
|
+
} else if (typeof origin === "string") {
|
|
1742
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
1743
|
+
headers.Vary = "Origin";
|
|
1744
|
+
} else if (Array.isArray(origin)) {
|
|
1745
|
+
if (reqOrigin && origin.includes(reqOrigin)) {
|
|
1746
|
+
headers["Access-Control-Allow-Origin"] = reqOrigin;
|
|
1747
|
+
headers.Vary = "Origin";
|
|
1748
|
+
}
|
|
1749
|
+
} else if (origin === true && reqOrigin) {
|
|
1750
|
+
headers["Access-Control-Allow-Origin"] = reqOrigin;
|
|
1751
|
+
headers.Vary = "Origin";
|
|
1752
|
+
}
|
|
1753
|
+
if (this.corsConfig.credentials) {
|
|
1754
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
1755
|
+
}
|
|
1756
|
+
return headers;
|
|
1757
|
+
}
|
|
1758
|
+
sendJson(res, status, data, req) {
|
|
1759
|
+
res.writeHead(status, {
|
|
1760
|
+
"Content-Type": "application/json",
|
|
1761
|
+
...this.getCorsHeaders(req)
|
|
1762
|
+
});
|
|
1763
|
+
res.end(JSON.stringify(data));
|
|
1764
|
+
}
|
|
1765
|
+
async readJsonBody(req) {
|
|
1766
|
+
return new Promise((resolve, reject) => {
|
|
1767
|
+
let body = "";
|
|
1768
|
+
req.on("data", (chunk) => {
|
|
1769
|
+
body += chunk;
|
|
1770
|
+
if (body.length > 1024 * 1024) {
|
|
1771
|
+
reject(
|
|
1772
|
+
new TetherServerError(
|
|
1773
|
+
5 /* LimitExceeded */,
|
|
1774
|
+
"Payload exceeds maximum allowed size"
|
|
1775
|
+
)
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
req.on("end", () => {
|
|
1780
|
+
try {
|
|
1781
|
+
resolve(body ? JSON.parse(body) : {});
|
|
1782
|
+
} catch {
|
|
1783
|
+
reject(
|
|
1784
|
+
new TetherServerError(
|
|
1785
|
+
0 /* InvalidInput */,
|
|
1786
|
+
"Invalid JSON payload"
|
|
1787
|
+
)
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
req.on("error", reject);
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
handleOptions(req, res) {
|
|
1795
|
+
res.writeHead(204, this.getCorsHeaders(req));
|
|
1796
|
+
res.end();
|
|
1797
|
+
}
|
|
1798
|
+
handleHealth(req, res) {
|
|
1799
|
+
this.sendJson(
|
|
1800
|
+
res,
|
|
1801
|
+
200,
|
|
1802
|
+
{
|
|
1803
|
+
status: "ok",
|
|
1804
|
+
uptime: process.uptime()
|
|
1805
|
+
},
|
|
1806
|
+
req
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
async handleReady(req, res) {
|
|
1810
|
+
try {
|
|
1811
|
+
await this.storage.getApps();
|
|
1812
|
+
this.sendJson(res, 200, { status: "ready" }, req);
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
const message = err instanceof Error ? err.message : "Storage unavailable";
|
|
1815
|
+
this.logger?.error("Storage readiness error:", err);
|
|
1816
|
+
this.sendJson(res, 503, { status: "unready", error: message }, req);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
async handleMetrics(req, res) {
|
|
1820
|
+
const apps = await this.storage.getApps();
|
|
1821
|
+
this.sendJson(
|
|
1822
|
+
res,
|
|
1823
|
+
200,
|
|
1824
|
+
{
|
|
1825
|
+
uptime: process.uptime(),
|
|
1826
|
+
connectedClients: this.sync.connectedClientsCount,
|
|
1827
|
+
appsCount: apps.length,
|
|
1828
|
+
memoryUsage: process.memoryUsage()
|
|
1829
|
+
},
|
|
1830
|
+
req
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
async handleRegister(req, res) {
|
|
1834
|
+
const ip = this.getClientIp(req);
|
|
1835
|
+
if (this.ipRegisterLimiter && !this.ipRegisterLimiter.consume(ip)) {
|
|
1836
|
+
this.sendJson(res, 429, { error: "Too many registration requests" }, req);
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
const credentials = await this.readCredentials(req, res);
|
|
1840
|
+
if (!credentials) return;
|
|
1841
|
+
try {
|
|
1842
|
+
const user = await this.storage.createUser(
|
|
1843
|
+
credentials.username,
|
|
1844
|
+
credentials.password
|
|
1845
|
+
);
|
|
1846
|
+
const token = await user.createToken();
|
|
1847
|
+
this.sendJson(
|
|
1848
|
+
res,
|
|
1849
|
+
201,
|
|
1850
|
+
{
|
|
1851
|
+
userId: user.id,
|
|
1852
|
+
username: user.username,
|
|
1853
|
+
token
|
|
1854
|
+
},
|
|
1855
|
+
req
|
|
1856
|
+
);
|
|
1857
|
+
} catch (err) {
|
|
1858
|
+
const status = getHttpStatusForError(err);
|
|
1859
|
+
if (status >= 500) {
|
|
1860
|
+
this.logger?.error("Registration error:", err);
|
|
1861
|
+
} else {
|
|
1862
|
+
this.logger?.debug("Client registration error:", err);
|
|
1863
|
+
}
|
|
1864
|
+
const msg = err instanceof Error ? err.message : "Registration error";
|
|
1865
|
+
this.sendJson(res, status, { error: msg }, req);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
async handleLogin(req, res) {
|
|
1869
|
+
const ip = this.getClientIp(req);
|
|
1870
|
+
if (this.ipLoginLimiter && !this.ipLoginLimiter.consume(ip)) {
|
|
1871
|
+
this.sendJson(res, 429, { error: "Too many login attempts" }, req);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
const credentials = await this.readCredentials(req, res);
|
|
1875
|
+
if (!credentials) return;
|
|
1876
|
+
const userKey = `${ip}:${credentials.username}`;
|
|
1877
|
+
if (this.userLoginLimiter && !this.userLoginLimiter.consume(userKey)) {
|
|
1878
|
+
this.sendJson(
|
|
1879
|
+
res,
|
|
1880
|
+
429,
|
|
1881
|
+
{ error: "Too many login attempts for this account" },
|
|
1882
|
+
req
|
|
1883
|
+
);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const user = await this.storage.getUserByUsername(credentials.username);
|
|
1887
|
+
const valid = user ? await user.verifyPassword(credentials.password) : await verifyDummyPasswordHash(credentials.password);
|
|
1888
|
+
if (!user || !valid) {
|
|
1889
|
+
this.ipLoginLimiter?.recordFailure(ip);
|
|
1890
|
+
this.userLoginLimiter?.recordFailure(userKey);
|
|
1891
|
+
this.sendJson(
|
|
1892
|
+
res,
|
|
1893
|
+
401,
|
|
1894
|
+
{
|
|
1895
|
+
error: "Invalid username or password"
|
|
1896
|
+
},
|
|
1897
|
+
req
|
|
1898
|
+
);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
this.ipLoginLimiter?.reset(ip);
|
|
1902
|
+
this.userLoginLimiter?.reset(userKey);
|
|
1903
|
+
const token = await user.createToken();
|
|
1904
|
+
this.sendJson(
|
|
1905
|
+
res,
|
|
1906
|
+
200,
|
|
1907
|
+
{
|
|
1908
|
+
userId: user.id,
|
|
1909
|
+
username: user.username,
|
|
1910
|
+
token
|
|
1911
|
+
},
|
|
1912
|
+
req
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
async readCredentials(req, res) {
|
|
1916
|
+
const body = await this.readJsonBody(req);
|
|
1917
|
+
const { username, password } = body;
|
|
1918
|
+
const normUsername = normalizeUsername(username ?? "");
|
|
1919
|
+
const normPassword = normalizePassword(password ?? "");
|
|
1920
|
+
if (!normUsername || !normPassword) {
|
|
1921
|
+
this.sendJson(
|
|
1922
|
+
res,
|
|
1923
|
+
400,
|
|
1924
|
+
{
|
|
1925
|
+
error: "Missing or invalid required field: username and password"
|
|
1926
|
+
},
|
|
1927
|
+
req
|
|
1928
|
+
);
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
return { username: normUsername, password: normPassword };
|
|
1932
|
+
}
|
|
1933
|
+
getClientIp(req) {
|
|
1934
|
+
if (this.trustProxy) {
|
|
1935
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
1936
|
+
if (typeof forwarded === "string") {
|
|
1937
|
+
const first = forwarded.split(",")[0].trim();
|
|
1938
|
+
if (first) return first;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
return req.socket.remoteAddress ?? "127.0.0.1";
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
function getHttpStatusForError(err) {
|
|
1945
|
+
if (err instanceof TetherServerError) {
|
|
1946
|
+
switch (err.code) {
|
|
1947
|
+
case 0 /* InvalidInput */:
|
|
1948
|
+
case 6 /* ConfigurationError */:
|
|
1949
|
+
return 400;
|
|
1950
|
+
case 3 /* Unauthorized */:
|
|
1951
|
+
case 4 /* AuthenticationFailed */:
|
|
1952
|
+
return 401;
|
|
1953
|
+
case 1 /* NotFound */:
|
|
1954
|
+
return 404;
|
|
1955
|
+
case 2 /* AlreadyExists */:
|
|
1956
|
+
return 409;
|
|
1957
|
+
case 5 /* LimitExceeded */:
|
|
1958
|
+
return 413;
|
|
1959
|
+
case 7 /* NotSupported */:
|
|
1960
|
+
return 501;
|
|
1961
|
+
case 8 /* InternalError */:
|
|
1962
|
+
return 500;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return 500;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// src/vite/index.ts
|
|
1969
|
+
function tetherPlugin(options = {}) {
|
|
1970
|
+
let tetherServer = null;
|
|
1971
|
+
async function setupServer(server) {
|
|
1972
|
+
tetherServer = new TetherServer({
|
|
1973
|
+
storage: options.storage ?? new MemoryStorage(),
|
|
1974
|
+
logger: options.logger ?? false,
|
|
1975
|
+
...options
|
|
1976
|
+
});
|
|
1977
|
+
if (options.apps) {
|
|
1978
|
+
for (const app of options.apps) {
|
|
1979
|
+
await tetherServer.declareApp(app.appId, app.tables ?? []);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
if (options.users) {
|
|
1983
|
+
for (const user of options.users) {
|
|
1984
|
+
await tetherServer.declareUser(user.username, user.password);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (server.httpServer) {
|
|
1988
|
+
tetherServer.attach(
|
|
1989
|
+
server.httpServer
|
|
1990
|
+
);
|
|
1991
|
+
server.httpServer.on("close", () => {
|
|
1992
|
+
tetherServer?.close().catch(() => {
|
|
1993
|
+
});
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
server.middlewares.use(tetherServer.createMiddleware());
|
|
1997
|
+
}
|
|
1998
|
+
return {
|
|
1999
|
+
name: "vite-plugin-tetherdb",
|
|
2000
|
+
configureServer: setupServer,
|
|
2001
|
+
configurePreviewServer: setupServer,
|
|
2002
|
+
async closeBundle() {
|
|
2003
|
+
if (tetherServer) {
|
|
2004
|
+
await tetherServer.close();
|
|
2005
|
+
tetherServer = null;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
export {
|
|
2011
|
+
tetherPlugin
|
|
2012
|
+
};
|
|
2013
|
+
//# sourceMappingURL=index.js.map
|