@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 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;
@@ -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;AAS5B,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,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,8EAS9C"}
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: getEffectiveConfig(configData),
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 updates);
449
+ const touchedKeys = ALLOWED_KEYS.filter((key) => key in allowedUpdates);
350
450
  for (const key of ALLOWED_KEYS) {
351
- if (!(key in updates)) continue;
352
- const error = validateAndApplyUpdate(nextConfig, key, updates[key], providers);
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 = Buffer.from(await c.req.arrayBuffer());
1760
- if (raw.length > MAX_SYNC_BODY_BYTES) return c.json({ error: "payload_too_large" }, 413);
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
  }