@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 +183 -60
- package/dist/index.js.map +1 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/app.js +261 -477
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 [
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 ?
|
|
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)
|
|
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
|
-
|
|
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
|
|
2930
|
-
|
|
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)
|
|
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
|
|
3002
|
-
|
|
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
|
|
3057
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|