@codemem/server 0.32.2 → 0.33.0-alpha.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, 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_CAPABILITY_HEADER, 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, isScopedSyncCapability, isSummaryLikeMemory, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listPerPeerScopeSyncState, 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) {
@@ -2258,8 +2258,18 @@ function peerAuthorizedScopes(store, peerDeviceId) {
2258
2258
  }
2259
2259
  function ensureLocalActorRecord(store) {
2260
2260
  const d = drizzle(store.db, { schema });
2261
- if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
2262
2261
  const now = (/* @__PURE__ */ new Date()).toISOString();
2262
+ d.update(schema.actors).set({
2263
+ is_local: 0,
2264
+ updated_at: now
2265
+ }).where(and(eq(schema.actors.is_local, 1), ne(schema.actors.actor_id, store.actorId))).run();
2266
+ if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) {
2267
+ d.update(schema.actors).set({
2268
+ is_local: 1,
2269
+ updated_at: now
2270
+ }).where(eq(schema.actors.actor_id, store.actorId)).run();
2271
+ return;
2272
+ }
2263
2273
  d.insert(schema.actors).values({
2264
2274
  actor_id: store.actorId,
2265
2275
  display_name: store.actorDisplayName,
@@ -2313,6 +2323,7 @@ function redactCoordinatorStatus(coordinator, showDiag) {
2313
2323
  const discovered = Array.isArray(coordinator.discovered_devices) ? coordinator.discovered_devices.map((item) => {
2314
2324
  if (!item || typeof item !== "object") return item;
2315
2325
  const record = item;
2326
+ const addressCount = Array.isArray(record.addresses) ? record.addresses.length : 0;
2316
2327
  return {
2317
2328
  device_id: record.device_id,
2318
2329
  display_name: record.display_name ?? null,
@@ -2325,7 +2336,8 @@ function redactCoordinatorStatus(coordinator, showDiag) {
2325
2336
  incoming_reciprocal_request_id: record.incoming_reciprocal_request_id ?? null,
2326
2337
  outgoing_reciprocal_request_id: record.outgoing_reciprocal_request_id ?? null,
2327
2338
  fingerprint: null,
2328
- addresses: []
2339
+ addresses: [],
2340
+ address_count: addressCount
2329
2341
  };
2330
2342
  }) : [];
2331
2343
  return {
@@ -2333,21 +2345,52 @@ function redactCoordinatorStatus(coordinator, showDiag) {
2333
2345
  discovered_devices: discovered
2334
2346
  };
2335
2347
  }
2348
+ function detailForNotConfiguredReason(reason) {
2349
+ switch (reason) {
2350
+ case "coordinator_url_missing": return "Configure a coordinator URL before pairing with discovered peers.";
2351
+ case "coordinator_groups_empty": return "Join a coordinator team before pairing with discovered peers.";
2352
+ case "sync_disabled": return "Enable sync on this device before pairing with discovered peers.";
2353
+ }
2354
+ }
2336
2355
  async function acceptDiscoveredPeer(store, input) {
2337
2356
  const config = readCoordinatorSyncConfig();
2338
- if (!config.syncEnabled || !config.syncCoordinatorUrl || config.syncCoordinatorGroups.length === 0) return {
2357
+ let notConfiguredReason = null;
2358
+ if (!config.syncCoordinatorUrl) notConfiguredReason = "coordinator_url_missing";
2359
+ else if (config.syncCoordinatorGroups.length === 0) notConfiguredReason = "coordinator_groups_empty";
2360
+ else if (!config.syncEnabled) notConfiguredReason = "sync_disabled";
2361
+ if (notConfiguredReason) return {
2339
2362
  ok: false,
2340
2363
  status: 400,
2341
2364
  error: "coordinator_not_configured",
2342
- detail: "Coordinator must be configured before accepting discovered peers."
2365
+ reason: notConfiguredReason,
2366
+ detail: detailForNotConfiguredReason(notConfiguredReason)
2367
+ };
2368
+ const discovered = await lookupCoordinatorPeers(store, config);
2369
+ const inputFingerprint = String(input.fingerprint ?? "").trim();
2370
+ const candidates = discovered.filter((peer) => String(peer.device_id ?? "").trim() === input.peerDeviceId);
2371
+ const matchingCandidates = inputFingerprint ? candidates.filter((peer) => String(peer.fingerprint ?? "").trim() === inputFingerprint) : candidates;
2372
+ const uniqueFingerprints = new Set(matchingCandidates.map((peer) => String(peer.fingerprint ?? "").trim()).filter(Boolean));
2373
+ if (!inputFingerprint && uniqueFingerprints.size > 1) return {
2374
+ ok: false,
2375
+ status: 409,
2376
+ error: "ambiguous_discovered_peer",
2377
+ detail: "That discovered device has multiple coordinator fingerprints. Enable diagnostics, refresh sync status, and choose the intended device before trusting it."
2343
2378
  };
2344
- const match = (await lookupCoordinatorPeers(store, config)).find((peer) => String(peer.device_id ?? "").trim() === input.peerDeviceId && String(peer.fingerprint ?? "").trim() === input.fingerprint);
2379
+ const match = matchingCandidates[0];
2345
2380
  if (!match) return {
2346
2381
  ok: false,
2347
2382
  status: 404,
2348
2383
  error: "discovered_peer_not_found",
2349
2384
  detail: "That discovered device is no longer available. Refresh sync status and try again."
2350
2385
  };
2386
+ const expiresAt = String(match.expires_at ?? "").trim();
2387
+ const expired = expiresAt ? Date.parse(expiresAt) <= Date.now() : false;
2388
+ if (match.stale || expired) return {
2389
+ ok: false,
2390
+ status: 409,
2391
+ error: "discovered_peer_stale",
2392
+ detail: "This discovered device's coordinator presence is stale. Wait for it to come online and refresh sync status before trusting it."
2393
+ };
2351
2394
  const nextFingerprint = String(match.fingerprint ?? "").trim();
2352
2395
  const nextPublicKey = String(match.public_key ?? "").trim();
2353
2396
  const nextName = String(match.display_name ?? "").trim() || null;
@@ -2358,7 +2401,14 @@ async function acceptDiscoveredPeer(store, input) {
2358
2401
  error: "discovered_peer_missing_public_key",
2359
2402
  detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
2360
2403
  };
2404
+ if (!nextFingerprint || fingerprintPublicKey(nextPublicKey) !== nextFingerprint) return {
2405
+ ok: false,
2406
+ status: 409,
2407
+ error: "discovered_peer_fingerprint_mismatch",
2408
+ detail: "This discovered device's coordinator public key does not match its fingerprint. Refresh sync status or re-enroll the device before trusting it."
2409
+ };
2361
2410
  const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
2411
+ const freshGroupIds = Array.isArray(match.fresh_groups) ? match.fresh_groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
2362
2412
  let groupId;
2363
2413
  if (input.expectedGroupId) {
2364
2414
  if (!groupIds.includes(input.expectedGroupId)) return {
@@ -2367,6 +2417,12 @@ async function acceptDiscoveredPeer(store, input) {
2367
2417
  error: "peer_not_in_group",
2368
2418
  detail: "This discovered device is not visible through the specified coordinator group. Refresh sync status and try again."
2369
2419
  };
2420
+ if (!freshGroupIds.includes(input.expectedGroupId)) return {
2421
+ ok: false,
2422
+ status: 409,
2423
+ error: "discovered_peer_stale",
2424
+ 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."
2425
+ };
2370
2426
  groupId = input.expectedGroupId;
2371
2427
  } else {
2372
2428
  if (groupIds.length !== 1) return {
@@ -2400,7 +2456,7 @@ async function acceptDiscoveredPeer(store, input) {
2400
2456
  return [];
2401
2457
  }
2402
2458
  })();
2403
- const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
2459
+ const addressesJson = JSON.stringify(mergeAddresses(nextAddresses, existingAddresses));
2404
2460
  await createCoordinatorReciprocalApproval(store, config, {
2405
2461
  groupId,
2406
2462
  requestedDeviceId: input.peerDeviceId
@@ -2502,19 +2558,25 @@ function claimedLocalActorScopeStatus(store, row) {
2502
2558
  * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
2503
2559
  * are redacted.
2504
2560
  */
2505
- function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer) {
2561
+ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId) {
2506
2562
  const peerId = String(row.peer_device_id ?? "");
2507
2563
  const recentOps = recentOpsByPeer?.get(peerId) ?? {
2508
2564
  in: 0,
2509
2565
  out: 0
2510
2566
  };
2511
2567
  const scopeRejections = scopeRejectionsByPeer?.get(peerId);
2568
+ const addresses = safeJsonList(row.addresses_json);
2569
+ const perScopeSync = listPerPeerScopeSyncState(store.db, {
2570
+ localDeviceId: localDeviceId ?? null,
2571
+ peerDeviceId: peerId
2572
+ });
2512
2573
  return {
2513
2574
  peer_device_id: row.peer_device_id,
2514
2575
  name: row.name,
2515
2576
  fingerprint: showDiag ? row.pinned_fingerprint : null,
2516
2577
  pinned: Boolean(row.pinned_fingerprint),
2517
- addresses: showDiag ? safeJsonList(row.addresses_json) : [],
2578
+ addresses: showDiag ? addresses : [],
2579
+ address_count: addresses.length,
2518
2580
  last_seen_at: row.last_seen_at,
2519
2581
  last_sync_at: row.last_sync_at,
2520
2582
  last_error: showDiag ? row.last_error : null,
@@ -2524,6 +2586,7 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2524
2586
  actor_id: row.actor_id ?? null,
2525
2587
  actor_display_name: row.actor_display_name ?? null,
2526
2588
  authorized_scopes: peerAuthorizedScopes(store, peerId),
2589
+ per_scope_sync: perScopeSync,
2527
2590
  project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) },
2528
2591
  recent_ops: {
2529
2592
  in: recentOps.in,
@@ -2538,6 +2601,18 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2538
2601
  discovered_via_group_id: typeof row.discovered_via_group_id === "string" ? row.discovered_via_group_id : null
2539
2602
  };
2540
2603
  }
2604
+ function redactSyncAttemptError(error) {
2605
+ if (!String(error ?? "").trim()) return null;
2606
+ return "sync attempt failed; enable diagnostics for details";
2607
+ }
2608
+ function mapSyncAttemptRow(row, showDiag, addresses) {
2609
+ return {
2610
+ ...row,
2611
+ error: showDiag ? row.error : redactSyncAttemptError(row.error),
2612
+ status: attemptStatus(row),
2613
+ address: showDiag && addresses?.length ? addresses[0] : null
2614
+ };
2615
+ }
2541
2616
  var SYNC_SCOPE_REJECTION_WINDOW_SECONDS = 1440 * 60;
2542
2617
  function recentScopeRejectionsByPeer(store) {
2543
2618
  const cutoff = (/* @__PURE__ */ new Date(Date.now() - SYNC_SCOPE_REJECTION_WINDOW_SECONDS * 1e3)).toISOString();
@@ -2853,6 +2928,20 @@ var PEERS_QUERY = `
2853
2928
  ORDER BY name, peer_device_id
2854
2929
  `;
2855
2930
  /**
2931
+ * Compute the negotiated sync capability for a request by combining the
2932
+ * server's local capability with the value the caller advertised in the
2933
+ * `X-Codemem-Sync-Capability` header. Routes use this to decide whether to
2934
+ * honor scoped-sync wire features such as explicit `scope_id` parameters
2935
+ * and `/v1/status` `authorized_scopes` enumeration.
2936
+ *
2937
+ * Unsigned headers are fine here: the server is computing what it is
2938
+ * willing to do, not granting access. Per-scope authorization is enforced
2939
+ * separately via the membership cache when a scope_id is actually used.
2940
+ */
2941
+ function negotiatedSyncCapability(c) {
2942
+ return negotiateSyncCapability(LOCAL_SYNC_CAPABILITY, normalizeSyncCapability(c.req.header(SYNC_CAPABILITY_HEADER) ?? null));
2943
+ }
2944
+ /**
2856
2945
  * Peer-to-peer sync protocol routes (/v1/*).
2857
2946
  *
2858
2947
  * These are mounted on the sync listener (0.0.0.0:7337) and are
@@ -2895,22 +2984,31 @@ function syncProtocolRoutes(getStore, opts = {}) {
2895
2984
  }
2896
2985
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2897
2986
  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}`);
2987
+ if (bootstrapAttempted) {
2988
+ const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
2989
+ console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant_present=${grantPresent} path=${c.req.path}`);
2990
+ }
2899
2991
  const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
2900
- return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
2992
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
2901
2993
  }
2902
2994
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2903
2995
  if (limited) return limited;
2904
2996
  try {
2905
2997
  const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2906
2998
  const syncReset = getSyncResetState(store.db);
2907
- return c.json({
2999
+ const authorizedScopes = isScopedSyncCapability(negotiatedSyncCapability(c)) ? listAuthorizedScopesForPeer(store.db, {
3000
+ localDeviceId: deviceId,
3001
+ peerDeviceId: auth.deviceId
3002
+ }) : null;
3003
+ const response = {
2908
3004
  device_id: deviceId,
2909
3005
  protocol_version: SYNC_PROTOCOL_VERSION,
2910
3006
  fingerprint,
2911
3007
  sync_reset: addSyncScopeToBoundary(syncReset, null),
2912
3008
  sync_capability: LOCAL_SYNC_CAPABILITY
2913
- });
3009
+ };
3010
+ if (authorizedScopes !== null) response.authorized_scopes = authorizedScopes;
3011
+ return c.json(response);
2914
3012
  } catch {
2915
3013
  return c.json({ error: "internal_error" }, 500);
2916
3014
  }
@@ -2920,14 +3018,21 @@ function syncProtocolRoutes(getStore, opts = {}) {
2920
3018
  const store = getStore();
2921
3019
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2922
3020
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2923
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
3021
+ if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason, true), 401);
2924
3022
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2925
3023
  if (limited) return limited;
2926
3024
  const peerDeviceId = auth.deviceId;
2927
3025
  try {
2928
3026
  const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
2929
- const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0);
2930
- if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY), 409);
3027
+ const negotiated = negotiatedSyncCapability(c);
3028
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3029
+ const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0, {
3030
+ db: store.db,
3031
+ localDeviceId,
3032
+ negotiatedCapability: negotiated,
3033
+ peerDeviceId
3034
+ });
3035
+ if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY, null), 409);
2931
3036
  const since = c.req.query("since") ?? null;
2932
3037
  const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
2933
3038
  const rawGeneration = c.req.query("generation");
@@ -2935,14 +3040,14 @@ function syncProtocolRoutes(getStore, opts = {}) {
2935
3040
  const snapshotId = c.req.query("snapshot_id") ?? null;
2936
3041
  const baselineCursor = c.req.query("baseline_cursor") ?? null;
2937
3042
  const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1e3)) : 200;
2938
- const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2939
3043
  const result = loadReplicationOpsForPeer(store.db, {
2940
3044
  since,
2941
3045
  limit,
2942
3046
  deviceId: localDeviceId,
2943
3047
  generation: Number.isFinite(generation) ? generation : null,
2944
3048
  snapshotId,
2945
- baselineCursor
3049
+ baselineCursor,
3050
+ scopeId: scopeRequest.mode === "scoped" ? scopeRequest.scope_id : void 0
2946
3051
  });
2947
3052
  if (result.reset_required) return c.json({
2948
3053
  error: "reset_required",
@@ -2990,16 +3095,26 @@ function syncProtocolRoutes(getStore, opts = {}) {
2990
3095
  }
2991
3096
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2992
3097
  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}`);
3098
+ if (bootstrapAttempted) {
3099
+ const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
3100
+ console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant_present=${grantPresent} path=${c.req.path}`);
3101
+ }
2994
3102
  const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
2995
- return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
3103
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
2996
3104
  }
2997
3105
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2998
3106
  if (limited) return limited;
2999
3107
  try {
3000
3108
  const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
3001
- const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0);
3002
- if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY), 409);
3109
+ const negotiated = negotiatedSyncCapability(c);
3110
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3111
+ const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0, {
3112
+ db: store.db,
3113
+ localDeviceId,
3114
+ negotiatedCapability: negotiated,
3115
+ peerDeviceId: auth.deviceId
3116
+ });
3117
+ if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY, null), 409);
3003
3118
  const rawGeneration = c.req.query("generation");
3004
3119
  const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
3005
3120
  const snapshotId = c.req.query("snapshot_id") ?? null;
@@ -3015,7 +3130,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
3015
3130
  baselineCursor,
3016
3131
  pageToken,
3017
3132
  limit,
3018
- peerDeviceId: auth.deviceId
3133
+ peerDeviceId: auth.deviceId,
3134
+ scopeId: scopeRequest.mode === "scoped" ? scopeRequest.scope_id : void 0
3019
3135
  });
3020
3136
  return c.json({
3021
3137
  scope_id: scopeRequest.scope_id,
@@ -3041,7 +3157,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
3041
3157
  if (raw == null) return c.json({ error: "payload_too_large" }, 413);
3042
3158
  const auth = authorizeSyncRequest(store, c.req, raw);
3043
3159
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
3044
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
3160
+ if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason, true), 401);
3045
3161
  const limited = rateLimitedResponse(c, auth.deviceId, true);
3046
3162
  if (limited) return limited;
3047
3163
  const peerDeviceId = auth.deviceId;
@@ -3053,8 +3169,15 @@ function syncProtocolRoutes(getStore, opts = {}) {
3053
3169
  } catch {
3054
3170
  return c.json({ error: "invalid_json" }, 400);
3055
3171
  }
3056
- const scopeRequest = parseSyncScopeRequest(body.scope_id, Object.hasOwn(body, "scope_id"));
3057
- if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY), 409);
3172
+ const negotiated = negotiateSyncCapability(LOCAL_SYNC_CAPABILITY, normalizeSyncCapability(body.sync_capability));
3173
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3174
+ const scopeRequest = parseSyncScopeRequest(body.scope_id, Object.hasOwn(body, "scope_id"), {
3175
+ db: store.db,
3176
+ localDeviceId,
3177
+ negotiatedCapability: negotiated,
3178
+ peerDeviceId
3179
+ });
3180
+ if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY, null), 409);
3058
3181
  if (!Array.isArray(body.ops)) return c.json({ error: "invalid_ops" }, 400);
3059
3182
  if (body.ops.length > MAX_SYNC_OPS) return c.json({ error: "too_many_ops" }, 413);
3060
3183
  const normalizedOps = extractReplicationOps(body);
@@ -3063,11 +3186,9 @@ function syncProtocolRoutes(getStore, opts = {}) {
3063
3186
  reason: "device_id_mismatch",
3064
3187
  op_id: op.op_id
3065
3188
  }, 400);
3066
- const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3067
- const negotiatedCapability = negotiateSyncCapability(LOCAL_SYNC_CAPABILITY, normalizeSyncCapability(body.sync_capability));
3068
3189
  const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, normalizedOps, { applyScopeFilter: false });
3069
3190
  const rejected = rejectInboundScopeFailures(store.db, normalizedOps, localDeviceId, {
3070
- enabled: negotiatedCapability !== "unsupported",
3191
+ enabled: negotiated !== "unsupported",
3071
3192
  peerDeviceId
3072
3193
  });
3073
3194
  if (rejected) return c.json({
@@ -3191,7 +3312,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3191
3312
  const recentOpsByPeer = traceSync("recentPeerOps", () => recentPeerOps(store));
3192
3313
  const scopeRejectionsByPeer = traceSync("recentScopeRejectionsByPeer", () => recentScopeRejectionsByPeer(store));
3193
3314
  const peersItems = peerRows.map((row) => {
3194
- const peer = mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer);
3315
+ const peer = mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, deviceRow?.device_id ?? null);
3195
3316
  peer.status = peerStatus(peer);
3196
3317
  return peer;
3197
3318
  });
@@ -3215,14 +3336,9 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3215
3336
  if (addrs.length) peerAddressMap.set(String(peerRow.peer_device_id ?? ""), addrs);
3216
3337
  });
3217
3338
  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
- };
3339
+ return mapSyncAttemptRow(row, showDiag, showDiag ? peerAddressMap.get(String(row.peer_device_id ?? "")) : void 0);
3224
3340
  });
3225
- const latestAttemptError = String(attemptsItems[0]?.error || "").trim();
3341
+ const latestAttemptError = String(attemptRows[0]?.error || "").trim();
3226
3342
  const statusBlock = {
3227
3343
  ...statusPayload,
3228
3344
  background_maintenance: summarizeMaintenanceJobs(listMaintenanceJobs(store.db), showDiag),
@@ -3282,7 +3398,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3282
3398
  const rows = store.db.prepare(PEERS_QUERY).all();
3283
3399
  const recentOpsByPeer = recentPeerOps(store);
3284
3400
  const scopeRejectionsByPeer = recentScopeRejectionsByPeer(store);
3285
- const peers = rows.map((row) => mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer));
3401
+ const localDeviceId = drizzle(store.db, { schema }).select({ device_id: schema.syncDevice.device_id }).from(schema.syncDevice).limit(1).get()?.device_id ?? null;
3402
+ const peers = rows.map((row) => mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId));
3286
3403
  return c.json({
3287
3404
  items: peers,
3288
3405
  redacted: !showDiag
@@ -3348,7 +3465,12 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3348
3465
  peer_sync_capability: schema.syncAttempts.peer_sync_capability,
3349
3466
  negotiated_sync_capability: schema.syncAttempts.negotiated_sync_capability
3350
3467
  }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(limit).all();
3351
- return c.json({ items: rows });
3468
+ const showDiag = queryBool(c.req.query("includeDiagnostics"));
3469
+ const items = rows.map((row) => mapSyncAttemptRow(row, showDiag));
3470
+ return c.json({
3471
+ items,
3472
+ redacted: !showDiag
3473
+ });
3352
3474
  }
3353
3475
  });
3354
3476
  app.get("/api/sync/pairing", (c) => {
@@ -3356,19 +3478,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3356
3478
  {
3357
3479
  const config = readCoordinatorSyncConfig();
3358
3480
  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
3481
  let deviceId;
3365
3482
  let publicKey;
3366
3483
  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 {
3484
+ try {
3372
3485
  const [id, fp] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3373
3486
  deviceId = id;
3374
3487
  fingerprint = fp;
@@ -3829,15 +3942,15 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3829
3942
  const peerDeviceId = String(body.peer_device_id ?? "").trim();
3830
3943
  const fingerprint = String(body.fingerprint ?? "").trim();
3831
3944
  if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
3832
- if (!fingerprint) return c.json({ error: "fingerprint required" }, 400);
3833
3945
  try {
3834
3946
  const result = await acceptDiscoveredPeer(store, {
3835
3947
  peerDeviceId,
3836
- fingerprint
3948
+ fingerprint: fingerprint || void 0
3837
3949
  });
3838
3950
  if (!result.ok) return c.json({
3839
3951
  error: result.error,
3840
- detail: result.detail
3952
+ detail: result.detail,
3953
+ ...result.reason ? { reason: result.reason } : {}
3841
3954
  }, { status: result.status });
3842
3955
  return c.json({
3843
3956
  ok: true,
@@ -3898,9 +4011,16 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3898
4011
  const pairingResult = tryParsePairingPayload(rawValue.match(/echo\s+['"]([A-Za-z0-9+/=]+)['"]\s*\|\s*base64\s+-d\s*\|\s*codemem/)?.[1]?.trim() || rawValue);
3899
4012
  if (pairingResult?.kind === "invalid-pair") return c.json({ error: pairingResult.error }, 400);
3900
4013
  if (pairingResult?.kind === "pair") try {
4014
+ 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();
4015
+ const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
4016
+ if (existingFingerprint && existingFingerprint !== pairingResult.fingerprint) return c.json({
4017
+ error: "peer_conflict",
4018
+ detail: "Pairing payload conflicts with the existing trusted fingerprint for this device. Remove or repair the old peer before accepting this pairing payload."
4019
+ }, 409);
3901
4020
  updatePeerAddresses(store.db, pairingResult.device_id, pairingResult.addresses, {
3902
4021
  pinnedFingerprint: pairingResult.fingerprint,
3903
- publicKey: pairingResult.public_key
4022
+ publicKey: pairingResult.public_key,
4023
+ replaceTrust: true
3904
4024
  });
3905
4025
  return c.json({
3906
4026
  ok: true,
@@ -4866,9 +4986,11 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
4866
4986
  const peerDeviceId = String(body.peer_device_id ?? "").trim();
4867
4987
  const publicKey = String(body.peer_public_key ?? "").trim();
4868
4988
  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);
4989
+ const requestedFingerprint = String(body.fingerprint ?? "").trim();
4990
+ const addresses = mergeAddresses([], (Array.isArray(body.peer_addresses) ? body.peer_addresses : []).map((item) => String(item ?? "").trim()).filter((item) => item.length > 0));
4871
4991
  if (!peerDeviceId || !publicKey) return c.json({ error: "peer_device_id_and_public_key_required" }, 400);
4992
+ const fingerprint = requestedFingerprint || fingerprintPublicKey(publicKey);
4993
+ if (fingerprintPublicKey(publicKey) !== fingerprint) return c.json({ error: "fingerprint_mismatch" }, 400);
4872
4994
  const manualInclude = overrideInclude && overrideInclude.length > 0 ? JSON.stringify(overrideInclude) : null;
4873
4995
  const manualExclude = overrideExclude && overrideExclude.length > 0 ? JSON.stringify(overrideExclude) : null;
4874
4996
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4928,7 +5050,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
4928
5050
  if (result.ok) return c.json(result);
4929
5051
  return c.json({
4930
5052
  error: result.error,
4931
- detail: result.detail
5053
+ detail: result.detail,
5054
+ ...result.reason ? { reason: result.reason } : {}
4932
5055
  }, result.status);
4933
5056
  } catch (error) {
4934
5057
  return c.json({