@codemem/server 0.29.2 → 0.30.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, MemoryStore, VERSION, VERSION as VERSION$1, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listMaintenanceJobs, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncProjectAllowedByFilters, syncVisibilityAllowed, updatePeerAddresses, upsertCoordinatorGroupPreference, verifySignature, writeCodememConfigFile } from "@codemem/core";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, 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, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, verifySignature, writeCodememConfigFile } from "@codemem/core";
4
4
  import { serveStatic } from "@hono/node-server/serve-static";
5
5
  import { Hono } from "hono";
6
6
  import { createMiddleware } from "hono/factory";
@@ -1526,6 +1526,51 @@ function coordinatorAdminStatusPayload(config = readCoordinatorSyncConfig()) {
1526
1526
  has_groups: hasGroups
1527
1527
  };
1528
1528
  }
1529
+ function coordinatorAdminUnavailable(status) {
1530
+ if (status.readiness === "not_configured") return {
1531
+ body: {
1532
+ error: "coordinator_not_configured",
1533
+ status
1534
+ },
1535
+ httpStatus: 400
1536
+ };
1537
+ if (!status.has_admin_secret) return {
1538
+ body: {
1539
+ error: "coordinator_admin_secret_missing",
1540
+ status
1541
+ },
1542
+ httpStatus: 400
1543
+ };
1544
+ return null;
1545
+ }
1546
+ async function parseViewerJsonBody(c, options = {}) {
1547
+ const raw = await c.req.text();
1548
+ if (!raw.trim()) return options.allowEmpty ? {} : null;
1549
+ try {
1550
+ const parsed = JSON.parse(raw);
1551
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
1552
+ return parsed;
1553
+ } catch {
1554
+ return null;
1555
+ }
1556
+ }
1557
+ function optionalViewerString(body, key) {
1558
+ const value = body[key];
1559
+ if (value == null) return null;
1560
+ return String(value).trim() || null;
1561
+ }
1562
+ function optionalViewerNumber(body, key) {
1563
+ const value = body[key];
1564
+ if (value == null || value === "") return null;
1565
+ const number = typeof value === "number" ? value : Number(value);
1566
+ return Number.isFinite(number) ? Math.trunc(number) : NaN;
1567
+ }
1568
+ function coordinatorAdminMutationStatus(message) {
1569
+ if (message.includes("scope_not_found") || message.includes("membership_not_found") || message.includes("group_not_found") || message.includes("Group not found") || message.includes("Scope not found") || message.includes("device_not_enrolled_for_scope_group") || message.includes("device must be enrolled")) return 404;
1570
+ if (message.includes("not active") || message.includes("scope_not_active") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
1571
+ if (message.includes("Remote coordinator request failed (5") || message.includes("fetch failed") || message.includes("network") || message.includes("timed out")) return 502;
1572
+ return 400;
1573
+ }
1529
1574
  function pairingAdvertiseAddresses(config = readCoordinatorSyncConfig()) {
1530
1575
  const advertise = String(config.syncAdvertise || "auto").trim().toLowerCase();
1531
1576
  if (advertise && advertise !== "auto" && advertise !== "default") return mergeAddresses([], String(config.syncAdvertise || "").split(",").map((item) => item.trim()).filter(Boolean));
@@ -1899,20 +1944,6 @@ function claimLegacyDeviceAsSelf(store, originDeviceId) {
1899
1944
  )`).run(store.actorId, store.actorDisplayName, personalWorkspaceId, deviceId, store.actorId, LEGACY_SYNC_ACTOR_DISPLAY_NAME, LEGACY_SHARED_WORKSPACE_ID);
1900
1945
  return Number(result.changes ?? 0);
1901
1946
  }
1902
- function peerClaimedLocalActor(store, peerDeviceId) {
1903
- const row = store.db.prepare("SELECT claimed_local_actor FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(peerDeviceId);
1904
- return Boolean(row?.claimed_local_actor);
1905
- }
1906
- function parseOpPayload(op) {
1907
- if (!op.payload_json || !String(op.payload_json).trim()) return null;
1908
- try {
1909
- const parsed = JSON.parse(op.payload_json);
1910
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
1911
- return parsed;
1912
- } catch {
1913
- return null;
1914
- }
1915
- }
1916
1947
  function redactCoordinatorStatus(coordinator, showDiag) {
1917
1948
  if (showDiag) return coordinator;
1918
1949
  const discovered = Array.isArray(coordinator.discovered_devices) ? coordinator.discovered_devices.map((item) => {
@@ -2076,30 +2107,29 @@ async function acceptDiscoveredPeer(store, input) {
2076
2107
  };
2077
2108
  })();
2078
2109
  }
2079
- function filterOpsForPeer(store, peerDeviceId, ops) {
2080
- const filters = readPeerProjectFilters(store, peerDeviceId);
2081
- const allowPrivate = peerClaimedLocalActor(store, peerDeviceId);
2082
- const allowed = [];
2083
- let skipped = 0;
2084
- for (const op of ops) {
2085
- if (op.entity_type !== "memory_item") {
2086
- allowed.push(op);
2087
- continue;
2088
- }
2089
- const payload = parseOpPayload(op);
2090
- if (!allowPrivate && !syncVisibilityAllowed(payload)) {
2091
- skipped++;
2092
- continue;
2093
- }
2094
- if (!syncProjectAllowedByFilters(payload && typeof payload.project === "string" ? payload.project : null, filters)) {
2095
- skipped++;
2096
- continue;
2097
- }
2098
- allowed.push(op);
2099
- }
2110
+ function filterOpsForPeer(store, peerDeviceId, localDeviceId, ops, options = {}) {
2111
+ const [allowed, , skipped] = filterReplicationOpsForSyncWithStatus(store.db, ops, peerDeviceId, {
2112
+ localDeviceId,
2113
+ applyScopeFilter: options.applyScopeFilter
2114
+ });
2100
2115
  return {
2101
2116
  allowed,
2102
- skipped
2117
+ skipped: skipped?.skipped_count ?? 0
2118
+ };
2119
+ }
2120
+ function claimedLocalActorScopeStatus(store, row) {
2121
+ if (!row.claimed_local_actor) return null;
2122
+ const peerDeviceId = String(row.peer_device_id ?? "").trim();
2123
+ const actorId = String(row.actor_id ?? store.actorId).trim() || store.actorId;
2124
+ const grant = personalScopeGrantStatusForPeer(store.db, {
2125
+ peerDeviceId,
2126
+ actorId
2127
+ });
2128
+ return {
2129
+ scope_id: grant.scope_id,
2130
+ authorized: grant.authorized,
2131
+ state: grant.state,
2132
+ action_required: !grant.authorized
2103
2133
  };
2104
2134
  }
2105
2135
  /**
@@ -2107,12 +2137,13 @@ function filterOpsForPeer(store, peerDeviceId, ops) {
2107
2137
  * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
2108
2138
  * are redacted.
2109
2139
  */
2110
- function mapPeerRow(store, row, showDiag, recentOpsByPeer) {
2140
+ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer) {
2111
2141
  const peerId = String(row.peer_device_id ?? "");
2112
2142
  const recentOps = recentOpsByPeer?.get(peerId) ?? {
2113
2143
  in: 0,
2114
2144
  out: 0
2115
2145
  };
2146
+ const scopeRejections = scopeRejectionsByPeer?.get(peerId);
2116
2147
  return {
2117
2148
  peer_device_id: row.peer_device_id,
2118
2149
  name: row.name,
@@ -2124,6 +2155,7 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer) {
2124
2155
  last_error: showDiag ? row.last_error : null,
2125
2156
  has_error: Boolean(row.last_error),
2126
2157
  claimed_local_actor: Boolean(row.claimed_local_actor),
2158
+ claimed_local_actor_scope: showDiag ? claimedLocalActorScopeStatus(store, row) : null,
2127
2159
  actor_id: row.actor_id ?? null,
2128
2160
  actor_display_name: row.actor_display_name ?? null,
2129
2161
  project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) },
@@ -2131,10 +2163,27 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer) {
2131
2163
  in: recentOps.in,
2132
2164
  out: recentOps.out
2133
2165
  },
2166
+ scope_rejections: {
2167
+ total: scopeRejections?.total ?? 0,
2168
+ by_reason: scopeRejections?.by_reason ?? {},
2169
+ last_at: scopeRejections?.last_at ?? null
2170
+ },
2134
2171
  discovered_via_coordinator_id: typeof row.discovered_via_coordinator_id === "string" ? row.discovered_via_coordinator_id : null,
2135
2172
  discovered_via_group_id: typeof row.discovered_via_group_id === "string" ? row.discovered_via_group_id : null
2136
2173
  };
2137
2174
  }
2175
+ var SYNC_SCOPE_REJECTION_WINDOW_SECONDS = 1440 * 60;
2176
+ function recentScopeRejectionsByPeer(store) {
2177
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - SYNC_SCOPE_REJECTION_WINDOW_SECONDS * 1e3)).toISOString();
2178
+ const summaries = summarizeInboundScopeRejections(store.db, { sinceIso: cutoff });
2179
+ const map = /* @__PURE__ */ new Map();
2180
+ for (const summary of summaries) {
2181
+ const id = String(summary.peer_device_id ?? "").trim();
2182
+ if (!id) continue;
2183
+ map.set(id, summary);
2184
+ }
2185
+ return map;
2186
+ }
2138
2187
  var SYNC_DIRECTION_WINDOW_SECONDS = 1440 * 60;
2139
2188
  function recentPeerOps(store) {
2140
2189
  const cutoff = (/* @__PURE__ */ new Date(Date.now() - SYNC_DIRECTION_WINDOW_SECONDS * 1e3)).toISOString();
@@ -2265,7 +2314,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
2265
2314
  device_id: deviceId,
2266
2315
  protocol_version: SYNC_PROTOCOL_VERSION,
2267
2316
  fingerprint,
2268
- sync_reset: syncReset
2317
+ sync_reset: addSyncScopeToBoundary(syncReset, null),
2318
+ sync_capability: LOCAL_SYNC_CAPABILITY
2269
2319
  });
2270
2320
  } catch {
2271
2321
  return c.json({ error: "internal_error" }, 500);
@@ -2280,6 +2330,9 @@ function syncProtocolRoutes(getStore, opts = {}) {
2280
2330
  if (limited) return limited;
2281
2331
  const peerDeviceId = auth.deviceId;
2282
2332
  try {
2333
+ const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
2334
+ const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0);
2335
+ if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY), 409);
2283
2336
  const since = c.req.query("since") ?? null;
2284
2337
  const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
2285
2338
  const rawGeneration = c.req.query("generation");
@@ -2298,12 +2351,15 @@ function syncProtocolRoutes(getStore, opts = {}) {
2298
2351
  });
2299
2352
  if (result.reset_required) return c.json({
2300
2353
  error: "reset_required",
2301
- ...result.reset
2354
+ sync_capability: LOCAL_SYNC_CAPABILITY,
2355
+ ...addSyncScopeToBoundary(result.reset, scopeRequest.scope_id)
2302
2356
  }, 409);
2303
2357
  const { ops, nextCursor, boundary } = result;
2304
- const filtered = filterOpsForPeer(store, peerDeviceId, ops);
2358
+ const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, ops);
2305
2359
  return c.json({
2306
2360
  reset_required: false,
2361
+ sync_capability: LOCAL_SYNC_CAPABILITY,
2362
+ scope_id: scopeRequest.scope_id,
2307
2363
  generation: boundary.generation,
2308
2364
  snapshot_id: boundary.snapshot_id,
2309
2365
  baseline_cursor: boundary.baseline_cursor,
@@ -2343,6 +2399,9 @@ function syncProtocolRoutes(getStore, opts = {}) {
2343
2399
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2344
2400
  if (limited) return limited;
2345
2401
  try {
2402
+ const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
2403
+ const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0);
2404
+ if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY), 409);
2346
2405
  const rawGeneration = c.req.query("generation");
2347
2406
  const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
2348
2407
  const snapshotId = c.req.query("snapshot_id") ?? null;
@@ -2361,10 +2420,12 @@ function syncProtocolRoutes(getStore, opts = {}) {
2361
2420
  peerDeviceId: auth.deviceId
2362
2421
  });
2363
2422
  return c.json({
2423
+ scope_id: scopeRequest.scope_id,
2364
2424
  generation: result.boundary.generation,
2365
2425
  snapshot_id: result.boundary.snapshot_id,
2366
2426
  baseline_cursor: result.boundary.baseline_cursor,
2367
2427
  retained_floor_cursor: result.boundary.retained_floor_cursor,
2428
+ sync_capability: LOCAL_SYNC_CAPABILITY,
2368
2429
  items: result.items,
2369
2430
  next_page_token: result.nextPageToken,
2370
2431
  has_more: result.hasMore
@@ -2393,6 +2454,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
2393
2454
  } catch {
2394
2455
  return c.json({ error: "invalid_json" }, 400);
2395
2456
  }
2457
+ const scopeRequest = parseSyncScopeRequest(body.scope_id, Object.hasOwn(body, "scope_id"));
2458
+ if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY), 409);
2396
2459
  if (!Array.isArray(body.ops)) return c.json({ error: "invalid_ops" }, 400);
2397
2460
  if (body.ops.length > MAX_SYNC_OPS) return c.json({ error: "too_many_ops" }, 413);
2398
2461
  const normalizedOps = extractReplicationOps(body);
@@ -2402,11 +2465,26 @@ function syncProtocolRoutes(getStore, opts = {}) {
2402
2465
  op_id: op.op_id
2403
2466
  }, 400);
2404
2467
  const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2405
- const filteredInbound = filterOpsForPeer(store, peerDeviceId, normalizedOps);
2406
- const result = applyReplicationOps(store.db, filteredInbound.allowed, localDeviceId, store.scanner);
2468
+ const negotiatedCapability = negotiateSyncCapability(LOCAL_SYNC_CAPABILITY, normalizeSyncCapability(body.sync_capability));
2469
+ const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, normalizedOps, { applyScopeFilter: false });
2470
+ const rejected = rejectInboundScopeFailures(store.db, normalizedOps, localDeviceId, {
2471
+ enabled: negotiatedCapability !== "unsupported",
2472
+ peerDeviceId
2473
+ });
2474
+ if (rejected) return c.json({
2475
+ error: "scope_rejected",
2476
+ reason: rejected.rejections[0]?.reason ?? "scope_mismatch",
2477
+ rejections: rejected.rejections,
2478
+ sync_capability: LOCAL_SYNC_CAPABILITY,
2479
+ scope_id: scopeRequest.scope_id
2480
+ }, 403);
2481
+ const result = applyReplicationOps(store.db, filtered.allowed, localDeviceId, store.scanner);
2482
+ const skipped = result.skipped + filtered.skipped;
2407
2483
  return c.json({
2408
2484
  ...result,
2409
- skipped: result.skipped + filteredInbound.skipped
2485
+ skipped,
2486
+ sync_capability: LOCAL_SYNC_CAPABILITY,
2487
+ scope_id: scopeRequest.scope_id
2410
2488
  });
2411
2489
  });
2412
2490
  return app;
@@ -2509,8 +2587,9 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2509
2587
  }
2510
2588
  const peerRows = traceSync("peerRows", () => store.db.prepare(PEERS_QUERY).all());
2511
2589
  const recentOpsByPeer = traceSync("recentPeerOps", () => recentPeerOps(store));
2590
+ const scopeRejectionsByPeer = traceSync("recentScopeRejectionsByPeer", () => recentScopeRejectionsByPeer(store));
2512
2591
  const peersItems = peerRows.map((row) => {
2513
- const peer = mapPeerRow(store, row, showDiag, recentOpsByPeer);
2592
+ const peer = mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer);
2514
2593
  peer.status = peerStatus(peer);
2515
2594
  return peer;
2516
2595
  });
@@ -2523,7 +2602,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2523
2602
  started_at: schema.syncAttempts.started_at,
2524
2603
  finished_at: schema.syncAttempts.finished_at,
2525
2604
  ops_in: schema.syncAttempts.ops_in,
2526
- ops_out: schema.syncAttempts.ops_out
2605
+ ops_out: schema.syncAttempts.ops_out,
2606
+ local_sync_capability: schema.syncAttempts.local_sync_capability,
2607
+ peer_sync_capability: schema.syncAttempts.peer_sync_capability,
2608
+ negotiated_sync_capability: schema.syncAttempts.negotiated_sync_capability
2527
2609
  }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(25).all());
2528
2610
  const peerAddressMap = /* @__PURE__ */ new Map();
2529
2611
  traceSync("peerAddressRows", () => store.db.prepare("SELECT peer_device_id, addresses_json FROM sync_peers").all()).forEach((peerRow) => {
@@ -2597,13 +2679,43 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2597
2679
  const showDiag = queryBool(c.req.query("includeDiagnostics"));
2598
2680
  const rows = store.db.prepare(PEERS_QUERY).all();
2599
2681
  const recentOpsByPeer = recentPeerOps(store);
2600
- const peers = rows.map((row) => mapPeerRow(store, row, showDiag, recentOpsByPeer));
2682
+ const scopeRejectionsByPeer = recentScopeRejectionsByPeer(store);
2683
+ const peers = rows.map((row) => mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer));
2601
2684
  return c.json({
2602
2685
  items: peers,
2603
2686
  redacted: !showDiag
2604
2687
  });
2605
2688
  }
2606
2689
  });
2690
+ app.get("/api/sync/peers/:peer_device_id/scope-rejections", (c) => {
2691
+ const store = getStore();
2692
+ const peerDeviceId = String(c.req.param("peer_device_id") || "").trim();
2693
+ if (!peerDeviceId) return c.json({ error: "missing_peer_device_id" }, 400);
2694
+ const limit = queryInt(c.req.query("limit"), 100);
2695
+ if (limit <= 0) return c.json({ error: "invalid_limit" }, 400);
2696
+ const sinceMinutesRaw = queryInt(c.req.query("sinceMinutes"), 1440);
2697
+ const sinceMinutes = Math.min(Math.max(sinceMinutesRaw, 0), 10 * 365 * 24 * 60);
2698
+ const sinceIso = sinceMinutes > 0 ? (/* @__PURE__ */ new Date(Date.now() - sinceMinutes * 6e4)).toISOString() : void 0;
2699
+ const records = listInboundScopeRejections(store.db, {
2700
+ peerDeviceId,
2701
+ sinceIso,
2702
+ limit
2703
+ });
2704
+ const summary = summarizeInboundScopeRejections(store.db, {
2705
+ peerDeviceId,
2706
+ sinceIso
2707
+ })[0] ?? {
2708
+ peer_device_id: peerDeviceId,
2709
+ total: 0,
2710
+ by_reason: {},
2711
+ last_at: null
2712
+ };
2713
+ return c.json({
2714
+ peer_device_id: peerDeviceId,
2715
+ summary,
2716
+ items: records
2717
+ });
2718
+ });
2607
2719
  app.get("/api/sync/actors", (c) => {
2608
2720
  const store = getStore();
2609
2721
  {
@@ -2629,7 +2741,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2629
2741
  started_at: schema.syncAttempts.started_at,
2630
2742
  finished_at: schema.syncAttempts.finished_at,
2631
2743
  ops_in: schema.syncAttempts.ops_in,
2632
- ops_out: schema.syncAttempts.ops_out
2744
+ ops_out: schema.syncAttempts.ops_out,
2745
+ local_sync_capability: schema.syncAttempts.local_sync_capability,
2746
+ peer_sync_capability: schema.syncAttempts.peer_sync_capability,
2747
+ negotiated_sync_capability: schema.syncAttempts.negotiated_sync_capability
2633
2748
  }).from(schema.syncAttempts).orderBy(desc(schema.syncAttempts.finished_at)).limit(limit).all();
2634
2749
  return c.json({ items: rows });
2635
2750
  }
@@ -3052,7 +3167,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3052
3167
  return c.json({
3053
3168
  error: message,
3054
3169
  status
3055
- }, 502);
3170
+ }, coordinatorAdminMutationStatus(message));
3056
3171
  }
3057
3172
  });
3058
3173
  app.post("/api/coordinator/admin/groups", async (c) => {
@@ -3229,6 +3344,304 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3229
3344
  }, 400);
3230
3345
  }
3231
3346
  });
3347
+ app.get("/api/coordinator/admin/groups/:group_id/scopes", async (c) => {
3348
+ const config = readCoordinatorSyncConfig();
3349
+ const status = coordinatorAdminStatusPayload(config);
3350
+ const unavailable = coordinatorAdminUnavailable(status);
3351
+ if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
3352
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3353
+ if (!groupId) return c.json({
3354
+ error: "group_id required",
3355
+ status
3356
+ }, 400);
3357
+ try {
3358
+ const items = await coordinatorListScopesAction({
3359
+ groupId,
3360
+ includeInactive: queryBool(c.req.query("include_inactive")),
3361
+ remoteUrl: config.syncCoordinatorUrl || null,
3362
+ adminSecret: config.syncCoordinatorAdminSecret || null
3363
+ });
3364
+ return c.json({
3365
+ items,
3366
+ group_id: groupId,
3367
+ status
3368
+ });
3369
+ } catch (error) {
3370
+ const message = error instanceof Error ? error.message : "unknown";
3371
+ return c.json({
3372
+ error: message,
3373
+ status
3374
+ }, coordinatorAdminMutationStatus(message));
3375
+ }
3376
+ });
3377
+ app.post("/api/coordinator/admin/groups/:group_id/scopes", async (c) => {
3378
+ const config = readCoordinatorSyncConfig();
3379
+ const status = coordinatorAdminStatusPayload(config);
3380
+ const unavailable = coordinatorAdminUnavailable(status);
3381
+ if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
3382
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3383
+ if (!groupId) return c.json({
3384
+ error: "group_id required",
3385
+ status
3386
+ }, 400);
3387
+ const body = await parseViewerJsonBody(c);
3388
+ if (!body) return c.json({
3389
+ error: "invalid json",
3390
+ status
3391
+ }, 400);
3392
+ const scopeId = optionalViewerString(body, "scope_id");
3393
+ const label = optionalViewerString(body, "label");
3394
+ const membershipEpoch = optionalViewerNumber(body, "membership_epoch");
3395
+ if (!scopeId || !label) return c.json({
3396
+ error: "scope_id and label required",
3397
+ status
3398
+ }, 400);
3399
+ if (Number.isNaN(membershipEpoch)) return c.json({
3400
+ error: "membership_epoch must be number",
3401
+ status
3402
+ }, 400);
3403
+ try {
3404
+ const scope = await coordinatorCreateScopeAction({
3405
+ groupId,
3406
+ scopeId,
3407
+ label,
3408
+ kind: optionalViewerString(body, "kind"),
3409
+ authorityType: optionalViewerString(body, "authority_type"),
3410
+ coordinatorId: optionalViewerString(body, "coordinator_id"),
3411
+ manifestIssuerDeviceId: optionalViewerString(body, "manifest_issuer_device_id"),
3412
+ membershipEpoch,
3413
+ manifestHash: optionalViewerString(body, "manifest_hash"),
3414
+ status: optionalViewerString(body, "status"),
3415
+ remoteUrl: config.syncCoordinatorUrl || null,
3416
+ adminSecret: config.syncCoordinatorAdminSecret || null
3417
+ });
3418
+ return c.json({
3419
+ ok: true,
3420
+ scope,
3421
+ group_id: groupId,
3422
+ status
3423
+ });
3424
+ } catch (error) {
3425
+ const message = error instanceof Error ? error.message : String(error);
3426
+ return c.json({
3427
+ error: message,
3428
+ status
3429
+ }, coordinatorAdminMutationStatus(message));
3430
+ }
3431
+ });
3432
+ app.patch("/api/coordinator/admin/groups/:group_id/scopes/:scope_id", async (c) => {
3433
+ const config = readCoordinatorSyncConfig();
3434
+ const status = coordinatorAdminStatusPayload(config);
3435
+ const unavailable = coordinatorAdminUnavailable(status);
3436
+ if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
3437
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3438
+ const scopeId = String(c.req.param("scope_id") ?? "").trim();
3439
+ if (!groupId) return c.json({
3440
+ error: "group_id required",
3441
+ status
3442
+ }, 400);
3443
+ if (!scopeId) return c.json({
3444
+ error: "scope_id required",
3445
+ status
3446
+ }, 400);
3447
+ const body = await parseViewerJsonBody(c);
3448
+ if (!body) return c.json({
3449
+ error: "invalid json",
3450
+ status
3451
+ }, 400);
3452
+ const membershipEpoch = optionalViewerNumber(body, "membership_epoch");
3453
+ if (Number.isNaN(membershipEpoch)) return c.json({
3454
+ error: "membership_epoch must be number",
3455
+ status
3456
+ }, 400);
3457
+ try {
3458
+ const scope = await coordinatorUpdateScopeAction({
3459
+ groupId,
3460
+ scopeId,
3461
+ label: body.label === void 0 ? void 0 : optionalViewerString(body, "label"),
3462
+ kind: body.kind === void 0 ? void 0 : optionalViewerString(body, "kind"),
3463
+ authorityType: body.authority_type === void 0 ? void 0 : optionalViewerString(body, "authority_type"),
3464
+ coordinatorId: body.coordinator_id === void 0 ? void 0 : optionalViewerString(body, "coordinator_id"),
3465
+ manifestIssuerDeviceId: body.manifest_issuer_device_id === void 0 ? void 0 : optionalViewerString(body, "manifest_issuer_device_id"),
3466
+ membershipEpoch,
3467
+ manifestHash: body.manifest_hash === void 0 ? void 0 : optionalViewerString(body, "manifest_hash"),
3468
+ status: body.status === void 0 ? void 0 : optionalViewerString(body, "status"),
3469
+ remoteUrl: config.syncCoordinatorUrl || null,
3470
+ adminSecret: config.syncCoordinatorAdminSecret || null
3471
+ });
3472
+ if (!scope) return c.json({
3473
+ error: "scope_not_found",
3474
+ status
3475
+ }, 404);
3476
+ return c.json({
3477
+ ok: true,
3478
+ scope,
3479
+ group_id: groupId,
3480
+ status
3481
+ });
3482
+ } catch (error) {
3483
+ const message = error instanceof Error ? error.message : String(error);
3484
+ return c.json({
3485
+ error: message,
3486
+ status
3487
+ }, coordinatorAdminMutationStatus(message));
3488
+ }
3489
+ });
3490
+ app.get("/api/coordinator/admin/groups/:group_id/scopes/:scope_id/members", async (c) => {
3491
+ const config = readCoordinatorSyncConfig();
3492
+ const status = coordinatorAdminStatusPayload(config);
3493
+ const unavailable = coordinatorAdminUnavailable(status);
3494
+ if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
3495
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3496
+ const scopeId = String(c.req.param("scope_id") ?? "").trim();
3497
+ if (!groupId) return c.json({
3498
+ error: "group_id required",
3499
+ status
3500
+ }, 400);
3501
+ if (!scopeId) return c.json({
3502
+ error: "scope_id required",
3503
+ status
3504
+ }, 400);
3505
+ try {
3506
+ const items = await coordinatorListScopeMembershipsAction({
3507
+ groupId,
3508
+ scopeId,
3509
+ includeRevoked: queryBool(c.req.query("include_revoked")),
3510
+ remoteUrl: config.syncCoordinatorUrl || null,
3511
+ adminSecret: config.syncCoordinatorAdminSecret || null
3512
+ });
3513
+ return c.json({
3514
+ items,
3515
+ group_id: groupId,
3516
+ scope_id: scopeId,
3517
+ status
3518
+ });
3519
+ } catch (error) {
3520
+ const message = error instanceof Error ? error.message : "unknown";
3521
+ return c.json({
3522
+ error: message,
3523
+ status
3524
+ }, coordinatorAdminMutationStatus(message));
3525
+ }
3526
+ });
3527
+ app.post("/api/coordinator/admin/groups/:group_id/scopes/:scope_id/members", async (c) => {
3528
+ const config = readCoordinatorSyncConfig();
3529
+ const status = coordinatorAdminStatusPayload(config);
3530
+ const unavailable = coordinatorAdminUnavailable(status);
3531
+ if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
3532
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3533
+ const scopeId = String(c.req.param("scope_id") ?? "").trim();
3534
+ if (!groupId) return c.json({
3535
+ error: "group_id required",
3536
+ status
3537
+ }, 400);
3538
+ if (!scopeId) return c.json({
3539
+ error: "scope_id required",
3540
+ status
3541
+ }, 400);
3542
+ const body = await parseViewerJsonBody(c);
3543
+ if (!body) return c.json({
3544
+ error: "invalid json",
3545
+ status
3546
+ }, 400);
3547
+ const deviceId = optionalViewerString(body, "device_id");
3548
+ const membershipEpoch = optionalViewerNumber(body, "membership_epoch");
3549
+ if (!deviceId) return c.json({
3550
+ error: "device_id required",
3551
+ status
3552
+ }, 400);
3553
+ if (Number.isNaN(membershipEpoch)) return c.json({
3554
+ error: "membership_epoch must be number",
3555
+ status
3556
+ }, 400);
3557
+ try {
3558
+ const membership = await coordinatorGrantScopeMembershipAction({
3559
+ groupId,
3560
+ scopeId,
3561
+ deviceId,
3562
+ role: optionalViewerString(body, "role"),
3563
+ membershipEpoch,
3564
+ coordinatorId: optionalViewerString(body, "coordinator_id"),
3565
+ manifestIssuerDeviceId: optionalViewerString(body, "manifest_issuer_device_id"),
3566
+ manifestHash: optionalViewerString(body, "manifest_hash"),
3567
+ signedManifestJson: optionalViewerString(body, "signed_manifest_json"),
3568
+ remoteUrl: config.syncCoordinatorUrl || null,
3569
+ adminSecret: config.syncCoordinatorAdminSecret || null
3570
+ });
3571
+ return c.json({
3572
+ ok: true,
3573
+ membership,
3574
+ group_id: groupId,
3575
+ scope_id: scopeId,
3576
+ status
3577
+ });
3578
+ } catch (error) {
3579
+ const message = error instanceof Error ? error.message : String(error);
3580
+ return c.json({
3581
+ error: message,
3582
+ status
3583
+ }, coordinatorAdminMutationStatus(message));
3584
+ }
3585
+ });
3586
+ app.post("/api/coordinator/admin/groups/:group_id/scopes/:scope_id/members/:device_id/revoke", async (c) => {
3587
+ const config = readCoordinatorSyncConfig();
3588
+ const status = coordinatorAdminStatusPayload(config);
3589
+ const unavailable = coordinatorAdminUnavailable(status);
3590
+ if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
3591
+ const groupId = String(c.req.param("group_id") ?? "").trim();
3592
+ const scopeId = String(c.req.param("scope_id") ?? "").trim();
3593
+ const deviceId = String(c.req.param("device_id") ?? "").trim();
3594
+ if (!groupId) return c.json({
3595
+ error: "group_id required",
3596
+ status
3597
+ }, 400);
3598
+ if (!scopeId) return c.json({
3599
+ error: "scope_id required",
3600
+ status
3601
+ }, 400);
3602
+ if (!deviceId) return c.json({
3603
+ error: "device_id required",
3604
+ status
3605
+ }, 400);
3606
+ const body = await parseViewerJsonBody(c, { allowEmpty: true });
3607
+ if (!body) return c.json({
3608
+ error: "invalid json",
3609
+ status
3610
+ }, 400);
3611
+ const membershipEpoch = optionalViewerNumber(body, "membership_epoch");
3612
+ if (Number.isNaN(membershipEpoch)) return c.json({
3613
+ error: "membership_epoch must be number",
3614
+ status
3615
+ }, 400);
3616
+ try {
3617
+ if (!await coordinatorRevokeScopeMembershipAction({
3618
+ groupId,
3619
+ scopeId,
3620
+ deviceId,
3621
+ membershipEpoch,
3622
+ manifestHash: optionalViewerString(body, "manifest_hash"),
3623
+ signedManifestJson: optionalViewerString(body, "signed_manifest_json"),
3624
+ remoteUrl: config.syncCoordinatorUrl || null,
3625
+ adminSecret: config.syncCoordinatorAdminSecret || null
3626
+ })) return c.json({
3627
+ error: "membership_not_found",
3628
+ status
3629
+ }, 404);
3630
+ return c.json({
3631
+ ok: true,
3632
+ group_id: groupId,
3633
+ scope_id: scopeId,
3634
+ device_id: deviceId,
3635
+ status
3636
+ });
3637
+ } catch (error) {
3638
+ const message = error instanceof Error ? error.message : String(error);
3639
+ return c.json({
3640
+ error: message,
3641
+ status
3642
+ }, coordinatorAdminMutationStatus(message));
3643
+ }
3644
+ });
3232
3645
  app.post("/api/coordinator/admin/invites", async (c) => {
3233
3646
  const config = readCoordinatorSyncConfig();
3234
3647
  const status = coordinatorAdminStatusPayload(config);
@@ -3322,7 +3735,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3322
3735
  return c.json({
3323
3736
  error: message,
3324
3737
  status
3325
- }, 502);
3738
+ }, coordinatorAdminMutationStatus(message));
3326
3739
  }
3327
3740
  });
3328
3741
  app.post("/api/coordinator/admin/join-requests/:request_id/approve", async (c) => {
@@ -3440,7 +3853,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
3440
3853
  return c.json({
3441
3854
  error: message,
3442
3855
  status
3443
- }, 502);
3856
+ }, coordinatorAdminMutationStatus(message));
3444
3857
  }
3445
3858
  });
3446
3859
  app.post("/api/coordinator/admin/devices/:device_id/rename", async (c) => {