@codemem/server 0.32.1 → 0.32.3

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, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifySignature, writeCodememConfigFile } from "@codemem/core";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeAddress, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, 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";
@@ -1729,7 +1729,7 @@ async function ensureDefaultSpaceForTeam(opts) {
1729
1729
  remoteUrl: opts.config.syncCoordinatorUrl || null,
1730
1730
  adminSecret: opts.config.syncCoordinatorAdminSecret || null
1731
1731
  });
1732
- const [deviceId] = ensureDeviceIdentity(opts.store.db);
1732
+ const [deviceId] = ensureDeviceIdentity(opts.store.db, { keysDir: syncKeysDir() });
1733
1733
  return {
1734
1734
  scope,
1735
1735
  membership: await coordinatorGrantScopeMembershipAction({
@@ -1876,9 +1876,9 @@ function coordinatorAdminMutationStatus(message) {
1876
1876
  }
1877
1877
  function pairingAdvertiseAddresses(config = readCoordinatorSyncConfig()) {
1878
1878
  const advertise = String(config.syncAdvertise || "auto").trim().toLowerCase();
1879
- if (advertise && advertise !== "auto" && advertise !== "default") return mergeAddresses([], String(config.syncAdvertise || "").split(",").map((item) => item.trim()).filter(Boolean));
1880
- if (config.syncHost && config.syncHost !== "0.0.0.0") return [`${config.syncHost}:${config.syncPort}`];
1881
- const addresses = Object.values(networkInterfaces()).flatMap((entries) => entries ?? []).filter((entry) => !entry.internal).map((entry) => entry.address).filter((address) => address && address !== "127.0.0.1" && address !== "::1").map((address) => `${address}:${config.syncPort}`);
1879
+ if (advertise && advertise !== "auto" && advertise !== "default") return mergeAddresses([], String(config.syncAdvertise || "").split(",").map((item) => item.trim()).filter(Boolean), { defaultHttpPort: config.syncPort });
1880
+ if (config.syncHost && config.syncHost !== "0.0.0.0") return [normalizeAddress(formatHostPort(config.syncHost, config.syncPort))].filter(Boolean);
1881
+ const addresses = Object.values(networkInterfaces()).flatMap((entries) => entries ?? []).filter((entry) => !entry.internal).map((entry) => entry.address).filter((address) => address && address !== "127.0.0.1" && address !== "::1").map((address) => normalizeAddress(formatHostPort(address, config.syncPort))).filter(Boolean);
1882
1882
  return [...new Set(addresses)];
1883
1883
  }
1884
1884
  function resolveCoordinatorAdminGroup(requestedGroup, status) {
@@ -2313,6 +2313,7 @@ function redactCoordinatorStatus(coordinator, showDiag) {
2313
2313
  const discovered = Array.isArray(coordinator.discovered_devices) ? coordinator.discovered_devices.map((item) => {
2314
2314
  if (!item || typeof item !== "object") return item;
2315
2315
  const record = item;
2316
+ const addressCount = Array.isArray(record.addresses) ? record.addresses.length : 0;
2316
2317
  return {
2317
2318
  device_id: record.device_id,
2318
2319
  display_name: record.display_name ?? null,
@@ -2325,7 +2326,8 @@ function redactCoordinatorStatus(coordinator, showDiag) {
2325
2326
  incoming_reciprocal_request_id: record.incoming_reciprocal_request_id ?? null,
2326
2327
  outgoing_reciprocal_request_id: record.outgoing_reciprocal_request_id ?? null,
2327
2328
  fingerprint: null,
2328
- addresses: []
2329
+ addresses: [],
2330
+ address_count: addressCount
2329
2331
  };
2330
2332
  }) : [];
2331
2333
  return {
@@ -2341,13 +2343,32 @@ async function acceptDiscoveredPeer(store, input) {
2341
2343
  error: "coordinator_not_configured",
2342
2344
  detail: "Coordinator must be configured before accepting discovered peers."
2343
2345
  };
2344
- const match = (await lookupCoordinatorPeers(store, config)).find((peer) => String(peer.device_id ?? "").trim() === input.peerDeviceId && String(peer.fingerprint ?? "").trim() === input.fingerprint);
2346
+ const discovered = await lookupCoordinatorPeers(store, config);
2347
+ const inputFingerprint = String(input.fingerprint ?? "").trim();
2348
+ const candidates = discovered.filter((peer) => String(peer.device_id ?? "").trim() === input.peerDeviceId);
2349
+ const matchingCandidates = inputFingerprint ? candidates.filter((peer) => String(peer.fingerprint ?? "").trim() === inputFingerprint) : candidates;
2350
+ const uniqueFingerprints = new Set(matchingCandidates.map((peer) => String(peer.fingerprint ?? "").trim()).filter(Boolean));
2351
+ if (!inputFingerprint && uniqueFingerprints.size > 1) return {
2352
+ ok: false,
2353
+ status: 409,
2354
+ error: "ambiguous_discovered_peer",
2355
+ detail: "That discovered device has multiple coordinator fingerprints. Enable diagnostics, refresh sync status, and choose the intended device before trusting it."
2356
+ };
2357
+ const match = matchingCandidates[0];
2345
2358
  if (!match) return {
2346
2359
  ok: false,
2347
2360
  status: 404,
2348
2361
  error: "discovered_peer_not_found",
2349
2362
  detail: "That discovered device is no longer available. Refresh sync status and try again."
2350
2363
  };
2364
+ const expiresAt = String(match.expires_at ?? "").trim();
2365
+ const expired = expiresAt ? Date.parse(expiresAt) <= Date.now() : false;
2366
+ if (match.stale || expired) return {
2367
+ ok: false,
2368
+ status: 409,
2369
+ error: "discovered_peer_stale",
2370
+ detail: "This discovered device's coordinator presence is stale. Wait for it to come online and refresh sync status before trusting it."
2371
+ };
2351
2372
  const nextFingerprint = String(match.fingerprint ?? "").trim();
2352
2373
  const nextPublicKey = String(match.public_key ?? "").trim();
2353
2374
  const nextName = String(match.display_name ?? "").trim() || null;
@@ -2358,7 +2379,14 @@ async function acceptDiscoveredPeer(store, input) {
2358
2379
  error: "discovered_peer_missing_public_key",
2359
2380
  detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
2360
2381
  };
2382
+ if (!nextFingerprint || fingerprintPublicKey(nextPublicKey) !== nextFingerprint) return {
2383
+ ok: false,
2384
+ status: 409,
2385
+ error: "discovered_peer_fingerprint_mismatch",
2386
+ detail: "This discovered device's coordinator public key does not match its fingerprint. Refresh sync status or re-enroll the device before trusting it."
2387
+ };
2361
2388
  const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
2389
+ const freshGroupIds = Array.isArray(match.fresh_groups) ? match.fresh_groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
2362
2390
  let groupId;
2363
2391
  if (input.expectedGroupId) {
2364
2392
  if (!groupIds.includes(input.expectedGroupId)) return {
@@ -2367,6 +2395,12 @@ async function acceptDiscoveredPeer(store, input) {
2367
2395
  error: "peer_not_in_group",
2368
2396
  detail: "This discovered device is not visible through the specified coordinator group. Refresh sync status and try again."
2369
2397
  };
2398
+ if (!freshGroupIds.includes(input.expectedGroupId)) return {
2399
+ ok: false,
2400
+ status: 409,
2401
+ error: "discovered_peer_stale",
2402
+ detail: "This discovered device's coordinator presence is stale for the specified group. Wait for it to come online and refresh sync status before trusting it."
2403
+ };
2370
2404
  groupId = input.expectedGroupId;
2371
2405
  } else {
2372
2406
  if (groupIds.length !== 1) return {
@@ -2400,7 +2434,7 @@ async function acceptDiscoveredPeer(store, input) {
2400
2434
  return [];
2401
2435
  }
2402
2436
  })();
2403
- const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
2437
+ const addressesJson = JSON.stringify(mergeAddresses(nextAddresses, existingAddresses));
2404
2438
  await createCoordinatorReciprocalApproval(store, config, {
2405
2439
  groupId,
2406
2440
  requestedDeviceId: input.peerDeviceId
@@ -2509,12 +2543,14 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2509
2543
  out: 0
2510
2544
  };
2511
2545
  const scopeRejections = scopeRejectionsByPeer?.get(peerId);
2546
+ const addresses = safeJsonList(row.addresses_json);
2512
2547
  return {
2513
2548
  peer_device_id: row.peer_device_id,
2514
2549
  name: row.name,
2515
2550
  fingerprint: showDiag ? row.pinned_fingerprint : null,
2516
2551
  pinned: Boolean(row.pinned_fingerprint),
2517
- addresses: showDiag ? safeJsonList(row.addresses_json) : [],
2552
+ addresses: showDiag ? addresses : [],
2553
+ address_count: addresses.length,
2518
2554
  last_seen_at: row.last_seen_at,
2519
2555
  last_sync_at: row.last_sync_at,
2520
2556
  last_error: showDiag ? row.last_error : null,
@@ -2538,6 +2574,18 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2538
2574
  discovered_via_group_id: typeof row.discovered_via_group_id === "string" ? row.discovered_via_group_id : null
2539
2575
  };
2540
2576
  }
2577
+ function redactSyncAttemptError(error) {
2578
+ if (!String(error ?? "").trim()) return null;
2579
+ return "sync attempt failed; enable diagnostics for details";
2580
+ }
2581
+ function mapSyncAttemptRow(row, showDiag, addresses) {
2582
+ return {
2583
+ ...row,
2584
+ error: showDiag ? row.error : redactSyncAttemptError(row.error),
2585
+ status: attemptStatus(row),
2586
+ address: showDiag && addresses?.length ? addresses[0] : null
2587
+ };
2588
+ }
2541
2589
  var SYNC_SCOPE_REJECTION_WINDOW_SECONDS = 1440 * 60;
2542
2590
  function recentScopeRejectionsByPeer(store) {
2543
2591
  const cutoff = (/* @__PURE__ */ new Date(Date.now() - SYNC_SCOPE_REJECTION_WINDOW_SECONDS * 1e3)).toISOString();
@@ -2895,9 +2943,12 @@ function syncProtocolRoutes(getStore, opts = {}) {
2895
2943
  }
2896
2944
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2897
2945
  if (!auth.ok) {
2898
- 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}`);
2946
+ if (bootstrapAttempted) {
2947
+ const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
2948
+ console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant_present=${grantPresent} path=${c.req.path}`);
2949
+ }
2899
2950
  const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
2900
- return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
2951
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
2901
2952
  }
2902
2953
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2903
2954
  if (limited) return limited;
@@ -2920,7 +2971,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2920
2971
  const store = getStore();
2921
2972
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2922
2973
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2923
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
2974
+ if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason, true), 401);
2924
2975
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2925
2976
  if (limited) return limited;
2926
2977
  const peerDeviceId = auth.deviceId;
@@ -2990,9 +3041,12 @@ function syncProtocolRoutes(getStore, opts = {}) {
2990
3041
  }
2991
3042
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2992
3043
  if (!auth.ok) {
2993
- 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}`);
3044
+ if (bootstrapAttempted) {
3045
+ const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
3046
+ console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant_present=${grantPresent} path=${c.req.path}`);
3047
+ }
2994
3048
  const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
2995
- return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
3049
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
2996
3050
  }
2997
3051
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2998
3052
  if (limited) return limited;
@@ -3041,7 +3095,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
3041
3095
  if (raw == null) return c.json({ error: "payload_too_large" }, 413);
3042
3096
  const auth = authorizeSyncRequest(store, c.req, raw);
3043
3097
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
3044
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
3098
+ if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason, true), 401);
3045
3099
  const limited = rateLimitedResponse(c, auth.deviceId, true);
