@codemem/server 0.28.0 → 0.29.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,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",
@@ -188,6 +187,7 @@ var SECRET_CONFIG_KEYS = new Set([
188
187
  "observer_headers",
189
188
  "sync_coordinator_admin_secret"
190
189
  ]);
190
+ var REMOVED_CONFIG_KEYS = new Set(["observer_rich_openai_use_responses"]);
191
191
  var ALLOWED_KEYS = [
192
192
  "claude_command",
193
193
  "observer_base_url",
@@ -198,7 +198,6 @@ var ALLOWED_KEYS = [
198
198
  "observer_simple_temperature",
199
199
  "observer_rich_model",
200
200
  "observer_rich_temperature",
201
- "observer_rich_openai_use_responses",
202
201
  "observer_rich_reasoning_effort",
203
202
  "observer_rich_reasoning_summary",
204
203
  "observer_rich_max_output_tokens",
@@ -231,7 +230,6 @@ var DEFAULTS = {
231
230
  observer_runtime: "api_http",
232
231
  observer_auth_source: "auto",
233
232
  observer_tier_routing_enabled: false,
234
- observer_rich_openai_use_responses: false,
235
233
  observer_auth_command: [],
236
234
  observer_auth_timeout_ms: 1500,
237
235
  observer_auth_cache_ttl_s: 300,
@@ -275,6 +273,11 @@ function readConfigFile(configPath) {
275
273
  return {};
276
274
  }
277
275
  }
276
+ function withoutRemovedConfigKeys(configData) {
277
+ const next = { ...configData };
278
+ for (const key of REMOVED_CONFIG_KEYS) delete next[key];
279
+ return next;
280
+ }
278
281
  function getEffectiveConfig(configData) {
279
282
  const effective = {
280
283
  ...DEFAULTS,
@@ -406,7 +409,7 @@ function validateAndApplyUpdate(configData, key, value, providers) {
406
409
  configData[key] = value;
407
410
  return null;
408
411
  }
409
- if (key === "observer_tier_routing_enabled" || key === "observer_rich_openai_use_responses") {
412
+ if (key === "observer_tier_routing_enabled") {
410
413
  if (typeof value !== "boolean") return `${key} must be boolean`;
411
414
  configData[key] = value;
412
415
  return null;
@@ -446,7 +449,7 @@ function configRoutes(opts = {}) {
446
449
  const app = new Hono();
447
450
  app.get("/api/config", (c) => {
448
451
  const configPath = getConfigPath();
449
- const configData = readConfigFile(configPath);
452
+ const configData = withoutRemovedConfigKeys(readConfigFile(configPath));
450
453
  const effective = getEffectiveConfig(configData);
451
454
  return c.json({
452
455
  path: configPath,
@@ -469,7 +472,7 @@ function configRoutes(opts = {}) {
469
472
  if ("config" in payload && payload.config != null && (typeof payload.config !== "object" || Array.isArray(payload.config))) return c.json({ error: "config must be an object" }, 400);
470
473
  const updates = "config" in payload && payload.config != null && typeof payload.config === "object" && !Array.isArray(payload.config) ? payload.config : payload;
471
474
  const configPath = getCodememConfigPath();
472
- const beforeConfig = readCodememConfigFile();
475
+ const beforeConfig = withoutRemovedConfigKeys(readCodememConfigFile());
473
476
  const { allowedUpdates, error: protectedKeyError } = partitionViewerUpdates(updates, beforeConfig);
474
477
  if (protectedKeyError) return c.json({ error: protectedKeyError }, 403);
475
478
  const beforeEffective = getEffectiveConfig(beforeConfig);
@@ -901,6 +904,28 @@ function memoryRoutes(getStore) {
901
904
  return c.json({ error: msg }, 400);
902
905
  }
903
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
+ });
904
929
  app.post("/api/memories/forget", async (c) => {
905
930
  const store = getStore();
906
931
  let body;
@@ -1319,20 +1344,31 @@ function statsRoutes(getStore) {
1319
1344
  return null;
1320
1345
  }
1321
1346
  };
1347
+ const RECENTLY_COMPLETED_WINDOW_MS = 12e4;
1322
1348
  app.get("/api/stats", (c) => {
1323
1349
  const store = getStore();
1324
- 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) => ({
1325
1360
  kind: job.kind,
1326
1361
  title: job.title,
1327
1362
  status: job.status,
1328
1363
  message: job.message,
1329
1364
  progress: job.progress,
1365
+ finished_at: job.finished_at,
1330
1366
  error: job.error
1331
1367
  }));
1332
1368
  return c.json({
1333
1369
  ...store.stats(),
1334
1370
  viewer_pid: process.pid,
1335
- maintenance_jobs: activeJobs
1371
+ maintenance_jobs: surfacedJobs
1336
1372
  });
1337
1373
  });
1338
1374
  app.get("/api/runtime", (c) => {
@@ -1418,6 +1454,60 @@ var SYNC_STALE_AFTER_SECONDS = 600;
1418
1454
  var SYNC_PROTOCOL_VERSION = "2";
1419
1455
  var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
1420
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
+ }
1421
1511
  function coordinatorAdminStatusPayload(config = readCoordinatorSyncConfig()) {
1422
1512
  const groups = config.syncCoordinatorGroups;
1423
1513
  const activeGroup = groups[0] ?? null;
@@ -1874,19 +1964,31 @@ async function acceptDiscoveredPeer(store, input) {
1874
1964
  detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
1875
1965
  };
1876
1966
  const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
1877
- if (groupIds.length !== 1) return {
1878
- ok: false,
1879
- status: 409,
1880
- error: "ambiguous_coordinator_group",
1881
- 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."
1882
- };
1883
- 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
+ }
1884
1985
  const d = drizzle(store.db, { schema });
1885
1986
  const existing = d.select({
1886
1987
  peer_device_id: schema.syncPeers.peer_device_id,
1887
1988
  pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
1888
1989
  public_key: schema.syncPeers.public_key,
1889
- addresses_json: schema.syncPeers.addresses_json
1990
+ addresses_json: schema.syncPeers.addresses_json,
1991
+ discovered_via_group_id: schema.syncPeers.discovered_via_group_id
1890
1992
  }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
1891
1993
  const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
1892
1994
  if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
@@ -1908,6 +2010,26 @@ async function acceptDiscoveredPeer(store, input) {
1908
2010
  groupId,
1909
2011
  requestedDeviceId: input.peerDeviceId
1910
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
+ }
1911
2033
  const now = (/* @__PURE__ */ new Date()).toISOString();
1912
2034
  return store.db.transaction(() => {
1913
2035
  if (!existing) {
@@ -1917,15 +2039,20 @@ async function acceptDiscoveredPeer(store, input) {
1917
2039
  pinned_fingerprint: nextFingerprint || null,
1918
2040
  public_key: nextPublicKey,
1919
2041
  addresses_json: addressesJson,
2042
+ projects_include_json: scopeInclude ?? null,
2043
+ projects_exclude_json: scopeExclude ?? null,
1920
2044
  created_at: now,
1921
- last_seen_at: now
2045
+ last_seen_at: now,
2046
+ discovered_via_coordinator_id: coordinatorUrl,
2047
+ discovered_via_group_id: groupId
1922
2048
  }).run();
1923
2049
  return {
1924
2050
  ok: true,
1925
2051
  peer_device_id: input.peerDeviceId,
1926
2052
  created: true,
1927
2053
  updated: false,
1928
- name: nextName
2054
+ name: nextName,
2055
+ group_id: groupId
1929
2056
  };
1930
2057
  }
1931
2058
  d.update(schema.syncPeers).set({
@@ -1933,14 +2060,19 @@ async function acceptDiscoveredPeer(store, input) {
1933
2060
  pinned_fingerprint: nextFingerprint || existing.pinned_fingerprint || null,
1934
2061
  public_key: nextPublicKey || existing.public_key || null,
1935
2062
  addresses_json: addressesJson,
1936
- 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
+ }
1937
2068
  }).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).run();
1938
2069
  return {
1939
2070
  ok: true,
1940
2071
  peer_device_id: input.peerDeviceId,
1941
2072
  created: false,
1942
2073
  updated: true,
1943
- name: nextName
2074
+ name: nextName,
2075
+ group_id: groupId
1944
2076
  };
1945
2077
  })();
1946
2078
  }
@@ -1998,7 +2130,9 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer) {
1998
2130
  recent_ops: {
1999
2131
  in: recentOps.in,
2000
2132
  out: recentOps.out
2001
- }
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
2002
2136
  };
2003
2137
  }
2004
2138
  var SYNC_DIRECTION_WINDOW_SECONDS = 1440 * 60;
@@ -2068,23 +2202,6 @@ function readViewerBinding(dbPath) {
2068
2202
  } catch {}
2069
2203
  return null;
2070
2204
  }
2071
- async function portOpen(host, port) {
2072
- return new Promise((resolve) => {
2073
- const socket = net.createConnection({
2074
- host,
2075
- port
2076
- });
2077
- const done = (ok) => {
2078
- socket.removeAllListeners();
2079
- socket.destroy();
2080
- resolve(ok);
2081
- };
2082
- socket.setTimeout(300);
2083
- socket.once("connect", () => done(true));
2084
- socket.once("timeout", () => done(false));
2085
- socket.once("error", () => done(false));
2086
- });
2087
- }
2088
2205
  var PEERS_QUERY = `
2089
2206
  SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
2090
2207
  p.last_seen_at, p.last_sync_at, p.last_error,
@@ -2335,13 +2452,13 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2335
2452
  retentionState = void 0;
2336
2453
  }
2337
2454
  const lastSyncRow = traceSync("lastSyncRow", () => d.select({ last_sync_at: max(schema.syncPeers.last_sync_at) }).from(schema.syncPeers).get());
2338
- const semanticIndex = traceSync("semanticIndex", () => redactSemanticIndexDiagnostics(getSemanticIndexDiagnostics(store.db, { fastCounts: !showDiag }), showDiag));
2455
+ const semanticIndex = traceSync("semanticIndex", () => redactSemanticIndexDiagnostics(getSemanticIndexDiagnostics(store.db), showDiag));
2339
2456
  const lastError = daemonState?.last_error;
2340
2457
  const lastErrorAt = daemonState?.last_error_at;
2341
2458
  const lastOkAt = daemonState?.last_ok_at;
2342
2459
  const viewerBinding = traceSync("readViewerBinding", () => readViewerBinding(store.dbPath));
2343
- const daemonRunning = viewerBinding ? await portOpen(viewerBinding.host, viewerBinding.port) : false;
2344
- 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;
2345
2462
  let daemonStateValue = "ok";
2346
2463
  if (!config.syncEnabled) daemonStateValue = "disabled";
2347
2464
  else if (lastError && (!lastOkAt || String(lastOkAt) < String(lastErrorAt ?? ""))) daemonStateValue = "error";
@@ -2520,10 +2637,6 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2520
2637
  app.get("/api/sync/pairing", (c) => {
2521
2638
  const store = getStore();
2522
2639
  {
2523
- if (!queryBool(c.req.query("includeDiagnostics"))) return c.json({
2524
- redacted: true,
2525
- pairing_filter_hint: PAIRING_FILTER_HINT
2526
- });
2527
2640
  const config = readCoordinatorSyncConfig();
2528
2641
  const d = drizzle(store.db, { schema });
2529
2642
  const deviceRow = d.select({
@@ -2878,14 +2991,32 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2878
2991
  } catch {
2879
2992
  return c.json({ error: "invalid json" }, 400);
2880
2993
  }
2881
- const inviteValue = String(body.invite ?? "").trim();
2882
- 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
+ }
2883
3011
  try {
2884
3012
  const result = await coordinatorImportInviteAction({
2885
- inviteValue,
3013
+ inviteValue: rawValue,
2886
3014
  dbPath: store.dbPath
2887
3015
  });
2888
- return c.json(result);
3016
+ return c.json({
3017
+ ...result,
3018
+ type: "team_join"
3019
+ });
2889
3020
  } catch (error) {
2890
3021
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
2891
3022
  }
@@ -3457,6 +3588,202 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3457
3588
  }, 400);
3458
3589
  }
3459
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
+ });
3460
3787
  app.post("/api/coordinator/admin/devices/:device_id/remove", async (c) => {
3461
3788
  const config = readCoordinatorSyncConfig();
3462
3789
  const status = coordinatorAdminStatusPayload(config);