@codemem/server 0.22.0-alpha.5 → 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.js CHANGED
@@ -1,13 +1,14 @@
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, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, 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";
10
- import { createHash } from "node:crypto";
11
+ import { createHash, randomUUID } from "node:crypto";
11
12
  import net from "node:net";
12
13
  //#region src/middleware.ts
13
14
  var LOOPBACK_HOSTS = new Set([
@@ -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
  {
@@ -1177,12 +1286,39 @@ function statsRoutes(getStore) {
1177
1286
  */
1178
1287
  var SYNC_STALE_AFTER_SECONDS = 600;
1179
1288
  var SYNC_PROTOCOL_VERSION = "2";
1289
+ var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
1290
+ var LEGACY_SHARED_WORKSPACE_ID = "shared:legacy";
1180
1291
  function intEnvOr(name, fallback) {
1181
1292
  const value = Number.parseInt(process.env[name] ?? "", 10);
1182
1293
  return Number.isFinite(value) ? value : fallback;
1183
1294
  }
1184
1295
  var MAX_SYNC_BODY_BYTES = intEnvOr("CODEMEM_SYNC_MAX_BODY_BYTES", 1048576);
1185
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
+ }
1186
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.";
1187
1323
  function pathWithQuery(url) {
1188
1324
  const parsed = new URL(url);
@@ -1295,6 +1431,67 @@ function readPeerProjectFilters(store, peerDeviceId) {
1295
1431
  exclude: parseJsonList(row.projects_exclude_json)
1296
1432
  };
1297
1433
  }
1434
+ function currentProjectScope(row, effective) {
1435
+ return {
1436
+ include: safeJsonList(row.projects_include_json),
1437
+ exclude: safeJsonList(row.projects_exclude_json),
1438
+ effective_include: effective?.include ?? safeJsonList(row.projects_include_json),
1439
+ effective_exclude: effective?.exclude ?? safeJsonList(row.projects_exclude_json),
1440
+ inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
1441
+ };
1442
+ }
1443
+ function ensureLocalActorRecord(store) {
1444
+ const d = drizzle(store.db, { schema });
1445
+ if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
1446
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1447
+ d.insert(schema.actors).values({
1448
+ actor_id: store.actorId,
1449
+ display_name: store.actorDisplayName,
1450
+ is_local: 1,
1451
+ status: "active",
1452
+ merged_into_actor_id: null,
1453
+ created_at: now,
1454
+ updated_at: now
1455
+ }).run();
1456
+ }
1457
+ function findPeerDeviceIdForAddress(store, address) {
1458
+ const rows = store.db.prepare("SELECT peer_device_id, addresses_json FROM sync_peers").all();
1459
+ for (const row of rows) if (safeJsonList(row.addresses_json).includes(address)) return String(row.peer_device_id ?? "").trim() || null;
1460
+ return null;
1461
+ }
1462
+ function claimLegacyDeviceAsSelf(store, originDeviceId) {
1463
+ const deviceId = String(originDeviceId || "").trim();
1464
+ if (!deviceId || deviceId === "unknown" || deviceId === store.deviceId) return 0;
1465
+ const personalWorkspaceId = `personal:${store.actorId}`;
1466
+ const result = store.db.prepare(`UPDATE memory_items
1467
+ SET actor_id = ?,
1468
+ actor_display_name = ?,
1469
+ visibility = 'private',
1470
+ workspace_id = ?,
1471
+ workspace_kind = 'personal',
1472
+ trust_state = 'trusted'
1473
+ WHERE origin_device_id = ?
1474
+ AND origin_device_id NOT IN (
1475
+ SELECT peer_device_id FROM sync_peers WHERE peer_device_id IS NOT NULL
1476
+ )
1477
+ AND (
1478
+ (
1479
+ actor_id IS NULL
1480
+ OR TRIM(actor_id) = ''
1481
+ OR actor_id LIKE 'legacy-sync:%'
1482
+ OR actor_id = ?
1483
+ )
1484
+ AND (
1485
+ actor_id IS NULL
1486
+ OR TRIM(actor_id) = ''
1487
+ OR actor_id LIKE 'legacy-sync:%'
1488
+ OR actor_display_name = ?
1489
+ OR workspace_id = ?
1490
+ OR trust_state = 'legacy_unknown'
1491
+ )
1492
+ )`).run(store.actorId, store.actorDisplayName, personalWorkspaceId, deviceId, store.actorId, LEGACY_SYNC_ACTOR_DISPLAY_NAME, LEGACY_SHARED_WORKSPACE_ID);
1493
+ return Number(result.changes ?? 0);
1494
+ }
1298
1495
  function peerClaimedLocalActor(store, peerDeviceId) {
1299
1496
  const row = store.db.prepare("SELECT claimed_local_actor FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(peerDeviceId);
1300
1497
  return Boolean(row?.claimed_local_actor);
@@ -1321,6 +1518,10 @@ function redactCoordinatorStatus(coordinator, showDiag) {
1321
1518
  last_seen_at: record.last_seen_at ?? null,
1322
1519
  expires_at: record.expires_at ?? null,
1323
1520
  stale: Boolean(record.stale),
1521
+ needs_local_approval: Boolean(record.needs_local_approval),
1522
+ waiting_for_peer_approval: Boolean(record.waiting_for_peer_approval),
1523
+ incoming_reciprocal_request_id: record.incoming_reciprocal_request_id ?? null,
1524
+ outgoing_reciprocal_request_id: record.outgoing_reciprocal_request_id ?? null,
1324
1525
  fingerprint: null,
1325
1526
  addresses: []
1326
1527
  };
@@ -1346,37 +1547,58 @@ async function acceptDiscoveredPeer(store, input) {
1346
1547
  detail: "That discovered device is no longer available. Refresh sync status and try again."
1347
1548
  };
1348
1549
  const nextFingerprint = String(match.fingerprint ?? "").trim();
1550
+ const nextPublicKey = String(match.public_key ?? "").trim();
1349
1551
  const nextName = String(match.display_name ?? "").trim() || null;
1350
1552
  const nextAddresses = Array.isArray(match.addresses) ? match.addresses.filter((value) => typeof value === "string") : [];
1553
+ if (!nextPublicKey) return {
1554
+ ok: false,
1555
+ status: 409,
1556
+ error: "discovered_peer_missing_public_key",
1557
+ detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
1558
+ };
1559
+ const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
1560
+ if (groupIds.length !== 1) return {
1561
+ ok: false,
1562
+ status: 409,
1563
+ error: "ambiguous_coordinator_group",
1564
+ detail: groupIds.length > 1 ? "This device is visible through multiple coordinator groups. Review the team setup before approving it here." : "This device is missing coordinator group context. Refresh sync status and try again."
1565
+ };
1566
+ const groupId = groupIds[0];
1351
1567
  const d = drizzle(store.db, { schema });
1568
+ const existing = d.select({
1569
+ peer_device_id: schema.syncPeers.peer_device_id,
1570
+ pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
1571
+ public_key: schema.syncPeers.public_key,
1572
+ addresses_json: schema.syncPeers.addresses_json
1573
+ }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
1574
+ const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
1575
+ if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
1576
+ ok: false,
1577
+ status: 409,
1578
+ error: "peer_conflict",
1579
+ detail: "An existing peer with this device id is pinned to a different fingerprint. Remove or repair the old peer before accepting this discovered device."
1580
+ };
1581
+ const existingAddresses = (() => {
1582
+ try {
1583
+ const raw = JSON.parse(String(existing?.addresses_json ?? "[]"));
1584
+ return Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : [];
1585
+ } catch {
1586
+ return [];
1587
+ }
1588
+ })();
1589
+ const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
1590
+ await createCoordinatorReciprocalApproval(store, config, {
1591
+ groupId,
1592
+ requestedDeviceId: input.peerDeviceId
1593
+ });
1352
1594
  const now = (/* @__PURE__ */ new Date()).toISOString();
1353
1595
  return store.db.transaction(() => {
1354
- const existing = d.select({
1355
- peer_device_id: schema.syncPeers.peer_device_id,
1356
- pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
1357
- addresses_json: schema.syncPeers.addresses_json
1358
- }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
1359
- const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
1360
- if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
1361
- ok: false,
1362
- status: 409,
1363
- error: "peer_conflict",
1364
- detail: "An existing peer with this device id is pinned to a different fingerprint. Remove or repair the old peer before accepting this discovered device."
1365
- };
1366
- const existingAddresses = (() => {
1367
- try {
1368
- const raw = JSON.parse(String(existing?.addresses_json ?? "[]"));
1369
- return Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : [];
1370
- } catch {
1371
- return [];
1372
- }
1373
- })();
1374
- const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
1375
1596
  if (!existing) {
1376
1597
  d.insert(schema.syncPeers).values({
1377
1598
  peer_device_id: input.peerDeviceId,
1378
1599
  name: nextName,
1379
1600
  pinned_fingerprint: nextFingerprint || null,
1601
+ public_key: nextPublicKey,
1380
1602
  addresses_json: addressesJson,
1381
1603
  created_at: now,
1382
1604
  last_seen_at: now
@@ -1392,6 +1614,7 @@ async function acceptDiscoveredPeer(store, input) {
1392
1614
  d.update(schema.syncPeers).set({
1393
1615
  name: nextName,
1394
1616
  pinned_fingerprint: nextFingerprint || existing.pinned_fingerprint || null,
1617
+ public_key: nextPublicKey || existing.public_key || null,
1395
1618
  addresses_json: addressesJson,
1396
1619
  last_seen_at: now
1397
1620
  }).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).run();
@@ -1459,7 +1682,7 @@ function filterOpsForPeer(store, peerDeviceId, ops) {
1459
1682
  * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
1460
1683
  * are redacted.
1461
1684
  */
1462
- function mapPeerRow(row, showDiag) {
1685
+ function mapPeerRow(store, row, showDiag) {
1463
1686
  return {
1464
1687
  peer_device_id: row.peer_device_id,
1465
1688
  name: row.name,
@@ -1473,13 +1696,7 @@ function mapPeerRow(row, showDiag) {
1473
1696
  claimed_local_actor: Boolean(row.claimed_local_actor),
1474
1697
  actor_id: row.actor_id ?? null,
1475
1698
  actor_display_name: row.actor_display_name ?? null,
1476
- project_scope: {
1477
- include: safeJsonList(row.projects_include_json),
1478
- exclude: safeJsonList(row.projects_exclude_json),
1479
- effective_include: safeJsonList(row.projects_include_json),
1480
- effective_exclude: safeJsonList(row.projects_exclude_json),
1481
- inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
1482
- }
1699
+ project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) }
1483
1700
  };
1484
1701
  }
1485
1702
  function isRecentIso(value, windowS = SYNC_STALE_AFTER_SECONDS) {
@@ -1562,12 +1779,26 @@ var PEERS_QUERY = `
1562
1779
  * network-accessible. All requests are auth-gated via signature
1563
1780
  * verification so unauthenticated callers are rejected.
1564
1781
  */
1565
- function syncProtocolRoutes(getStore) {
1782
+ function syncProtocolRoutes(getStore, opts = {}) {
1566
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
+ }
1567
1796
  app.get("/v1/status", (c) => {
1568
1797
  const store = getStore();
1569
1798
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1570
- 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;
1571
1802
  try {
1572
1803
  let device = store.db.prepare("SELECT device_id, fingerprint FROM sync_device LIMIT 1").get();
1573
1804
  if (!device) {
@@ -1591,7 +1822,9 @@ function syncProtocolRoutes(getStore) {
1591
1822
  app.get("/v1/ops", (c) => {
1592
1823
  const store = getStore();
1593
1824
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1594
- 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;
1595
1828
  const peerDeviceId = auth.deviceId;
1596
1829
  try {
1597
1830
  const since = c.req.query("since") ?? null;
@@ -1637,7 +1870,9 @@ function syncProtocolRoutes(getStore) {
1637
1870
  app.get("/v1/snapshot", (c) => {
1638
1871
  const store = getStore();
1639
1872
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1640
- 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;
1641
1876
  try {
1642
1877
  const rawGeneration = c.req.query("generation");
1643
1878
  const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
@@ -1673,10 +1908,12 @@ function syncProtocolRoutes(getStore) {
1673
1908
  });
1674
1909
  app.post("/v1/ops", async (c) => {
1675
1910
  const store = getStore();
1676
- const raw = Buffer.from(await c.req.arrayBuffer());
1677
- 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);
1678
1913
  const auth = authorizeSyncRequest(store, c.req, raw);
1679
- 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;
1680
1917
  const peerDeviceId = auth.deviceId;
1681
1918
  let body;
1682
1919
  try {
@@ -1793,7 +2030,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1793
2030
  statusPayload.daemon_detail = runtimeStatus.detail ?? daemonDetail;
1794
2031
  }
1795
2032
  const peersItems = store.db.prepare(PEERS_QUERY).all().map((row) => {
1796
- const peer = mapPeerRow(row, showDiag);
2033
+ const peer = mapPeerRow(store, row, showDiag);
1797
2034
  peer.status = peerStatus(peer);
1798
2035
  return peer;
1799
2036
  });
@@ -1824,7 +2061,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1824
2061
  const sharingReview = store.sharingReviewSummary(project);
1825
2062
  const coordinator = redactCoordinatorStatus(await coordinatorStatusSnapshot(store, config), showDiag);
1826
2063
  let joinRequests = [];
1827
- if (includeJoinRequests && config.syncCoordinatorAdminSecret) try {
2064
+ if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
1828
2065
  joinRequests = await listCoordinatorJoinRequests(config);
1829
2066
  } catch {
1830
2067
  joinRequests = [];
@@ -1859,7 +2096,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1859
2096
  sharing_review: sharingReview,
1860
2097
  coordinator
1861
2098
  };
1862
- if (includeJoinRequests) responsePayload.join_requests = joinRequests;
2099
+ if (includeJoinRequests && showDiag) responsePayload.join_requests = joinRequests;
1863
2100
  return c.json(responsePayload);
1864
2101
  }
1865
2102
  });
@@ -1867,7 +2104,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1867
2104
  const store = getStore();
1868
2105
  {
1869
2106
  const showDiag = queryBool(c.req.query("includeDiagnostics"));
1870
- const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(row, showDiag));
2107
+ const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(store, row, showDiag));
1871
2108
  return c.json({
1872
2109
  items: peers,
1873
2110
  redacted: !showDiag
@@ -1877,6 +2114,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1877
2114
  app.get("/api/sync/actors", (c) => {
1878
2115
  const store = getStore();
1879
2116
  {
2117
+ ensureLocalActorRecord(store);
1880
2118
  const d = drizzle(store.db, { schema });
1881
2119
  const includeMerged = queryBool(c.req.query("includeMerged"));
1882
2120
  const query = d.select().from(schema.actors);
@@ -1955,6 +2193,216 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1955
2193
  return c.json({ ok: true });
1956
2194
  }
1957
2195
  });
2196
+ app.post("/api/sync/run", async (c) => {
2197
+ const store = getStore();
2198
+ let body = {};
2199
+ try {
2200
+ body = await c.req.json();
2201
+ } catch {
2202
+ body = {};
2203
+ }
2204
+ if (!readCoordinatorSyncConfig().syncEnabled) return c.json({ error: "sync_disabled" }, 403);
2205
+ const address = typeof body.address === "string" && body.address.trim() ? body.address.trim() : null;
2206
+ const requestedPeerId = typeof body.peer_device_id === "string" && body.peer_device_id.trim() ? body.peer_device_id.trim() : null;
2207
+ let peerIds;
2208
+ if (address) {
2209
+ const peerId = findPeerDeviceIdForAddress(store, address);
2210
+ if (!peerId) return c.json({ error: "unknown peer address" }, 404);
2211
+ peerIds = [peerId];
2212
+ } else if (requestedPeerId) peerIds = store.db.prepare("SELECT peer_device_id FROM sync_peers WHERE peer_device_id = ?").all(requestedPeerId).map((row) => String(row.peer_device_id ?? "").trim()).filter(Boolean);
2213
+ else peerIds = store.db.prepare("SELECT peer_device_id FROM sync_peers").all().map((row) => String(row.peer_device_id ?? "").trim()).filter(Boolean);
2214
+ const items = [];
2215
+ for (const peerId of peerIds) {
2216
+ const result = await runSyncPass(store.db, peerId);
2217
+ items.push({
2218
+ peer_device_id: peerId,
2219
+ ...result
2220
+ });
2221
+ }
2222
+ return c.json({ items });
2223
+ });
2224
+ app.post("/api/sync/peers/scope", async (c) => {
2225
+ const store = getStore();
2226
+ let body;
2227
+ try {
2228
+ body = await c.req.json();
2229
+ } catch {
2230
+ return c.json({ error: "invalid json" }, 400);
2231
+ }
2232
+ const peerDeviceId = String(body.peer_device_id ?? "").trim();
2233
+ if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
2234
+ const include = Array.isArray(body.include) ? body.include.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean) : null;
2235
+ const exclude = Array.isArray(body.exclude) ? body.exclude.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean) : null;
2236
+ const inheritGlobal = Boolean(body.inherit_global);
2237
+ const d = drizzle(store.db, { schema });
2238
+ if (!d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get()) return c.json({ error: "peer not found" }, 404);
2239
+ d.update(schema.syncPeers).set({
2240
+ projects_include_json: inheritGlobal ? null : JSON.stringify(include ?? []),
2241
+ projects_exclude_json: inheritGlobal ? null : JSON.stringify(exclude ?? [])
2242
+ }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
2243
+ const row = d.select({
2244
+ projects_include_json: schema.syncPeers.projects_include_json,
2245
+ projects_exclude_json: schema.syncPeers.projects_exclude_json
2246
+ }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
2247
+ if (!row) return c.json({ error: "peer not found" }, 404);
2248
+ return c.json({
2249
+ ok: true,
2250
+ project_scope: currentProjectScope(row, readPeerProjectFilters(store, peerDeviceId))
2251
+ });
2252
+ });
2253
+ app.post("/api/sync/peers/identity", async (c) => {
2254
+ const store = getStore();
2255
+ ensureLocalActorRecord(store);
2256
+ let body;
2257
+ try {
2258
+ body = await c.req.json();
2259
+ } catch {
2260
+ return c.json({ error: "invalid json" }, 400);
2261
+ }
2262
+ const peerDeviceId = String(body.peer_device_id ?? "").trim();
2263
+ if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
2264
+ const requestedActorId = body.actor_id == null ? void 0 : String(body.actor_id ?? "").trim() || null;
2265
+ const claimedLocalActor = typeof body.claimed_local_actor === "boolean" ? body.claimed_local_actor : void 0;
2266
+ const d = drizzle(store.db, { schema });
2267
+ const peer = d.select({
2268
+ peer_device_id: schema.syncPeers.peer_device_id,
2269
+ actor_id: schema.syncPeers.actor_id,
2270
+ claimed_local_actor: schema.syncPeers.claimed_local_actor
2271
+ }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
2272
+ if (!peer) return c.json({ error: "peer not found" }, 404);
2273
+ let nextActorId = peer.actor_id ?? null;
2274
+ let nextClaimedLocalActor = Boolean(peer.claimed_local_actor);
2275
+ if (requestedActorId !== void 0) {
2276
+ nextActorId = requestedActorId;
2277
+ if (requestedActorId) {
2278
+ const actor = d.select({
2279
+ actor_id: schema.actors.actor_id,
2280
+ is_local: schema.actors.is_local,
2281
+ status: schema.actors.status
2282
+ }).from(schema.actors).where(eq(schema.actors.actor_id, requestedActorId)).get();
2283
+ if (!actor) return c.json({ error: "actor not found" }, 404);
2284
+ if (actor.status !== "active") return c.json({ error: "actor not active" }, 409);
2285
+ nextClaimedLocalActor = claimedLocalActor ?? Boolean(actor.is_local);
2286
+ } else nextClaimedLocalActor = claimedLocalActor ?? false;
2287
+ } else if (claimedLocalActor !== void 0) {
2288
+ nextClaimedLocalActor = claimedLocalActor;
2289
+ if (claimedLocalActor) nextActorId = store.actorId;
2290
+ else if (nextActorId === store.actorId) nextActorId = null;
2291
+ }
2292
+ d.update(schema.syncPeers).set({
2293
+ actor_id: nextActorId,
2294
+ claimed_local_actor: nextClaimedLocalActor ? 1 : 0
2295
+ }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
2296
+ return c.json({
2297
+ ok: true,
2298
+ actor_id: nextActorId,
2299
+ claimed_local_actor: nextClaimedLocalActor
2300
+ });
2301
+ });
2302
+ app.post("/api/sync/actors", async (c) => {
2303
+ const store = getStore();
2304
+ ensureLocalActorRecord(store);
2305
+ let body;
2306
+ try {
2307
+ body = await c.req.json();
2308
+ } catch {
2309
+ return c.json({ error: "invalid json" }, 400);
2310
+ }
2311
+ const displayName = String(body.display_name ?? "").trim();
2312
+ if (!displayName) return c.json({ error: "display_name required" }, 400);
2313
+ const d = drizzle(store.db, { schema });
2314
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2315
+ const actor = {
2316
+ actor_id: randomUUID(),
2317
+ display_name: displayName,
2318
+ is_local: 0,
2319
+ status: "active",
2320
+ merged_into_actor_id: null,
2321
+ created_at: now,
2322
+ updated_at: now
2323
+ };
2324
+ d.insert(schema.actors).values(actor).run();
2325
+ return c.json(actor);
2326
+ });
2327
+ app.post("/api/sync/actors/rename", async (c) => {
2328
+ const store = getStore();
2329
+ ensureLocalActorRecord(store);
2330
+ let body;
2331
+ try {
2332
+ body = await c.req.json();
2333
+ } catch {
2334
+ return c.json({ error: "invalid json" }, 400);
2335
+ }
2336
+ const actorId = String(body.actor_id ?? "").trim();
2337
+ const displayName = String(body.display_name ?? "").trim();
2338
+ if (!actorId) return c.json({ error: "actor_id required" }, 400);
2339
+ if (!displayName) return c.json({ error: "display_name required" }, 400);
2340
+ const d = drizzle(store.db, { schema });
2341
+ const actor = d.select().from(schema.actors).where(eq(schema.actors.actor_id, actorId)).get();
2342
+ if (!actor) return c.json({ error: "actor not found" }, 404);
2343
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2344
+ d.update(schema.actors).set({
2345
+ display_name: displayName,
2346
+ updated_at: updatedAt
2347
+ }).where(eq(schema.actors.actor_id, actorId)).run();
2348
+ return c.json({
2349
+ ...actor,
2350
+ display_name: displayName,
2351
+ updated_at: updatedAt
2352
+ });
2353
+ });
2354
+ app.post("/api/sync/actors/merge", async (c) => {
2355
+ const store = getStore();
2356
+ ensureLocalActorRecord(store);
2357
+ let body;
2358
+ try {
2359
+ body = await c.req.json();
2360
+ } catch {
2361
+ return c.json({ error: "invalid json" }, 400);
2362
+ }
2363
+ const primaryActorId = String(body.primary_actor_id ?? "").trim();
2364
+ const secondaryActorId = String(body.secondary_actor_id ?? "").trim();
2365
+ if (!primaryActorId) return c.json({ error: "primary_actor_id required" }, 400);
2366
+ if (!secondaryActorId) return c.json({ error: "secondary_actor_id required" }, 400);
2367
+ if (primaryActorId === secondaryActorId) return c.json({ error: "actor ids must differ" }, 400);
2368
+ const d = drizzle(store.db, { schema });
2369
+ const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
2370
+ const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
2371
+ if (!primary || !secondary) return c.json({ error: "actor not found" }, 404);
2372
+ if (primary.status !== "active") return c.json({ error: "primary actor not active" }, 409);
2373
+ if (secondary.status !== "active") return c.json({ error: "secondary actor not active" }, 409);
2374
+ if (secondary.is_local) return c.json({ error: "cannot merge local actor" }, 409);
2375
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2376
+ const mergedCount = store.db.transaction(() => {
2377
+ const peerUpdate = d.update(schema.syncPeers).set({ actor_id: primaryActorId }).where(eq(schema.syncPeers.actor_id, secondaryActorId)).run();
2378
+ if (primary.is_local) d.update(schema.syncPeers).set({ claimed_local_actor: 1 }).where(eq(schema.syncPeers.actor_id, primaryActorId)).run();
2379
+ d.update(schema.actors).set({
2380
+ status: "merged",
2381
+ merged_into_actor_id: primaryActorId,
2382
+ updated_at: now
2383
+ }).where(eq(schema.actors.actor_id, secondaryActorId)).run();
2384
+ return Number(peerUpdate.changes ?? 0);
2385
+ })();
2386
+ return c.json({ merged_count: mergedCount });
2387
+ });
2388
+ app.post("/api/sync/legacy-devices/claim", async (c) => {
2389
+ const store = getStore();
2390
+ let body;
2391
+ try {
2392
+ body = await c.req.json();
2393
+ } catch {
2394
+ return c.json({ error: "invalid json" }, 400);
2395
+ }
2396
+ const originDeviceId = String(body.origin_device_id ?? "").trim();
2397
+ if (!originDeviceId) return c.json({ error: "origin_device_id required" }, 400);
2398
+ const updated = claimLegacyDeviceAsSelf(store, originDeviceId);
2399
+ if (updated <= 0) return c.json({ error: "legacy device not found" }, 404);
2400
+ return c.json({
2401
+ ok: true,
2402
+ origin_device_id: originDeviceId,
2403
+ updated
2404
+ });
2405
+ });
1958
2406
  app.post("/api/sync/peers/accept-discovered", async (c) => {
1959
2407
  const store = getStore();
1960
2408
  let body;
@@ -2132,7 +2580,9 @@ function createApp(opts) {
2132
2580
  root: staticRoot,
2133
2581
  rewriteRequestPath: (path) => path.replace(/^\/assets/, "")
2134
2582
  }));
2135
- const indexHtml = readFileSync(join(staticRoot, "index.html"), "utf-8");
2583
+ const indexPath = join(staticRoot, "index.html");
2584
+ if (!existsSync(indexPath)) throw new Error(`Viewer assets missing at ${indexPath}. Run \`pnpm build\` from the repo root before starting the viewer.`);
2585
+ const indexHtml = readFileSync(indexPath, "utf-8");
2136
2586
  app.get("*", (c) => {
2137
2587
  if (c.req.path.startsWith("/api/")) return c.json({ error: "not found" }, 404);
2138
2588
  return c.html(indexHtml);
@@ -2151,8 +2601,18 @@ function createApp(opts) {
2151
2601
  */
2152
2602
  function createSyncApp(opts) {
2153
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));
2154
2608
  const app = new Hono();
2155
- 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
+ } }));
2156
2616
  app.all("*", (c) => c.json({ error: "not found" }, 404));
2157
2617
  return app;
2158
2618
  }