@codemem/server 0.20.0-alpha.5 → 0.20.0-alpha.6
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.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +542 -67
- package/dist/index.js.map +1 -1
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/memory.d.ts.map +1 -1
- package/dist/routes/observer-status.d.ts +3 -1
- package/dist/routes/observer-status.d.ts.map +1 -1
- package/dist/routes/raw-events.d.ts.map +1 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/app.js +55 -7
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) =>
|
|
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
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
"
|
|
507
|
-
"
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,94 @@ var PEERS_QUERY = `
|
|
|
1139
1423
|
`;
|
|
1140
1424
|
function syncRoutes(getStore) {
|
|
1141
1425
|
const app = new Hono();
|
|
1142
|
-
app.get("/
|
|
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 project = c.req.query("project") || null;
|
|
1513
|
+
const config = readCoordinatorSyncConfig();
|
|
1147
1514
|
const d = drizzle(store.db, { schema });
|
|
1148
1515
|
const deviceRow = d.select({
|
|
1149
1516
|
device_id: schema.syncDevice.device_id,
|
|
@@ -1155,27 +1522,32 @@ function syncRoutes(getStore) {
|
|
|
1155
1522
|
const lastError = daemonState?.last_error;
|
|
1156
1523
|
const lastErrorAt = daemonState?.last_error_at;
|
|
1157
1524
|
const lastOkAt = daemonState?.last_ok_at;
|
|
1525
|
+
const viewerBinding = readViewerBinding(store.dbPath);
|
|
1526
|
+
const daemonRunning = viewerBinding ? await portOpen(viewerBinding.host, viewerBinding.port) : false;
|
|
1527
|
+
const daemonDetail = viewerBinding ? daemonRunning ? `viewer pidfile at ${viewerBinding.host}:${viewerBinding.port}` : `pidfile present but ${viewerBinding.host}:${viewerBinding.port} is unreachable` : null;
|
|
1158
1528
|
let daemonStateValue = "ok";
|
|
1159
|
-
if (
|
|
1529
|
+
if (!config.syncEnabled) daemonStateValue = "disabled";
|
|
1530
|
+
else if (lastError && (!lastOkAt || String(lastOkAt) < String(lastErrorAt ?? ""))) daemonStateValue = "error";
|
|
1531
|
+
else if (!daemonRunning) daemonStateValue = "stopped";
|
|
1160
1532
|
const statusPayload = {
|
|
1161
|
-
enabled:
|
|
1162
|
-
interval_s:
|
|
1533
|
+
enabled: config.syncEnabled,
|
|
1534
|
+
interval_s: config.syncIntervalS,
|
|
1163
1535
|
peer_count: Number(peerCountRow?.total ?? 0),
|
|
1164
1536
|
last_sync_at: lastSyncRow?.last_sync_at ?? null,
|
|
1165
1537
|
daemon_state: daemonStateValue,
|
|
1166
|
-
daemon_running:
|
|
1167
|
-
daemon_detail:
|
|
1168
|
-
project_filter_active:
|
|
1538
|
+
daemon_running: daemonRunning,
|
|
1539
|
+
daemon_detail: daemonDetail,
|
|
1540
|
+
project_filter_active: config.syncProjectsInclude.length > 0 || config.syncProjectsExclude.length > 0,
|
|
1169
1541
|
project_filter: {
|
|
1170
|
-
include:
|
|
1171
|
-
exclude:
|
|
1542
|
+
include: config.syncProjectsInclude,
|
|
1543
|
+
exclude: config.syncProjectsExclude
|
|
1172
1544
|
},
|
|
1173
1545
|
redacted: !showDiag
|
|
1174
1546
|
};
|
|
1175
1547
|
if (showDiag) {
|
|
1176
1548
|
statusPayload.device_id = deviceRow?.device_id ?? null;
|
|
1177
1549
|
statusPayload.fingerprint = deviceRow?.fingerprint ?? null;
|
|
1178
|
-
statusPayload.bind =
|
|
1550
|
+
statusPayload.bind = `${config.syncHost}:${config.syncPort}`;
|
|
1179
1551
|
statusPayload.daemon_last_error = lastError;
|
|
1180
1552
|
statusPayload.daemon_last_error_at = lastErrorAt;
|
|
1181
1553
|
statusPayload.daemon_last_ok_at = lastOkAt;
|
|
@@ -1207,18 +1579,38 @@ function syncRoutes(getStore) {
|
|
|
1207
1579
|
sync: {},
|
|
1208
1580
|
ping: {}
|
|
1209
1581
|
};
|
|
1582
|
+
const legacyDevices = store.claimableLegacyDeviceIds();
|
|
1583
|
+
const sharingReview = store.sharingReviewSummary(project);
|
|
1584
|
+
const coordinator = await coordinatorStatusSnapshot(store, config);
|
|
1585
|
+
let joinRequests = [];
|
|
1586
|
+
try {
|
|
1587
|
+
joinRequests = await listCoordinatorJoinRequests(config);
|
|
1588
|
+
} catch {
|
|
1589
|
+
joinRequests = [];
|
|
1590
|
+
}
|
|
1591
|
+
if (daemonStateValue === "ok") {
|
|
1592
|
+
const peerStates = new Set(peersItems.map((peer) => String(peer.status?.peer_state ?? "")));
|
|
1593
|
+
const latestFailedRecently = Boolean(attemptsItems[0] && attemptsItems[0].status === "error" && isRecentIso(attemptsItems[0].finished_at));
|
|
1594
|
+
const allOffline = peersItems.length > 0 && peersItems.every((peer) => String(peer.status?.peer_state ?? "") === "offline");
|
|
1595
|
+
if (latestFailedRecently) {
|
|
1596
|
+
if (peerStates.has("online") || peerStates.has("degraded")) daemonStateValue = "degraded";
|
|
1597
|
+
else if (allOffline) daemonStateValue = "offline-peers";
|
|
1598
|
+
else if (peersItems.length > 0) daemonStateValue = "stale";
|
|
1599
|
+
} else if (peerStates.has("degraded")) daemonStateValue = "degraded";
|
|
1600
|
+
else if (allOffline) daemonStateValue = "offline-peers";
|
|
1601
|
+
else if (peersItems.length > 0 && !peerStates.has("online")) daemonStateValue = "stale";
|
|
1602
|
+
statusPayload.daemon_state = daemonStateValue;
|
|
1603
|
+
statusBlock.daemon_state = daemonStateValue;
|
|
1604
|
+
}
|
|
1210
1605
|
return c.json({
|
|
1211
1606
|
...statusPayload,
|
|
1212
1607
|
status: statusBlock,
|
|
1213
1608
|
peers: peersItems,
|
|
1214
1609
|
attempts: attemptsItems.slice(0, 5),
|
|
1215
|
-
legacy_devices:
|
|
1216
|
-
sharing_review:
|
|
1217
|
-
coordinator
|
|
1218
|
-
|
|
1219
|
-
configured: false
|
|
1220
|
-
},
|
|
1221
|
-
join_requests: []
|
|
1610
|
+
legacy_devices: legacyDevices,
|
|
1611
|
+
sharing_review: sharingReview,
|
|
1612
|
+
coordinator,
|
|
1613
|
+
join_requests: joinRequests
|
|
1222
1614
|
});
|
|
1223
1615
|
}
|
|
1224
1616
|
});
|
|
@@ -1314,6 +1706,87 @@ function syncRoutes(getStore) {
|
|
|
1314
1706
|
return c.json({ ok: true });
|
|
1315
1707
|
}
|
|
1316
1708
|
});
|
|
1709
|
+
app.post("/api/sync/invites/create", async (c) => {
|
|
1710
|
+
let body;
|
|
1711
|
+
try {
|
|
1712
|
+
body = await c.req.json();
|
|
1713
|
+
} catch {
|
|
1714
|
+
return c.json({ error: "invalid json" }, 400);
|
|
1715
|
+
}
|
|
1716
|
+
const groupId = String(body.group_id ?? "").trim();
|
|
1717
|
+
const coordinatorUrl = body.coordinator_url == null ? null : String(body.coordinator_url ?? "");
|
|
1718
|
+
const policy = String(body.policy ?? "auto_admit").trim();
|
|
1719
|
+
const ttlHours = Number.parseInt(String(body.ttl_hours ?? 24), 10);
|
|
1720
|
+
if (!groupId) return c.json({ error: "group_id required" }, 400);
|
|
1721
|
+
if (body.coordinator_url != null && typeof body.coordinator_url !== "string") return c.json({ error: "coordinator_url must be string" }, 400);
|
|
1722
|
+
if (!["auto_admit", "approval_required"].includes(policy)) return c.json({ error: "policy must be auto_admit or approval_required" }, 400);
|
|
1723
|
+
if (!Number.isFinite(ttlHours)) return c.json({ error: "ttl_hours must be int" }, 400);
|
|
1724
|
+
try {
|
|
1725
|
+
const config = readCoordinatorSyncConfig();
|
|
1726
|
+
const result = await coordinatorCreateInviteAction({
|
|
1727
|
+
groupId,
|
|
1728
|
+
coordinatorUrl,
|
|
1729
|
+
policy,
|
|
1730
|
+
ttlHours,
|
|
1731
|
+
createdBy: null,
|
|
1732
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
1733
|
+
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
1734
|
+
});
|
|
1735
|
+
return c.json(result);
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
app.post("/api/sync/invites/import", async (c) => {
|
|
1741
|
+
const store = getStore();
|
|
1742
|
+
let body;
|
|
1743
|
+
try {
|
|
1744
|
+
body = await c.req.json();
|
|
1745
|
+
} catch {
|
|
1746
|
+
return c.json({ error: "invalid json" }, 400);
|
|
1747
|
+
}
|
|
1748
|
+
const inviteValue = String(body.invite ?? "").trim();
|
|
1749
|
+
if (!inviteValue) return c.json({ error: "invite required" }, 400);
|
|
1750
|
+
try {
|
|
1751
|
+
const result = await coordinatorImportInviteAction({
|
|
1752
|
+
inviteValue,
|
|
1753
|
+
dbPath: store.dbPath
|
|
1754
|
+
});
|
|
1755
|
+
return c.json(result);
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
app.post("/api/sync/join-requests/review", async (c) => {
|
|
1761
|
+
let body;
|
|
1762
|
+
try {
|
|
1763
|
+
body = await c.req.json();
|
|
1764
|
+
} catch {
|
|
1765
|
+
return c.json({ error: "invalid json" }, 400);
|
|
1766
|
+
}
|
|
1767
|
+
const requestId = String(body.request_id ?? "").trim();
|
|
1768
|
+
const action = String(body.action ?? "").trim();
|
|
1769
|
+
if (!requestId) return c.json({ error: "request_id required" }, 400);
|
|
1770
|
+
if (!["approve", "deny"].includes(action)) return c.json({ error: "action must be approve or deny" }, 400);
|
|
1771
|
+
try {
|
|
1772
|
+
const config = readCoordinatorSyncConfig();
|
|
1773
|
+
const result = await coordinatorReviewJoinRequestAction({
|
|
1774
|
+
requestId,
|
|
1775
|
+
approve: action === "approve",
|
|
1776
|
+
reviewedBy: null,
|
|
1777
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
1778
|
+
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
1779
|
+
});
|
|
1780
|
+
if (!result) return c.json({ error: "join request not found" }, 404);
|
|
1781
|
+
return c.json({
|
|
1782
|
+
ok: true,
|
|
1783
|
+
request: result
|
|
1784
|
+
});
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1787
|
+
return c.json({ error: message }, message.includes("request_not_found") || message.includes("not found") ? 404 : 400);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1317
1790
|
app.delete("/api/sync/peers/:peer_device_id", (c) => {
|
|
1318
1791
|
const store = getStore();
|
|
1319
1792
|
{
|
|
@@ -1353,6 +1826,7 @@ function closeStore() {
|
|
|
1353
1826
|
function createApp(opts) {
|
|
1354
1827
|
const storeFactory = opts?.storeFactory ?? getStore;
|
|
1355
1828
|
const sweeper = opts?.sweeper ?? null;
|
|
1829
|
+
const observer = opts?.observer ?? null;
|
|
1356
1830
|
const app = new Hono();
|
|
1357
1831
|
app.use("*", preflightHandler());
|
|
1358
1832
|
app.use("*", originGuard());
|
|
@@ -1360,7 +1834,8 @@ function createApp(opts) {
|
|
|
1360
1834
|
app.route("/", memoryRoutes(storeFactory));
|
|
1361
1835
|
app.route("/", observerStatusRoutes({
|
|
1362
1836
|
getStore: storeFactory,
|
|
1363
|
-
getSweeper: () => sweeper
|
|
1837
|
+
getSweeper: () => sweeper,
|
|
1838
|
+
getObserver: () => observer
|
|
1364
1839
|
}));
|
|
1365
1840
|
app.route("/", configRoutes({ getSweeper: () => sweeper }));
|
|
1366
1841
|
app.route("/", rawEventsRoutes(storeFactory, sweeper));
|