@codemem/server 0.32.3 → 0.33.0-alpha.2

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, 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";
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";
@@ -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,
@@ -2335,13 +2345,25 @@ function redactCoordinatorStatus(coordinator, showDiag) {
2335
2345
  discovered_devices: discovered
2336
2346
  };
2337
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
+ }
2338
2355
  async function acceptDiscoveredPeer(store, input) {
2339
2356
  const config = readCoordinatorSyncConfig();
2340
- 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 {
2341
2362
  ok: false,
2342
2363
  status: 400,
2343
2364
  error: "coordinator_not_configured",
2344
- detail: "Coordinator must be configured before accepting discovered peers."
2365
+ reason: notConfiguredReason,
2366
+ detail: detailForNotConfiguredReason(notConfiguredReason)
2345
2367
  };
2346
2368
  const discovered = await lookupCoordinatorPeers(store, config);
2347
2369
  const inputFingerprint = String(input.fingerprint ?? "").trim();
@@ -2536,7 +2558,7 @@ function claimedLocalActorScopeStatus(store, row) {
2536
2558
  * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
2537
2559
  * are redacted.
2538
2560
  */
2539
- function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer) {
2561
+ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId) {
2540
2562
  const peerId = String(row.peer_device_id ?? "");
2541
2563
  const recentOps = recentOpsByPeer?.get(peerId) ?? {
2542
2564
  in: 0,
@@ -2544,6 +2566,10 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2544
2566
  };
2545
2567
  const scopeRejections = scopeRejectionsByPeer?.get(peerId);
2546
2568
  const addresses = safeJsonList(row.addresses_json);
2569
+ const perScopeSync = listPerPeerScopeSyncState(store.db, {
2570
+ localDeviceId: localDeviceId ?? null,
2571
+ peerDeviceId: peerId
2572
+ });
2547
2573
  return {
2548
2574
  peer_device_id: row.peer_device_id,
2549
2575
  name: row.name,
@@ -2560,6 +2586,7 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2560
2586
  actor_id: row.actor_id ?? null,
2561
2587
  actor_display_name: row.actor_display_name ?? null,
2562
2588
  authorized_scopes: peerAuthorizedScopes(store, peerId),
2589
+ per_scope_sync: perScopeSync,
2563
2590
  project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) },
2564
2591
  recent_ops: {
2565
2592
  in: recentOps.in,
@@ -2901,6 +2928,20 @@ var PEERS_QUERY = `
2901
2928
  ORDER BY name, peer_device_id
2902
2929
  `;
2903
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
+ /**
2904
2945
  * Peer-to-peer sync protocol routes (/v1/*).
2905
2946
  *
2906
2947
  * These are mounted on the sync listener (0.0.0.0:7337) and are
@@ -2955,13 +2996,19 @@ function syncProtocolRoutes(getStore, opts = {}) {
2955
2996
  try {
2956
2997
  const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2957
2998
  const syncReset = getSyncResetState(store.db);
2958
- return c.json({
2999
+ const authorizedScopes = isScopedSyncCapability(negotiatedSyncCapability(c)) ? listAuthorizedScopesForPeer(store.db, {
3000
+ localDeviceId: deviceId,
3001
+ peerDeviceId: auth.deviceId
3002
+ }) : null;
3003
+ const response = {
2959
3004
  device_id: deviceId,
2960
3005
  protocol_version: SYNC_PROTOCOL_VERSION,
2961
3006
  fingerprint,
2962
3007
  sync_reset: addSyncScopeToBoundary(syncReset, null),
2963
3008
  sync_capability: LOCAL_SYNC_CAPABILITY
2964
- });
3009
+ };
3010
+ if (authorizedScopes !== null) response.authorized_scopes = authorizedScopes;
3011
+ return c.json(response);
2965
3012
  } catch {
2966
3013
  return c.json({ error: "internal_error" }, 500);
2967
3014
  }
@@ -2977,8 +3024,15 @@ function syncProtocolRoutes(getStore, opts = {}) {
2977
3024
  const peerDeviceId = auth.deviceId;
2978
3025
  try {
2979
3026
  const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
2980
- const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0);
2981
- 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);
2982
3036
  const since = c.req.query("since") ?? null;
2983
3037
  const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
2984
3038
  const rawGeneration = c.req.query("generation");
@@ -2986,14 +3040,14 @@ function syncProtocolRoutes(getStore, opts = {}) {
2986
3040
  const snapshotId = c.req.query("snapshot_id") ?? null;
2987
3041
  const baselineCursor = c.req.query("baseline_cursor") ?? null;
2988
3042
  const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1e3)) : 200;
2989
- const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2990
3043
  const result = loadReplicationOpsForPeer(store.db, {
2991
3044
  since,
2992
3045
  limit,
2993
3046
  deviceId: localDeviceId,
2994
3047
  generation: Number.isFinite(generation) ? generation : null,
2995
3048
  snapshotId,
2996
- baselineCursor
3049
+ baselineCursor,
3050
+ scopeId: scopeRequest.mode === "scoped" ? scopeRequest.scope_id : void 0
2997
3051
  });
2998
3052
  if (result.reset_required) return c.json({
2999
3053
  error: "reset_required",
@@ -3052,8 +3106,15 @@ function syncProtocolRoutes(getStore, opts = {}) {
3052
3106
  if (limited) return limited;
3053
3107
  try {
3054
3108
  const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
3055
- const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0);
3056
- 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);
3057
3118
  const rawGeneration = c.req.query("generation");
3058
3119
  const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
3059
3120
  const snapshotId = c.req.query("snapshot_id") ?? null;
@@ -3069,7 +3130,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
3069
3130
  baselineCursor,
3070
3131
  pageToken,
3071
3132
  limit,
3072
- peerDeviceId: auth.deviceId
3133
+ peerDeviceId: auth.deviceId,
3134
+ scopeId: scopeRequest.mode === "scoped" ? scopeRequest.scope_id : void 0
3073
3135
  });
3074
3136
  return c.json({
3075
3137
  scope_id: scopeRequest.scope_id,
@@ -3107,8 +3169,15 @@ function syncProtocolRoutes(getStore, opts = {}) {
3107
3169
  } catch {
3108
3170
  return c.json({ error: "invalid_json" }, 400);
3109
3171
  }
3110
- const scopeRequest = parseSyncScopeRequest(body.scope_id, Object.hasOwn(body, "scope_id"));
3111
- 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);
3112
3181
  if (!Array.isArray(body.ops)) return c.json({ error: "invalid_ops" }, 400);
3113
3182
  if (body.ops.length > MAX_SYNC_OPS) return c.json({ error: "too_many_ops" }, 413);
3114
3183
  const normalizedOps = extractReplicationOps(body);
@@ -3117,11 +3186,9 @@ function syncProtocolRoutes(getStore, opts = {}) {
3117
3186
  reason: "device_id_mismatch",
3118
3187
  op_id: op.op_id
3119
3188
  }, 400);
3120
- const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3121
- const negotiatedCapability = negotiateSyncCapability(LOCAL_SYNC_CAPABILITY, normalizeSyncCapability(body.sync_capability));
3122
3189
  const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, normalizedOps, { applyScopeFilter: false });
3123
3190
  const rejected = rejectInboundScopeFailures(store.db, normalizedOps, localDeviceId, {
3124
- enabled: negotiatedCapability !== "unsupported",
3191
+ enabled: negotiated !== "unsupported",
3125
3192
  peerDeviceId
3126
3193
  });
3127
3194
  if (rejected) return c.json({
@@ -3245,7 +3312,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3245
3312
  const recentOpsByPeer = traceSync("recentPeerOps", () => recentPeerOps(store));
3246
3313
  const scopeRejectionsByPeer = traceSync("recentScopeRejectionsByPeer", () => recentScopeRejectionsByPeer(store));
3247
3314
  const peersItems = peerRows.map((row) => {
3248
- const peer = mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer);
3315
+ const peer = mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, deviceRow?.device_id ?? null);
3249
3316
  peer.status = peerStatus(peer);
3250
3317
  return peer;
3251
3318
  });
@@ -3331,7 +3398,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3331
3398
  const rows = store.db.prepare(PEERS_QUERY).all();
3332
3399
  const recentOpsByPeer = recentPeerOps(store);
3333
3400
  const scopeRejectionsByPeer = recentScopeRejectionsByPeer(store);
3334
- 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));
3335
3403
  return c.json({
3336
3404
  items: peers,
3337
3405
  redacted: !showDiag
@@ -3881,7 +3949,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3881
3949
  });
3882
3950
  if (!result.ok) return c.json({
3883
3951
  error: result.error,
3884
- detail: result.detail
3952
+ detail: result.detail,
3953
+ ...result.reason ? { reason: result.reason } : {}
3885
3954
  }, { status: result.status });
3886
3955
  return c.json({
3887
3956
  ok: true,
@@ -4981,7 +5050,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
4981
5050
  if (result.ok) return c.json(result);
4982
5051
  return c.json({
4983
5052
  error: result.error,
4984
- detail: result.detail
5053
+ detail: result.detail,
5054
+ ...result.reason ? { reason: result.reason } : {}
4985
5055
  }, result.status);
4986
5056
  } catch (error) {
4987
5057
  return c.json({