@codemem/server 0.30.0-alpha.2 → 0.30.0-alpha.4

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,12 +1,12 @@
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, 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";
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, 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, deleteProjectScopeSettingsMapping, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listProjectScopeCandidates, listProjectScopeSettingsMappings, listSharingDomainSettingsScopes, 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, 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";
7
7
  import { RateLimiter } from "limiter";
8
8
  import { homedir, networkInterfaces } from "node:os";
9
- import { and, count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
9
+ import { and, count, desc, eq, inArray, max, ne } from "drizzle-orm";
10
10
  import { drizzle } from "drizzle-orm/better-sqlite3";
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  //#region src/middleware.ts
@@ -641,14 +641,65 @@ function normalizeScope(raw) {
641
641
  const value = String(raw ?? "").trim().toLowerCase();
642
642
  if (value === "mine" || value === "theirs") return value;
643
643
  }
644
+ function buildViewerMemoryFilters(store, filters) {
645
+ return buildFilterClausesWithContext(filters, {
646
+ actorId: store.actorId,
647
+ deviceId: store.deviceId,
648
+ enforceScopeVisibility: true
649
+ });
650
+ }
651
+ function countVisibleMemoryRows(store, filters) {
652
+ const filterResult = buildViewerMemoryFilters(store, filters);
653
+ const clauses = ["memory_items.active = 1", ...filterResult.clauses];
654
+ const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
655
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`).get(...filterResult.params);
656
+ return Number(row?.total ?? 0);
657
+ }
658
+ function sessionAllowsArtifactAccess(store, sessionId) {
659
+ const visibleCount = countVisibleMemoryRows(store, { session_id: sessionId });
660
+ if (visibleCount === 0) return false;
661
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM memory_items
662
+ WHERE session_id = ? AND active = 1`).get(sessionId);
663
+ return visibleCount === Number(row?.total ?? 0);
664
+ }
665
+ function countVisiblePromptRows(store, project) {
666
+ const filterResult = buildViewerMemoryFilters(store, null);
667
+ const clauses = ["user_prompts.session_id IS NOT NULL", `EXISTS (
668
+ SELECT 1 FROM memory_items
669
+ WHERE memory_items.session_id = user_prompts.session_id
670
+ AND memory_items.active = 1
671
+ AND ${filterResult.clauses.join(" AND ")}
672
+ )`];
673
+ const params = [...filterResult.params];
674
+ if (project) {
675
+ clauses.unshift("user_prompts.project = ?");
676
+ params.unshift(project);
677
+ }
678
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM user_prompts WHERE ${clauses.join(" AND ")}`).get(...params);
679
+ return Number(row?.total ?? 0);
680
+ }
681
+ function countVisibleArtifactRows(store, project) {
682
+ const filterResult = buildViewerMemoryFilters(store, null);
683
+ const clauses = [`EXISTS (
684
+ SELECT 1 FROM memory_items
685
+ WHERE memory_items.session_id = artifacts.session_id
686
+ AND memory_items.active = 1
687
+ AND ${filterResult.clauses.join(" AND ")}
688
+ )`];
689
+ const params = [...filterResult.params];
690
+ const from = project ? "artifacts JOIN sessions ON sessions.id = artifacts.session_id" : "artifacts";
691
+ if (project) {
692
+ clauses.unshift("sessions.project = ?");
693
+ params.unshift(project);
694
+ }
695
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`).get(...params);
696
+ return Number(row?.total ?? 0);
697
+ }
644
698
  function queryMemoryPage(store, options) {
645
699
  const filters = {};
646
700
  if (options.project) filters.project = options.project;
647
701
  if (options.scope) filters.ownership_scope = options.scope;
648
- const filterResult = buildFilterClausesWithContext(filters, {
649
- actorId: store.actorId,
650
- deviceId: store.deviceId
651
- });
702
+ const filterResult = buildViewerMemoryFilters(store, filters);
652
703
  const where = ["memory_items.active = 1", ...filterResult.clauses].join(" AND ");
653
704
  const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
654
705
  const rows = store.db.prepare(`SELECT memory_items.* FROM ${from}
@@ -688,9 +739,18 @@ function memoryRoutes(getStore) {
688
739
  const store = getStore();
689
740
  {
690
741
  const limit = queryInt(c.req.query("limit"), 20);
691
- const items = drizzle(store.db, { schema }).select().from(schema.sessions).orderBy(desc(schema.sessions.started_at)).limit(limit).all().map((row) => ({
742
+ const filterResult = buildViewerMemoryFilters(store, null);
743
+ const clauses = [
744
+ "memory_items.session_id = sessions.id",
745
+ "memory_items.active = 1",
746
+ ...filterResult.clauses
747
+ ];
748
+ const items = store.db.prepare(`SELECT sessions.* FROM sessions
749
+ WHERE EXISTS (SELECT 1 FROM memory_items WHERE ${clauses.join(" AND ")})
750
+ ORDER BY sessions.started_at DESC
751
+ LIMIT ?`).all(...filterResult.params, limit).map((row) => ({
692
752
  ...row,
693
- metadata_json: fromJson(row.metadata_json)
753
+ metadata_json: fromJson(row.metadata_json ?? null)
694
754
  }));
695
755
  return c.json({ items });
696
756
  }
@@ -698,7 +758,16 @@ function memoryRoutes(getStore) {
698
758
  app.get("/api/projects", (c) => {
699
759
  const store = getStore();
700
760
  {
701
- const rows = drizzle(store.db, { schema }).selectDistinct({ project: schema.sessions.project }).from(schema.sessions).where(isNotNull(schema.sessions.project)).all();
761
+ const filterResult = buildViewerMemoryFilters(store, null);
762
+ const clauses = [
763
+ "memory_items.session_id = sessions.id",
764
+ "memory_items.active = 1",
765
+ "sessions.project IS NOT NULL",
766
+ ...filterResult.clauses
767
+ ];
768
+ const rows = store.db.prepare(`SELECT DISTINCT sessions.project AS project FROM sessions
769
+ JOIN memory_items ON memory_items.session_id = sessions.id
770
+ WHERE ${clauses.join(" AND ")}`).all(...filterResult.params);
702
771
  const projects = [...new Set(rows.map((r) => String(r.project ?? "").trim()).filter((p) => p && !p.toLowerCase().startsWith("fatal:")).map((p) => projectBasename(p)).filter(Boolean))].sort();
703
772
  return c.json({ projects });
704
773
  }
@@ -765,10 +834,6 @@ function memoryRoutes(getStore) {
765
834
  const store = getStore();
766
835
  {
767
836
  const project = c.req.query("project") || null;
768
- const count = (sql, ...params) => {
769
- const row = store.db.prepare(sql).get(...params);
770
- return Number(row?.total ?? 0);
771
- };
772
837
  let prompts;
773
838
  let artifacts;
774
839
  let memories;
@@ -790,18 +855,14 @@ function memoryRoutes(getStore) {
790
855
  return total;
791
856
  };
792
857
  if (project) {
793
- prompts = count("SELECT COUNT(*) AS total FROM user_prompts WHERE project = ?", project);
794
- artifacts = count(`SELECT COUNT(*) AS total FROM artifacts
795
- JOIN sessions ON sessions.id = artifacts.session_id
796
- WHERE sessions.project = ?`, project);
797
- memories = count(`SELECT COUNT(*) AS total FROM memory_items
798
- JOIN sessions ON sessions.id = memory_items.session_id
799
- WHERE sessions.project = ?`, project);
858
+ prompts = countVisiblePromptRows(store, project);
859
+ artifacts = countVisibleArtifactRows(store, project);
860
+ memories = countVisibleMemoryRows(store, { project });
800
861
  observations = countObservations(project);
801
862
  } else {
802
- prompts = count("SELECT COUNT(*) AS total FROM user_prompts");
803
- artifacts = count("SELECT COUNT(*) AS total FROM artifacts");
804
- memories = count("SELECT COUNT(*) AS total FROM memory_items");
863
+ prompts = countVisiblePromptRows(store);
864
+ artifacts = countVisibleArtifactRows(store);
865
+ memories = countVisibleMemoryRows(store);
805
866
  observations = countObservations();
806
867
  }
807
868
  const total = prompts + artifacts + memories;
@@ -878,6 +939,7 @@ function memoryRoutes(getStore) {
878
939
  if (!sessionIdStr) return c.json({ error: "session_id required" }, 400);
879
940
  const sessionId = parseStrictInteger(sessionIdStr);
880
941
  if (sessionId == null) return c.json({ error: "session_id must be int" }, 400);
942
+ if (!sessionAllowsArtifactAccess(store, sessionId)) return c.json({ error: "session not found" }, 404);
881
943
  const rows = drizzle(store.db, { schema }).select().from(schema.artifacts).where(eq(schema.artifacts.session_id, sessionId)).all();
882
944
  return c.json({ items: rows });
883
945
  }
@@ -892,6 +954,7 @@ function memoryRoutes(getStore) {
892
954
  }
893
955
  const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
894
956
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
957
+ if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
895
958
  const visibility = String(body.visibility ?? "").trim();
896
959
  if (visibility !== "private" && visibility !== "shared") return c.json({ error: "visibility must be private or shared" }, 400);
897
960
  try {
@@ -916,6 +979,7 @@ function memoryRoutes(getStore) {
916
979
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
917
980
  const project = String(body.project ?? "").trim();
918
981
  if (!project) return c.json({ error: "project must be a non-empty string" }, 400);
982
+ if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
919
983
  try {
920
984
  const result = store.moveMemoryProject(memoryId, project);
921
985
  return c.json(result);
@@ -936,6 +1000,7 @@ function memoryRoutes(getStore) {
936
1000
  }
937
1001
  const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
938
1002
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
1003
+ if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
939
1004
  const row = drizzle(store.db, { schema }).select().from(schema.memoryItems).where(eq(schema.memoryItems.id, memoryId)).get();
940
1005
  if (!row) return c.json({ error: "memory not found" }, 404);
941
1006
  if (!memoryOwnedBySelf(row, buildOwnershipContext(store))) return c.json({ error: "memory not owned by this device" }, 403);
@@ -1330,6 +1395,131 @@ function sortActiveMaintenanceJobs$1(jobs) {
1330
1395
  return aKind.localeCompare(bKind);
1331
1396
  });
1332
1397
  }
1398
+ var MEMORY_ID_METADATA_KEYS = [
1399
+ "pack_item_ids",
1400
+ "added_ids",
1401
+ "removed_ids",
1402
+ "retained_ids"
1403
+ ];
1404
+ function toUsageRow(row, metadata) {
1405
+ return {
1406
+ id: Number(row.id ?? 0),
1407
+ session_id: row.session_id == null ? null : Number(row.session_id),
1408
+ event: String(row.event ?? "pack"),
1409
+ tokens_read: Number(row.tokens_read ?? 0),
1410
+ tokens_written: Number(row.tokens_written ?? 0),
1411
+ tokens_saved: Number(row.tokens_saved ?? 0),
1412
+ created_at: String(row.created_at ?? ""),
1413
+ metadata_json: metadata
1414
+ };
1415
+ }
1416
+ function metadataMemoryIds(value) {
1417
+ if (!Array.isArray(value)) return [];
1418
+ return value.map((item) => typeof item === "number" ? item : Number(item)).filter((item) => Number.isInteger(item) && item > 0);
1419
+ }
1420
+ function chunkNumbers(values, chunkSize = 500) {
1421
+ const chunks = [];
1422
+ for (let start = 0; start < values.length; start += chunkSize) chunks.push(values.slice(start, start + chunkSize));
1423
+ return chunks;
1424
+ }
1425
+ function visibleMemoryIdSet(store, memoryIds) {
1426
+ if (memoryIds.size === 0) return /* @__PURE__ */ new Set();
1427
+ const filterResult = buildFilterClausesWithContext(null, {
1428
+ actorId: store.actorId,
1429
+ deviceId: store.deviceId,
1430
+ enforceScopeVisibility: true
1431
+ });
1432
+ const visibleIds = /* @__PURE__ */ new Set();
1433
+ for (const chunk of chunkNumbers([...memoryIds])) {
1434
+ const clauses = [
1435
+ "memory_items.active = 1",
1436
+ `memory_items.id IN (${chunk.map(() => "?").join(", ")})`,
1437
+ ...filterResult.clauses
1438
+ ];
1439
+ const rows = store.db.prepare(`SELECT memory_items.id FROM memory_items WHERE ${clauses.join(" AND ")}`).all(...chunk, ...filterResult.params);
1440
+ for (const row of rows) visibleIds.add(Number(row.id));
1441
+ }
1442
+ return visibleIds;
1443
+ }
1444
+ function visibleSessionIdSet(store, sessionIds) {
1445
+ if (sessionIds.size === 0) return /* @__PURE__ */ new Set();
1446
+ const filterResult = buildFilterClausesWithContext(null, {
1447
+ actorId: store.actorId,
1448
+ deviceId: store.deviceId,
1449
+ enforceScopeVisibility: true
1450
+ });
1451
+ const visibleIds = /* @__PURE__ */ new Set();
1452
+ for (const chunk of chunkNumbers([...sessionIds])) {
1453
+ const clauses = [
1454
+ "memory_items.active = 1",
1455
+ `memory_items.session_id IN (${chunk.map(() => "?").join(", ")})`,
1456
+ ...filterResult.clauses
1457
+ ];
1458
+ const rows = store.db.prepare(`SELECT DISTINCT memory_items.session_id AS session_id
1459
+ FROM memory_items
1460
+ WHERE ${clauses.join(" AND ")}`).all(...chunk, ...filterResult.params);
1461
+ for (const row of rows) if (typeof row.session_id === "number") visibleIds.add(row.session_id);
1462
+ }
1463
+ return visibleIds;
1464
+ }
1465
+ function buildUsageVisibility(store, rows) {
1466
+ const memoryIds = /* @__PURE__ */ new Set();
1467
+ const sessionIds = /* @__PURE__ */ new Set();
1468
+ for (const row of rows) {
1469
+ if (typeof row.session_id === "number") sessionIds.add(row.session_id);
1470
+ for (const key of MEMORY_ID_METADATA_KEYS) for (const memoryId of metadataMemoryIds(row.metadata_json?.[key])) memoryIds.add(memoryId);
1471
+ }
1472
+ return {
1473
+ visibleMemoryIds: visibleMemoryIdSet(store, memoryIds),
1474
+ visibleSessionIds: visibleSessionIdSet(store, sessionIds)
1475
+ };
1476
+ }
1477
+ function usageRowVisible(row, visibility) {
1478
+ const rowSessionVisible = row.session_id == null || visibility.visibleSessionIds.has(row.session_id);
1479
+ const packItemIds = row.metadata_json?.pack_item_ids;
1480
+ if (Array.isArray(packItemIds)) {
1481
+ if (packItemIds.length === 0) return row.session_id != null && visibility.visibleSessionIds.has(row.session_id);
1482
+ return rowSessionVisible && metadataMemoryIds(packItemIds).some((memoryId) => visibility.visibleMemoryIds.has(memoryId));
1483
+ }
1484
+ return row.session_id != null && visibility.visibleSessionIds.has(row.session_id);
1485
+ }
1486
+ function sanitizePackUsageMetadata(metadata, visibility) {
1487
+ if (!metadata) return null;
1488
+ const sanitized = { ...metadata };
1489
+ for (const key of MEMORY_ID_METADATA_KEYS) if (Array.isArray(sanitized[key])) sanitized[key] = metadataMemoryIds(sanitized[key]).filter((memoryId) => visibility.visibleMemoryIds.has(memoryId));
1490
+ return sanitized;
1491
+ }
1492
+ function summarizeUsageEvents(rows) {
1493
+ const byEvent = /* @__PURE__ */ new Map();
1494
+ for (const row of rows) {
1495
+ const summary = byEvent.get(row.event) ?? {
1496
+ event: row.event,
1497
+ total_tokens_read: 0,
1498
+ total_tokens_written: 0,
1499
+ total_tokens_saved: 0,
1500
+ count: 0
1501
+ };
1502
+ summary.total_tokens_read += row.tokens_read;
1503
+ summary.total_tokens_written += row.tokens_written;
1504
+ summary.total_tokens_saved += row.tokens_saved;
1505
+ summary.count += 1;
1506
+ byEvent.set(row.event, summary);
1507
+ }
1508
+ return [...byEvent.values()].sort((a, b) => a.event.localeCompare(b.event));
1509
+ }
1510
+ function totalUsageEvents(rows) {
1511
+ return rows.reduce((acc, row) => ({
1512
+ tokens_read: Number(acc.tokens_read) + row.tokens_read,
1513
+ tokens_written: Number(acc.tokens_written) + row.tokens_written,
1514
+ tokens_saved: Number(acc.tokens_saved) + row.tokens_saved,
1515
+ count: Number(acc.count) + 1
1516
+ }), {
1517
+ tokens_read: 0,
1518
+ tokens_written: 0,
1519
+ tokens_saved: 0,
1520
+ count: 0
1521
+ });
1522
+ }
1333
1523
  /**
1334
1524
  * Create stats routes. The store factory is called per-request to get a
1335
1525
  * fresh connection (matching the Python viewer pattern).
@@ -1378,58 +1568,32 @@ function statsRoutes(getStore) {
1378
1568
  const store = getStore();
1379
1569
  {
1380
1570
  const projectFilter = c.req.query("project") || null;
1381
- const recentPacksQuery = projectFilter ? store.db.prepare(`SELECT usage_events.id, usage_events.session_id, usage_events.event,
1382
- usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved,
1383
- usage_events.created_at, usage_events.metadata_json
1384
- FROM usage_events
1385
- JOIN sessions ON sessions.id = usage_events.session_id
1386
- WHERE usage_events.event = 'pack' AND sessions.project = ?
1387
- ORDER BY usage_events.created_at DESC
1388
- LIMIT 10`) : store.db.prepare(`SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved, created_at, metadata_json
1389
- FROM usage_events
1390
- WHERE event = 'pack'
1391
- ORDER BY created_at DESC
1392
- LIMIT 10`);
1393
- const eventsGlobal = store.db.prepare(`SELECT event,
1394
- SUM(tokens_read) AS total_tokens_read,
1395
- SUM(tokens_written) AS total_tokens_written,
1396
- SUM(tokens_saved) AS total_tokens_saved,
1397
- COUNT(*) AS count
1398
- FROM usage_events GROUP BY event ORDER BY event`).all();
1399
- const totalsGlobal = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
1400
- COALESCE(SUM(tokens_written),0) AS tokens_written,
1401
- COALESCE(SUM(tokens_saved),0) AS tokens_saved,
1402
- COUNT(*) AS count
1403
- FROM usage_events`).get();
1404
- let eventsFiltered = null;
1405
- let totalsFiltered = null;
1406
- if (projectFilter) {
1407
- eventsFiltered = store.db.prepare(`SELECT event,
1408
- SUM(tokens_read) AS total_tokens_read,
1409
- SUM(tokens_written) AS total_tokens_written,
1410
- SUM(tokens_saved) AS total_tokens_saved,
1411
- COUNT(*) AS count
1412
- FROM usage_events
1413
- JOIN sessions ON sessions.id = usage_events.session_id
1414
- WHERE sessions.project = ?
1415
- GROUP BY event ORDER BY event`).all(projectFilter);
1416
- totalsFiltered = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
1417
- COALESCE(SUM(tokens_written),0) AS tokens_written,
1418
- COALESCE(SUM(tokens_saved),0) AS tokens_saved,
1419
- COUNT(*) AS count
1420
- FROM usage_events
1421
- JOIN sessions ON sessions.id = usage_events.session_id
1422
- WHERE sessions.project = ?`).get(projectFilter);
1423
- }
1424
- const recentPacks = (projectFilter ? recentPacksQuery.all(projectFilter) : recentPacksQuery.all()).map((row) => ({
1425
- id: Number(row.id ?? 0),
1426
- session_id: row.session_id == null ? null : Number(row.session_id),
1427
- event: String(row.event ?? "pack"),
1428
- tokens_read: Number(row.tokens_read ?? 0),
1429
- tokens_written: Number(row.tokens_written ?? 0),
1430
- tokens_saved: Number(row.tokens_saved ?? 0),
1431
- created_at: String(row.created_at ?? ""),
1432
- metadata_json: parseMetadataJson(row.metadata_json)
1571
+ const loadUsageRows = (project) => {
1572
+ return (project ? store.db.prepare(`SELECT usage_events.id, usage_events.session_id, usage_events.event,
1573
+ usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved,
1574
+ usage_events.created_at, usage_events.metadata_json
1575
+ FROM usage_events
1576
+ JOIN sessions ON sessions.id = usage_events.session_id
1577
+ WHERE sessions.project = ?
1578
+ ORDER BY usage_events.created_at DESC`).all(project) : store.db.prepare(`SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved,
1579
+ created_at, metadata_json
1580
+ FROM usage_events
1581
+ ORDER BY created_at DESC`).all()).map((row) => toUsageRow(row, parseMetadataJson(row.metadata_json)));
1582
+ };
1583
+ const loadedGlobalRows = loadUsageRows();
1584
+ const globalVisibility = buildUsageVisibility(store, loadedGlobalRows);
1585
+ const globalRows = loadedGlobalRows.filter((row) => usageRowVisible(row, globalVisibility));
1586
+ const loadedFilteredRows = projectFilter ? loadUsageRows(projectFilter) : null;
1587
+ const filteredVisibility = loadedFilteredRows ? buildUsageVisibility(store, loadedFilteredRows) : null;
1588
+ const filteredRows = loadedFilteredRows ? loadedFilteredRows.filter((row) => usageRowVisible(row, filteredVisibility ?? globalVisibility)) : null;
1589
+ const eventsGlobal = summarizeUsageEvents(globalRows);
1590
+ const totalsGlobal = totalUsageEvents(globalRows);
1591
+ const eventsFiltered = filteredRows ? summarizeUsageEvents(filteredRows) : null;
1592
+ const totalsFiltered = filteredRows ? totalUsageEvents(filteredRows) : null;
1593
+ const recentVisibility = filteredVisibility ?? globalVisibility;
1594
+ const recentPacks = (filteredRows ?? globalRows).filter((row) => row.event === "pack").slice(0, 10).map((row) => ({
1595
+ ...row,
1596
+ metadata_json: sanitizePackUsageMetadata(row.metadata_json, recentVisibility)
1433
1597
  }));
1434
1598
  return c.json({
1435
1599
  project: projectFilter,
@@ -1450,6 +1614,16 @@ function statsRoutes(getStore) {
1450
1614
  /**
1451
1615
  * Sync routes — status, peers, actors, attempts, pairing, mutations.
1452
1616
  */
1617
+ function safeSkippedSyncDetail(detail) {
1618
+ if (!detail) return null;
1619
+ return {
1620
+ reason: detail.reason,
1621
+ skipped_count: detail.skipped_count,
1622
+ scope_id: detail.scope_id ?? null,
1623
+ project: detail.project ?? null,
1624
+ visibility: detail.visibility ?? null
1625
+ };
1626
+ }
1453
1627
  var SYNC_STALE_AFTER_SECONDS = 600;
1454
1628
  var SYNC_PROTOCOL_VERSION = "2";
1455
1629
  var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
@@ -1559,12 +1733,37 @@ function optionalViewerString(body, key) {
1559
1733
  if (value == null) return null;
1560
1734
  return String(value).trim() || null;
1561
1735
  }
1736
+ function optionalViewerStrictString(body, key) {
1737
+ const value = body[key];
1738
+ if (value == null) return null;
1739
+ if (typeof value !== "string") throw new Error(`${key} must be string`);
1740
+ return value.trim() || null;
1741
+ }
1742
+ function optionalViewerStringList(body, key) {
1743
+ const value = body[key];
1744
+ if (value == null) return [];
1745
+ if (!Array.isArray(value)) throw new Error(`${key} must be an array of strings`);
1746
+ return [...new Set(value.map((item) => {
1747
+ if (typeof item !== "string") throw new Error(`${key} must be an array of strings`);
1748
+ return item.trim();
1749
+ }).filter(Boolean))];
1750
+ }
1562
1751
  function optionalViewerNumber(body, key) {
1563
1752
  const value = body[key];
1564
1753
  if (value == null || value === "") return null;
1565
1754
  const number = typeof value === "number" ? value : Number(value);
1566
1755
  return Number.isFinite(number) ? Math.trunc(number) : NaN;
1567
1756
  }
1757
+ function optionalViewerInteger(body, key) {
1758
+ const value = body[key];
1759
+ if (value == null || value === "") return null;
1760
+ const number = typeof value === "number" ? value : Number(value);
1761
+ return Number.isInteger(number) ? number : NaN;
1762
+ }
1763
+ function missingGuardrailConfirmations(warnings, confirmedTokens) {
1764
+ const confirmed = new Set(confirmedTokens);
1765
+ return warnings.filter((warning) => warning.requires_confirmation && (!warning.confirmation_token || !confirmed.has(warning.confirmation_token)));
1766
+ }
1568
1767
  function coordinatorAdminMutationStatus(message) {
1569
1768
  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
1769
  if (message.includes("not active") || message.includes("scope_not_active") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
@@ -1672,6 +1871,43 @@ function unauthorizedPayload(reason, exposeReason = false) {
1672
1871
  };
1673
1872
  return { error: "unauthorized" };
1674
1873
  }
1874
+ var SYNC_AUTH_STORE_BUSY_REASON = "sync_auth_store_busy";
1875
+ function isSqliteBusy(err) {
1876
+ if (!err || typeof err !== "object") return false;
1877
+ const code = "code" in err ? String(err.code ?? "") : "";
1878
+ const message = err instanceof Error ? err.message : "";
1879
+ return code === "SQLITE_BUSY" || message.includes("database is locked");
1880
+ }
1881
+ function recordSyncAuthNonce(store, deviceId, nonce, createdAt) {
1882
+ try {
1883
+ if (!recordNonce(store.db, deviceId, nonce, createdAt)) return {
1884
+ ok: false,
1885
+ reason: "nonce_replay",
1886
+ deviceId
1887
+ };
1888
+ } catch (err) {
1889
+ if (isSqliteBusy(err)) return {
1890
+ ok: false,
1891
+ reason: SYNC_AUTH_STORE_BUSY_REASON,
1892
+ deviceId
1893
+ };
1894
+ throw err;
1895
+ }
1896
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - DEFAULT_TIME_WINDOW_S * 2 * 1e3)).toISOString();
1897
+ try {
1898
+ cleanupNonces(store.db, cutoff);
1899
+ } catch (err) {
1900
+ if (!isSqliteBusy(err)) throw err;
1901
+ }
1902
+ return null;
1903
+ }
1904
+ function isSyncAuthStoreBusy(auth) {
1905
+ return !auth.ok && auth.reason === SYNC_AUTH_STORE_BUSY_REASON;
1906
+ }
1907
+ function syncAuthStoreBusyResponse(c) {
1908
+ c.header("Retry-After", "1");
1909
+ return c.json({ error: SYNC_AUTH_STORE_BUSY_REASON }, 503);
1910
+ }
1675
1911
  function authorizeSyncRequest(store, request, body) {
1676
1912
  const deviceId = (request.header("X-Opencode-Device") ?? "").trim();
1677
1913
  const signature = request.header("X-Opencode-Signature") ?? "";
@@ -1724,14 +1960,8 @@ function authorizeSyncRequest(store, request, body) {
1724
1960
  reason: "invalid_signature",
1725
1961
  deviceId
1726
1962
  };
1727
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1728
- if (!recordNonce(store.db, deviceId, nonce, createdAt)) return {
1729
- ok: false,
1730
- reason: "nonce_replay",
1731
- deviceId
1732
- };
1733
- const cutoff = (/* @__PURE__ */ new Date(Date.now() - DEFAULT_TIME_WINDOW_S * 2 * 1e3)).toISOString();
1734
- cleanupNonces(store.db, cutoff);
1963
+ const nonceResult = recordSyncAuthNonce(store, deviceId, nonce, (/* @__PURE__ */ new Date()).toISOString());
1964
+ if (nonceResult) return nonceResult;
1735
1965
  return {
1736
1966
  ok: true,
1737
1967
  reason: "ok",
@@ -1841,14 +2071,8 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
1841
2071
  reason: "bootstrap_grant_invalid_signature",
1842
2072
  deviceId
1843
2073
  };
1844
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1845
- if (!recordNonce(store.db, deviceId, nonce, createdAt)) return {
1846
- ok: false,
1847
- reason: "nonce_replay",
1848
- deviceId
1849
- };
1850
- const cutoff = (/* @__PURE__ */ new Date(Date.now() - DEFAULT_TIME_WINDOW_S * 2 * 1e3)).toISOString();
1851
- cleanupNonces(store.db, cutoff);
2074
+ const nonceResult = recordSyncAuthNonce(store, deviceId, nonce, (/* @__PURE__ */ new Date()).toISOString());
2075
+ if (nonceResult) return nonceResult;
1852
2076
  return {
1853
2077
  ok: true,
1854
2078
  reason: "ok",
@@ -1892,6 +2116,42 @@ function currentProjectScope(row, effective) {
1892
2116
  inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
1893
2117
  };
1894
2118
  }
2119
+ function cleanPeerScopeText(value) {
2120
+ return String(value ?? "").trim() || null;
2121
+ }
2122
+ function peerAuthorizedScopes(store, peerDeviceId) {
2123
+ const deviceId = peerDeviceId.trim();
2124
+ if (!deviceId) return [];
2125
+ return store.db.prepare(`SELECT
2126
+ rs.scope_id,
2127
+ rs.label,
2128
+ rs.kind,
2129
+ rs.authority_type,
2130
+ rs.coordinator_id,
2131
+ rs.group_id,
2132
+ sm.role,
2133
+ sm.membership_epoch,
2134
+ sm.updated_at
2135
+ FROM scope_memberships sm
2136
+ JOIN replication_scopes rs ON rs.scope_id = sm.scope_id
2137
+ WHERE sm.device_id = ?
2138
+ AND sm.status = 'active'
2139
+ AND rs.status = 'active'
2140
+ AND sm.membership_epoch >= rs.membership_epoch
2141
+ ORDER BY CASE WHEN rs.authority_type = 'local' THEN 1 ELSE 0 END,
2142
+ rs.label COLLATE NOCASE,
2143
+ rs.scope_id`).all(deviceId).map((row) => ({
2144
+ scope_id: String(row.scope_id ?? ""),
2145
+ label: String(row.label ?? row.scope_id ?? ""),
2146
+ kind: String(row.kind ?? "user"),
2147
+ authority_type: String(row.authority_type ?? "local"),
2148
+ coordinator_id: cleanPeerScopeText(row.coordinator_id),
2149
+ group_id: cleanPeerScopeText(row.group_id),
2150
+ role: String(row.role ?? "member"),
2151
+ membership_epoch: Number(row.membership_epoch ?? 0),
2152
+ updated_at: cleanPeerScopeText(row.updated_at)
2153
+ }));
2154
+ }
1895
2155
  function ensureLocalActorRecord(store) {
1896
2156
  const d = drizzle(store.db, { schema });
1897
2157
  if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
@@ -2114,7 +2374,8 @@ function filterOpsForPeer(store, peerDeviceId, localDeviceId, ops, options = {})
2114
2374
  });
2115
2375
  return {
2116
2376
  allowed,
2117
- skipped: skipped?.skipped_count ?? 0
2377
+ skipped: skipped?.skipped_count ?? 0,
2378
+ skippedDetail: skipped ? safeSkippedSyncDetail(skipped) : null
2118
2379
  };
2119
2380
  }
2120
2381
  function claimedLocalActorScopeStatus(store, row) {
@@ -2155,9 +2416,10 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2155
2416
  last_error: showDiag ? row.last_error : null,
2156
2417
  has_error: Boolean(row.last_error),
2157
2418
  claimed_local_actor: Boolean(row.claimed_local_actor),
2158
- claimed_local_actor_scope: showDiag ? claimedLocalActorScopeStatus(store, row) : null,
2419
+ claimed_local_actor_scope: claimedLocalActorScopeStatus(store, row),
2159
2420
  actor_id: row.actor_id ?? null,
2160
2421
  actor_display_name: row.actor_display_name ?? null,
2422
+ authorized_scopes: peerAuthorizedScopes(store, peerId),
2161
2423
  project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) },
2162
2424
  recent_ops: {
2163
2425
  in: recentOps.in,
@@ -2184,6 +2446,21 @@ function recentScopeRejectionsByPeer(store) {
2184
2446
  }
2185
2447
  return map;
2186
2448
  }
2449
+ function legacySharedReviewSummary(store) {
2450
+ const row = store.db.prepare(`SELECT COUNT(*) AS memory_count,
2451
+ MAX(updated_at) AS last_updated_at
2452
+ FROM memory_items
2453
+ WHERE scope_id = ?
2454
+ AND active = 1
2455
+ AND deleted_at IS NULL`).get(LEGACY_SHARED_REVIEW_SCOPE_ID);
2456
+ const memoryCount = Number(row?.memory_count ?? 0);
2457
+ return {
2458
+ scope_id: LEGACY_SHARED_REVIEW_SCOPE_ID,
2459
+ memory_count: memoryCount,
2460
+ has_data: memoryCount > 0,
2461
+ last_updated_at: row?.last_updated_at ?? null
2462
+ };
2463
+ }
2187
2464
  var SYNC_DIRECTION_WINDOW_SECONDS = 1440 * 60;
2188
2465
  function recentPeerOps(store) {
2189
2466
  const cutoff = (/* @__PURE__ */ new Date(Date.now() - SYNC_DIRECTION_WINDOW_SECONDS * 1e3)).toISOString();
@@ -2255,7 +2532,8 @@ var PEERS_QUERY = `
2255
2532
  SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
2256
2533
  p.last_seen_at, p.last_sync_at, p.last_error,
2257
2534
  p.projects_include_json, p.projects_exclude_json, p.claimed_local_actor,
2258
- p.actor_id, a.display_name AS actor_display_name
2535
+ p.actor_id, p.discovered_via_coordinator_id, p.discovered_via_group_id,
2536
+ a.display_name AS actor_display_name
2259
2537
  FROM sync_peers AS p
2260
2538
  LEFT JOIN actors AS a ON a.actor_id = p.actor_id
2261
2539
  ORDER BY name, peer_device_id
@@ -2285,6 +2563,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2285
2563
  const store = getStore();
2286
2564
  return (async () => {
2287
2565
  let auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2566
+ if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2288
2567
  let preauthChecked = false;
2289
2568
  let bootstrapAttempted = false;
2290
2569
  if (!auth.ok) {
@@ -2300,6 +2579,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2300
2579
  auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
2301
2580
  bootstrapAttempted = true;
2302
2581
  }
2582
+ if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2303
2583
  if (!auth.ok) {
2304
2584
  if (bootstrapAttempted) console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant=${(c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()} path=${c.req.path}`);
2305
2585
  const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
@@ -2325,6 +2605,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2325
2605
  app.get("/v1/ops", (c) => {
2326
2606
  const store = getStore();
2327
2607
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2608
+ if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2328
2609
  if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
2329
2610
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2330
2611
  if (limited) return limited;
@@ -2366,7 +2647,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
2366
2647
  retained_floor_cursor: boundary.retained_floor_cursor,
2367
2648
  ops: filtered.allowed,
2368
2649
  next_cursor: nextCursor,
2369
- skipped: filtered.skipped
2650
+ skipped: filtered.skipped,
2651
+ skipped_detail: filtered.skippedDetail
2370
2652
  });
2371
2653
  } catch {
2372
2654
  return c.json({ error: "internal_error" }, 500);
@@ -2376,6 +2658,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2376
2658
  const store = getStore();
2377
2659
  return (async () => {
2378
2660
  let auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2661
+ if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2379
2662
  let preauthChecked = false;
2380
2663
  let bootstrapAttempted = false;
2381
2664
  if (!auth.ok) {
@@ -2391,6 +2674,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2391
2674
  auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
2392
2675
  bootstrapAttempted = true;
2393
2676
  }
2677
+ if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2394
2678
  if (!auth.ok) {
2395
2679
  if (bootstrapAttempted) console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant=${(c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()} path=${c.req.path}`);
2396
2680
  const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
@@ -2442,6 +2726,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2442
2726
  const raw = await readBoundedRequestBytes(c.req.raw, MAX_SYNC_BODY_BYTES);
2443
2727
  if (raw == null) return c.json({ error: "payload_too_large" }, 413);
2444
2728
  const auth = authorizeSyncRequest(store, c.req, raw);
2729
+ if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
2445
2730
  if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
2446
2731
  const limited = rateLimitedResponse(c, auth.deviceId, true);
2447
2732
  if (limited) return limited;
@@ -2483,6 +2768,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2483
2768
  return c.json({
2484
2769
  ...result,
2485
2770
  skipped,
2771
+ skipped_detail: filtered.skippedDetail,
2486
2772
  sync_capability: LOCAL_SYNC_CAPABILITY,
2487
2773
  scope_id: scopeRequest.scope_id
2488
2774
  });
@@ -2585,6 +2871,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2585
2871
  statusPayload.daemon_running = runtimeStatus.phase === "starting" || daemonRunning;
2586
2872
  statusPayload.daemon_detail = runtimeStatus.detail ?? daemonDetail;
2587
2873
  }
2874
+ const coordinatorSnapshot = await coordinatorStatusSnapshot(store, config);
2875
+ const coordinator = traceSync("coordinator", () => redactCoordinatorStatus(coordinatorSnapshot, showDiag));
2588
2876
  const peerRows = traceSync("peerRows", () => store.db.prepare(PEERS_QUERY).all());
2589
2877
  const recentOpsByPeer = traceSync("recentPeerOps", () => recentPeerOps(store));
2590
2878
  const scopeRejectionsByPeer = traceSync("recentScopeRejectionsByPeer", () => recentScopeRejectionsByPeer(store));
@@ -2630,9 +2918,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2630
2918
  ping: {}
2631
2919
  };
2632
2920
  const legacyDevices = traceSync("legacyDevices", () => store.claimableLegacyDeviceIds());
2921
+ const legacyReview = traceSync("legacySharedReview", () => legacySharedReviewSummary(store));
2633
2922
  const sharingReview = traceSync("sharingReview", () => store.sharingReviewSummary(project));
2634
- const coordinatorSnapshot = await coordinatorStatusSnapshot(store, config);
2635
- const coordinator = traceSync("coordinator", () => redactCoordinatorStatus(coordinatorSnapshot, showDiag));
2636
2923
  let joinRequests = [];
2637
2924
  if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
2638
2925
  joinRequests = await listCoordinatorJoinRequests(config);
@@ -2666,6 +2953,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2666
2953
  peers: peersItems,
2667
2954
  attempts: attemptsItems.slice(0, 5),
2668
2955
  legacy_devices: legacyDevices,
2956
+ legacy_shared_review: legacyReview,
2669
2957
  sharing_review: sharingReview,
2670
2958
  coordinator
2671
2959
  };
@@ -2821,7 +3109,9 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2821
3109
  const result = await runSyncPass(store.db, peerId, { scanner: store.scanner });
2822
3110
  items.push({
2823
3111
  peer_device_id: peerId,
2824
- ...result
3112
+ ...result,
3113
+ skippedOut: void 0,
3114
+ skipped_out: safeSkippedSyncDetail(result.skippedOut)
2825
3115
  });
2826
3116
  }
2827
3117
  return c.json({ items });
@@ -2855,6 +3145,78 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2855
3145
  project_scope: currentProjectScope(row, readPeerProjectFilters(store, peerDeviceId))
2856
3146
  });
