@codemem/server 0.20.0-alpha.5 → 0.20.0-alpha.7

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
- import { join } from "node:path";
3
- import { CODEMEM_CONFIG_ENV_OVERRIDES, MemoryStore, VERSION, buildRawEventEnvelopeFromHook, ensureDeviceIdentity, fromJson, getCodememConfigPath, getCodememEnvOverrides, parseStrictInteger, readCodememConfigFile, resolveDbPath, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, writeCodememConfigFile } from "@codemem/core";
2
+ import { dirname, join } from "node:path";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, applyReplicationOps, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorReviewJoinRequestAction, coordinatorStatusSnapshot, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, listCoordinatorJoinRequests, listObserverProviderOptions, loadReplicationOpsSince, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, 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";
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
8
8
  import { count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
9
9
  import { drizzle } from "drizzle-orm/better-sqlite3";
10
10
  import { createHash } from "node:crypto";
11
+ import net from "node:net";
11
12
  //#region src/middleware.ts
12
13
  var LOOPBACK_HOSTS = new Set([
13
14
  "127.0.0.1",
@@ -175,16 +176,7 @@ var DEFAULTS = {
175
176
  raw_events_sweeper_interval_s: 30
176
177
  };
177
178
  function loadProviderOptions() {
178
- return [
179
- "openai",
180
- "anthropic",
181
- "google",
182
- "xai",
183
- "groq",
184
- "deepseek",
185
- "mistral",
186
- "together"
187
- ];
179
+ return listObserverProviderOptions();
188
180
  }
189
181
  function getConfigPath() {
190
182
  const envPath = process.env.CODEMEM_CONFIG;
@@ -448,7 +440,7 @@ function attachSessionFields(store, items) {
448
440
  const bySession = /* @__PURE__ */ new Map();
449
441
  for (const row of rows) {
450
442
  const projectRaw = String(row.project ?? "").trim();
451
- const project = projectRaw ? projectBasename(projectRaw) : "";
443
+ const project = projectRaw ? projectBasename$1(projectRaw) : "";
452
444
  const cwd = String(row.cwd ?? "");
453
445
  bySession.set(row.id, {
454
446
  project,
@@ -468,11 +460,57 @@ function attachSessionFields(store, items) {
468
460
  * Extract the basename of a project path.
469
461
  * Strips "fatal:" prefixed values.
470
462
  */
471
- function projectBasename(raw) {
463
+ function projectBasename$1(raw) {
472
464
  if (raw.toLowerCase().startsWith("fatal:")) return "";
473
465
  const parts = raw.replace(/\\/g, "/").split("/");
474
466
  return parts[parts.length - 1] ?? raw;
475
467
  }
468
+ function normalizeScope(raw) {
469
+ const value = String(raw ?? "").trim().toLowerCase();
470
+ if (value === "mine" || value === "theirs") return value;
471
+ }
472
+ function queryMemoryPage(store, options) {
473
+ const filters = {};
474
+ if (options.project) filters.project = options.project;
475
+ if (options.scope) filters.ownership_scope = options.scope;
476
+ const filterResult = buildFilterClausesWithContext(filters, {
477
+ actorId: store.actorId,
478
+ deviceId: store.deviceId
479
+ });
480
+ const where = ["memory_items.active = 1", ...filterResult.clauses].join(" AND ");
481
+ const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
482
+ return store.db.prepare(`SELECT memory_items.* FROM ${from}
483
+ WHERE ${where}
484
+ ORDER BY memory_items.created_at DESC
485
+ LIMIT ? OFFSET ?`).all(...filterResult.params, options.limit + 1, options.offset).map((row) => ({
486
+ ...row,
487
+ metadata_json: fromJson(row.metadata_json ?? null)
488
+ }));
489
+ }
490
+ function isSummaryLikeMemory(item) {
491
+ if (String(item.kind ?? "").toLowerCase() === "session_summary") return true;
492
+ const metadata = item.metadata_json ?? {};
493
+ if (metadata.is_summary === true) return true;
494
+ return String(metadata.source ?? "").trim().toLowerCase() === "observer_summary";
495
+ }
496
+ function selectMemoryPage(store, options) {
497
+ const pageSize = Math.max(options.limit + options.offset + 10, 50);
498
+ let rawOffset = 0;
499
+ const matched = [];
500
+ while (matched.length < options.offset + options.limit + 1) {
501
+ const page = queryMemoryPage(store, {
502
+ limit: pageSize,
503
+ offset: rawOffset,
504
+ project: options.project,
505
+ scope: options.scope
506
+ });
507
+ if (page.length === 0) break;
508
+ matched.push(...page.filter(options.matcher));
509
+ if (page.length < pageSize) break;
510
+ rawOffset += page.length;
511
+ }
512
+ return matched.slice(options.offset, options.offset + options.limit + 1);
513
+ }
476
514
  function memoryRoutes(getStore) {
477
515
  const app = new Hono();
478
516
  app.get("/api/sessions", (c) => {
@@ -490,29 +528,26 @@ function memoryRoutes(getStore) {
490
528
  const store = getStore();
491
529
  {
492
530
  const rows = drizzle(store.db, { schema }).selectDistinct({ project: schema.sessions.project }).from(schema.sessions).where(isNotNull(schema.sessions.project)).all();
493
- 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();
531
+ const projects = [...new Set(rows.map((r) => String(r.project ?? "").trim()).filter((p) => p && !p.toLowerCase().startsWith("fatal:")).map((p) => projectBasename$1(p)).filter(Boolean))].sort();
494
532
  return c.json({ projects });
495
533
  }
496
534
  });
497
- app.get("/api/memories", (c) => c.redirect("/api/observations", 301));
535
+ app.get("/api/memories", (c) => {
536
+ const search = new URL(c.req.url).search;
537
+ return c.redirect(`/api/observations${search}`, 301);
538
+ });
498
539
  app.get("/api/observations", (c) => {
499
540
  const store = getStore();
500
541
  {
501
542
  const limit = Math.max(1, queryInt(c.req.query("limit"), 20));
502
543
  const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
503
- const project = c.req.query("project") || void 0;
504
- const kinds = [
505
- "bugfix",
506
- "change",
507
- "decision",
508
- "discovery",
509
- "exploration",
510
- "feature",
511
- "refactor"
512
- ];
513
- const filters = {};
514
- if (project) filters.project = project;
515
- const items = store.recentByKinds(kinds, limit + 1, filters, offset);
544
+ const items = selectMemoryPage(store, {
545
+ limit,
546
+ offset,
547
+ project: c.req.query("project") || void 0,
548
+ scope: normalizeScope(c.req.query("scope")),
549
+ matcher: (item) => !isSummaryLikeMemory(item)
550
+ });
516
551
  const hasMore = items.length > limit;
517
552
  const result = hasMore ? items.slice(0, limit) : items;
518
553
  const asRecords = result;
@@ -533,10 +568,13 @@ function memoryRoutes(getStore) {
533
568
  {
534
569
  const limit = Math.max(1, queryInt(c.req.query("limit"), 50));
535
570
  const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
536
- const project = c.req.query("project") || void 0;
537
- const filters = { kind: "session_summary" };
538
- if (project) filters.project = project;
539
- const items = store.recent(limit + 1, filters, offset);
571
+ const items = selectMemoryPage(store, {
572
+ limit,
573
+ offset,
574
+ project: c.req.query("project") || void 0,
575
+ scope: normalizeScope(c.req.query("scope")),
576
+ matcher: (item) => isSummaryLikeMemory(item)
577
+ });
540
578
  const hasMore = items.length > limit;
541
579
  const result = hasMore ? items.slice(0, limit) : items;
542
580
  const asRecords = result;
@@ -564,6 +602,22 @@ function memoryRoutes(getStore) {
564
602
  let artifacts;
565
603
  let memories;
566
604
  let observations;
605
+ const countObservations = (scopeProject) => {
606
+ let offset = 0;
607
+ let total = 0;
608
+ while (true) {
609
+ const page = queryMemoryPage(store, {
610
+ limit: 200,
611
+ offset,
612
+ project: scopeProject
613
+ });
614
+ if (page.length === 0) break;
615
+ total += page.filter((item) => !isSummaryLikeMemory(item)).length;
616
+ if (page.length < 200) break;
617
+ offset += page.length;
618
+ }
619
+ return total;
620
+ };
567
621
  if (project) {
568
622
  prompts = count("SELECT COUNT(*) AS total FROM user_prompts WHERE project = ?", project);
569
623
  artifacts = count(`SELECT COUNT(*) AS total FROM artifacts
@@ -572,14 +626,12 @@ function memoryRoutes(getStore) {
572
626
  memories = count(`SELECT COUNT(*) AS total FROM memory_items
573
627
  JOIN sessions ON sessions.id = memory_items.session_id
574
628
  WHERE sessions.project = ?`, project);
575
- observations = count(`SELECT COUNT(*) AS total FROM memory_items
576
- JOIN sessions ON sessions.id = memory_items.session_id
577
- WHERE kind != 'session_summary' AND sessions.project = ?`, project);
629
+ observations = countObservations(project);
578
630
  } else {
579
631
  prompts = count("SELECT COUNT(*) AS total FROM user_prompts");
580
632
  artifacts = count("SELECT COUNT(*) AS total FROM artifacts");
581
633
  memories = count("SELECT COUNT(*) AS total FROM memory_items");
582
- observations = count("SELECT COUNT(*) AS total FROM memory_items WHERE kind != 'session_summary'");
634
+ observations = countObservations();
583
635
  }
584
636
  const total = prompts + artifacts + memories;
585
637
  return c.json({
@@ -591,7 +643,7 @@ function memoryRoutes(getStore) {
591
643
  });
592
644
  }
593
645
  });
594
- app.get("/api/pack", (c) => {
646
+ app.get("/api/pack", async (c) => {
595
647
  const store = getStore();
596
648
  {
597
649
  const context = c.req.query("context") || "";
@@ -606,7 +658,7 @@ function memoryRoutes(getStore) {
606
658
  const project = c.req.query("project") || void 0;
607
659
  const filters = {};
608
660
  if (project) filters.project = project;
609
- const pack = store.buildMemoryPack(context, limit, tokenBudget ?? null, filters);
661
+ const pack = await store.buildMemoryPackAsync(context, limit, tokenBudget ?? null, filters);
610
662
  return c.json(pack);
611
663
  }
612
664
  });
@@ -637,7 +689,12 @@ function memoryRoutes(getStore) {
637
689
  });
638
690
  app.post("/api/memories/visibility", async (c) => {
639
691
  const store = getStore();
640
- const body = await c.req.json();
692
+ let body;
693
+ try {
694
+ body = await c.req.json();
695
+ } catch {
696
+ return c.json({ error: "invalid JSON" }, 400);
697
+ }
641
698
  const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
642
699
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
643
700
  const visibility = String(body.visibility ?? "").trim();
@@ -656,6 +713,17 @@ function memoryRoutes(getStore) {
656
713
  }
657
714
  //#endregion
658
715
  //#region src/routes/observer-status.ts
716
+ function normalizeActiveObserver(active) {
717
+ if (!active) return null;
718
+ return {
719
+ ...active,
720
+ auth: {
721
+ ...active.auth,
722
+ method: active.auth.type,
723
+ token_present: active.auth.hasToken
724
+ }
725
+ };
726
+ }
659
727
  function buildFailureImpact(latestFailure, queueTotals, authBackoff) {
660
728
  if (!latestFailure) return null;
661
729
  if (authBackoff.active) return `Queue retries paused for ~${authBackoff.remainingS}s after an observer auth failure.`;
@@ -667,6 +735,7 @@ function observerStatusRoutes(deps) {
667
735
  app.get("/api/observer-status", (c) => {
668
736
  const store = deps?.getStore();
669
737
  const sweeper = deps?.getSweeper();
738
+ const observer = deps?.getObserver?.() ?? null;
670
739
  if (!store || typeof store.rawEventBacklogTotals !== "function") return c.json({
671
740
  active: null,
672
741
  available_credentials: {},
@@ -684,13 +753,15 @@ function observerStatusRoutes(deps) {
684
753
  remainingS: 0
685
754
  };
686
755
  const latestFailure = store.latestRawEventFlushFailure();
687
- const failureWithImpact = latestFailure ? {
756
+ const active = normalizeActiveObserver(observer?.getStatus() ?? null);
757
+ const availableCredentials = probeAvailableCredentials();
758
+ const failureWithImpact = latestFailure != null && (authBackoff.active || queueTotals.pending > 0) && latestFailure ? {
688
759
  ...latestFailure,
689
760
  impact: buildFailureImpact(latestFailure, queueTotals, authBackoff)
690
761
  } : null;
691
762
  return c.json({
692
- active: null,
693
- available_credentials: {},
763
+ active,
764
+ available_credentials: availableCredentials,
694
765
  latest_failure: failureWithImpact,
695
766
  queue: {
696
767
  ...queueTotals,
@@ -767,9 +838,9 @@ async function parseJsonObjectBody(c, maxBytes) {
767
838
  return parsed;
768
839
  }
769
840
  /** Nudge the sweeper safely — never crashes the caller. */
770
- function nudgeSweeper(sweeper) {
841
+ function nudgeSweeper(sweeper, sessionIds, source = "opencode") {
771
842
  try {
772
- sweeper?.nudge();
843
+ for (const sessionId of sessionIds) sweeper?.nudge(sessionId, source);
773
844
  } catch {}
774
845
  }
775
846
  function rawEventsRoutes(getStore, sweeper) {
@@ -946,7 +1017,7 @@ function rawEventsRoutes(getStore, sweeper) {
946
1017
  lastSeenTsWallMs: lastSeenBySession.get(metaSessionId) ?? null
947
1018
  });
948
1019
  }
949
- nudgeSweeper(sweeper);
1020
+ nudgeSweeper(sweeper, sessionIds);
950
1021
  return c.json({
951
1022
  inserted,
952
1023
  received: items.length
@@ -986,7 +1057,7 @@ function rawEventsRoutes(getStore, sweeper) {
986
1057
  startedAt: envelope.started_at,
987
1058
  lastSeenTsWallMs: envelope.ts_wall_ms
988
1059
  });
989
- nudgeSweeper(sweeper);
1060
+ nudgeSweeper(sweeper, [opencodeSessionId], source);
990
1061
  return c.json({
991
1062
  inserted: inserted ? 1 : 0,
992
1063
  skipped: 0
@@ -1062,8 +1133,193 @@ function statsRoutes(getStore) {
1062
1133
  }
1063
1134
  //#endregion
1064
1135
  //#region src/routes/sync.ts
1136
+ /**
1137
+ * Sync routes — status, peers, actors, attempts, pairing, mutations.
1138
+ */
1065
1139
  var SYNC_STALE_AFTER_SECONDS = 600;
1066
- var PAIRING_FILTER_HINT = "Run this on another device with codemem sync pair --accept '<payload>'. On that accepting device, --include/--exclude only control what it sends to peers. This device does not yet enforce incoming project filters.";
1140
+ var SYNC_PROTOCOL_VERSION = "1";
1141
+ function intEnvOr(name, fallback) {
1142
+ const value = Number.parseInt(process.env[name] ?? "", 10);
1143
+ return Number.isFinite(value) ? value : fallback;
1144
+ }
1145
+ var MAX_SYNC_BODY_BYTES = intEnvOr("CODEMEM_SYNC_MAX_BODY_BYTES", 1048576);
1146
+ var MAX_SYNC_OPS = intEnvOr("CODEMEM_SYNC_MAX_OPS", 2e3);
1147
+ var PAIRING_FILTER_HINT = "Run this on another device with codemem sync pair --accept '<payload>'. On the accepting device, --include/--exclude control both what it sends and what it accepts from that peer.";
1148
+ function pathWithQuery(url) {
1149
+ const parsed = new URL(url);
1150
+ return parsed.search ? `${parsed.pathname}${parsed.search}` : parsed.pathname;
1151
+ }
1152
+ function unauthorizedPayload(reason) {
1153
+ if (process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
1154
+ error: "unauthorized",
1155
+ reason
1156
+ };
1157
+ return { error: "unauthorized" };
1158
+ }
1159
+ function authorizeSyncRequest(store, request, body) {
1160
+ const deviceId = (request.header("X-Opencode-Device") ?? "").trim();
1161
+ const signature = request.header("X-Opencode-Signature") ?? "";
1162
+ const timestamp = request.header("X-Opencode-Timestamp") ?? "";
1163
+ const nonce = request.header("X-Opencode-Nonce") ?? "";
1164
+ if (!deviceId || !signature || !timestamp || !nonce) return {
1165
+ ok: false,
1166
+ reason: "missing_headers",
1167
+ deviceId
1168
+ };
1169
+ const peerRow = store.db.prepare("SELECT pinned_fingerprint, public_key FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(deviceId);
1170
+ if (!peerRow) return {
1171
+ ok: false,
1172
+ reason: "unknown_peer",
1173
+ deviceId
1174
+ };
1175
+ const pinnedFingerprint = String(peerRow.pinned_fingerprint ?? "").trim();
1176
+ const publicKey = String(peerRow.public_key ?? "").trim();
1177
+ if (!pinnedFingerprint || !publicKey) return {
1178
+ ok: false,
1179
+ reason: "peer_record_incomplete",
1180
+ deviceId
1181
+ };
1182
+ if (fingerprintPublicKey(publicKey) !== pinnedFingerprint) return {
1183
+ ok: false,
1184
+ reason: "fingerprint_mismatch",
1185
+ deviceId
1186
+ };
1187
+ let valid = false;
1188
+ try {
1189
+ valid = verifySignature({
1190
+ method: request.method,
1191
+ pathWithQuery: pathWithQuery(request.url),
1192
+ bodyBytes: body,
1193
+ timestamp,
1194
+ nonce,
1195
+ signature,
1196
+ publicKey,
1197
+ deviceId
1198
+ });
1199
+ } catch {
1200
+ return {
1201
+ ok: false,
1202
+ reason: "signature_verification_error",
1203
+ deviceId
1204
+ };
1205
+ }
1206
+ if (!valid) return {
1207
+ ok: false,
1208
+ reason: "invalid_signature",
1209
+ deviceId
1210
+ };
1211
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1212
+ if (!recordNonce(store.db, deviceId, nonce, createdAt)) return {
1213
+ ok: false,
1214
+ reason: "nonce_replay",
1215
+ deviceId
1216
+ };
1217
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - DEFAULT_TIME_WINDOW_S * 2 * 1e3)).toISOString();
1218
+ cleanupNonces(store.db, cutoff);
1219
+ return {
1220
+ ok: true,
1221
+ reason: "ok",
1222
+ deviceId
1223
+ };
1224
+ }
1225
+ function projectBasename(value) {
1226
+ const project = String(value ?? "").trim().replaceAll("\\", "/");
1227
+ if (!project) return "";
1228
+ const parts = project.split("/").filter(Boolean);
1229
+ return parts.length > 0 ? parts[parts.length - 1] ?? "" : "";
1230
+ }
1231
+ function parseJsonList(value) {
1232
+ if (value == null) return [];
1233
+ if (typeof value === "string") try {
1234
+ const parsed = JSON.parse(value);
1235
+ if (!Array.isArray(parsed)) return [];
1236
+ return parsed.map((entry) => String(entry ?? "").trim()).filter(Boolean);
1237
+ } catch {
1238
+ return [];
1239
+ }
1240
+ if (!Array.isArray(value)) return [];
1241
+ return value.map((entry) => String(entry ?? "").trim()).filter(Boolean);
1242
+ }
1243
+ function readPeerProjectFilters(store, peerDeviceId) {
1244
+ const globalConfig = readCoordinatorSyncConfig();
1245
+ const row = store.db.prepare("SELECT projects_include_json, projects_exclude_json FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(peerDeviceId);
1246
+ if (!row) return {
1247
+ include: globalConfig.syncProjectsInclude,
1248
+ exclude: globalConfig.syncProjectsExclude
1249
+ };
1250
+ if (!(row.projects_include_json != null || row.projects_exclude_json != null)) return {
1251
+ include: globalConfig.syncProjectsInclude,
1252
+ exclude: globalConfig.syncProjectsExclude
1253
+ };
1254
+ return {
1255
+ include: parseJsonList(row.projects_include_json),
1256
+ exclude: parseJsonList(row.projects_exclude_json)
1257
+ };
1258
+ }
1259
+ function peerClaimedLocalActor(store, peerDeviceId) {
1260
+ const row = store.db.prepare("SELECT claimed_local_actor FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(peerDeviceId);
1261
+ return Boolean(row?.claimed_local_actor);
1262
+ }
1263
+ function parseOpPayload(op) {
1264
+ if (!op.payload_json || !String(op.payload_json).trim()) return null;
1265
+ try {
1266
+ const parsed = JSON.parse(op.payload_json);
1267
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
1268
+ return parsed;
1269
+ } catch {
1270
+ return null;
1271
+ }
1272
+ }
1273
+ function isSharedVisibility(payload) {
1274
+ if (!payload) return false;
1275
+ let visibility = String(payload.visibility ?? "").trim().toLowerCase();
1276
+ const metadata = payload.metadata_json && typeof payload.metadata_json === "object" && !Array.isArray(payload.metadata_json) ? payload.metadata_json : {};
1277
+ const metadataVisibility = String(metadata.visibility ?? "").trim().toLowerCase();
1278
+ if (!visibility && metadataVisibility) visibility = metadataVisibility;
1279
+ if (!visibility) {
1280
+ let workspaceKind = String(payload.workspace_kind ?? "").trim().toLowerCase();
1281
+ let workspaceId = String(payload.workspace_id ?? "").trim().toLowerCase();
1282
+ if (!workspaceKind) workspaceKind = String(metadata.workspace_kind ?? "").trim().toLowerCase();
1283
+ if (!workspaceId) workspaceId = String(metadata.workspace_id ?? "").trim().toLowerCase();
1284
+ if (workspaceKind === "shared" || workspaceId.startsWith("shared:")) visibility = "shared";
1285
+ else return true;
1286
+ }
1287
+ return visibility === "shared";
1288
+ }
1289
+ function projectAllowed(projectValue, filters) {
1290
+ const value = String(projectValue ?? "").trim();
1291
+ const valueBase = projectBasename(value);
1292
+ for (const blocked of filters.exclude) if (blocked === value || blocked === valueBase) return false;
1293
+ if (filters.include.length === 0) return true;
1294
+ for (const allowed of filters.include) if (allowed === value || allowed === valueBase) return true;
1295
+ return false;
1296
+ }
1297
+ function filterOpsForPeer(store, peerDeviceId, ops) {
1298
+ const filters = readPeerProjectFilters(store, peerDeviceId);
1299
+ const allowPrivate = peerClaimedLocalActor(store, peerDeviceId);
1300
+ const allowed = [];
1301
+ let skipped = 0;
1302
+ for (const op of ops) {
1303
+ if (op.entity_type !== "memory_item") {
1304
+ allowed.push(op);
1305
+ continue;
1306
+ }
1307
+ const payload = parseOpPayload(op);
1308
+ if (!allowPrivate && !isSharedVisibility(payload)) {
1309
+ skipped++;
1310
+ continue;
1311
+ }
1312
+ if (!projectAllowed(payload && typeof payload.project === "string" ? payload.project : null, filters)) {
1313
+ skipped++;
1314
+ continue;
1315
+ }
1316
+ allowed.push(op);
1317
+ }
1318
+ return {
1319
+ allowed,
1320
+ skipped
1321
+ };
1322
+ }
1067
1323
  /**
1068
1324
  * Map a raw sync_peers DB row to the API response shape.
1069
1325
  * When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
@@ -1128,6 +1384,34 @@ function attemptStatus(attempt) {
1128
1384
  if (attempt.error) return "error";
1129
1385
  return "unknown";
1130
1386
  }
1387
+ function readViewerBinding(dbPath) {
1388
+ try {
1389
+ const raw = readFileSync(join(dirname(dbPath), "viewer.pid"), "utf8");
1390
+ const parsed = JSON.parse(raw);
1391
+ if (typeof parsed.host === "string" && typeof parsed.port === "number") return {
1392
+ host: parsed.host,
1393
+ port: parsed.port
1394
+ };
1395
+ } catch {}
1396
+ return null;
1397
+ }
1398
+ async function portOpen(host, port) {
1399
+ return new Promise((resolve) => {
1400
+ const socket = net.createConnection({
1401
+ host,
1402
+ port
1403
+ });
1404
+ const done = (ok) => {
1405
+ socket.removeAllListeners();
1406
+ socket.destroy();
1407
+ resolve(ok);
1408
+ };
1409
+ socket.setTimeout(300);
1410
+ socket.once("connect", () => done(true));
1411
+ socket.once("timeout", () => done(false));
1412
+ socket.once("error", () => done(false));
1413
+ });
1414
+ }
1131
1415
  var PEERS_QUERY = `
1132
1416
  SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
1133
1417
  p.last_seen_at, p.last_sync_at, p.last_error,
@@ -1139,11 +1423,95 @@ var PEERS_QUERY = `
1139
1423
  `;
1140
1424
  function syncRoutes(getStore) {
1141
1425
  const app = new Hono();
1142
- app.get("/api/sync/status", (c) => {
1426
+ app.get("/v1/status", (c) => {
1427
+ const store = getStore();
1428
+ const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1429
+ if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
1430
+ try {
1431
+ let device = store.db.prepare("SELECT device_id, fingerprint FROM sync_device LIMIT 1").get();
1432
+ if (!device) {
1433
+ const [deviceId, fingerprint] = ensureDeviceIdentity(store.db);
1434
+ device = {
1435
+ device_id: deviceId,
1436
+ fingerprint
1437
+ };
1438
+ }
1439
+ return c.json({
1440
+ device_id: device.device_id,
1441
+ protocol_version: SYNC_PROTOCOL_VERSION,
1442
+ fingerprint: device.fingerprint
1443
+ });
1444
+ } catch {
1445
+ return c.json({ error: "internal_error" }, 500);
1446
+ }
1447
+ });
1448
+ app.get("/v1/ops", (c) => {
1449
+ const store = getStore();
1450
+ const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1451
+ if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
1452
+ const peerDeviceId = auth.deviceId;
1453
+ try {
1454
+ const since = c.req.query("since") ?? null;
1455
+ const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
1456
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1e3)) : 200;
1457
+ let localDeviceId = store.db.prepare("SELECT device_id FROM sync_device LIMIT 1").get();
1458
+ if (!localDeviceId) {
1459
+ const [deviceId] = ensureDeviceIdentity(store.db);
1460
+ localDeviceId = { device_id: deviceId };
1461
+ }
1462
+ const [ops, nextCursor] = loadReplicationOpsSince(store.db, since, limit, localDeviceId.device_id);
1463
+ const filtered = filterOpsForPeer(store, peerDeviceId, ops);
1464
+ return c.json({
1465
+ ops: filtered.allowed,
1466
+ next_cursor: nextCursor,
1467
+ skipped: filtered.skipped
1468
+ });
1469
+ } catch {
1470
+ return c.json({ error: "internal_error" }, 500);
1471
+ }
1472
+ });
1473
+ app.post("/v1/ops", async (c) => {
1474
+ const store = getStore();
1475
+ const raw = Buffer.from(await c.req.arrayBuffer());
1476
+ if (raw.length > MAX_SYNC_BODY_BYTES) return c.json({ error: "payload_too_large" }, 413);
1477
+ const auth = authorizeSyncRequest(store, c.req, raw);
1478
+ if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
1479
+ const peerDeviceId = auth.deviceId;
1480
+ let body;
1481
+ try {
1482
+ const parsed = JSON.parse(raw.toString("utf-8"));
1483
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return c.json({ error: "invalid_json" }, 400);
1484
+ body = parsed;
1485
+ } catch {
1486
+ return c.json({ error: "invalid_json" }, 400);
1487
+ }
1488
+ if (!Array.isArray(body.ops)) return c.json({ error: "invalid_ops" }, 400);
1489
+ if (body.ops.length > MAX_SYNC_OPS) return c.json({ error: "too_many_ops" }, 413);
1490
+ const normalizedOps = extractReplicationOps(body);
1491
+ for (const op of normalizedOps) if (op.device_id !== peerDeviceId || op.clock_device_id !== peerDeviceId) return c.json({
1492
+ error: "invalid_op_device",
1493
+ reason: "device_id_mismatch",
1494
+ op_id: op.op_id
1495
+ }, 400);
1496
+ let localDeviceId = store.db.prepare("SELECT device_id FROM sync_device LIMIT 1").get();
1497
+ if (!localDeviceId) {
1498
+ const [deviceId] = ensureDeviceIdentity(store.db);
1499
+ localDeviceId = { device_id: deviceId };
1500
+ }
1501
+ const filteredInbound = filterOpsForPeer(store, peerDeviceId, normalizedOps);
1502
+ const result = applyReplicationOps(store.db, filteredInbound.allowed, localDeviceId.device_id);
1503
+ return c.json({
1504
+ ...result,
1505
+ skipped: result.skipped + filteredInbound.skipped
1506
+ });
1507
+ });
1508
+ app.get("/api/sync/status", async (c) => {
1143
1509
  const store = getStore();
1144
1510
  {
1145
1511
  const showDiag = queryBool(c.req.query("includeDiagnostics"));
1146
- c.req.query("project");
1512
+ const includeJoinRequests = queryBool(c.req.query("includeJoinRequests"));
1513
+ const project = c.req.query("project") || null;
1514
+ const config = readCoordinatorSyncConfig();
1147
1515
  const d = drizzle(store.db, { schema });
1148
1516
  const deviceRow = d.select({
1149
1517
  device_id: schema.syncDevice.device_id,
@@ -1155,27 +1523,32 @@ function syncRoutes(getStore) {
1155
1523
  const lastError = daemonState?.last_error;
1156
1524
  const lastErrorAt = daemonState?.last_error_at;
1157
1525
  const lastOkAt = daemonState?.last_ok_at;
1526
+ const viewerBinding = readViewerBinding(store.dbPath);
1527
+ const daemonRunning = viewerBinding ? await portOpen(viewerBinding.host, viewerBinding.port) : false;
1528
+ const daemonDetail = viewerBinding ? daemonRunning ? `viewer pidfile at ${viewerBinding.host}:${viewerBinding.port}` : `pidfile present but ${viewerBinding.host}:${viewerBinding.port} is unreachable` : null;
1158
1529
  let daemonStateValue = "ok";
1159
- if (lastError && (!lastOkAt || String(lastOkAt) < String(lastErrorAt ?? ""))) daemonStateValue = "error";
1530
+ if (!config.syncEnabled) daemonStateValue = "disabled";
1531
+ else if (lastError && (!lastOkAt || String(lastOkAt) < String(lastErrorAt ?? ""))) daemonStateValue = "error";
1532
+ else if (!daemonRunning) daemonStateValue = "stopped";
1160
1533
  const statusPayload = {
1161
- enabled: true,
1162
- interval_s: 60,
1534
+ enabled: config.syncEnabled,
1535
+ interval_s: config.syncIntervalS,
1163
1536
  peer_count: Number(peerCountRow?.total ?? 0),
1164
1537
  last_sync_at: lastSyncRow?.last_sync_at ?? null,
1165
1538
  daemon_state: daemonStateValue,
1166
- daemon_running: false,
1167
- daemon_detail: null,
1168
- project_filter_active: false,
1539
+ daemon_running: daemonRunning,
1540
+ daemon_detail: daemonDetail,
1541
+ project_filter_active: config.syncProjectsInclude.length > 0 || config.syncProjectsExclude.length > 0,
1169
1542
  project_filter: {
1170
- include: [],
1171
- exclude: []
1543
+ include: config.syncProjectsInclude,
1544
+ exclude: config.syncProjectsExclude
1172
1545
  },
1173
1546
  redacted: !showDiag
1174
1547
  };
1175
1548
  if (showDiag) {
1176
1549
  statusPayload.device_id = deviceRow?.device_id ?? null;
1177
1550
  statusPayload.fingerprint = deviceRow?.fingerprint ?? null;
1178
- statusPayload.bind = null;
1551
+ statusPayload.bind = `${config.syncHost}:${config.syncPort}`;
1179
1552
  statusPayload.daemon_last_error = lastError;
1180
1553
  statusPayload.daemon_last_error_at = lastErrorAt;
1181
1554
  statusPayload.daemon_last_ok_at = lastOkAt;
@@ -1207,19 +1580,40 @@ function syncRoutes(getStore) {
1207
1580
  sync: {},
1208
1581
  ping: {}
1209
1582
  };
1210
- return c.json({
1583
+ const legacyDevices = store.claimableLegacyDeviceIds();
1584
+ const sharingReview = store.sharingReviewSummary(project);
1585
+ const coordinator = await coordinatorStatusSnapshot(store, config);
1586
+ let joinRequests = [];
1587
+ if (includeJoinRequests && config.syncCoordinatorAdminSecret) try {
1588
+ joinRequests = await listCoordinatorJoinRequests(config);
1589
+ } catch {
1590
+ joinRequests = [];
1591
+ }
1592
+ if (daemonStateValue === "ok") {
1593
+ const peerStates = new Set(peersItems.map((peer) => String(peer.status?.peer_state ?? "")));
1594
+ const latestFailedRecently = Boolean(attemptsItems[0] && attemptsItems[0].status === "error" && isRecentIso(attemptsItems[0].finished_at));
1595
+ const allOffline = peersItems.length > 0 && peersItems.every((peer) => String(peer.status?.peer_state ?? "") === "offline");
1596
+ if (latestFailedRecently) {
1597
+ if (peerStates.has("online") || peerStates.has("degraded")) daemonStateValue = "degraded";
1598
+ else if (allOffline) daemonStateValue = "offline-peers";
1599
+ else if (peersItems.length > 0) daemonStateValue = "stale";
1600
+ } else if (peerStates.has("degraded")) daemonStateValue = "degraded";
1601
+ else if (allOffline) daemonStateValue = "offline-peers";
1602
+ else if (peersItems.length > 0 && !peerStates.has("online")) daemonStateValue = "stale";
1603
+ statusPayload.daemon_state = daemonStateValue;
1604
+ statusBlock.daemon_state = daemonStateValue;
1605
+ }
1606
+ const responsePayload = {
1211
1607
  ...statusPayload,
1212
1608
  status: statusBlock,
1213
1609
  peers: peersItems,
1214
1610
  attempts: attemptsItems.slice(0, 5),
1215
- legacy_devices: [],
1216
- sharing_review: { unreviewed: 0 },
1217
- coordinator: {
1218
- enabled: false,
1219
- configured: false
1220
- },
1221
- join_requests: []
1222
- });
1611
+ legacy_devices: legacyDevices,
1612
+ sharing_review: sharingReview,
1613
+ coordinator
1614
+ };
1615
+ if (includeJoinRequests) responsePayload.join_requests = joinRequests;
1616
+ return c.json(responsePayload);
1223
1617
  }
1224
1618
  });
1225
1619
  app.get("/api/sync/peers", (c) => {
@@ -1314,6 +1708,87 @@ function syncRoutes(getStore) {
1314
1708
  return c.json({ ok: true });
1315
1709
  }
1316
1710
  });
1711
+ app.post("/api/sync/invites/create", async (c) => {
1712
+ let body;
1713
+ try {
1714
+ body = await c.req.json();
1715
+ } catch {
1716
+ return c.json({ error: "invalid json" }, 400);
1717
+ }
1718
+ const groupId = String(body.group_id ?? "").trim();
1719
+ const coordinatorUrl = body.coordinator_url == null ? null : String(body.coordinator_url ?? "");
1720
+ const policy = String(body.policy ?? "auto_admit").trim();
1721
+ const ttlHours = Number.parseInt(String(body.ttl_hours ?? 24), 10);
1722
+ if (!groupId) return c.json({ error: "group_id required" }, 400);
1723
+ if (body.coordinator_url != null && typeof body.coordinator_url !== "string") return c.json({ error: "coordinator_url must be string" }, 400);
1724
+ if (!["auto_admit", "approval_required"].includes(policy)) return c.json({ error: "policy must be auto_admit or approval_required" }, 400);
1725
+ if (!Number.isFinite(ttlHours)) return c.json({ error: "ttl_hours must be int" }, 400);
1726
+ try {
1727
+ const config = readCoordinatorSyncConfig();
1728
+ const result = await coordinatorCreateInviteAction({
1729
+ groupId,
1730
+ coordinatorUrl,
1731
+ policy,
1732
+ ttlHours,
1733
+ createdBy: null,
1734
+ remoteUrl: config.syncCoordinatorUrl || null,
1735
+ adminSecret: config.syncCoordinatorAdminSecret || null
1736
+ });
1737
+ return c.json(result);
1738
+ } catch (error) {
1739
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
1740
+ }
1741
+ });
1742
+ app.post("/api/sync/invites/import", async (c) => {
1743
+ const store = getStore();
1744
+ let body;
1745
+ try {
1746
+ body = await c.req.json();
1747
+ } catch {
1748
+ return c.json({ error: "invalid json" }, 400);
1749
+ }
1750
+ const inviteValue = String(body.invite ?? "").trim();
1751
+ if (!inviteValue) return c.json({ error: "invite required" }, 400);
1752
+ try {
1753
+ const result = await coordinatorImportInviteAction({
1754
+ inviteValue,
1755
+ dbPath: store.dbPath
1756
+ });
1757
+ return c.json(result);
1758
+ } catch (error) {
1759
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
1760
+ }
1761
+ });
1762
+ app.post("/api/sync/join-requests/review", async (c) => {
1763
+ let body;
1764
+ try {
1765
+ body = await c.req.json();
1766
+ } catch {
1767
+ return c.json({ error: "invalid json" }, 400);
1768
+ }
1769
+ const requestId = String(body.request_id ?? "").trim();
1770
+ const action = String(body.action ?? "").trim();
1771
+ if (!requestId) return c.json({ error: "request_id required" }, 400);
1772
+ if (!["approve", "deny"].includes(action)) return c.json({ error: "action must be approve or deny" }, 400);
1773
+ try {
1774
+ const config = readCoordinatorSyncConfig();
1775
+ const result = await coordinatorReviewJoinRequestAction({
1776
+ requestId,
1777
+ approve: action === "approve",
1778
+ reviewedBy: null,
1779
+ remoteUrl: config.syncCoordinatorUrl || null,
1780
+ adminSecret: config.syncCoordinatorAdminSecret || null
1781
+ });
1782
+ if (!result) return c.json({ error: "join request not found" }, 404);
1783
+ return c.json({
1784
+ ok: true,
1785
+ request: result
1786
+ });
1787
+ } catch (error) {
1788
+ const message = error instanceof Error ? error.message : String(error);
1789
+ return c.json({ error: message }, message.includes("request_not_found") || message.includes("not found") ? 404 : 400);
1790
+ }
1791
+ });
1317
1792
  app.delete("/api/sync/peers/:peer_device_id", (c) => {
1318
1793
  const store = getStore();
1319
1794
  {
@@ -1353,6 +1828,7 @@ function closeStore() {
1353
1828
  function createApp(opts) {
1354
1829
  const storeFactory = opts?.storeFactory ?? getStore;
1355
1830
  const sweeper = opts?.sweeper ?? null;
1831
+ const observer = opts?.observer ?? null;
1356
1832
  const app = new Hono();
1357
1833
  app.use("*", preflightHandler());
1358
1834
  app.use("*", originGuard());
@@ -1360,7 +1836,8 @@ function createApp(opts) {
1360
1836
  app.route("/", memoryRoutes(storeFactory));
1361
1837
  app.route("/", observerStatusRoutes({
1362
1838
  getStore: storeFactory,
1363
- getSweeper: () => sweeper
1839
+ getSweeper: () => sweeper,
1840
+ getObserver: () => observer
1364
1841
  }));
1365
1842
  app.route("/", configRoutes({ getSweeper: () => sweeper }));
1366
1843
  app.route("/", rawEventsRoutes(storeFactory, sweeper));