@codemem/server 0.22.3 → 0.23.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.js CHANGED
@@ -1,12 +1,12 @@
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, 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";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, VERSION as VERSION$1, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorStatusSnapshot, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, requestJson, 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 { RateLimiter } from "limiter";
8
8
  import { homedir } from "node:os";
9
- import { count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
9
+ import { and, count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
10
10
  import { drizzle } from "drizzle-orm/better-sqlite3";
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  import net from "node:net";
@@ -591,14 +591,15 @@ function queryMemoryPage(store, options) {
591
591
  ORDER BY memory_items.created_at DESC
592
592
  LIMIT ? OFFSET ?`).all(...filterResult.params, options.limit + 1, options.offset).map((row) => ({
593
593
  ...row,
594
+ kind: canonicalMemoryKind(row.kind ?? null, row.metadata_json),
594
595
  metadata_json: fromJson(row.metadata_json ?? null)
595
596
  }));
596
597
  }
597
- function isSummaryLikeMemory(item) {
598
- if (String(item.kind ?? "").toLowerCase() === "session_summary") return true;
599
- const metadata = item.metadata_json ?? {};
600
- if (metadata.is_summary === true) return true;
601
- return String(metadata.source ?? "").trim().toLowerCase() === "observer_summary";
598
+ function isSummaryLikeMemory$1(item) {
599
+ return isSummaryLikeMemory({
600
+ kind: item.kind,
601
+ metadata: item.metadata_json
602
+ });
602
603
  }
603
604
  function selectMemoryPage(store, options) {
604
605
  const pageSize = Math.max(options.limit + options.offset + 10, 50);
@@ -653,7 +654,7 @@ function memoryRoutes(getStore) {
653
654
  offset,
654
655
  project: c.req.query("project") || void 0,
655
656
  scope: normalizeScope(c.req.query("scope")),
656
- matcher: (item) => !isSummaryLikeMemory(item)
657
+ matcher: (item) => !isSummaryLikeMemory$1(item)
657
658
  });
658
659
  const hasMore = items.length > limit;
659
660
  const result = hasMore ? items.slice(0, limit) : items;
@@ -680,7 +681,7 @@ function memoryRoutes(getStore) {
680
681
  offset,
681
682
  project: c.req.query("project") || void 0,
682
683
  scope: normalizeScope(c.req.query("scope")),
683
- matcher: (item) => isSummaryLikeMemory(item)
684
+ matcher: (item) => isSummaryLikeMemory$1(item)
684
685
  });
685
686
  const hasMore = items.length > limit;
686
687
  const result = hasMore ? items.slice(0, limit) : items;
@@ -719,7 +720,7 @@ function memoryRoutes(getStore) {
719
720
  project: scopeProject
720
721
  });
721
722
  if (page.length === 0) break;
722
- total += page.filter((item) => !isSummaryLikeMemory(item)).length;
723
+ total += page.filter((item) => !isSummaryLikeMemory$1(item)).length;
723
724
  if (page.length < 200) break;
724
725
  offset += page.length;
725
726
  }
@@ -1288,6 +1289,9 @@ var SYNC_STALE_AFTER_SECONDS = 600;
1288
1289
  var SYNC_PROTOCOL_VERSION = "2";
1289
1290
  var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
1290
1291
  var LEGACY_SHARED_WORKSPACE_ID = "shared:legacy";
1292
+ function syncKeysDir() {
1293
+ return process.env.CODEMEM_KEYS_DIR?.trim() || void 0;
1294
+ }
1291
1295
  function intEnvOr(name, fallback) {
1292
1296
  const value = Number.parseInt(process.env[name] ?? "", 10);
1293
1297
  return Number.isFinite(value) ? value : fallback;
@@ -1324,8 +1328,8 @@ function pathWithQuery(url) {
1324
1328
  const parsed = new URL(url);
1325
1329
  return parsed.search ? `${parsed.pathname}${parsed.search}` : parsed.pathname;
1326
1330
  }
1327
- function unauthorizedPayload(reason) {
1328
- if (process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
1331
+ function unauthorizedPayload(reason, exposeReason = false) {
1332
+ if (exposeReason && process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
1329
1333
  error: "unauthorized",
1330
1334
  reason
1331
1335
  };
@@ -1397,6 +1401,123 @@ function authorizeSyncRequest(store, request, body) {
1397
1401
  deviceId
1398
1402
  };
1399
1403
  }
1404
+ async function authorizeBootstrapGrantRequest(store, request, body) {
1405
+ const grantId = (request.header("X-Codemem-Bootstrap-Grant") ?? "").trim();
1406
+ const deviceId = (request.header("X-Opencode-Device") ?? "").trim();
1407
+ const signature = request.header("X-Opencode-Signature") ?? "";
1408
+ const timestamp = request.header("X-Opencode-Timestamp") ?? "";
1409
+ const nonce = request.header("X-Opencode-Nonce") ?? "";
1410
+ if (!grantId || !deviceId || !signature || !timestamp || !nonce) return {
1411
+ ok: false,
1412
+ reason: "missing_bootstrap_grant_headers",
1413
+ deviceId
1414
+ };
1415
+ const config = readCoordinatorSyncConfig();
1416
+ if (!config.syncCoordinatorUrl || !config.syncCoordinatorAdminSecret) return {
1417
+ ok: false,
1418
+ reason: "bootstrap_grant_coordinator_not_configured",
1419
+ deviceId
1420
+ };
1421
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1422
+ let verification;
1423
+ try {
1424
+ const [status, payload] = await requestJson("GET", `${buildBaseUrl(config.syncCoordinatorUrl)}/v1/admin/bootstrap-grants/${encodeURIComponent(grantId)}`, {
1425
+ headers: { "X-Codemem-Coordinator-Admin": config.syncCoordinatorAdminSecret },
1426
+ timeoutS: Math.max(1, config.syncCoordinatorTimeoutS)
1427
+ });
1428
+ if (status !== 200 || !payload) return {
1429
+ ok: false,
1430
+ reason: "bootstrap_grant_lookup_failed",
1431
+ deviceId
1432
+ };
1433
+ verification = payload;
1434
+ } catch {
1435
+ return {
1436
+ ok: false,
1437
+ reason: "bootstrap_grant_lookup_failed",
1438
+ deviceId
1439
+ };
1440
+ }
1441
+ const grant = verification.grant;
1442
+ const workerEnrollment = verification.worker_enrollment;
1443
+ if (!grant || !workerEnrollment) return {
1444
+ ok: false,
1445
+ reason: "bootstrap_grant_invalid_payload",
1446
+ deviceId
1447
+ };
1448
+ if (grant.revoked_at) return {
1449
+ ok: false,
1450
+ reason: "bootstrap_grant_revoked",
1451
+ deviceId
1452
+ };
1453
+ if (String(workerEnrollment.device_id) !== String(grant.worker_device_id)) return {
1454
+ ok: false,
1455
+ reason: "bootstrap_grant_worker_enrollment_mismatch",
1456
+ deviceId
1457
+ };
1458
+ if (String(workerEnrollment.group_id) !== String(grant.group_id)) return {
1459
+ ok: false,
1460
+ reason: "bootstrap_grant_group_mismatch",
1461
+ deviceId
1462
+ };
1463
+ if (Number(workerEnrollment.enabled) !== 1) return {
1464
+ ok: false,
1465
+ reason: "bootstrap_grant_worker_disabled",
1466
+ deviceId
1467
+ };
1468
+ if (grant.worker_device_id !== deviceId) return {
1469
+ ok: false,
1470
+ reason: "bootstrap_grant_worker_mismatch",
1471
+ deviceId
1472
+ };
1473
+ if (grant.seed_device_id !== localDeviceId) return {
1474
+ ok: false,
1475
+ reason: "bootstrap_grant_seed_mismatch",
1476
+ deviceId
1477
+ };
1478
+ if (new Date(grant.expires_at) <= /* @__PURE__ */ new Date()) return {
1479
+ ok: false,
1480
+ reason: "bootstrap_grant_expired",
1481
+ deviceId
1482
+ };
1483
+ let valid = false;
1484
+ try {
1485
+ valid = verifySignature({
1486
+ method: request.method,
1487
+ pathWithQuery: pathWithQuery(request.url),
1488
+ bodyBytes: body,
1489
+ timestamp,
1490
+ nonce,
1491
+ signature,
1492
+ publicKey: String(workerEnrollment.public_key),
1493
+ deviceId
1494
+ });
1495
+ } catch {
1496
+ return {
1497
+ ok: false,
1498
+ reason: "bootstrap_grant_signature_verification_error",
1499
+ deviceId
1500
+ };
1501
+ }
1502
+ if (!valid) return {
1503
+ ok: false,
1504
+ reason: "bootstrap_grant_invalid_signature",
1505
+ deviceId
1506
+ };
1507
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1508
+ if (!recordNonce(store.db, deviceId, nonce, createdAt)) return {
1509
+ ok: false,
1510
+ reason: "nonce_replay",
1511
+ deviceId
1512
+ };
1513
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - DEFAULT_TIME_WINDOW_S * 2 * 1e3)).toISOString();
1514
+ cleanupNonces(store.db, cutoff);
1515
+ return {
1516
+ ok: true,
1517
+ reason: "ok",
1518
+ deviceId
1519
+ };
1520
+ }
1400
1521
  function projectBasename(value) {
1401
1522
  const project = String(value ?? "").trim().replaceAll("\\", "/");
1402
1523
  if (!project) return "";
@@ -1795,29 +1916,43 @@ function syncProtocolRoutes(getStore, opts = {}) {
1795
1916
  }
1796
1917
  app.get("/v1/status", (c) => {
1797
1918
  const store = getStore();
1798
- const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
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;
1802
- try {
1803
- let device = store.db.prepare("SELECT device_id, fingerprint FROM sync_device LIMIT 1").get();
1804
- if (!device) {
1805
- const [deviceId, fingerprint] = ensureDeviceIdentity(store.db);
1806
- device = {
1919
+ return (async () => {
1920
+ let auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1921
+ let preauthChecked = false;
1922
+ let bootstrapAttempted = false;
1923
+ if (!auth.ok) {
1924
+ if ((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()) {
1925
+ preauthChecked = true;
1926
+ const bootstrapLimited = rateLimitedResponse(c, c.req.path, false);
1927
+ if (bootstrapLimited) return bootstrapLimited;
1928
+ } else {
1929
+ preauthChecked = true;
1930
+ const unauthLimited = rateLimitedResponse(c, c.req.path, false);
1931
+ if (unauthLimited) return unauthLimited;
1932
+ }
1933
+ auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
1934
+ bootstrapAttempted = true;
1935
+ }
1936
+ if (!auth.ok) {
1937
+ if (bootstrapAttempted) console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant=${(c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()} path=${c.req.path}`);
1938
+ const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
1939
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
1940
+ }
1941
+ const limited = rateLimitedResponse(c, auth.deviceId, true);
1942
+ if (limited) return limited;
1943
+ try {
1944
+ const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1945
+ const syncReset = getSyncResetState(store.db);
1946
+ return c.json({
1807
1947
  device_id: deviceId,
1808
- fingerprint
1809
- };
1948
+ protocol_version: SYNC_PROTOCOL_VERSION,
1949
+ fingerprint,
1950
+ sync_reset: syncReset
1951
+ });
1952
+ } catch {
1953
+ return c.json({ error: "internal_error" }, 500);
1810
1954
  }