2857
3147
  });
3148
+ app.get("/api/sync/sharing-domains/settings", (c) => {
3149
+ const store = getStore();
3150
+ const limit = Math.max(1, queryInt(c.req.query("limit"), 250));
3151
+ return c.json({
3152
+ scopes: listSharingDomainSettingsScopes(store.db),
3153
+ mappings: listProjectScopeSettingsMappings(store.db),
3154
+ projects: listProjectScopeCandidates(store.db, { limit }),
3155
+ local_default_scope_id: LOCAL_DEFAULT_SCOPE_ID
3156
+ });
3157
+ });
3158
+ app.put("/api/sync/sharing-domains/project-mappings", async (c) => {
3159
+ const store = getStore();
3160
+ const body = await parseViewerJsonBody(c);
3161
+ if (!body) return c.json({ error: "invalid json" }, 400);
3162
+ try {
3163
+ const id = optionalViewerInteger(body, "id");
3164
+ const priority = optionalViewerInteger(body, "priority");
3165
+ if (Number.isNaN(id)) return c.json({ error: "id must be an integer" }, 400);
3166
+ if (Number.isNaN(priority)) return c.json({ error: "priority must be an integer" }, 400);
3167
+ const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity");
3168
+ const projectPattern = optionalViewerStrictString(body, "project_pattern");
3169
+ const scopeId = optionalViewerStrictString(body, "scope_id") ?? "";
3170
+ const source = optionalViewerStrictString(body, "source") ?? "user";
3171
+ const confirmedGuardrailTokens = optionalViewerStringList(body, "confirmed_guardrail_tokens");
3172
+ const mappingInput = {
3173
+ id,
3174
+ workspace_identity: workspaceIdentity,
3175
+ project_pattern: projectPattern,
3176
+ scope_id: scopeId,
3177
+ priority,
3178
+ source
3179
+ };
3180
+ const analysis = analyzeProjectScopeMappingChangeGuardrails(store.db, mappingInput);
3181
+ if (analysis.requested_workspace_identity?.startsWith("unmapped:") && scopeId !== LOCAL_DEFAULT_SCOPE_ID) return c.json({
3182
+ error: "unmapped_project_local_only",
3183
+ message: "Unmapped projects need a stable path, git remote, or workspace id before assigning a non-local Sharing domain.",
3184
+ guardrail_warnings: analysis.warnings
3185
+ }, 400);
3186
+ const missingConfirmations = missingGuardrailConfirmations(analysis.warnings, confirmedGuardrailTokens);
3187
+ if (missingConfirmations.length > 0) {
3188
+ const missingCodes = [...new Set(missingConfirmations.map((warning) => warning.code))];
3189
+ const missingTokens = [...new Set(missingConfirmations.map((warning) => warning.confirmation_token))].filter((token) => typeof token === "string" && token.length > 0);
3190
+ return c.json({
3191
+ error: "guardrail_confirmation_required",
3192
+ required_guardrails: missingCodes,
3193
+ required_guardrail_tokens: missingTokens,
3194
+ guardrail_warnings: analysis.warnings
3195
+ }, 409);
3196
+ }
3197
+ const mapping = upsertProjectScopeSettingsMapping(store.db, { ...mappingInput });
3198
+ return c.json({
3199
+ ok: true,
3200
+ mapping,
3201
+ guardrail_warnings: analysis.warnings
3202
+ });
3203
+ } catch (error) {
3204
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3205
+ }
3206
+ });
3207
+ app.delete("/api/sync/sharing-domains/project-mappings/:id", (c) => {
3208
+ const store = getStore();
3209
+ const id = Number(c.req.param("id"));
3210
+ try {
3211
+ const deleted = deleteProjectScopeSettingsMapping(store.db, id);
3212
+ return c.json({
3213
+ ok: true,
3214
+ deleted
3215
+ });
3216
+ } catch (error) {
3217
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3218
+ }
3219
+ });
2858
3220
  app.post("/api/sync/peers/identity", async (c) => {
2859
3221
  const store = getStore();
2860
3222
  ensureLocalActorRecord(store);