@codemem/server 0.28.1 → 0.29.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,6 +1,6 @@
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, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listMaintenanceJobs, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncProjectAllowedByFilters, syncVisibilityAllowed, 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, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listMaintenanceJobs, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncProjectAllowedByFilters, syncVisibilityAllowed, updatePeerAddresses, upsertCoordinatorGroupPreference, 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";
@@ -9,7 +9,6 @@ import { homedir, networkInterfaces } from "node:os";
9
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
- import net from "node:net";
13
12
  //#region src/middleware.ts
14
13
  var LOOPBACK_HOSTS = new Set([
15
14
  "127.0.0.1",
@@ -905,6 +904,28 @@ function memoryRoutes(getStore) {
905
904
  return c.json({ error: msg }, 400);
906
905
  }
907
906
  });
907
+ app.post("/api/memories/project", async (c) => {
908
+ const store = getStore();
909
+ let body;
910
+ try {
911
+ body = await c.req.json();
912
+ } catch {
913
+ return c.json({ error: "invalid JSON" }, 400);
914
+ }
915
+ const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
916
+ if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
917
+ const project = String(body.project ?? "").trim();
918
+ if (!project) return c.json({ error: "project must be a non-empty string" }, 400);
919
+ try {
920
+ const result = store.moveMemoryProject(memoryId, project);
921
+ return c.json(result);
922
+ } catch (err) {
923
+ const msg = err instanceof Error ? err.message : String(err);
924
+ if (msg.includes("not found")) return c.json({ error: msg }, 404);
925
+ if (msg.includes("not owned")) return c.json({ error: msg }, 403);
926
+ return c.json({ error: msg }, 400);
927
+ }
928
+ });
908
929
  app.post("/api/memories/forget", async (c) => {
909
930
  const store = getStore();
910
931
  let body;
@@ -1323,20 +1344,31 @@ function statsRoutes(getStore) {
1323
1344
  return null;
1324
1345
  }
1325
1346
  };
1347
+ const RECENTLY_COMPLETED_WINDOW_MS = 12e4;
1326
1348
  app.get("/api/stats", (c) => {
1327
1349
  const store = getStore();
1328
- const activeJobs = sortActiveMaintenanceJobs$1(listMaintenanceJobs(store.db).filter((job) => job.status === "pending" || job.status === "running" || job.status === "failed")).map((job) => ({
1350
+ const jobs = listMaintenanceJobs(store.db);
1351
+ const now = Date.now();
1352
+ const surfacedJobs = sortActiveMaintenanceJobs$1(jobs.filter((job) => {
1353
+ if (job.status === "pending" || job.status === "running" || job.status === "failed") return true;
1354
+ if (job.status === "completed" && job.finished_at) {
1355
+ const finished = Date.parse(job.finished_at);
1356
+ if (Number.isFinite(finished) && now - finished <= RECENTLY_COMPLETED_WINDOW_MS) return true;
1357
+ }
1358
+ return false;
1359
+ })).map((job) => ({
1329
1360
  kind: job.kind,
1330
1361
  title: job.title,
1331
1362
  status: job.status,
1332
1363
  message: job.message,
1333
1364
  progress: job.progress,
1365
+ finished_at: job.finished_at,
1334
1366
  error: job.error
1335
1367
  }));
1336
1368
  return c.json({
1337
1369
  ...store.stats(),
1338
1370
  viewer_pid: process.pid,
1339
- maintenance_jobs: activeJobs
1371
+ maintenance_jobs: surfacedJobs
1340
1372
  });
1341
1373
  });
1342
1374
  app.get("/api/runtime", (c) => {
@@ -1422,6 +1454,60 @@ var SYNC_STALE_AFTER_SECONDS = 600;
1422
1454
  var SYNC_PROTOCOL_VERSION = "2";
1423
1455
  var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
1424
1456
  var LEGACY_SHARED_WORKSPACE_ID = "shared:legacy";
1457
+ /**
1458
+ * Attempt to decode a pasted string as a device-pairing payload.
1459
+ *
1460
+ * Returns:
1461
+ * - `{ kind: "pair", ... }` when the payload base64-decodes to JSON with
1462
+ * the expected pairing shape AND the fingerprint matches the public
1463
+ * key.
1464
+ * - `{ kind: "invalid-pair", error }` when the payload looked like a
1465
+ * pairing payload but failed validation — the caller should 400 this
1466
+ * rather than falling back to coordinator-invite handling.
1467
+ * - `null` when the string is not a pairing payload at all.
1468
+ */
1469
+ function tryParsePairingPayload(value) {
1470
+ let parsed;
1471
+ if (value.trimStart().startsWith("{")) try {
1472
+ parsed = JSON.parse(value);
1473
+ } catch {
1474
+ return null;
1475
+ }
1476
+ else {
1477
+ let decoded;
1478
+ try {
1479
+ decoded = Buffer.from(value, "base64").toString("utf8");
1480
+ } catch {
1481
+ return null;
1482
+ }
1483
+ if (!decoded?.trimStart().startsWith("{")) return null;
1484
+ try {
1485
+ parsed = JSON.parse(decoded);
1486
+ } catch {
1487
+ return null;
1488
+ }
1489
+ }
1490
+ const deviceId = String(parsed.device_id ?? "").trim();
1491
+ const fingerprint = String(parsed.fingerprint ?? "").trim();
1492
+ const publicKey = String(parsed.public_key ?? "").trim();
1493
+ if (!deviceId && !fingerprint && !publicKey) return null;
1494
+ const addresses = (Array.isArray(parsed.addresses) ? parsed.addresses : []).filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
1495
+ if (!deviceId || !fingerprint || !publicKey || addresses.length === 0) return {
1496
+ kind: "invalid-pair",
1497
+ error: "Pairing payload missing device_id, fingerprint, public_key, or addresses"
1498
+ };
1499
+ if (fingerprintPublicKey(publicKey) !== fingerprint) return {
1500
+ kind: "invalid-pair",
1501
+ error: "Pairing payload fingerprint mismatch"
1502
+ };
1503
+ return {
1504
+ kind: "pair",
1505
+ device_id: deviceId,
1506
+ fingerprint,
1507
+ public_key: publicKey,
1508
+ addresses
1509
+ };
1510
+ }
1425
1511
  function coordinatorAdminStatusPayload(config = readCoordinatorSyncConfig()) {
1426
1512
  const groups = config.syncCoordinatorGroups;
1427
1513
  const activeGroup = groups[0] ?? null;
@@ -1878,19 +1964,31 @@ async function acceptDiscoveredPeer(store, input) {
1878
1964
  detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
1879
1965
  };
1880
1966
  const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
1881
- if (groupIds.length !== 1) return {
1882
- ok: false,
1883
- status: 409,
1884
- error: "ambiguous_coordinator_group",
1885
- 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."
1886
- };
1887
- const groupId = groupIds[0];
1967
+ let groupId;
1968
+ if (input.expectedGroupId) {
1969
+ if (!groupIds.includes(input.expectedGroupId)) return {
1970
+ ok: false,
1971
+ status: 409,
1972
+ error: "peer_not_in_group",
1973
+ detail: "This discovered device is not visible through the specified coordinator group. Refresh sync status and try again."
1974
+ };
1975
+ groupId = input.expectedGroupId;
1976
+ } else {
1977
+ if (groupIds.length !== 1) return {
1978
+ ok: false,
1979
+ status: 409,
1980
+ error: "ambiguous_coordinator_group",
1981
+ 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."
1982
+ };
1983
+ groupId = groupIds[0];
1984
+ }
1888
1985
  const d = drizzle(store.db, { schema });
1889
1986
  const existing = d.select({
1890
1987
  peer_device_id: schema.syncPeers.peer_device_id,
1891
1988
  pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
1892
1989
  public_key: schema.syncPeers.public_key,
1893
- addresses_json: schema.syncPeers.addresses_json
1990
+ addresses_json: schema.syncPeers.addresses_json,
1991
+ discovered_via_group_id: schema.syncPeers.discovered_via_group_id
1894
1992
  }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
1895
1993
  const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
1896
1994
  if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
@@ -1912,6 +2010,26 @@ async function acceptDiscoveredPeer(store, input) {
1912
2010
  groupId,
1913
2011
  requestedDeviceId: input.peerDeviceId
1914
2012
  });
2013
+ const coordinatorUrl = config.syncCoordinatorUrl || null;
2014
+ let scopeInclude;
2015
+ let scopeExclude;
2016
+ if (!existing) {
2017
+ let templateInclude = null;
2018
+ let templateExclude = null;
2019
+ if (coordinatorUrl) {
2020
+ const prefs = getCoordinatorGroupPreference(store.db, coordinatorUrl, groupId);
2021
+ if (prefs?.auto_seed_scope) {
2022
+ templateInclude = prefs.projects_include ?? null;
2023
+ templateExclude = prefs.projects_exclude ?? null;
2024
+ }
2025
+ }
2026
+ const overrideInclude = input.scopeOverride?.projects_include;
2027
+ const overrideExclude = input.scopeOverride?.projects_exclude;
2028
+ const resolvedInclude = overrideInclude === void 0 ? templateInclude : overrideInclude;
2029
+ const resolvedExclude = overrideExclude === void 0 ? templateExclude : overrideExclude;
2030
+ scopeInclude = resolvedInclude ? JSON.stringify(resolvedInclude) : null;
2031
+ scopeExclude = resolvedExclude ? JSON.stringify(resolvedExclude) : null;
2032
+ }
1915
2033
  const now = (/* @__PURE__ */ new Date()).toISOString();
1916
2034
  return store.db.transaction(() => {
1917
2035
  if (!existing) {
@@ -1921,15 +2039,20 @@ async function acceptDiscoveredPeer(store, input) {
1921
2039
  pinned_fingerprint: nextFingerprint || null,
1922
2040
  public_key: nextPublicKey,
1923
2041
  addresses_json: addressesJson,
2042
+ projects_include_json: scopeInclude ?? null,
2043
+ projects_exclude_json: scopeExclude ?? null,
1924
2044
  created_at: now,
1925
- last_seen_at: now
2045
+ last_seen_at: now,
2046
+ discovered_via_coordinator_id: coordinatorUrl,
2047
+ discovered_via_group_id: groupId
1926
2048
  }).run();
1927
2049
  return {
1928
2050
  ok: true,
1929
2051
  peer_device_id: input.peerDeviceId,
1930
2052
  created: true,
1931
2053
  updated: false,
1932
- name: nextName
2054
+ name: nextName,
2055
+ group_id: groupId
1933
2056
  };
1934
2057
  }
1935
2058
  d.update(schema.syncPeers).set({
@@ -1937,14 +2060,19 @@ async function acceptDiscoveredPeer(store, input) {
1937
2060
  pinned_fingerprint: nextFingerprint || existing.pinned_fingerprint || null,
1938
2061
  public_key: nextPublicKey || existing.public_key || null,
1939
2062
  addresses_json: addressesJson,
1940
- last_seen_at: now
2063
+ last_seen_at: now,
2064
+ ...existing.discovered_via_group_id ? {} : {
2065
+ discovered_via_group_id: groupId,
2066
+ discovered_via_coordinator_id: coordinatorUrl
2067
+ }
1941
2068
  }).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).run();
1942
2069
  return {
1943
2070
  ok: true,
1944
2071
  peer_device_id: input.peerDeviceId,
1945
2072
  created: false,
1946
2073
  updated: true,
1947
- name: nextName
2074
+ name: nextName,
2075
+ group_id: groupId
1948
2076
  };
1949
2077
  })();
1950
2078
  }
@@ -2002,7 +2130,9 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer) {
2002
2130
  recent_ops: {
2003
2131
  in: recentOps.in,
2004
2132
  out: recentOps.out
2005
- }
2133
+ },
2134
+ discovered_via_coordinator_id: typeof row.discovered_via_coordinator_id === "string" ? row.discovered_via_coordinator_id : null,
2135
+ discovered_via_group_id: typeof row.discovered_via_group_id === "string" ? row.discovered_via_group_id : null
2006
2136
  };
2007
2137
  }
2008
2138
  var SYNC_DIRECTION_WINDOW_SECONDS = 1440 * 60;
@@ -2072,23 +2202,6 @@ function readViewerBinding(dbPath) {
2072
2202
  } catch {}
2073
2203
  return null;
2074
2204
  }
2075
- async function portOpen(host, port) {
2076
- return new Promise((resolve) => {
2077
- const socket = net.createConnection({
2078
- host,
2079
- port
2080
- });
2081
- const done = (ok) => {
2082
- socket.removeAllListeners();
2083
- socket.destroy();
2084
- resolve(ok);
2085
- };
2086
- socket.setTimeout(300);
2087
- socket.once("connect", () => done(true));
2088
- socket.once("timeout", () => done(false));
2089
- socket.once("error", () => done(false));
2090
- });
2091
- }
2092
2205
  var PEERS_QUERY = `
2093
2206
  SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
2094
2207
  p.last_seen_at, p.last_sync_at, p.last_error,
@@ -2339,13 +2452,13 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2339
2452
  retentionState = void 0;
2340
2453
  }
2341
2454
  const lastSyncRow = traceSync("lastSyncRow", () => d.select({ last_sync_at: max(schema.syncPeers.last_sync_at) }).from(schema.syncPeers).get());
2342
- const semanticIndex = traceSync("semanticIndex", () => redactSemanticIndexDiagnostics(getSemanticIndexDiagnostics(store.db, { fastCounts: !showDiag }), showDiag));
2455
+ const semanticIndex = traceSync("semanticIndex", () => redactSemanticIndexDiagnostics(getSemanticIndexDiagnostics(store.db), showDiag));
2343
2456
  const lastError = daemonState?.last_error;
2344
2457
  const lastErrorAt = daemonState?.last_error_at;
2345
2458
  const lastOkAt = daemonState?.last_ok_at;
2346
2459
  const viewerBinding = traceSync("readViewerBinding", () => readViewerBinding(store.dbPath));
2347
- const daemonRunning = viewerBinding ? await portOpen(viewerBinding.host, viewerBinding.port) : false;
2348
- const daemonDetail = viewerBinding ? daemonRunning ? `viewer pidfile at ${viewerBinding.host}:${viewerBinding.port}` : `pidfile present but ${viewerBinding.host}:${viewerBinding.port} is unreachable` : null;
2460
+ const daemonRunning = Boolean(viewerBinding);
2461
+ const daemonDetail = viewerBinding ? `viewer pidfile at ${viewerBinding.host}:${viewerBinding.port}` : null;
2349
2462
  let daemonStateValue = "ok";
2350
2463
  if (!config.syncEnabled) daemonStateValue = "disabled";
2351
2464
  else if (lastError && (!lastOkAt || String(lastOkAt) < String(lastErrorAt ?? ""))) daemonStateValue = "error";
@@ -2524,10 +2637,6 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2524
2637
  app.get("/api/sync/pairing", (c) => {
2525
2638
  const store = getStore();
2526
2639
  {
2527
- if (!queryBool(c.req.query("includeDiagnostics"))) return c.json({
2528
- redacted: true,
2529
- pairing_filter_hint: PAIRING_FILTER_HINT
2530
- });
2531
2640
  const config = readCoordinatorSyncConfig();
2532
2641
  const d = drizzle(store.db, { schema });
2533
2642
  const deviceRow = d.select({
@@ -2882,14 +2991,32 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2882
2991
  } catch {
2883
2992
  return c.json({ error: "invalid json" }, 400);
2884
2993
  }
2885
- const inviteValue = String(body.invite ?? "").trim();
2886
- if (!inviteValue) return c.json({ error: "invite required" }, 400);
2994
+ const rawValue = String(body.invite ?? "").trim();
2995
+ if (!rawValue) return c.json({ error: "invite required" }, 400);
2996
+ const pairingResult = tryParsePairingPayload(rawValue.match(/echo\s+['"]([A-Za-z0-9+/=]+)['"]\s*\|\s*base64\s+-d\s*\|\s*codemem/)?.[1]?.trim() || rawValue);
2997
+ if (pairingResult?.kind === "invalid-pair") return c.json({ error: pairingResult.error }, 400);
2998
+ if (pairingResult?.kind === "pair") try {
2999
+ updatePeerAddresses(store.db, pairingResult.device_id, pairingResult.addresses, {
3000
+ pinnedFingerprint: pairingResult.fingerprint,
3001
+ publicKey: pairingResult.public_key
3002
+ });
3003
+ return c.json({
3004
+ ok: true,
3005
+ type: "pair",
3006
+ peer_device_id: pairingResult.device_id
3007
+ });
3008
+ } catch (error) {
3009
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3010
+ }
2887
3011
  try {
2888
3012
  const result = await coordinatorImportInviteAction({
2889
- inviteValue,
3013
+ inviteValue: rawValue,
2890
3014
  dbPath: store.dbPath
2891
3015
  });
2892
- return c.json(result);
3016
+ return c.json({
3017
+ ...result,
3018
+ type: "team_join"
3019
+ });
2893
3020
  } catch (error) {
2894
3021
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
2895
3022
  }
@@ -3461,6 +3588,202 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3461
3588
  }, 400);
3462
3589
  }
3463
3590
  });
3591
+ app.post("/api/coordinator/admin/groups/:group_id/enroll-peer", async (c) => {
3592
+ const status = coordinatorAdminStatusPayload(readCoordinatorSyncConfig());
3593
+ if (status.readiness === "not_configured") return c.json({
3594
+ error: "coordinator_not_configured",
3595
+ status
3596
+ }, 400);
3597
+ const rawGroupId = String(c.req.param("group_id") ?? "").trim();
3598
+ if (!rawGroupId) return c.json({ error: "group_id required" }, 400);
3599
+ let body;
3600
+ try {
3601
+ body = await c.req.json();
3602
+ } catch {
3603
+ return c.json({ error: "invalid_json" }, 400);
3604
+ }
3605
+ const rawMode = String(body.mode ?? "").trim().toLowerCase();
3606
+ if (rawMode && rawMode !== "manual" && rawMode !== "discovered") return c.json({
3607
+ error: "invalid_mode",
3608
+ detail: `mode must be 'discovered' or 'manual', got ${rawMode}`
3609
+ }, 400);
3610
+ const mode = rawMode === "manual" ? "manual" : "discovered";
3611
+ const normalizeOverride = (value) => {
3612
+ if (value === void 0) return void 0;
3613
+ if (value === null) return null;
3614
+ if (!Array.isArray(value)) return void 0;
3615
+ const cleaned = value.map((item) => String(item ?? "").trim()).filter((item) => item.length > 0);
3616
+ return cleaned.length === 0 ? null : cleaned;
3617
+ };
3618
+ const overrideInclude = normalizeOverride(body.projects_include);
3619
+ const overrideExclude = normalizeOverride(body.projects_exclude);
3620
+ const scopeOverride = overrideInclude === void 0 && overrideExclude === void 0 ? void 0 : {
3621
+ projects_include: overrideInclude,
3622
+ projects_exclude: overrideExclude
3623
+ };
3624
+ const store = getStore();
3625
+ if (mode === "manual") {
3626
+ const peerDeviceId = String(body.peer_device_id ?? "").trim();
3627
+ const publicKey = String(body.peer_public_key ?? "").trim();
3628
+ const name = String(body.name ?? "").trim() || null;
3629
+ const fingerprint = String(body.fingerprint ?? "").trim() || null;
3630
+ const addresses = (Array.isArray(body.peer_addresses) ? body.peer_addresses : []).map((item) => String(item ?? "").trim()).filter((item) => item.length > 0);
3631
+ if (!peerDeviceId || !publicKey) return c.json({ error: "peer_device_id_and_public_key_required" }, 400);
3632
+ const manualInclude = overrideInclude && overrideInclude.length > 0 ? JSON.stringify(overrideInclude) : null;
3633
+ const manualExclude = overrideExclude && overrideExclude.length > 0 ? JSON.stringify(overrideExclude) : null;
3634
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3635
+ const d = drizzle(store.db, { schema });
3636
+ try {
3637
+ if (!store.db.transaction(() => {
3638
+ if (d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get()) return false;
3639
+ d.insert(schema.syncPeers).values({
3640
+ peer_device_id: peerDeviceId,
3641
+ name,
3642
+ pinned_fingerprint: fingerprint,
3643
+ public_key: publicKey,
3644
+ addresses_json: JSON.stringify(addresses),
3645
+ projects_include_json: manualInclude,
3646
+ projects_exclude_json: manualExclude,
3647
+ created_at: now,
3648
+ last_seen_at: now,
3649
+ discovered_via_coordinator_id: null,
3650
+ discovered_via_group_id: null
3651
+ }).run();
3652
+ return true;
3653
+ })()) return c.json({
3654
+ error: "peer_exists",
3655
+ detail: "Peer is already enrolled."
3656
+ }, 409);
3657
+ } catch (error) {
3658
+ const msg = error instanceof Error ? error.message.toLowerCase() : "";
3659
+ if (msg.includes("unique") || msg.includes("primary key")) return c.json({
3660
+ error: "peer_exists",
3661
+ detail: "Peer is already enrolled."
3662
+ }, 409);
3663
+ throw error;
3664
+ }
3665
+ return c.json({
3666
+ ok: true,
3667
+ peer_device_id: peerDeviceId,
3668
+ created: true,
3669
+ updated: false,
3670
+ name,
3671
+ group_id: null
3672
+ });
3673
+ }
3674
+ if (!status.has_admin_secret) return c.json({
3675
+ error: "coordinator_admin_secret_missing",
3676
+ status
3677
+ }, 400);
3678
+ const peerDeviceId = String(body.peer_device_id ?? body.discovered_device_id ?? "").trim();
3679
+ const fingerprint = String(body.fingerprint ?? "").trim();
3680
+ if (!peerDeviceId || !fingerprint) return c.json({ error: "peer_device_id_and_fingerprint_required" }, 400);
3681
+ try {
3682
+ const result = await acceptDiscoveredPeer(store, {
3683
+ peerDeviceId,
3684
+ fingerprint,
3685
+ expectedGroupId: rawGroupId,
3686
+ scopeOverride
3687
+ });
3688
+ if (result.ok) return c.json(result);
3689
+ return c.json({
3690
+ error: result.error,
3691
+ detail: result.detail
3692
+ }, result.status);
3693
+ } catch (error) {
3694
+ return c.json({
3695
+ error: "coordinator_lookup_failed",
3696
+ detail: error instanceof Error ? error.message : String(error)
3697
+ }, 502);
3698
+ }
3699
+ });
3700
+ app.get("/api/coordinator/admin/groups/:group_id/preferences", (c) => {
3701
+ const status = coordinatorAdminStatusPayload(readCoordinatorSyncConfig());
3702
+ if (status.readiness === "not_configured") return c.json({
3703
+ error: "coordinator_not_configured",
3704
+ status
3705
+ }, 400);
3706
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3707
+ if (!groupId) return c.json({
3708
+ error: "group_id required",
3709
+ status
3710
+ }, 400);
3711
+ const coordinatorId = status.coordinator_url;
3712
+ if (!coordinatorId) return c.json({
3713
+ error: "coordinator_not_configured",
3714
+ status
3715
+ }, 400);
3716
+ const existing = getCoordinatorGroupPreference(getStore().db, coordinatorId, groupId);
3717
+ if (existing) return c.json({
3718
+ preferences: existing,
3719
+ status
3720
+ });
3721
+ return c.json({
3722
+ preferences: {
3723
+ coordinator_id: coordinatorId,
3724
+ group_id: groupId,
3725
+ projects_include: null,
3726
+ projects_exclude: null,
3727
+ auto_seed_scope: true,
3728
+ updated_at: null
3729
+ },
3730
+ status
3731
+ });
3732
+ });
3733
+ app.put("/api/coordinator/admin/groups/:group_id/preferences", async (c) => {
3734
+ const status = coordinatorAdminStatusPayload(readCoordinatorSyncConfig());
3735
+ if (status.readiness === "not_configured") return c.json({
3736
+ error: "coordinator_not_configured",
3737
+ status
3738
+ }, 400);
3739
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3740
+ if (!groupId) return c.json({
3741
+ error: "group_id required",
3742
+ status
3743
+ }, 400);
3744
+ const coordinatorId = status.coordinator_url;
3745
+ if (!coordinatorId) return c.json({
3746
+ error: "coordinator_not_configured",
3747
+ status
3748
+ }, 400);
3749
+ let body;
3750
+ try {
3751
+ body = await c.req.json();
3752
+ } catch {
3753
+ return c.json({
3754
+ error: "invalid_json",
3755
+ status
3756
+ }, 400);
3757
+ }
3758
+ const normalizeList = (value) => {
3759
+ if (value === void 0) return void 0;
3760
+ if (value === null) return null;
3761
+ if (!Array.isArray(value)) return void 0;
3762
+ const cleaned = value.map((item) => String(item ?? "").trim()).filter((item) => item.length > 0);
3763
+ return cleaned.length === 0 ? null : cleaned;
3764
+ };
3765
+ const autoSeed = typeof body.auto_seed_scope === "boolean" ? body.auto_seed_scope : void 0;
3766
+ const store = getStore();
3767
+ try {
3768
+ const preferences = upsertCoordinatorGroupPreference(store.db, {
3769
+ coordinator_id: coordinatorId,
3770
+ group_id: groupId,
3771
+ projects_include: normalizeList(body.projects_include),
3772
+ projects_exclude: normalizeList(body.projects_exclude),
3773
+ auto_seed_scope: autoSeed
3774
+ });
3775
+ return c.json({
3776
+ preferences,
3777
+ status
3778
+ });
3779
+ } catch (error) {
3780
+ const msg = error instanceof Error ? error.message : String(error);
3781
+ return c.json({
3782
+ error: msg,
3783
+ status
3784
+ }, 400);
3785
+ }
3786
+ });
3464
3787
  app.post("/api/coordinator/admin/devices/:device_id/remove", async (c) => {
3465
3788
  const config = readCoordinatorSyncConfig();
3466
3789
  const status = coordinatorAdminStatusPayload(config);