1811
- const syncReset = getSyncResetState(store.db);
1812
- return c.json({
1813
- device_id: device.device_id,
1814
- protocol_version: SYNC_PROTOCOL_VERSION,
1815
- fingerprint: device.fingerprint,
1816
- sync_reset: syncReset
1817
- });
1818
- } catch {
1819
- return c.json({ error: "internal_error" }, 500);
1820
- }
1955
+ })();
1821
1956
  });
1822
1957
  app.get("/v1/ops", (c) => {
1823
1958
  const store = getStore();
@@ -1834,15 +1969,11 @@ function syncProtocolRoutes(getStore, opts = {}) {
1834
1969
  const snapshotId = c.req.query("snapshot_id") ?? null;
1835
1970
  const baselineCursor = c.req.query("baseline_cursor") ?? null;
1836
1971
  const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1e3)) : 200;
1837
- let localDeviceId = store.db.prepare("SELECT device_id FROM sync_device LIMIT 1").get();
1838
- if (!localDeviceId) {
1839
- const [deviceId] = ensureDeviceIdentity(store.db);
1840
- localDeviceId = { device_id: deviceId };
1841
- }
1972
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1842
1973
  const result = loadReplicationOpsForPeer(store.db, {
1843
1974
  since,
1844
1975
  limit,
1845
- deviceId: localDeviceId.device_id,
1976
+ deviceId: localDeviceId,
1846
1977
  generation: Number.isFinite(generation) ? generation : null,
1847
1978
  snapshotId,
1848
1979
  baselineCursor
@@ -1869,42 +2000,63 @@ function syncProtocolRoutes(getStore, opts = {}) {
1869
2000
  });
1870
2001
  app.get("/v1/snapshot", (c) => {
1871
2002
  const store = getStore();
1872
- const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
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;
1876
- try {
1877
- const rawGeneration = c.req.query("generation");
1878
- const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
1879
- const snapshotId = c.req.query("snapshot_id") ?? null;
1880
- const baselineCursor = c.req.query("baseline_cursor") ?? null;
1881
- const pageToken = c.req.query("page_token") ?? null;
1882
- const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
1883
- const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1e3)) : 200;
1884
- if (generation == null || !Number.isFinite(generation)) return c.json({ error: "missing_generation" }, 400);
1885
- if (!snapshotId) return c.json({ error: "missing_snapshot_id" }, 400);
1886
- const result = loadMemorySnapshotPageForPeer(store.db, {
1887
- generation,
1888
- snapshotId,
1889
- baselineCursor,
1890
- pageToken,
1891
- limit,
1892
- peerDeviceId: auth.deviceId
1893
- });
1894
- return c.json({
1895
- generation: result.boundary.generation,
1896
- snapshot_id: result.boundary.snapshot_id,
1897
- baseline_cursor: result.boundary.baseline_cursor,
1898
- retained_floor_cursor: result.boundary.retained_floor_cursor,
1899
- items: result.items,
1900
- next_page_token: result.nextPageToken,
1901
- has_more: result.hasMore
1902
- });
1903
- } catch (err) {
1904
- const message = err instanceof Error ? err.message : "";
1905
- if (message === "generation_mismatch" || message === "boundary_mismatch") return c.json({ error: message }, 409);
1906
- return c.json({ error: "internal_error" }, 500);
1907
- }
2003
+ return (async () => {
2004
+ let auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2005
+ let preauthChecked = false;
2006
+ let bootstrapAttempted = false;
2007
+ if (!auth.ok) {
2008
+ if ((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()) {
2009
+ preauthChecked = true;
2010
+ const bootstrapLimited = rateLimitedResponse(c, c.req.path, false);
2011
+ if (bootstrapLimited) return bootstrapLimited;
2012
+ } else {
2013
+ preauthChecked = true;
2014
+ const unauthLimited = rateLimitedResponse(c, c.req.path, false);
2015
+ if (unauthLimited) return unauthLimited;
2016
+ }
2017
+ auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
2018
+ bootstrapAttempted = true;
2019
+ }
2020
+ if (!auth.ok) {
2021
+ if (bootstrapAttempted) console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant=${(c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()} path=${c.req.path}`);
2022
+ const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
2023
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
2024
+ }
2025
+ const limited = rateLimitedResponse(c, auth.deviceId, true);
2026
+ if (limited) return limited;
2027
+ try {
2028
+ const rawGeneration = c.req.query("generation");
2029
+ const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
2030
+ const snapshotId = c.req.query("snapshot_id") ?? null;
2031
+ const baselineCursor = c.req.query("baseline_cursor") ?? null;
2032
+ const pageToken = c.req.query("page_token") ?? null;
2033
+ const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
2034
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 5e3)) : 200;
2035
+ if (generation == null || !Number.isFinite(generation)) return c.json({ error: "missing_generation" }, 400);
2036
+ if (!snapshotId) return c.json({ error: "missing_snapshot_id" }, 400);
2037
+ const result = loadMemorySnapshotPageForPeer(store.db, {
2038
+ generation,
2039
+ snapshotId,
2040
+ baselineCursor,
2041
+ pageToken,
2042
+ limit,
2043
+ peerDeviceId: auth.deviceId
2044
+ });
2045
+ return c.json({
2046
+ generation: result.boundary.generation,
2047
+ snapshot_id: result.boundary.snapshot_id,
2048
+ baseline_cursor: result.boundary.baseline_cursor,
2049
+ retained_floor_cursor: result.boundary.retained_floor_cursor,
2050
+ items: result.items,
2051
+ next_page_token: result.nextPageToken,
2052
+ has_more: result.hasMore
2053
+ });
2054
+ } catch (err) {
2055
+ const message = err instanceof Error ? err.message : "";
2056
+ if (message === "generation_mismatch" || message === "boundary_mismatch") return c.json({ error: message }, 409);
2057
+ return c.json({ error: "internal_error" }, 500);
2058
+ }
2059
+ })();
1908
2060
  });
1909
2061
  app.post("/v1/ops", async (c) => {
1910
2062
  const store = getStore();
@@ -1931,13 +2083,9 @@ function syncProtocolRoutes(getStore, opts = {}) {
1931
2083
  reason: "device_id_mismatch",
1932
2084
  op_id: op.op_id
1933
2085
  }, 400);
1934
- let localDeviceId = store.db.prepare("SELECT device_id FROM sync_device LIMIT 1").get();
1935
- if (!localDeviceId) {
1936
- const [deviceId] = ensureDeviceIdentity(store.db);
1937
- localDeviceId = { device_id: deviceId };
1938
- }
2086
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1939
2087
  const filteredInbound = filterOpsForPeer(store, peerDeviceId, normalizedOps);
1940
- const result = applyReplicationOps(store.db, filteredInbound.allowed, localDeviceId.device_id);
2088
+ const result = applyReplicationOps(store.db, filteredInbound.allowed, localDeviceId);
1941
2089
  return c.json({
1942
2090
  ...result,
1943
2091
  skipped: result.skipped + filteredInbound.skipped
@@ -2036,7 +2184,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2036
2184
  });
2037
2185
  const peersMap = {};
2038
2186
  for (const peer of peersItems) peersMap[String(peer.peer_device_id)] = peer.status;
2039
- const attemptsItems = d.select({
2187
+ const attemptRows = d.select({
2040
2188
  peer_device_id: schema.syncAttempts.peer_device_id,
2041
2189
  ok: schema.syncAttempts.ok,
2042
2190
  error: schema.syncAttempts.error,
@@ -2044,11 +2192,20 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2044
2192
  finished_at: schema.syncAttempts.finished_at,
2045
2193
  ops_in: schema.syncAttempts.ops_in,
2046
2194
  ops_out: schema.syncAttempts.ops_out
2047
- }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(25).all().map((row) => ({
2048
- ...row,
2049
- status: attemptStatus(row),
2050
- address: null
2051
- }));
2195
+ }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(25).all();
2196
+ const peerAddressMap = /* @__PURE__ */ new Map();
2197
+ store.db.prepare("SELECT peer_device_id, addresses_json FROM sync_peers").all().forEach((peerRow) => {
2198
+ const addrs = safeJsonList(peerRow.addresses_json);
2199
+ if (addrs.length) peerAddressMap.set(String(peerRow.peer_device_id ?? ""), addrs);
2200
+ });
2201
+ const attemptsItems = attemptRows.map((row) => {
2202
+ const addrs = showDiag ? peerAddressMap.get(String(row.peer_device_id ?? "")) : void 0;
2203
+ return {
2204
+ ...row,
2205
+ status: attemptStatus(row),
2206
+ address: addrs?.length ? addrs[0] : null
2207
+ };
2208
+ });
2052
2209
  const latestAttemptError = String(attemptsItems[0]?.error || "").trim();
2053
2210
  const statusBlock = {
2054
2211
  ...statusPayload,
@@ -2118,7 +2275,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2118
2275
  const d = drizzle(store.db, { schema });
2119
2276
  const includeMerged = queryBool(c.req.query("includeMerged"));
2120
2277
  const query = d.select().from(schema.actors);
2121
- const rows = includeMerged ? query.orderBy(schema.actors.display_name).all() : query.where(ne(schema.actors.status, "merged")).orderBy(schema.actors.display_name).all();
2278
+ const rows = includeMerged ? query.orderBy(schema.actors.display_name).all() : query.where(and(ne(schema.actors.status, "merged"), ne(schema.actors.status, "deactivated"))).orderBy(schema.actors.display_name).all();
2122
2279
  return c.json({ items: rows });
2123
2280
  }
2124
2281
  });
@@ -2162,7 +2319,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2162
2319
  publicKey = String(deviceRow.public_key);
2163
2320
  fingerprint = String(deviceRow.fingerprint);
2164
2321
  } else try {
2165
- const [id, fp] = ensureDeviceIdentity(store.db);
2322
+ const [id, fp] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2166
2323
  deviceId = id;
2167
2324
  fingerprint = fp;
2168
2325
  publicKey = d.select({ public_key: schema.syncDevice.public_key }).from(schema.syncDevice).where(eq(schema.syncDevice.device_id, id)).get()?.public_key ?? "";
@@ -2371,7 +2528,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2371
2528
  if (!primary || !secondary) return c.json({ error: "actor not found" }, 404);
2372
2529
  if (primary.status !== "active") return c.json({ error: "primary actor not active" }, 409);
2373
2530
  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);
2531
+ if (secondary.is_local && secondary.actor_id === store.actorId) return c.json({ error: "cannot merge this device's own local actor" }, 409);
2375
2532
  const now = (/* @__PURE__ */ new Date()).toISOString();
2376
2533
  const mergedCount = store.db.transaction(() => {
2377
2534
  const peerUpdate = d.update(schema.syncPeers).set({ actor_id: primaryActorId }).where(eq(schema.syncPeers.actor_id, secondaryActorId)).run();
@@ -2385,6 +2542,29 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2385
2542
  })();
2386
2543
  return c.json({ merged_count: mergedCount });
2387
2544
  });
2545
+ app.post("/api/sync/actors/deactivate", async (c) => {
2546
+ const store = getStore();
2547
+ let body;
2548
+ try {
2549
+ body = await c.req.json();
2550
+ } catch {
2551
+ return c.json({ error: "invalid json" }, 400);
2552
+ }
2553
+ const actorId = String(body.actor_id ?? "").trim();
2554
+ if (!actorId) return c.json({ error: "actor_id required" }, 400);
2555
+ if (actorId === store.actorId) return c.json({ error: "cannot deactivate this device's own local actor" }, 409);
2556
+ const d = drizzle(store.db, { schema });
2557
+ const actor = d.select().from(schema.actors).where(eq(schema.actors.actor_id, actorId)).get();
2558
+ if (!actor) return c.json({ error: "actor not found" }, 404);
2559
+ if (actor.status !== "active") return c.json({ error: "actor not active" }, 409);
2560
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2561
+ d.update(schema.actors).set({
2562
+ status: "deactivated",
2563
+ updated_at: now
2564
+ }).where(eq(schema.actors.actor_id, actorId)).run();
2565
+ d.update(schema.syncPeers).set({ actor_id: null }).where(eq(schema.syncPeers.actor_id, actorId)).run();
2566
+ return c.json({ ok: true });
2567
+ });
2388
2568
  app.post("/api/sync/legacy-devices/claim", async (c) => {
2389
2569
  const store = getStore();
2390
2570
  let body;
@@ -2520,6 +2700,47 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2520
2700
  return c.json({ error: message }, message.includes("request_not_found") || message.includes("not found") ? 404 : 400);
2521
2701
  }
2522
2702
  });
2703
+ app.get("/api/sync/bootstrap-grants", async (c) => {
2704
+ const groupId = String(c.req.query("group_id") ?? "").trim();
2705
+ if (!groupId) return c.json({ error: "group_id_required" }, 400);
2706
+ const config = readCoordinatorSyncConfig();
2707
+ try {
2708
+ const items = await coordinatorListBootstrapGrantsAction({
2709
+ groupId,
2710
+ remoteUrl: config.syncCoordinatorUrl || null,
2711
+ adminSecret: config.syncCoordinatorAdminSecret || null
2712
+ });
2713
+ return c.json({ items });
2714
+ } catch (error) {
2715
+ const message = error instanceof Error ? error.message : "unknown";
2716
+ return c.json({ error: message }, 500);
2717
+ }
2718
+ });
2719
+ app.post("/api/sync/bootstrap-grants/revoke", async (c) => {
2720
+ let body;
2721
+ try {
2722
+ body = await c.req.json();
2723
+ } catch {
2724
+ return c.json({ error: "invalid json" }, 400);
2725
+ }
2726
+ const grantId = String(body.grant_id ?? "").trim();
2727
+ if (!grantId) return c.json({ error: "grant_id_required" }, 400);
2728
+ const config = readCoordinatorSyncConfig();
2729
+ try {
2730
+ if (!await coordinatorRevokeBootstrapGrantAction({
2731
+ grantId,
2732
+ remoteUrl: config.syncCoordinatorUrl || null,
2733
+ adminSecret: config.syncCoordinatorAdminSecret || null
2734
+ })) return c.json({ error: "grant_not_found" }, 404);
2735
+ return c.json({
2736
+ ok: true,
2737
+ grant_id: grantId
2738
+ });
2739
+ } catch (error) {
2740
+ const message = error instanceof Error ? error.message : "unknown";
2741
+ return c.json({ error: message }, 500);
2742
+ }
2743
+ });
2523
2744
  app.delete("/api/sync/peers/:peer_device_id", (c) => {
2524
2745
  const store = getStore();
2525
2746
  {