@codemem/server 0.22.0-alpha.5 → 0.22.0

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.
@@ -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,8EAiD1C;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;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"}
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
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, 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
7
  import { homedir } from "node:os";
8
8
  import { count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
9
9
  import { drizzle } from "drizzle-orm/better-sqlite3";
10
- import { createHash } from "node:crypto";
10
+ import { createHash, randomUUID } from "node:crypto";
11
11
  import net from "node:net";
12
12
  //#region src/middleware.ts
13
13
  var LOOPBACK_HOSTS = new Set([
@@ -1177,6 +1177,8 @@ function statsRoutes(getStore) {
1177
1177
  */
1178
1178
  var SYNC_STALE_AFTER_SECONDS = 600;
1179
1179
  var SYNC_PROTOCOL_VERSION = "2";
1180
+ var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
1181
+ var LEGACY_SHARED_WORKSPACE_ID = "shared:legacy";
1180
1182
  function intEnvOr(name, fallback) {
1181
1183
  const value = Number.parseInt(process.env[name] ?? "", 10);
1182
1184
  return Number.isFinite(value) ? value : fallback;
@@ -1295,6 +1297,67 @@ function readPeerProjectFilters(store, peerDeviceId) {
1295
1297
  exclude: parseJsonList(row.projects_exclude_json)
1296
1298
  };
1297
1299
  }
1300
+ function currentProjectScope(row, effective) {
1301
+ return {
1302
+ include: safeJsonList(row.projects_include_json),
1303
+ exclude: safeJsonList(row.projects_exclude_json),
1304
+ effective_include: effective?.include ?? safeJsonList(row.projects_include_json),
1305
+ effective_exclude: effective?.exclude ?? safeJsonList(row.projects_exclude_json),
1306
+ inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
1307
+ };
1308
+ }
1309
+ function ensureLocalActorRecord(store) {
1310
+ const d = drizzle(store.db, { schema });
1311
+ if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
1312
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1313
+ d.insert(schema.actors).values({
1314
+ actor_id: store.actorId,
1315
+ display_name: store.actorDisplayName,
1316
+ is_local: 1,
1317
+ status: "active",
1318
+ merged_into_actor_id: null,
1319
+ created_at: now,
1320
+ updated_at: now
1321
+ }).run();
1322
+ }
1323
+ function findPeerDeviceIdForAddress(store, address) {
1324
+ const rows = store.db.prepare("SELECT peer_device_id, addresses_json FROM sync_peers").all();
1325
+ for (const row of rows) if (safeJsonList(row.addresses_json).includes(address)) return String(row.peer_device_id ?? "").trim() || null;
1326
+ return null;
1327
+ }
1328
+ function claimLegacyDeviceAsSelf(store, originDeviceId) {
1329
+ const deviceId = String(originDeviceId || "").trim();
1330
+ if (!deviceId || deviceId === "unknown" || deviceId === store.deviceId) return 0;
1331
+ const personalWorkspaceId = `personal:${store.actorId}`;
1332
+ const result = store.db.prepare(`UPDATE memory_items
1333
+ SET actor_id = ?,
1334
+ actor_display_name = ?,
1335
+ visibility = 'private',
1336
+ workspace_id = ?,
1337
+ workspace_kind = 'personal',
1338
+ trust_state = 'trusted'
1339
+ WHERE origin_device_id = ?
1340
+ AND origin_device_id NOT IN (
1341
+ SELECT peer_device_id FROM sync_peers WHERE peer_device_id IS NOT NULL
1342
+ )
1343
+ AND (
1344
+ (
1345
+ actor_id IS NULL
1346
+ OR TRIM(actor_id) = ''
1347
+ OR actor_id LIKE 'legacy-sync:%'
1348
+ OR actor_id = ?
1349
+ )
1350
+ AND (
1351
+ actor_id IS NULL
1352
+ OR TRIM(actor_id) = ''
1353
+ OR actor_id LIKE 'legacy-sync:%'
1354
+ OR actor_display_name = ?
1355
+ OR workspace_id = ?
1356
+ OR trust_state = 'legacy_unknown'
1357
+ )
1358
+ )`).run(store.actorId, store.actorDisplayName, personalWorkspaceId, deviceId, store.actorId, LEGACY_SYNC_ACTOR_DISPLAY_NAME, LEGACY_SHARED_WORKSPACE_ID);
1359
+ return Number(result.changes ?? 0);
1360
+ }
1298
1361
  function peerClaimedLocalActor(store, peerDeviceId) {
1299
1362
  const row = store.db.prepare("SELECT claimed_local_actor FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(peerDeviceId);
1300
1363
  return Boolean(row?.claimed_local_actor);
@@ -1321,6 +1384,10 @@ function redactCoordinatorStatus(coordinator, showDiag) {
1321
1384
  last_seen_at: record.last_seen_at ?? null,
1322
1385
  expires_at: record.expires_at ?? null,
1323
1386
  stale: Boolean(record.stale),
1387
+ needs_local_approval: Boolean(record.needs_local_approval),
1388
+ waiting_for_peer_approval: Boolean(record.waiting_for_peer_approval),
1389
+ incoming_reciprocal_request_id: record.incoming_reciprocal_request_id ?? null,
1390
+ outgoing_reciprocal_request_id: record.outgoing_reciprocal_request_id ?? null,
1324
1391
  fingerprint: null,
1325
1392
  addresses: []
1326
1393
  };
@@ -1346,37 +1413,58 @@ async function acceptDiscoveredPeer(store, input) {
1346
1413
  detail: "That discovered device is no longer available. Refresh sync status and try again."
1347
1414
  };
1348
1415
  const nextFingerprint = String(match.fingerprint ?? "").trim();
1416
+ const nextPublicKey = String(match.public_key ?? "").trim();
1349
1417
  const nextName = String(match.display_name ?? "").trim() || null;
1350
1418
  const nextAddresses = Array.isArray(match.addresses) ? match.addresses.filter((value) => typeof value === "string") : [];
1419
+ if (!nextPublicKey) return {
1420
+ ok: false,
1421
+ status: 409,
1422
+ error: "discovered_peer_missing_public_key",
1423
+ detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
1424
+ };
1425
+ const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
1426
+ if (groupIds.length !== 1) return {
1427
+ ok: false,
1428
+ status: 409,
1429
+ error: "ambiguous_coordinator_group",
1430
+ 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."
1431
+ };
1432
+ const groupId = groupIds[0];
1351
1433
  const d = drizzle(store.db, { schema });
1434
+ const existing = d.select({
1435
+ peer_device_id: schema.syncPeers.peer_device_id,
1436
+ pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
1437
+ public_key: schema.syncPeers.public_key,
1438
+ addresses_json: schema.syncPeers.addresses_json
1439
+ }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
1440
+ const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
1441
+ if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
1442
+ ok: false,
1443
+ status: 409,
1444
+ error: "peer_conflict",
1445
+ 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."
1446
+ };
1447
+ const existingAddresses = (() => {
1448
+ try {
1449
+ const raw = JSON.parse(String(existing?.addresses_json ?? "[]"));
1450
+ return Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : [];
1451
+ } catch {
1452
+ return [];
1453
+ }
1454
+ })();
1455
+ const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
1456
+ await createCoordinatorReciprocalApproval(store, config, {
1457
+ groupId,
1458
+ requestedDeviceId: input.peerDeviceId
1459
+ });
1352
1460
  const now = (/* @__PURE__ */ new Date()).toISOString();
1353
1461
  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
1462
  if (!existing) {
1376
1463
  d.insert(schema.syncPeers).values({
1377
1464
  peer_device_id: input.peerDeviceId,
1378
1465
  name: nextName,
1379
1466
  pinned_fingerprint: nextFingerprint || null,
1467
+ public_key: nextPublicKey,
1380
1468
  addresses_json: addressesJson,
1381
1469
  created_at: now,
1382
1470
  last_seen_at: now
@@ -1392,6 +1480,7 @@ async function acceptDiscoveredPeer(store, input) {
1392
1480
  d.update(schema.syncPeers).set({
1393
1481
  name: nextName,
1394
1482
  pinned_fingerprint: nextFingerprint || existing.pinned_fingerprint || null,
1483
+ public_key: nextPublicKey || existing.public_key || null,
1395
1484
  addresses_json: addressesJson,
1396
1485
  last_seen_at: now
1397
1486
  }).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).run();
@@ -1459,7 +1548,7 @@ function filterOpsForPeer(store, peerDeviceId, ops) {
1459
1548
  * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
1460
1549
  * are redacted.
1461
1550
  */
1462
- function mapPeerRow(row, showDiag) {
1551
+ function mapPeerRow(store, row, showDiag) {
1463
1552
  return {
1464
1553
  peer_device_id: row.peer_device_id,
1465
1554
  name: row.name,
@@ -1473,13 +1562,7 @@ function mapPeerRow(row, showDiag) {
1473
1562
  claimed_local_actor: Boolean(row.claimed_local_actor),
1474
1563
  actor_id: row.actor_id ?? null,
1475
1564
  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
- }
1565
+ project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) }
1483
1566
  };
1484
1567
  }
1485
1568
  function isRecentIso(value, windowS = SYNC_STALE_AFTER_SECONDS) {
@@ -1793,7 +1876,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1793
1876
  statusPayload.daemon_detail = runtimeStatus.detail ?? daemonDetail;
1794
1877
  }
1795
1878
  const peersItems = store.db.prepare(PEERS_QUERY).all().map((row) => {
1796
- const peer = mapPeerRow(row, showDiag);
1879
+ const peer = mapPeerRow(store, row, showDiag);
1797
1880
  peer.status = peerStatus(peer);
1798
1881
  return peer;
1799
1882
  });
@@ -1867,7 +1950,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1867
1950
  const store = getStore();
1868
1951
  {
1869
1952
  const showDiag = queryBool(c.req.query("includeDiagnostics"));
1870
- const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(row, showDiag));
1953
+ const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(store, row, showDiag));
1871
1954
  return c.json({
1872
1955
  items: peers,
1873
1956
  redacted: !showDiag
@@ -1877,6 +1960,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1877
1960
  app.get("/api/sync/actors", (c) => {
1878
1961
  const store = getStore();
1879
1962
  {
1963
+ ensureLocalActorRecord(store);
1880
1964
  const d = drizzle(store.db, { schema });
1881
1965
  const includeMerged = queryBool(c.req.query("includeMerged"));
1882
1966
  const query = d.select().from(schema.actors);
@@ -1955,6 +2039,216 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
1955
2039
  return c.json({ ok: true });
1956
2040
  }
1957
2041
  });
2042
+ app.post("/api/sync/run", async (c) => {
2043
+ const store = getStore();
2044
+ let body = {};
2045
+ try {
2046
+ body = await c.req.json();
2047
+ } catch {
2048
+ body = {};
2049
+ }
2050
+ if (!readCoordinatorSyncConfig().syncEnabled) return c.json({ error: "sync_disabled" }, 403);
2051
+ const address = typeof body.address === "string" && body.address.trim() ? body.address.trim() : null;
2052
+ const requestedPeerId = typeof body.peer_device_id === "string" && body.peer_device_id.trim() ? body.peer_device_id.trim() : null;
2053
+ let peerIds;
2054
+ if (address) {
2055
+ const peerId = findPeerDeviceIdForAddress(store, address);
2056
+ if (!peerId) return c.json({ error: "unknown peer address" }, 404);
2057
+ peerIds = [peerId];
2058
+ } 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);
2059
+ else peerIds = store.db.prepare("SELECT peer_device_id FROM sync_peers").all().map((row) => String(row.peer_device_id ?? "").trim()).filter(Boolean);
2060
+ const items = [];
2061
+ for (const peerId of peerIds) {
2062
+ const result = await runSyncPass(store.db, peerId);
2063
+ items.push({
2064
+ peer_device_id: peerId,
2065
+ ...result
2066
+ });
2067
+ }
2068
+ return c.json({ items });
2069
+ });
2070
+ app.post("/api/sync/peers/scope", async (c) => {
2071
+ const store = getStore();
2072
+ let body;
2073
+ try {
2074
+ body = await c.req.json();
2075
+ } catch {
2076
+ return c.json({ error: "invalid json" }, 400);
2077
+ }
2078
+ const peerDeviceId = String(body.peer_device_id ?? "").trim();
2079
+ if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
2080
+ const include = Array.isArray(body.include) ? body.include.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean) : null;
2081
+ const exclude = Array.isArray(body.exclude) ? body.exclude.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean) : null;
2082
+ const inheritGlobal = Boolean(body.inherit_global);
2083
+ const d = drizzle(store.db, { schema });
2084
+ 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);
2085
+ d.update(schema.syncPeers).set({
2086
+ projects_include_json: inheritGlobal ? null : JSON.stringify(include ?? []),
2087
+ projects_exclude_json: inheritGlobal ? null : JSON.stringify(exclude ?? [])
2088
+ }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
2089
+ const row = d.select({
2090
+ projects_include_json: schema.syncPeers.projects_include_json,
2091
+ projects_exclude_json: schema.syncPeers.projects_exclude_json
2092
+ }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
2093
+ if (!row) return c.json({ error: "peer not found" }, 404);
2094
+ return c.json({
2095
+ ok: true,
2096
+ project_scope: currentProjectScope(row, readPeerProjectFilters(store, peerDeviceId))
2097
+ });
2098
+ });
2099
+ app.post("/api/sync/peers/identity", async (c) => {
2100
+ const store = getStore();
2101
+ ensureLocalActorRecord(store);
2102
+ let body;
2103
+ try {
2104
+ body = await c.req.json();
2105
+ } catch {
2106
+ return c.json({ error: "invalid json" }, 400);
2107
+ }
2108
+ const peerDeviceId = String(body.peer_device_id ?? "").trim();
2109
+ if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
2110
+ const requestedActorId = body.actor_id == null ? void 0 : String(body.actor_id ?? "").trim() || null;
2111
+ const claimedLocalActor = typeof body.claimed_local_actor === "boolean" ? body.claimed_local_actor : void 0;
2112
+ const d = drizzle(store.db, { schema });
2113
+ const peer = d.select({
2114
+ peer_device_id: schema.syncPeers.peer_device_id,
2115
+ actor_id: schema.syncPeers.actor_id,
2116
+ claimed_local_actor: schema.syncPeers.claimed_local_actor
2117
+ }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
2118
+ if (!peer) return c.json({ error: "peer not found" }, 404);
2119
+ let nextActorId = peer.actor_id ?? null;
2120
+ let nextClaimedLocalActor = Boolean(peer.claimed_local_actor);
2121
+ if (requestedActorId !== void 0) {
2122
+ nextActorId = requestedActorId;
2123
+ if (requestedActorId) {
2124
+ const actor = d.select({
2125
+ actor_id: schema.actors.actor_id,
2126
+ is_local: schema.actors.is_local,
2127
+ status: schema.actors.status
2128
+ }).from(schema.actors).where(eq(schema.actors.actor_id, requestedActorId)).get();
2129
+ if (!actor) return c.json({ error: "actor not found" }, 404);
2130
+ if (actor.status !== "active") return c.json({ error: "actor not active" }, 409);
2131
+ nextClaimedLocalActor = claimedLocalActor ?? Boolean(actor.is_local);
2132
+ } else nextClaimedLocalActor = claimedLocalActor ?? false;
2133
+ } else if (claimedLocalActor !== void 0) {
2134
+ nextClaimedLocalActor = claimedLocalActor;
2135
+ if (claimedLocalActor) nextActorId = store.actorId;
2136
+ else if (nextActorId === store.actorId) nextActorId = null;
2137
+ }
2138
+ d.update(schema.syncPeers).set({
2139
+ actor_id: nextActorId,
2140
+ claimed_local_actor: nextClaimedLocalActor ? 1 : 0
2141
+ }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
2142
+ return c.json({
2143
+ ok: true,
2144
+ actor_id: nextActorId,
2145
+ claimed_local_actor: nextClaimedLocalActor
2146
+ });
2147
+ });
2148
+ app.post("/api/sync/actors", async (c) => {
2149
+ const store = getStore();
2150
+ ensureLocalActorRecord(store);
2151
+ let body;
2152
+ try {
2153
+ body = await c.req.json();
2154
+ } catch {
2155
+ return c.json({ error: "invalid json" }, 400);
2156
+ }
2157
+ const displayName = String(body.display_name ?? "").trim();
2158
+ if (!displayName) return c.json({ error: "display_name required" }, 400);
2159
+ const d = drizzle(store.db, { schema });
2160
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2161
+ const actor = {
2162
+ actor_id: randomUUID(),
2163
+ display_name: displayName,
2164
+ is_local: 0,
2165
+ status: "active",
2166
+ merged_into_actor_id: null,
2167
+ created_at: now,
2168
+ updated_at: now
2169
+ };
2170
+ d.insert(schema.actors).values(actor).run();
2171
+ return c.json(actor);
2172
+ });
2173
+ app.post("/api/sync/actors/rename", async (c) => {
2174
+ const store = getStore();
2175
+ ensureLocalActorRecord(store);
2176
+ let body;
2177
+ try {
2178
+ body = await c.req.json();
2179
+ } catch {
2180
+ return c.json({ error: "invalid json" }, 400);
2181
+ }
2182
+ const actorId = String(body.actor_id ?? "").trim();
2183
+ const displayName = String(body.display_name ?? "").trim();
2184
+ if (!actorId) return c.json({ error: "actor_id required" }, 400);
2185
+ if (!displayName) return c.json({ error: "display_name required" }, 400);
2186
+ const d = drizzle(store.db, { schema });
2187
+ const actor = d.select().from(schema.actors).where(eq(schema.actors.actor_id, actorId)).get();
2188
+ if (!actor) return c.json({ error: "actor not found" }, 404);
2189
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2190
+ d.update(schema.actors).set({
2191
+ display_name: displayName,
2192
+ updated_at: updatedAt
2193
+ }).where(eq(schema.actors.actor_id, actorId)).run();
2194
+ return c.json({
2195
+ ...actor,
2196
+ display_name: displayName,
2197
+ updated_at: updatedAt
2198
+ });
2199
+ });
2200
+ app.post("/api/sync/actors/merge", async (c) => {
2201
+ const store = getStore();
2202
+ ensureLocalActorRecord(store);
2203
+ let body;
2204
+ try {
2205
+ body = await c.req.json();
2206
+ } catch {
2207
+ return c.json({ error: "invalid json" }, 400);
2208
+ }
2209
+ const primaryActorId = String(body.primary_actor_id ?? "").trim();
2210
+ const secondaryActorId = String(body.secondary_actor_id ?? "").trim();
2211
+ if (!primaryActorId) return c.json({ error: "primary_actor_id required" }, 400);
2212
+ if (!secondaryActorId) return c.json({ error: "secondary_actor_id required" }, 400);
2213
+ if (primaryActorId === secondaryActorId) return c.json({ error: "actor ids must differ" }, 400);
2214
+ const d = drizzle(store.db, { schema });
2215
+ const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
2216
+ const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
2217
+ if (!primary || !secondary) return c.json({ error: "actor not found" }, 404);
2218
+ if (primary.status !== "active") return c.json({ error: "primary actor not active" }, 409);
2219
+ if (secondary.status !== "active") return c.json({ error: "secondary actor not active" }, 409);
2220
+ if (secondary.is_local) return c.json({ error: "cannot merge local actor" }, 409);
2221
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2222
+ const mergedCount = store.db.transaction(() => {
2223
+ const peerUpdate = d.update(schema.syncPeers).set({ actor_id: primaryActorId }).where(eq(schema.syncPeers.actor_id, secondaryActorId)).run();
2224
+ if (primary.is_local) d.update(schema.syncPeers).set({ claimed_local_actor: 1 }).where(eq(schema.syncPeers.actor_id, primaryActorId)).run();
2225
+ d.update(schema.actors).set({
2226
+ status: "merged",
2227
+ merged_into_actor_id: primaryActorId,
2228
+ updated_at: now
2229
+ }).where(eq(schema.actors.actor_id, secondaryActorId)).run();
2230
+ return Number(peerUpdate.changes ?? 0);
2231
+ })();
2232
+ return c.json({ merged_count: mergedCount });
2233
+ });
2234
+ app.post("/api/sync/legacy-devices/claim", async (c) => {
2235
+ const store = getStore();
2236
+ let body;
2237
+ try {
2238
+ body = await c.req.json();
2239
+ } catch {
2240
+ return c.json({ error: "invalid json" }, 400);
2241
+ }
2242
+ const originDeviceId = String(body.origin_device_id ?? "").trim();
2243
+ if (!originDeviceId) return c.json({ error: "origin_device_id required" }, 400);
2244
+ const updated = claimLegacyDeviceAsSelf(store, originDeviceId);
2245
+ if (updated <= 0) return c.json({ error: "legacy device not found" }, 404);
2246
+ return c.json({
2247
+ ok: true,
2248
+ origin_device_id: originDeviceId,
2249
+ updated
2250
+ });
2251
+ });
1958
2252
  app.post("/api/sync/peers/accept-discovered", async (c) => {
1959
2253
  const store = getStore();
1960
2254
  let body;
@@ -2132,7 +2426,9 @@ function createApp(opts) {
2132
2426
  root: staticRoot,
2133
2427
  rewriteRequestPath: (path) => path.replace(/^\/assets/, "")
2134
2428
  }));
2135
- const indexHtml = readFileSync(join(staticRoot, "index.html"), "utf-8");
2429
+ const indexPath = join(staticRoot, "index.html");
2430
+ if (!existsSync(indexPath)) throw new Error(`Viewer assets missing at ${indexPath}. Run \`pnpm build\` from the repo root before starting the viewer.`);
2431
+ const indexHtml = readFileSync(indexPath, "utf-8");
2136
2432
  app.get("*", (c) => {
2137
2433
  if (c.req.path.startsWith("/api/")) return c.json({ error: "not found" }, 404);
2138
2434
  return c.html(indexHtml);