3046
3100
  if (limited) return limited;
3047
3101
  const peerDeviceId = auth.deviceId;
@@ -3215,14 +3269,9 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3215
3269
  if (addrs.length) peerAddressMap.set(String(peerRow.peer_device_id ?? ""), addrs);
3216
3270
  });
3217
3271
  const attemptsItems = attemptRows.map((row) => {
3218
- const addrs = showDiag ? peerAddressMap.get(String(row.peer_device_id ?? "")) : void 0;
3219
- return {
3220
- ...row,
3221
- status: attemptStatus(row),
3222
- address: addrs?.length ? addrs[0] : null
3223
- };
3272
+ return mapSyncAttemptRow(row, showDiag, showDiag ? peerAddressMap.get(String(row.peer_device_id ?? "")) : void 0);
3224
3273
  });
3225
- const latestAttemptError = String(attemptsItems[0]?.error || "").trim();
3274
+ const latestAttemptError = String(attemptRows[0]?.error || "").trim();
3226
3275
  const statusBlock = {
3227
3276
  ...statusPayload,
3228
3277
  background_maintenance: summarizeMaintenanceJobs(listMaintenanceJobs(store.db), showDiag),
@@ -3348,7 +3397,12 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3348
3397
  peer_sync_capability: schema.syncAttempts.peer_sync_capability,
3349
3398
  negotiated_sync_capability: schema.syncAttempts.negotiated_sync_capability
3350
3399
  }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(limit).all();
