@codemem/server 0.22.0 → 0.22.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/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +182 -18
- package/dist/index.js.map +1 -1
- package/dist/request-rate-limit.d.ts +8 -0
- package/dist/request-rate-limit.d.ts.map +1 -0
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/stats.d.ts +1 -1
- package/dist/routes/stats.d.ts.map +1 -1
- package/dist/routes/sync.d.ts +11 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +3 -2
- package/static/app.js +80 -40
- package/static/index.html +8 -0
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { ObserverClient } from "@codemem/core";
|
|
11
11
|
import { MemoryStore, type RawEventSweeper, VERSION } from "@codemem/core";
|
|
12
12
|
import { Hono } from "hono";
|
|
13
|
+
import { type InMemoryRequestRateLimiter } from "./request-rate-limit.js";
|
|
13
14
|
export { VERSION };
|
|
14
15
|
/** Get (or create) the shared store instance. Exported so the sweeper can share it. */
|
|
15
16
|
export declare function getStore(): MemoryStore;
|
|
@@ -23,6 +24,13 @@ export interface AppOptions {
|
|
|
23
24
|
storeFactory?: () => MemoryStore;
|
|
24
25
|
sweeper?: RawEventSweeper | null;
|
|
25
26
|
observer?: ObserverClient | null;
|
|
27
|
+
syncRequestRateLimit?: {
|
|
28
|
+
limiter?: InMemoryRequestRateLimiter;
|
|
29
|
+
readLimit?: number;
|
|
30
|
+
mutationLimit?: number;
|
|
31
|
+
unauthenticatedReadLimit?: number;
|
|
32
|
+
unauthenticatedMutationLimit?: number;
|
|
33
|
+
};
|
|
26
34
|
getSyncRuntimeStatus?: () => {
|
|
27
35
|
phase: "starting" | "running" | "stopping" | "error" | "disabled" | "rebootstrapping" | "needs_attention" | null;
|
|
28
36
|
detail?: string | null;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAiB,OAAO,EAAE,MAAM,eAAe,CAAC;AAE1F,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAiB,OAAO,EAAE,MAAM,eAAe,CAAC;AAE1F,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAEN,KAAK,0BAA0B,EAC/B,MAAM,yBAAyB,CAAC;AAQjC,OAAO,EAAE,OAAO,EAAE,CAAC;AAKnB,uFAAuF;AACvF,wBAAgB,QAAQ,IAAI,WAAW,CAKtC;AAED,mDAAmD;AACnD,wBAAgB,UAAU,IAAI,IAAI,CAGjC;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IAC1B,YAAY,CAAC,EAAE,MAAM,WAAW,CAAC;IACjC,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,EAAE,cAAc,GAAG,IAAI,CAAC;IACjC,oBAAoB,CAAC,EAAE;QACtB,OAAO,CAAC,EAAE,0BAA0B,CAAC;QACrC,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,4BAA4B,CAAC,EAAE,MAAM,CAAC;KACtC,CAAC;IACF,oBAAoB,CAAC,EAAE,MAAM;QAC5B,KAAK,EACF,UAAU,GACV,SAAS,GACT,UAAU,GACV,OAAO,GACP,UAAU,GACV,iBAAiB,GACjB,iBAAiB,GACjB,IAAI,CAAC;QACR,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACvB,GAAG,IAAI,CAAC;CACT;AAED,wBAAgB,SAAS,CAAC,IAAI,CAAC,EAAE,UAAU,8EAuD1C;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,CAAC,EAAE,UAAU,8EA8B9C"}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, applyReplicationOps, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorReviewJoinRequestAction, coordinatorStatusSnapshot, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
3
|
+
import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, VERSION as VERSION$1, applyReplicationOps, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorReviewJoinRequestAction, coordinatorStatusSnapshot, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
4
4
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { createMiddleware } from "hono/factory";
|
|
7
|
+
import { RateLimiter } from "limiter";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
|
|
9
10
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
@@ -113,6 +114,49 @@ function preflightHandler() {
|
|
|
113
114
|
});
|
|
114
115
|
}
|
|
115
116
|
//#endregion
|
|
117
|
+
//#region src/request-rate-limit.ts
|
|
118
|
+
function createInMemoryRequestRateLimiter(windowMs = 6e4) {
|
|
119
|
+
const safeWindowMs = Math.max(1e3, Math.trunc(windowMs));
|
|
120
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
121
|
+
let checks = 0;
|
|
122
|
+
function estimatedRetryAfterS(limit) {
|
|
123
|
+
return Math.max(1, Math.ceil(safeWindowMs / Math.max(1, limit) / 1e3));
|
|
124
|
+
}
|
|
125
|
+
function cleanupExpiredBuckets(currentNow) {
|
|
126
|
+
if (buckets.size < 64 || checks % 64 !== 0) return;
|
|
127
|
+
const staleBefore = currentNow - safeWindowMs * 10;
|
|
128
|
+
for (const [key, bucket] of buckets) if (bucket.lastUsedAt < staleBefore) buckets.delete(key);
|
|
129
|
+
}
|
|
130
|
+
return { check(key, limit) {
|
|
131
|
+
checks += 1;
|
|
132
|
+
const currentNow = Date.now();
|
|
133
|
+
cleanupExpiredBuckets(currentNow);
|
|
134
|
+
const safeLimit = Math.max(1, Math.trunc(limit));
|
|
135
|
+
const bucketKey = `${safeLimit}:${key}`;
|
|
136
|
+
let bucket = buckets.get(bucketKey);
|
|
137
|
+
if (!bucket) {
|
|
138
|
+
bucket = {
|
|
139
|
+
limiter: new RateLimiter({
|
|
140
|
+
tokensPerInterval: safeLimit,
|
|
141
|
+
interval: safeWindowMs,
|
|
142
|
+
fireImmediately: true
|
|
143
|
+
}),
|
|
144
|
+
lastUsedAt: currentNow
|
|
145
|
+
};
|
|
146
|
+
buckets.set(bucketKey, bucket);
|
|
147
|
+
}
|
|
148
|
+
bucket.lastUsedAt = currentNow;
|
|
149
|
+
if (bucket.limiter.tryRemoveTokens(1)) return {
|
|
150
|
+
allowed: true,
|
|
151
|
+
retryAfterS: 0
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
allowed: false,
|
|
155
|
+
retryAfterS: estimatedRetryAfterS(safeLimit)
|
|
156
|
+
};
|
|
157
|
+
} };
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
116
160
|
//#region src/routes/config.ts
|
|
117
161
|
/**
|
|
118
162
|
* Config routes — GET /api/config, POST /api/config.
|
|
@@ -120,6 +164,7 @@ function preflightHandler() {
|
|
|
120
164
|
* Ports the user-facing config read/write path from Python's
|
|
121
165
|
* codemem/viewer_routes/config.py, scoped to the TS runtime's current needs.
|
|
122
166
|
*/
|
|
167
|
+
var REDACTED_VALUE = "[redacted]";
|
|
123
168
|
var RUNTIMES = new Set(["api_http", "claude_sidecar"]);
|
|
124
169
|
var AUTH_SOURCES = new Set([
|
|
125
170
|
"auto",
|
|
@@ -129,6 +174,20 @@ var AUTH_SOURCES = new Set([
|
|
|
129
174
|
"none"
|
|
130
175
|
]);
|
|
131
176
|
var HOT_RELOAD_KEYS = new Set(["raw_events_sweeper_interval_s"]);
|
|
177
|
+
var PROTECTED_WRITE_KEYS = new Set([
|
|
178
|
+
"claude_command",
|
|
179
|
+
"observer_base_url",
|
|
180
|
+
"observer_auth_file",
|
|
181
|
+
"observer_auth_command",
|
|
182
|
+
"observer_headers",
|
|
183
|
+
"sync_coordinator_url"
|
|
184
|
+
]);
|
|
185
|
+
var SECRET_CONFIG_KEYS = new Set([
|
|
186
|
+
"observer_auth_file",
|
|
187
|
+
"observer_auth_command",
|
|
188
|
+
"observer_headers",
|
|
189
|
+
"sync_coordinator_admin_secret"
|
|
190
|
+
]);
|
|
132
191
|
var ALLOWED_KEYS = [
|
|
133
192
|
"claude_command",
|
|
134
193
|
"observer_base_url",
|
|
@@ -216,6 +275,43 @@ function getEffectiveConfig(configData) {
|
|
|
216
275
|
}
|
|
217
276
|
return effective;
|
|
218
277
|
}
|
|
278
|
+
function redactConfigValue(key, value) {
|
|
279
|
+
if (value == null || !SECRET_CONFIG_KEYS.has(key)) return value;
|
|
280
|
+
if (Array.isArray(value)) return value.length > 0 ? REDACTED_VALUE : [];
|
|
281
|
+
if (typeof value === "object") return Object.keys(value).length > 0 ? REDACTED_VALUE : {};
|
|
282
|
+
if (typeof value === "string") return value.trim() ? REDACTED_VALUE : "";
|
|
283
|
+
return REDACTED_VALUE;
|
|
284
|
+
}
|
|
285
|
+
function sanitizeConfigForResponse(configData) {
|
|
286
|
+
const sanitized = {};
|
|
287
|
+
for (const [key, value] of Object.entries(configData)) sanitized[key] = redactConfigValue(key, value);
|
|
288
|
+
return sanitized;
|
|
289
|
+
}
|
|
290
|
+
function configValuesEqual(left, right) {
|
|
291
|
+
if (Object.is(left, right)) return true;
|
|
292
|
+
if (left == null || right == null) return left == null && right == null;
|
|
293
|
+
if (typeof left === "object" || typeof right === "object") return JSON.stringify(left) === JSON.stringify(right);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
function partitionViewerUpdates(updates, beforeConfig) {
|
|
297
|
+
const allowedUpdates = {};
|
|
298
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
299
|
+
if (!PROTECTED_WRITE_KEYS.has(key)) {
|
|
300
|
+
allowedUpdates[key] = value;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (SECRET_CONFIG_KEYS.has(key) && value === REDACTED_VALUE) continue;
|
|
304
|
+
if (configValuesEqual(value, beforeConfig[key])) continue;
|
|
305
|
+
return {
|
|
306
|
+
allowedUpdates: {},
|
|
307
|
+
error: `${key} cannot be changed from the viewer API; edit the config file or environment instead`
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
allowedUpdates,
|
|
312
|
+
error: null
|
|
313
|
+
};
|
|
314
|
+
}
|
|
219
315
|
function parsePositiveInt(value, allowZero = false) {
|
|
220
316
|
if (typeof value === "boolean") return null;
|
|
221
317
|
const parsed = typeof value === "number" ? value : typeof value === "string" && /^-?\d+$/.test(value.trim()) ? Number(value.trim()) : NaN;
|
|
@@ -322,12 +418,14 @@ function configRoutes(opts = {}) {
|
|
|
322
418
|
app.get("/api/config", (c) => {
|
|
323
419
|
const configPath = getConfigPath();
|
|
324
420
|
const configData = readConfigFile(configPath);
|
|
421
|
+
const effective = getEffectiveConfig(configData);
|
|
325
422
|
return c.json({
|
|
326
423
|
path: configPath,
|
|
327
|
-
config: configData,
|
|
424
|
+
config: sanitizeConfigForResponse(configData),
|
|
328
425
|
defaults: DEFAULTS,
|
|
329
|
-
effective:
|
|
426
|
+
effective: sanitizeConfigForResponse(effective),
|
|
330
427
|
env_overrides: getCodememEnvOverrides(),
|
|
428
|
+
protected_keys: [...PROTECTED_WRITE_KEYS].sort(),
|
|
331
429
|
providers: loadProviderOptions()
|
|
332
430
|
});
|
|
333
431
|
});
|
|
@@ -343,13 +441,15 @@ function configRoutes(opts = {}) {
|
|
|
343
441
|
const updates = "config" in payload && payload.config != null && typeof payload.config === "object" && !Array.isArray(payload.config) ? payload.config : payload;
|
|
344
442
|
const configPath = getCodememConfigPath();
|
|
345
443
|
const beforeConfig = readCodememConfigFile();
|
|
444
|
+
const { allowedUpdates, error: protectedKeyError } = partitionViewerUpdates(updates, beforeConfig);
|
|
445
|
+
if (protectedKeyError) return c.json({ error: protectedKeyError }, 403);
|
|
346
446
|
const beforeEffective = getEffectiveConfig(beforeConfig);
|
|
347
447
|
const nextConfig = { ...beforeConfig };
|
|
348
448
|
const providers = new Set(loadProviderOptions());
|
|
349
|
-
const touchedKeys = ALLOWED_KEYS.filter((key) => key in
|
|
449
|
+
const touchedKeys = ALLOWED_KEYS.filter((key) => key in allowedUpdates);
|
|
350
450
|
for (const key of ALLOWED_KEYS) {
|
|
351
|
-
if (!(key in
|
|
352
|
-
const error = validateAndApplyUpdate(nextConfig, key,
|
|
451
|
+
if (!(key in allowedUpdates)) continue;
|
|
452
|
+
const error = validateAndApplyUpdate(nextConfig, key, allowedUpdates[key], providers);
|
|
353
453
|
if (error) return c.json({ error }, 400);
|
|
354
454
|
}
|
|
355
455
|
let savedPath;
|
|
@@ -371,8 +471,9 @@ function configRoutes(opts = {}) {
|
|
|
371
471
|
const restartRequiredKeys = effectiveChangedKeys.filter((key) => !HOT_RELOAD_KEYS.has(key) && !(key in envOverrides));
|
|
372
472
|
return c.json({
|
|
373
473
|
path: savedPath,
|
|
374
|
-
config: nextConfig,
|
|
375
|
-
effective: afterEffective,
|
|
474
|
+
config: sanitizeConfigForResponse(nextConfig),
|
|
475
|
+
effective: sanitizeConfigForResponse(afterEffective),
|
|
476
|
+
protected_keys: [...PROTECTED_WRITE_KEYS].sort(),
|
|
376
477
|
effects: {
|
|
377
478
|
saved_keys: savedChangedKeys,
|
|
378
479
|
effective_keys: effectiveChangedKeys,
|
|
@@ -1079,6 +1180,11 @@ function rawEventsRoutes(getStore, sweeper) {
|
|
|
1079
1180
|
//#endregion
|
|
1080
1181
|
//#region src/routes/stats.ts
|
|
1081
1182
|
/**
|
|
1183
|
+
* Stats routes — GET /api/stats, GET /api/usage.
|
|
1184
|
+
*
|
|
1185
|
+
* Ports Python's viewer_routes/stats.py.
|
|
1186
|
+
*/
|
|
1187
|
+
/**
|
|
1082
1188
|
* Create stats routes. The store factory is called per-request to get a
|
|
1083
1189
|
* fresh connection (matching the Python viewer pattern).
|
|
1084
1190
|
*/
|
|
@@ -1099,6 +1205,9 @@ function statsRoutes(getStore) {
|
|
|
1099
1205
|
viewer_pid: process.pid
|
|
1100
1206
|
});
|
|
1101
1207
|
});
|
|
1208
|
+
app.get("/api/runtime", (c) => {
|
|
1209
|
+
return c.json({ version: VERSION$1 });
|
|
1210
|
+
});
|
|
1102
1211
|
app.get("/api/usage", (c) => {
|
|
1103
1212
|
const store = getStore();
|
|
1104
1213
|
{
|
|
@@ -1185,6 +1294,31 @@ function intEnvOr(name, fallback) {
|
|
|
1185
1294
|
}
|
|
1186
1295
|
var MAX_SYNC_BODY_BYTES = intEnvOr("CODEMEM_SYNC_MAX_BODY_BYTES", 1048576);
|
|
1187
1296
|
var MAX_SYNC_OPS = intEnvOr("CODEMEM_SYNC_MAX_OPS", 2e3);
|
|
1297
|
+
async function readBoundedRequestBytes(request, maxBytes) {
|
|
1298
|
+
const contentLength = Number.parseInt(request.headers.get("content-length") ?? "", 10);
|
|
1299
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
|
|
1300
|
+
const stream = request.body;
|
|
1301
|
+
if (!stream) return Buffer.alloc(0);
|
|
1302
|
+
const reader = stream.getReader();
|
|
1303
|
+
const chunks = [];
|
|
1304
|
+
let total = 0;
|
|
1305
|
+
try {
|
|
1306
|
+
while (true) {
|
|
1307
|
+
const { done, value } = await reader.read();
|
|
1308
|
+
if (done) break;
|
|
1309
|
+
if (!value) continue;
|
|
1310
|
+
total += value.byteLength;
|
|
1311
|
+
if (total > maxBytes) {
|
|
1312
|
+
await reader.cancel();
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
chunks.push(value);
|
|
1316
|
+
}
|
|
1317
|
+
} finally {
|
|
1318
|
+
reader.releaseLock();
|
|
1319
|
+
}
|
|
1320
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
1321
|
+
}
|
|
1188
1322
|
var PAIRING_FILTER_HINT = "Run this on another device with codemem sync pair --accept '<payload>'. On the accepting device, --include/--exclude control both what it sends and what it accepts from that peer.";
|
|
1189
1323
|
function pathWithQuery(url) {
|
|
1190
1324
|
const parsed = new URL(url);
|
|
@@ -1645,12 +1779,26 @@ var PEERS_QUERY = `
|
|
|
1645
1779
|
* network-accessible. All requests are auth-gated via signature
|
|
1646
1780
|
* verification so unauthenticated callers are rejected.
|
|
1647
1781
|
*/
|
|
1648
|
-
function syncProtocolRoutes(getStore) {
|
|
1782
|
+
function syncProtocolRoutes(getStore, opts = {}) {
|
|
1649
1783
|
const app = new Hono();
|
|
1784
|
+
const routeRateLimit = opts.routeRateLimit ?? null;
|
|
1785
|
+
function rateLimitedResponse(c, key, authenticated) {
|
|
1786
|
+
if (!routeRateLimit) return null;
|
|
1787
|
+
const isRead = c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS";
|
|
1788
|
+
const result = routeRateLimit.limiter.check(`${c.req.method}:${authenticated ? "auth" : "anon"}:${key}`, authenticated ? isRead ? routeRateLimit.readLimit : routeRateLimit.mutationLimit : isRead ? routeRateLimit.unauthenticatedReadLimit : routeRateLimit.unauthenticatedMutationLimit);
|
|
1789
|
+
if (result.allowed) return null;
|
|
1790
|
+
c.header("Retry-After", String(result.retryAfterS));
|
|
1791
|
+
return c.json({
|
|
1792
|
+
error: "rate_limited",
|
|
1793
|
+
retry_after_s: result.retryAfterS
|
|
1794
|
+
}, 429);
|
|
1795
|
+
}
|
|
1650
1796
|
app.get("/v1/status", (c) => {
|
|
1651
1797
|
const store = getStore();
|
|
1652
1798
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
1653
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1799
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1800
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1801
|
+
if (limited) return limited;
|
|
1654
1802
|
try {
|
|
1655
1803
|
let device = store.db.prepare("SELECT device_id, fingerprint FROM sync_device LIMIT 1").get();
|
|
1656
1804
|
if (!device) {
|
|
@@ -1674,7 +1822,9 @@ function syncProtocolRoutes(getStore) {
|
|
|
1674
1822
|
app.get("/v1/ops", (c) => {
|
|
1675
1823
|
const store = getStore();
|
|
1676
1824
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
1677
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1825
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1826
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1827
|
+
if (limited) return limited;
|
|
1678
1828
|
const peerDeviceId = auth.deviceId;
|
|
1679
1829
|
try {
|
|
1680
1830
|
const since = c.req.query("since") ?? null;
|
|
@@ -1720,7 +1870,9 @@ function syncProtocolRoutes(getStore) {
|
|
|
1720
1870
|
app.get("/v1/snapshot", (c) => {
|
|
1721
1871
|
const store = getStore();
|
|
1722
1872
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
1723
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1873
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1874
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1875
|
+
if (limited) return limited;
|
|
1724
1876
|
try {
|
|
1725
1877
|
const rawGeneration = c.req.query("generation");
|
|
1726
1878
|
const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
|
|
@@ -1756,10 +1908,12 @@ function syncProtocolRoutes(getStore) {
|
|
|
1756
1908
|
});
|
|
1757
1909
|
app.post("/v1/ops", async (c) => {
|
|
1758
1910
|
const store = getStore();
|
|
1759
|
-
const raw =
|
|
1760
|
-
if (raw
|
|
1911
|
+
const raw = await readBoundedRequestBytes(c.req.raw, MAX_SYNC_BODY_BYTES);
|
|
1912
|
+
if (raw == null) return c.json({ error: "payload_too_large" }, 413);
|
|
1761
1913
|
const auth = authorizeSyncRequest(store, c.req, raw);
|
|
1762
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1914
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1915
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1916
|
+
if (limited) return limited;
|
|
1763
1917
|
const peerDeviceId = auth.deviceId;
|
|
1764
1918
|
let body;
|
|
1765
1919
|
try {
|
|
@@ -1907,7 +2061,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1907
2061
|
const sharingReview = store.sharingReviewSummary(project);
|
|
1908
2062
|
const coordinator = redactCoordinatorStatus(await coordinatorStatusSnapshot(store, config), showDiag);
|
|
1909
2063
|
let joinRequests = [];
|
|
1910
|
-
if (includeJoinRequests && config.syncCoordinatorAdminSecret) try {
|
|
2064
|
+
if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
|
|
1911
2065
|
joinRequests = await listCoordinatorJoinRequests(config);
|
|
1912
2066
|
} catch {
|
|
1913
2067
|
joinRequests = [];
|
|
@@ -1942,7 +2096,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1942
2096
|
sharing_review: sharingReview,
|
|
1943
2097
|
coordinator
|
|
1944
2098
|
};
|
|
1945
|
-
if (includeJoinRequests) responsePayload.join_requests = joinRequests;
|
|
2099
|
+
if (includeJoinRequests && showDiag) responsePayload.join_requests = joinRequests;
|
|
1946
2100
|
return c.json(responsePayload);
|
|
1947
2101
|
}
|
|
1948
2102
|
});
|
|
@@ -2447,8 +2601,18 @@ function createApp(opts) {
|
|
|
2447
2601
|
*/
|
|
2448
2602
|
function createSyncApp(opts) {
|
|
2449
2603
|
const storeFactory = opts?.storeFactory ?? getStore;
|
|
2604
|
+
const requestRateLimit = opts?.syncRequestRateLimit ?? {};
|
|
2605
|
+
const rateLimiter = requestRateLimit.limiter ?? createInMemoryRequestRateLimiter();
|
|
2606
|
+
const readLimit = Math.max(1, Math.trunc(requestRateLimit.readLimit ?? 120));
|
|
2607
|
+
const mutationLimit = Math.max(1, Math.trunc(requestRateLimit.mutationLimit ?? 30));
|
|
2450
2608
|
const app = new Hono();
|
|
2451
|
-
app.route("/", syncProtocolRoutes(storeFactory
|
|
2609
|
+
app.route("/", syncProtocolRoutes(storeFactory, { routeRateLimit: {
|
|
2610
|
+
limiter: rateLimiter,
|
|
2611
|
+
readLimit,
|
|
2612
|
+
mutationLimit,
|
|
2613
|
+
unauthenticatedReadLimit: Math.max(1, Math.trunc(requestRateLimit.unauthenticatedReadLimit ?? Math.min(20, readLimit))),
|
|
2614
|
+
unauthenticatedMutationLimit: Math.max(1, Math.trunc(requestRateLimit.unauthenticatedMutationLimit ?? Math.min(10, mutationLimit)))
|
|
2615
|
+
} }));
|
|
2452
2616
|
app.all("*", (c) => c.json({ error: "not found" }, 404));
|
|
2453
2617
|
return app;
|
|
2454
2618
|
}
|