@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +330 -34
- package/dist/index.js.map +1 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/app.js +10109 -1585
- package/static/index.html +167 -45
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;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,
|
|
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
|
|
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);
|