3351
- return c.json({ items: rows });
3400
+ const showDiag = queryBool(c.req.query("includeDiagnostics"));
3401
+ const items = rows.map((row) => mapSyncAttemptRow(row, showDiag));
3402
+ return c.json({
3403
+ items,
3404
+ redacted: !showDiag
3405
+ });
3352
3406
  }
3353
3407
  });
3354
3408
  app.get("/api/sync/pairing", (c) => {
@@ -3356,19 +3410,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3356
3410
  {
3357
3411
  const config = readCoordinatorSyncConfig();
3358
3412
  const d = drizzle(store.db, { schema });
3359
- const deviceRow = d.select({
3360
- device_id: schema.syncDevice.device_id,
3361
- public_key: schema.syncDevice.public_key,
3362
- fingerprint: schema.syncDevice.fingerprint
3363
- }).from(schema.syncDevice).limit(1).get();
3364
3413
  let deviceId;
3365
3414
  let publicKey;
3366
3415
  let fingerprint;
3367
- if (deviceRow) {
3368
- deviceId = String(deviceRow.device_id);
3369
- publicKey = String(deviceRow.public_key);
3370
- fingerprint = String(deviceRow.fingerprint);
3371
- } else try {
3416
+ try {
3372
3417
  const [id, fp] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3373
3418
  deviceId = id;
3374
3419
  fingerprint = fp;
@@ -3829,11 +3874,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3829
3874
  const peerDeviceId = String(body.peer_device_id ?? "").trim();
3830
3875
  const fingerprint = String(body.fingerprint ?? "").trim();
3831
3876
  if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
3832
- if (!fingerprint) return c.json({ error: "fingerprint required" }, 400);
3833
3877
  try {
3834
3878
  const result = await acceptDiscoveredPeer(store, {
3835
3879
  peerDeviceId,
3836
- fingerprint
3880
+ fingerprint: fingerprint || void 0
3837
3881
  });
3838
3882
  if (!result.ok) return c.json({
3839
3883
  error: result.error,
@@ -3898,9 +3942,16 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3898
3942
  const pairingResult = tryParsePairingPayload(rawValue.match(/echo\s+['"]([A-Za-z0-9+/=]+)['"]\s*\|\s*base64\s+-d\s*\|\s*codemem/)?.[1]?.trim() || rawValue);
3899
3943
  if (pairingResult?.kind === "invalid-pair") return c.json({ error: pairingResult.error }, 400);
3900
3944
  if (pairingResult?.kind === "pair") try {
3945
+ const existing = drizzle(store.db, { schema }).select({ pinned_fingerprint: schema.syncPeers.pinned_fingerprint }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, pairingResult.device_id)).get();
3946
+ const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
3947
+ if (existingFingerprint && existingFingerprint !== pairingResult.fingerprint) return c.json({
3948
+ error: "peer_conflict",
3949
+ detail: "Pairing payload conflicts with the existing trusted fingerprint for this device. Remove or repair the old peer before accepting this pairing payload."
3950
+ }, 409);
3901
3951
  updatePeerAddresses(store.db, pairingResult.device_id, pairingResult.addresses, {
3902
3952
  pinnedFingerprint: pairingResult.fingerprint,
3903
- publicKey: pairingResult.public_key
3953
+ publicKey: pairingResult.public_key,
3954
+ replaceTrust: true
3904
3955
  });
3905
3956
  return c.json({
3906
3957
  ok: true,
@@ -4866,9 +4917,11 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
4866
4917
  const peerDeviceId = String(body.peer_device_id ?? "").trim();
4867
4918
  const publicKey = String(body.peer_public_key ?? "").trim();
4868
4919
  const name = String(body.name ?? "").trim() || null;
4869
- const fingerprint = String(body.fingerprint ?? "").trim() || null;
4870
- const addresses = (Array.isArray(body.peer_addresses) ? body.peer_addresses : []).map((item) => String(item ?? "").trim()).filter((item) => item.length > 0);
4920
+ const requestedFingerprint = String(body.fingerprint ?? "").trim();
4921
+ const addresses = mergeAddresses([], (Array.isArray(body.peer_addresses) ? body.peer_addresses : []).map((item) => String(item ?? "").trim()).filter((item) => item.length > 0));
4871
4922
  if (!peerDeviceId || !publicKey) return c.json({ error: "peer_device_id_and_public_key_required" }, 400);
4923
+ const fingerprint = requestedFingerprint || fingerprintPublicKey(publicKey);
4924
+ if (fingerprintPublicKey(publicKey) !== fingerprint) return c.json({ error: "fingerprint_mismatch" }, 400);
4872
4925
  const manualInclude = overrideInclude && overrideInclude.length > 0 ? JSON.stringify(overrideInclude) : null;
4873
4926
  const manualExclude = overrideExclude && overrideExclude.length > 0 ? JSON.stringify(overrideExclude) : null;
4874
4927
  const now = (/* @__PURE__ */ new Date()).toISOString();