@codemem/server 0.22.0-alpha.5 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +511 -51
- package/dist/index.js.map +1 -1
- package/dist/request-rate-limit.d.ts +8 -0
- package/dist/request-rate-limit.d.ts.map +1 -0
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/stats.d.ts +1 -1
- package/dist/routes/stats.d.ts.map +1 -1
- package/dist/routes/sync.d.ts +11 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +3 -2
- package/static/app.js +10158 -1594
- package/static/index.html +175 -45
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, applyReplicationOps, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorReviewJoinRequestAction, coordinatorStatusSnapshot, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
3
|
+
import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, VERSION as VERSION$1, applyReplicationOps, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorReviewJoinRequestAction, coordinatorStatusSnapshot, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, runSyncPass, 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";
|
|
7
|
+
import { RateLimiter } from "limiter";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
|
|
9
10
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
12
|
import net from "node:net";
|
|
12
13
|
//#region src/middleware.ts
|
|
13
14
|
var LOOPBACK_HOSTS = new Set([
|
|
@@ -113,6 +114,49 @@ function preflightHandler() {
|
|
|
113
114
|
});
|
|
114
115
|
}
|
|
115
116
|
//#endregion
|
|
117
|
+
//#region src/request-rate-limit.ts
|
|
118
|
+
function createInMemoryRequestRateLimiter(windowMs = 6e4) {
|
|
119
|
+
const safeWindowMs = Math.max(1e3, Math.trunc(windowMs));
|
|
120
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
121
|
+
let checks = 0;
|
|
122
|
+
function estimatedRetryAfterS(limit) {
|
|
123
|
+
return Math.max(1, Math.ceil(safeWindowMs / Math.max(1, limit) / 1e3));
|
|
124
|
+
}
|
|
125
|
+
function cleanupExpiredBuckets(currentNow) {
|
|
126
|
+
if (buckets.size < 64 || checks % 64 !== 0) return;
|
|
127
|
+
const staleBefore = currentNow - safeWindowMs * 10;
|
|
128
|
+
for (const [key, bucket] of buckets) if (bucket.lastUsedAt < staleBefore) buckets.delete(key);
|
|
129
|
+
}
|
|
130
|
+
return { check(key, limit) {
|
|
131
|
+
checks += 1;
|
|
132
|
+
const currentNow = Date.now();
|
|
133
|
+
cleanupExpiredBuckets(currentNow);
|
|
134
|
+
const safeLimit = Math.max(1, Math.trunc(limit));
|
|
135
|
+
const bucketKey = `${safeLimit}:${key}`;
|
|
136
|
+
let bucket = buckets.get(bucketKey);
|
|
137
|
+
if (!bucket) {
|
|
138
|
+
bucket = {
|
|
139
|
+
limiter: new RateLimiter({
|
|
140
|
+
tokensPerInterval: safeLimit,
|
|
141
|
+
interval: safeWindowMs,
|
|
142
|
+
fireImmediately: true
|
|
143
|
+
}),
|
|
144
|
+
lastUsedAt: currentNow
|
|
145
|
+
};
|
|
146
|
+
buckets.set(bucketKey, bucket);
|
|
147
|
+
}
|
|
148
|
+
bucket.lastUsedAt = currentNow;
|
|
149
|
+
if (bucket.limiter.tryRemoveTokens(1)) return {
|
|
150
|
+
allowed: true,
|
|
151
|
+
retryAfterS: 0
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
allowed: false,
|
|
155
|
+
retryAfterS: estimatedRetryAfterS(safeLimit)
|
|
156
|
+
};
|
|
157
|
+
} };
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
116
160
|
//#region src/routes/config.ts
|
|
117
161
|
/**
|
|
118
162
|
* Config routes — GET /api/config, POST /api/config.
|
|
@@ -120,6 +164,7 @@ function preflightHandler() {
|
|
|
120
164
|
* Ports the user-facing config read/write path from Python's
|
|
121
165
|
* codemem/viewer_routes/config.py, scoped to the TS runtime's current needs.
|
|
122
166
|
*/
|
|
167
|
+
var REDACTED_VALUE = "[redacted]";
|
|
123
168
|
var RUNTIMES = new Set(["api_http", "claude_sidecar"]);
|
|
124
169
|
var AUTH_SOURCES = new Set([
|
|
125
170
|
"auto",
|
|
@@ -129,6 +174,20 @@ var AUTH_SOURCES = new Set([
|
|
|
129
174
|
"none"
|
|
130
175
|
]);
|
|
131
176
|
var HOT_RELOAD_KEYS = new Set(["raw_events_sweeper_interval_s"]);
|
|
177
|
+
var PROTECTED_WRITE_KEYS = new Set([
|
|
178
|
+
"claude_command",
|
|
179
|
+
"observer_base_url",
|
|
180
|
+
"observer_auth_file",
|
|
181
|
+
"observer_auth_command",
|
|
182
|
+
"observer_headers",
|
|
183
|
+
"sync_coordinator_url"
|
|
184
|
+
]);
|
|
185
|
+
var SECRET_CONFIG_KEYS = new Set([
|
|
186
|
+
"observer_auth_file",
|
|
187
|
+
"observer_auth_command",
|
|
188
|
+
"observer_headers",
|
|
189
|
+
"sync_coordinator_admin_secret"
|
|
190
|
+
]);
|
|
132
191
|
var ALLOWED_KEYS = [
|
|
133
192
|
"claude_command",
|
|
134
193
|
"observer_base_url",
|
|
@@ -216,6 +275,43 @@ function getEffectiveConfig(configData) {
|
|
|
216
275
|
}
|
|
217
276
|
return effective;
|
|
218
277
|
}
|
|
278
|
+
function redactConfigValue(key, value) {
|
|
279
|
+
if (value == null || !SECRET_CONFIG_KEYS.has(key)) return value;
|
|
280
|
+
if (Array.isArray(value)) return value.length > 0 ? REDACTED_VALUE : [];
|
|
281
|
+
if (typeof value === "object") return Object.keys(value).length > 0 ? REDACTED_VALUE : {};
|
|
282
|
+
if (typeof value === "string") return value.trim() ? REDACTED_VALUE : "";
|
|
283
|
+
return REDACTED_VALUE;
|
|
284
|
+
}
|
|
285
|
+
function sanitizeConfigForResponse(configData) {
|
|
286
|
+
const sanitized = {};
|
|
287
|
+
for (const [key, value] of Object.entries(configData)) sanitized[key] = redactConfigValue(key, value);
|
|
288
|
+
return sanitized;
|
|
289
|
+
}
|
|
290
|
+
function configValuesEqual(left, right) {
|
|
291
|
+
if (Object.is(left, right)) return true;
|
|
292
|
+
if (left == null || right == null) return left == null && right == null;
|
|
293
|
+
if (typeof left === "object" || typeof right === "object") return JSON.stringify(left) === JSON.stringify(right);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
function partitionViewerUpdates(updates, beforeConfig) {
|
|
297
|
+
const allowedUpdates = {};
|
|
298
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
299
|
+
if (!PROTECTED_WRITE_KEYS.has(key)) {
|
|
300
|
+
allowedUpdates[key] = value;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (SECRET_CONFIG_KEYS.has(key) && value === REDACTED_VALUE) continue;
|
|
304
|
+
if (configValuesEqual(value, beforeConfig[key])) continue;
|
|
305
|
+
return {
|
|
306
|
+
allowedUpdates: {},
|
|
307
|
+
error: `${key} cannot be changed from the viewer API; edit the config file or environment instead`
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
allowedUpdates,
|
|
312
|
+
error: null
|
|
313
|
+
};
|
|
314
|
+
}
|
|
219
315
|
function parsePositiveInt(value, allowZero = false) {
|
|
220
316
|
if (typeof value === "boolean") return null;
|
|
221
317
|
const parsed = typeof value === "number" ? value : typeof value === "string" && /^-?\d+$/.test(value.trim()) ? Number(value.trim()) : NaN;
|
|
@@ -322,12 +418,14 @@ function configRoutes(opts = {}) {
|
|
|
322
418
|
app.get("/api/config", (c) => {
|
|
323
419
|
const configPath = getConfigPath();
|
|
324
420
|
const configData = readConfigFile(configPath);
|
|
421
|
+
const effective = getEffectiveConfig(configData);
|
|
325
422
|
return c.json({
|
|
326
423
|
path: configPath,
|
|
327
|
-
config: configData,
|
|
424
|
+
config: sanitizeConfigForResponse(configData),
|
|
328
425
|
defaults: DEFAULTS,
|
|
329
|
-
effective:
|
|
426
|
+
effective: sanitizeConfigForResponse(effective),
|
|
330
427
|
env_overrides: getCodememEnvOverrides(),
|
|
428
|
+
protected_keys: [...PROTECTED_WRITE_KEYS].sort(),
|
|
331
429
|
providers: loadProviderOptions()
|
|
332
430
|
});
|
|
333
431
|
});
|
|
@@ -343,13 +441,15 @@ function configRoutes(opts = {}) {
|
|
|
343
441
|
const updates = "config" in payload && payload.config != null && typeof payload.config === "object" && !Array.isArray(payload.config) ? payload.config : payload;
|
|
344
442
|
const configPath = getCodememConfigPath();
|
|
345
443
|
const beforeConfig = readCodememConfigFile();
|
|
444
|
+
const { allowedUpdates, error: protectedKeyError } = partitionViewerUpdates(updates, beforeConfig);
|
|
445
|
+
if (protectedKeyError) return c.json({ error: protectedKeyError }, 403);
|
|
346
446
|
const beforeEffective = getEffectiveConfig(beforeConfig);
|
|
347
447
|
const nextConfig = { ...beforeConfig };
|
|
348
448
|
const providers = new Set(loadProviderOptions());
|
|
349
|
-
const touchedKeys = ALLOWED_KEYS.filter((key) => key in
|
|
449
|
+
const touchedKeys = ALLOWED_KEYS.filter((key) => key in allowedUpdates);
|
|
350
450
|
for (const key of ALLOWED_KEYS) {
|
|
351
|
-
if (!(key in
|
|
352
|
-
const error = validateAndApplyUpdate(nextConfig, key,
|
|
451
|
+
if (!(key in allowedUpdates)) continue;
|
|
452
|
+
const error = validateAndApplyUpdate(nextConfig, key, allowedUpdates[key], providers);
|
|
353
453
|
if (error) return c.json({ error }, 400);
|
|
354
454
|
}
|
|
355
455
|
let savedPath;
|
|
@@ -371,8 +471,9 @@ function configRoutes(opts = {}) {
|
|
|
371
471
|
const restartRequiredKeys = effectiveChangedKeys.filter((key) => !HOT_RELOAD_KEYS.has(key) && !(key in envOverrides));
|
|
372
472
|
return c.json({
|
|
373
473
|
path: savedPath,
|
|
374
|
-
config: nextConfig,
|
|
375
|
-
effective: afterEffective,
|
|
474
|
+
config: sanitizeConfigForResponse(nextConfig),
|
|
475
|
+
effective: sanitizeConfigForResponse(afterEffective),
|
|
476
|
+
protected_keys: [...PROTECTED_WRITE_KEYS].sort(),
|
|
376
477
|
effects: {
|
|
377
478
|
saved_keys: savedChangedKeys,
|
|
378
479
|
effective_keys: effectiveChangedKeys,
|
|
@@ -1079,6 +1180,11 @@ function rawEventsRoutes(getStore, sweeper) {
|
|
|
1079
1180
|
//#endregion
|
|
1080
1181
|
//#region src/routes/stats.ts
|
|
1081
1182
|
/**
|
|
1183
|
+
* Stats routes — GET /api/stats, GET /api/usage.
|
|
1184
|
+
*
|
|
1185
|
+
* Ports Python's viewer_routes/stats.py.
|
|
1186
|
+
*/
|
|
1187
|
+
/**
|
|
1082
1188
|
* Create stats routes. The store factory is called per-request to get a
|
|
1083
1189
|
* fresh connection (matching the Python viewer pattern).
|
|
1084
1190
|
*/
|
|
@@ -1099,6 +1205,9 @@ function statsRoutes(getStore) {
|
|
|
1099
1205
|
viewer_pid: process.pid
|
|
1100
1206
|
});
|
|
1101
1207
|
});
|
|
1208
|
+
app.get("/api/runtime", (c) => {
|
|
1209
|
+
return c.json({ version: VERSION$1 });
|
|
1210
|
+
});
|
|
1102
1211
|
app.get("/api/usage", (c) => {
|
|
1103
1212
|
const store = getStore();
|
|
1104
1213
|
{
|
|
@@ -1177,12 +1286,39 @@ function statsRoutes(getStore) {
|
|
|
1177
1286
|
*/
|
|
1178
1287
|
var SYNC_STALE_AFTER_SECONDS = 600;
|
|
1179
1288
|
var SYNC_PROTOCOL_VERSION = "2";
|
|
1289
|
+
var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
|
|
1290
|
+
var LEGACY_SHARED_WORKSPACE_ID = "shared:legacy";
|
|
1180
1291
|
function intEnvOr(name, fallback) {
|
|
1181
1292
|
const value = Number.parseInt(process.env[name] ?? "", 10);
|
|
1182
1293
|
return Number.isFinite(value) ? value : fallback;
|
|
1183
1294
|
}
|
|
1184
1295
|
var MAX_SYNC_BODY_BYTES = intEnvOr("CODEMEM_SYNC_MAX_BODY_BYTES", 1048576);
|
|
1185
1296
|
var MAX_SYNC_OPS = intEnvOr("CODEMEM_SYNC_MAX_OPS", 2e3);
|
|
1297
|
+
async function readBoundedRequestBytes(request, maxBytes) {
|
|
1298
|
+
const contentLength = Number.parseInt(request.headers.get("content-length") ?? "", 10);
|
|
1299
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
|
|
1300
|
+
const stream = request.body;
|
|
1301
|
+
if (!stream) return Buffer.alloc(0);
|
|
1302
|
+
const reader = stream.getReader();
|
|
1303
|
+
const chunks = [];
|
|
1304
|
+
let total = 0;
|
|
1305
|
+
try {
|
|
1306
|
+
while (true) {
|
|
1307
|
+
const { done, value } = await reader.read();
|
|
1308
|
+
if (done) break;
|
|
1309
|
+
if (!value) continue;
|
|
1310
|
+
total += value.byteLength;
|
|
1311
|
+
if (total > maxBytes) {
|
|
1312
|
+
await reader.cancel();
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
chunks.push(value);
|
|
1316
|
+
}
|
|
1317
|
+
} finally {
|
|
1318
|
+
reader.releaseLock();
|
|
1319
|
+
}
|
|
1320
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
1321
|
+
}
|
|
1186
1322
|
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.";
|
|
1187
1323
|
function pathWithQuery(url) {
|
|
1188
1324
|
const parsed = new URL(url);
|
|
@@ -1295,6 +1431,67 @@ function readPeerProjectFilters(store, peerDeviceId) {
|
|
|
1295
1431
|
exclude: parseJsonList(row.projects_exclude_json)
|
|
1296
1432
|
};
|
|
1297
1433
|
}
|
|
1434
|
+
function currentProjectScope(row, effective) {
|
|
1435
|
+
return {
|
|
1436
|
+
include: safeJsonList(row.projects_include_json),
|
|
1437
|
+
exclude: safeJsonList(row.projects_exclude_json),
|
|
1438
|
+
effective_include: effective?.include ?? safeJsonList(row.projects_include_json),
|
|
1439
|
+
effective_exclude: effective?.exclude ?? safeJsonList(row.projects_exclude_json),
|
|
1440
|
+
inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
function ensureLocalActorRecord(store) {
|
|
1444
|
+
const d = drizzle(store.db, { schema });
|
|
1445
|
+
if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
|
|
1446
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1447
|
+
d.insert(schema.actors).values({
|
|
1448
|
+
actor_id: store.actorId,
|
|
1449
|
+
display_name: store.actorDisplayName,
|
|
1450
|
+
is_local: 1,
|
|
1451
|
+
status: "active",
|
|
1452
|
+
merged_into_actor_id: null,
|
|
1453
|
+
created_at: now,
|
|
1454
|
+
updated_at: now
|
|
1455
|
+
}).run();
|
|
1456
|
+
}
|
|
1457
|
+
function findPeerDeviceIdForAddress(store, address) {
|
|
1458
|
+
const rows = store.db.prepare("SELECT peer_device_id, addresses_json FROM sync_peers").all();
|
|
1459
|
+
for (const row of rows) if (safeJsonList(row.addresses_json).includes(address)) return String(row.peer_device_id ?? "").trim() || null;
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
function claimLegacyDeviceAsSelf(store, originDeviceId) {
|
|
1463
|
+
const deviceId = String(originDeviceId || "").trim();
|
|
1464
|
+
if (!deviceId || deviceId === "unknown" || deviceId === store.deviceId) return 0;
|
|
1465
|
+
const personalWorkspaceId = `personal:${store.actorId}`;
|
|
1466
|
+
const result = store.db.prepare(`UPDATE memory_items
|
|
1467
|
+
SET actor_id = ?,
|
|
1468
|
+
actor_display_name = ?,
|
|
1469
|
+
visibility = 'private',
|
|
1470
|
+
workspace_id = ?,
|
|
1471
|
+
workspace_kind = 'personal',
|
|
1472
|
+
trust_state = 'trusted'
|
|
1473
|
+
WHERE origin_device_id = ?
|
|
1474
|
+
AND origin_device_id NOT IN (
|
|
1475
|
+
SELECT peer_device_id FROM sync_peers WHERE peer_device_id IS NOT NULL
|
|
1476
|
+
)
|
|
1477
|
+
AND (
|
|
1478
|
+
(
|
|
1479
|
+
actor_id IS NULL
|
|
1480
|
+
OR TRIM(actor_id) = ''
|
|
1481
|
+
OR actor_id LIKE 'legacy-sync:%'
|
|
1482
|
+
OR actor_id = ?
|
|
1483
|
+
)
|
|
1484
|
+
AND (
|
|
1485
|
+
actor_id IS NULL
|
|
1486
|
+
OR TRIM(actor_id) = ''
|
|
1487
|
+
OR actor_id LIKE 'legacy-sync:%'
|
|
1488
|
+
OR actor_display_name = ?
|
|
1489
|
+
OR workspace_id = ?
|
|
1490
|
+
OR trust_state = 'legacy_unknown'
|
|
1491
|
+
)
|
|
1492
|
+
)`).run(store.actorId, store.actorDisplayName, personalWorkspaceId, deviceId, store.actorId, LEGACY_SYNC_ACTOR_DISPLAY_NAME, LEGACY_SHARED_WORKSPACE_ID);
|
|
1493
|
+
return Number(result.changes ?? 0);
|
|
1494
|
+
}
|
|
1298
1495
|
function peerClaimedLocalActor(store, peerDeviceId) {
|
|
1299
1496
|
const row = store.db.prepare("SELECT claimed_local_actor FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(peerDeviceId);
|
|
1300
1497
|
return Boolean(row?.claimed_local_actor);
|
|
@@ -1321,6 +1518,10 @@ function redactCoordinatorStatus(coordinator, showDiag) {
|
|
|
1321
1518
|
last_seen_at: record.last_seen_at ?? null,
|
|
1322
1519
|
expires_at: record.expires_at ?? null,
|
|
1323
1520
|
stale: Boolean(record.stale),
|
|
1521
|
+
needs_local_approval: Boolean(record.needs_local_approval),
|
|
1522
|
+
waiting_for_peer_approval: Boolean(record.waiting_for_peer_approval),
|
|
1523
|
+
incoming_reciprocal_request_id: record.incoming_reciprocal_request_id ?? null,
|
|
1524
|
+
outgoing_reciprocal_request_id: record.outgoing_reciprocal_request_id ?? null,
|
|
1324
1525
|
fingerprint: null,
|
|
1325
1526
|
addresses: []
|
|
1326
1527
|
};
|
|
@@ -1346,37 +1547,58 @@ async function acceptDiscoveredPeer(store, input) {
|
|
|
1346
1547
|
detail: "That discovered device is no longer available. Refresh sync status and try again."
|
|
1347
1548
|
};
|
|
1348
1549
|
const nextFingerprint = String(match.fingerprint ?? "").trim();
|
|
1550
|
+
const nextPublicKey = String(match.public_key ?? "").trim();
|
|
1349
1551
|
const nextName = String(match.display_name ?? "").trim() || null;
|
|
1350
1552
|
const nextAddresses = Array.isArray(match.addresses) ? match.addresses.filter((value) => typeof value === "string") : [];
|
|
1553
|
+
if (!nextPublicKey) return {
|
|
1554
|
+
ok: false,
|
|
1555
|
+
status: 409,
|
|
1556
|
+
error: "discovered_peer_missing_public_key",
|
|
1557
|
+
detail: "This discovered device is missing its coordinator public key. Refresh sync status and try again."
|
|
1558
|
+
};
|
|
1559
|
+
const groupIds = Array.isArray(match.groups) ? match.groups.map((value) => String(value ?? "").trim()).filter(Boolean) : [];
|
|
1560
|
+
if (groupIds.length !== 1) return {
|
|
1561
|
+
ok: false,
|
|
1562
|
+
status: 409,
|
|
1563
|
+
error: "ambiguous_coordinator_group",
|
|
1564
|
+
detail: groupIds.length > 1 ? "This device is visible through multiple coordinator groups. Review the team setup before approving it here." : "This device is missing coordinator group context. Refresh sync status and try again."
|
|
1565
|
+
};
|
|
1566
|
+
const groupId = groupIds[0];
|
|
1351
1567
|
const d = drizzle(store.db, { schema });
|
|
1568
|
+
const existing = d.select({
|
|
1569
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
1570
|
+
pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
|
|
1571
|
+
public_key: schema.syncPeers.public_key,
|
|
1572
|
+
addresses_json: schema.syncPeers.addresses_json
|
|
1573
|
+
}).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
|
|
1574
|
+
const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
|
|
1575
|
+
if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
|
|
1576
|
+
ok: false,
|
|
1577
|
+
status: 409,
|
|
1578
|
+
error: "peer_conflict",
|
|
1579
|
+
detail: "An existing peer with this device id is pinned to a different fingerprint. Remove or repair the old peer before accepting this discovered device."
|
|
1580
|
+
};
|
|
1581
|
+
const existingAddresses = (() => {
|
|
1582
|
+
try {
|
|
1583
|
+
const raw = JSON.parse(String(existing?.addresses_json ?? "[]"));
|
|
1584
|
+
return Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : [];
|
|
1585
|
+
} catch {
|
|
1586
|
+
return [];
|
|
1587
|
+
}
|
|
1588
|
+
})();
|
|
1589
|
+
const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
|
|
1590
|
+
await createCoordinatorReciprocalApproval(store, config, {
|
|
1591
|
+
groupId,
|
|
1592
|
+
requestedDeviceId: input.peerDeviceId
|
|
1593
|
+
});
|
|
1352
1594
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1353
1595
|
return store.db.transaction(() => {
|
|
1354
|
-
const existing = d.select({
|
|
1355
|
-
peer_device_id: schema.syncPeers.peer_device_id,
|
|
1356
|
-
pinned_fingerprint: schema.syncPeers.pinned_fingerprint,
|
|
1357
|
-
addresses_json: schema.syncPeers.addresses_json
|
|
1358
|
-
}).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).get();
|
|
1359
|
-
const existingFingerprint = String(existing?.pinned_fingerprint ?? "").trim();
|
|
1360
|
-
if (existing && existingFingerprint && existingFingerprint !== nextFingerprint) return {
|
|
1361
|
-
ok: false,
|
|
1362
|
-
status: 409,
|
|
1363
|
-
error: "peer_conflict",
|
|
1364
|
-
detail: "An existing peer with this device id is pinned to a different fingerprint. Remove or repair the old peer before accepting this discovered device."
|
|
1365
|
-
};
|
|
1366
|
-
const existingAddresses = (() => {
|
|
1367
|
-
try {
|
|
1368
|
-
const raw = JSON.parse(String(existing?.addresses_json ?? "[]"));
|
|
1369
|
-
return Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : [];
|
|
1370
|
-
} catch {
|
|
1371
|
-
return [];
|
|
1372
|
-
}
|
|
1373
|
-
})();
|
|
1374
|
-
const addressesJson = JSON.stringify(mergeAddresses(existingAddresses, nextAddresses));
|
|
1375
1596
|
if (!existing) {
|
|
1376
1597
|
d.insert(schema.syncPeers).values({
|
|
1377
1598
|
peer_device_id: input.peerDeviceId,
|
|
1378
1599
|
name: nextName,
|
|
1379
1600
|
pinned_fingerprint: nextFingerprint || null,
|
|
1601
|
+
public_key: nextPublicKey,
|
|
1380
1602
|
addresses_json: addressesJson,
|
|
1381
1603
|
created_at: now,
|
|
1382
1604
|
last_seen_at: now
|
|
@@ -1392,6 +1614,7 @@ async function acceptDiscoveredPeer(store, input) {
|
|
|
1392
1614
|
d.update(schema.syncPeers).set({
|
|
1393
1615
|
name: nextName,
|
|
1394
1616
|
pinned_fingerprint: nextFingerprint || existing.pinned_fingerprint || null,
|
|
1617
|
+
public_key: nextPublicKey || existing.public_key || null,
|
|
1395
1618
|
addresses_json: addressesJson,
|
|
1396
1619
|
last_seen_at: now
|
|
1397
1620
|
}).where(eq(schema.syncPeers.peer_device_id, input.peerDeviceId)).run();
|
|
@@ -1459,7 +1682,7 @@ function filterOpsForPeer(store, peerDeviceId, ops) {
|
|
|
1459
1682
|
* When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
|
|
1460
1683
|
* are redacted.
|
|
1461
1684
|
*/
|
|
1462
|
-
function mapPeerRow(row, showDiag) {
|
|
1685
|
+
function mapPeerRow(store, row, showDiag) {
|
|
1463
1686
|
return {
|
|
1464
1687
|
peer_device_id: row.peer_device_id,
|
|
1465
1688
|
name: row.name,
|
|
@@ -1473,13 +1696,7 @@ function mapPeerRow(row, showDiag) {
|
|
|
1473
1696
|
claimed_local_actor: Boolean(row.claimed_local_actor),
|
|
1474
1697
|
actor_id: row.actor_id ?? null,
|
|
1475
1698
|
actor_display_name: row.actor_display_name ?? null,
|
|
1476
|
-
project_scope: {
|
|
1477
|
-
include: safeJsonList(row.projects_include_json),
|
|
1478
|
-
exclude: safeJsonList(row.projects_exclude_json),
|
|
1479
|
-
effective_include: safeJsonList(row.projects_include_json),
|
|
1480
|
-
effective_exclude: safeJsonList(row.projects_exclude_json),
|
|
1481
|
-
inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
|
|
1482
|
-
}
|
|
1699
|
+
project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) }
|
|
1483
1700
|
};
|
|
1484
1701
|
}
|
|
1485
1702
|
function isRecentIso(value, windowS = SYNC_STALE_AFTER_SECONDS) {
|
|
@@ -1562,12 +1779,26 @@ var PEERS_QUERY = `
|
|
|
1562
1779
|
* network-accessible. All requests are auth-gated via signature
|
|
1563
1780
|
* verification so unauthenticated callers are rejected.
|
|
1564
1781
|
*/
|
|
1565
|
-
function syncProtocolRoutes(getStore) {
|
|
1782
|
+
function syncProtocolRoutes(getStore, opts = {}) {
|
|
1566
1783
|
const app = new Hono();
|
|
1784
|
+
const routeRateLimit = opts.routeRateLimit ?? null;
|
|
1785
|
+
function rateLimitedResponse(c, key, authenticated) {
|
|
1786
|
+
if (!routeRateLimit) return null;
|
|
1787
|
+
const isRead = c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS";
|
|
1788
|
+
const result = routeRateLimit.limiter.check(`${c.req.method}:${authenticated ? "auth" : "anon"}:${key}`, authenticated ? isRead ? routeRateLimit.readLimit : routeRateLimit.mutationLimit : isRead ? routeRateLimit.unauthenticatedReadLimit : routeRateLimit.unauthenticatedMutationLimit);
|
|
1789
|
+
if (result.allowed) return null;
|
|
1790
|
+
c.header("Retry-After", String(result.retryAfterS));
|
|
1791
|
+
return c.json({
|
|
1792
|
+
error: "rate_limited",
|
|
1793
|
+
retry_after_s: result.retryAfterS
|
|
1794
|
+
}, 429);
|
|
1795
|
+
}
|
|
1567
1796
|
app.get("/v1/status", (c) => {
|
|
1568
1797
|
const store = getStore();
|
|
1569
1798
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
1570
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1799
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1800
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1801
|
+
if (limited) return limited;
|
|
1571
1802
|
try {
|
|
1572
1803
|
let device = store.db.prepare("SELECT device_id, fingerprint FROM sync_device LIMIT 1").get();
|
|
1573
1804
|
if (!device) {
|
|
@@ -1591,7 +1822,9 @@ function syncProtocolRoutes(getStore) {
|
|
|
1591
1822
|
app.get("/v1/ops", (c) => {
|
|
1592
1823
|
const store = getStore();
|
|
1593
1824
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
1594
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1825
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1826
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1827
|
+
if (limited) return limited;
|
|
1595
1828
|
const peerDeviceId = auth.deviceId;
|
|
1596
1829
|
try {
|
|
1597
1830
|
const since = c.req.query("since") ?? null;
|
|
@@ -1637,7 +1870,9 @@ function syncProtocolRoutes(getStore) {
|
|
|
1637
1870
|
app.get("/v1/snapshot", (c) => {
|
|
1638
1871
|
const store = getStore();
|
|
1639
1872
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
1640
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1873
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1874
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1875
|
+
if (limited) return limited;
|
|
1641
1876
|
try {
|
|
1642
1877
|
const rawGeneration = c.req.query("generation");
|
|
1643
1878
|
const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
|
|
@@ -1673,10 +1908,12 @@ function syncProtocolRoutes(getStore) {
|
|
|
1673
1908
|
});
|
|
1674
1909
|
app.post("/v1/ops", async (c) => {
|
|
1675
1910
|
const store = getStore();
|
|
1676
|
-
const raw =
|
|
1677
|
-
if (raw
|
|
1911
|
+
const raw = await readBoundedRequestBytes(c.req.raw, MAX_SYNC_BODY_BYTES);
|
|
1912
|
+
if (raw == null) return c.json({ error: "payload_too_large" }, 413);
|
|
1678
1913
|
const auth = authorizeSyncRequest(store, c.req, raw);
|
|
1679
|
-
if (!auth.ok) return c.json(unauthorizedPayload(auth.reason), 401);
|
|
1914
|
+
if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
|
|
1915
|
+
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
1916
|
+
if (limited) return limited;
|
|
1680
1917
|
const peerDeviceId = auth.deviceId;
|
|
1681
1918
|
let body;
|
|
1682
1919
|
try {
|
|
@@ -1793,7 +2030,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1793
2030
|
statusPayload.daemon_detail = runtimeStatus.detail ?? daemonDetail;
|
|
1794
2031
|
}
|
|
1795
2032
|
const peersItems = store.db.prepare(PEERS_QUERY).all().map((row) => {
|
|
1796
|
-
const peer = mapPeerRow(row, showDiag);
|
|
2033
|
+
const peer = mapPeerRow(store, row, showDiag);
|
|
1797
2034
|
peer.status = peerStatus(peer);
|
|
1798
2035
|
return peer;
|
|
1799
2036
|
});
|
|
@@ -1824,7 +2061,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1824
2061
|
const sharingReview = store.sharingReviewSummary(project);
|
|
1825
2062
|
const coordinator = redactCoordinatorStatus(await coordinatorStatusSnapshot(store, config), showDiag);
|
|
1826
2063
|
let joinRequests = [];
|
|
1827
|
-
if (includeJoinRequests && config.syncCoordinatorAdminSecret) try {
|
|
2064
|
+
if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
|
|
1828
2065
|
joinRequests = await listCoordinatorJoinRequests(config);
|
|
1829
2066
|
} catch {
|
|
1830
2067
|
joinRequests = [];
|
|
@@ -1859,7 +2096,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1859
2096
|
sharing_review: sharingReview,
|
|
1860
2097
|
coordinator
|
|
1861
2098
|
};
|
|
1862
|
-
if (includeJoinRequests) responsePayload.join_requests = joinRequests;
|
|
2099
|
+
if (includeJoinRequests && showDiag) responsePayload.join_requests = joinRequests;
|
|
1863
2100
|
return c.json(responsePayload);
|
|
1864
2101
|
}
|
|
1865
2102
|
});
|
|
@@ -1867,7 +2104,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1867
2104
|
const store = getStore();
|
|
1868
2105
|
{
|
|
1869
2106
|
const showDiag = queryBool(c.req.query("includeDiagnostics"));
|
|
1870
|
-
const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(row, showDiag));
|
|
2107
|
+
const peers = store.db.prepare(PEERS_QUERY).all().map((row) => mapPeerRow(store, row, showDiag));
|
|
1871
2108
|
return c.json({
|
|
1872
2109
|
items: peers,
|
|
1873
2110
|
redacted: !showDiag
|
|
@@ -1877,6 +2114,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1877
2114
|
app.get("/api/sync/actors", (c) => {
|
|
1878
2115
|
const store = getStore();
|
|
1879
2116
|
{
|
|
2117
|
+
ensureLocalActorRecord(store);
|
|
1880
2118
|
const d = drizzle(store.db, { schema });
|
|
1881
2119
|
const includeMerged = queryBool(c.req.query("includeMerged"));
|
|
1882
2120
|
const query = d.select().from(schema.actors);
|
|
@@ -1955,6 +2193,216 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
1955
2193
|
return c.json({ ok: true });
|
|
1956
2194
|
}
|
|
1957
2195
|
});
|
|
2196
|
+
app.post("/api/sync/run", async (c) => {
|
|
2197
|
+
const store = getStore();
|
|
2198
|
+
let body = {};
|
|
2199
|
+
try {
|
|
2200
|
+
body = await c.req.json();
|
|
2201
|
+
} catch {
|
|
2202
|
+
body = {};
|
|
2203
|
+
}
|
|
2204
|
+
if (!readCoordinatorSyncConfig().syncEnabled) return c.json({ error: "sync_disabled" }, 403);
|
|
2205
|
+
const address = typeof body.address === "string" && body.address.trim() ? body.address.trim() : null;
|
|
2206
|
+
const requestedPeerId = typeof body.peer_device_id === "string" && body.peer_device_id.trim() ? body.peer_device_id.trim() : null;
|
|
2207
|
+
let peerIds;
|
|
2208
|
+
if (address) {
|
|
2209
|
+
const peerId = findPeerDeviceIdForAddress(store, address);
|
|
2210
|
+
if (!peerId) return c.json({ error: "unknown peer address" }, 404);
|
|
2211
|
+
peerIds = [peerId];
|
|
2212
|
+
} else if (requestedPeerId) peerIds = store.db.prepare("SELECT peer_device_id FROM sync_peers WHERE peer_device_id = ?").all(requestedPeerId).map((row) => String(row.peer_device_id ?? "").trim()).filter(Boolean);
|
|
2213
|
+
else peerIds = store.db.prepare("SELECT peer_device_id FROM sync_peers").all().map((row) => String(row.peer_device_id ?? "").trim()).filter(Boolean);
|
|
2214
|
+
const items = [];
|
|
2215
|
+
for (const peerId of peerIds) {
|
|
2216
|
+
const result = await runSyncPass(store.db, peerId);
|
|
2217
|
+
items.push({
|
|
2218
|
+
peer_device_id: peerId,
|
|
2219
|
+
...result
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
return c.json({ items });
|
|
2223
|
+
});
|
|
2224
|
+
app.post("/api/sync/peers/scope", async (c) => {
|
|
2225
|
+
const store = getStore();
|
|
2226
|
+
let body;
|
|
2227
|
+
try {
|
|
2228
|
+
body = await c.req.json();
|
|
2229
|
+
} catch {
|
|
2230
|
+
return c.json({ error: "invalid json" }, 400);
|
|
2231
|
+
}
|
|
2232
|
+
const peerDeviceId = String(body.peer_device_id ?? "").trim();
|
|
2233
|
+
if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
|
|
2234
|
+
const include = Array.isArray(body.include) ? body.include.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean) : null;
|
|
2235
|
+
const exclude = Array.isArray(body.exclude) ? body.exclude.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean) : null;
|
|
2236
|
+
const inheritGlobal = Boolean(body.inherit_global);
|
|
2237
|
+
const d = drizzle(store.db, { schema });
|
|
2238
|
+
if (!d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get()) return c.json({ error: "peer not found" }, 404);
|
|
2239
|
+
d.update(schema.syncPeers).set({
|
|
2240
|
+
projects_include_json: inheritGlobal ? null : JSON.stringify(include ?? []),
|
|
2241
|
+
projects_exclude_json: inheritGlobal ? null : JSON.stringify(exclude ?? [])
|
|
2242
|
+
}).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
|
|
2243
|
+
const row = d.select({
|
|
2244
|
+
projects_include_json: schema.syncPeers.projects_include_json,
|
|
2245
|
+
projects_exclude_json: schema.syncPeers.projects_exclude_json
|
|
2246
|
+
}).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
|
|
2247
|
+
if (!row) return c.json({ error: "peer not found" }, 404);
|
|
2248
|
+
return c.json({
|
|
2249
|
+
ok: true,
|
|
2250
|
+
project_scope: currentProjectScope(row, readPeerProjectFilters(store, peerDeviceId))
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
app.post("/api/sync/peers/identity", async (c) => {
|
|
2254
|
+
const store = getStore();
|
|
2255
|
+
ensureLocalActorRecord(store);
|
|
2256
|
+
let body;
|
|
2257
|
+
try {
|
|
2258
|
+
body = await c.req.json();
|
|
2259
|
+
} catch {
|
|
2260
|
+
return c.json({ error: "invalid json" }, 400);
|
|
2261
|
+
}
|
|
2262
|
+
const peerDeviceId = String(body.peer_device_id ?? "").trim();
|
|
2263
|
+
if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
|
|
2264
|
+
const requestedActorId = body.actor_id == null ? void 0 : String(body.actor_id ?? "").trim() || null;
|
|
2265
|
+
const claimedLocalActor = typeof body.claimed_local_actor === "boolean" ? body.claimed_local_actor : void 0;
|
|
2266
|
+
const d = drizzle(store.db, { schema });
|
|
2267
|
+
const peer = d.select({
|
|
2268
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
2269
|
+
actor_id: schema.syncPeers.actor_id,
|
|
2270
|
+
claimed_local_actor: schema.syncPeers.claimed_local_actor
|
|
2271
|
+
}).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
|
|
2272
|
+
if (!peer) return c.json({ error: "peer not found" }, 404);
|
|
2273
|
+
let nextActorId = peer.actor_id ?? null;
|
|
2274
|
+
let nextClaimedLocalActor = Boolean(peer.claimed_local_actor);
|
|
2275
|
+
if (requestedActorId !== void 0) {
|
|
2276
|
+
nextActorId = requestedActorId;
|
|
2277
|
+
if (requestedActorId) {
|
|
2278
|
+
const actor = d.select({
|
|
2279
|
+
actor_id: schema.actors.actor_id,
|
|
2280
|
+
is_local: schema.actors.is_local,
|
|
2281
|
+
status: schema.actors.status
|
|
2282
|
+
}).from(schema.actors).where(eq(schema.actors.actor_id, requestedActorId)).get();
|
|
2283
|
+
if (!actor) return c.json({ error: "actor not found" }, 404);
|
|
2284
|
+
if (actor.status !== "active") return c.json({ error: "actor not active" }, 409);
|
|
2285
|
+
nextClaimedLocalActor = claimedLocalActor ?? Boolean(actor.is_local);
|
|
2286
|
+
} else nextClaimedLocalActor = claimedLocalActor ?? false;
|
|
2287
|
+
} else if (claimedLocalActor !== void 0) {
|
|
2288
|
+
nextClaimedLocalActor = claimedLocalActor;
|
|
2289
|
+
if (claimedLocalActor) nextActorId = store.actorId;
|
|
2290
|
+
else if (nextActorId === store.actorId) nextActorId = null;
|
|
2291
|
+
}
|
|
2292
|
+
d.update(schema.syncPeers).set({
|
|
2293
|
+
actor_id: nextActorId,
|
|
2294
|
+
claimed_local_actor: nextClaimedLocalActor ? 1 : 0
|
|
2295
|
+
}).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
|
|
2296
|
+
return c.json({
|
|
2297
|
+
ok: true,
|
|
2298
|
+
actor_id: nextActorId,
|
|
2299
|
+
claimed_local_actor: nextClaimedLocalActor
|
|
2300
|
+
});
|
|
2301
|
+
});
|
|
2302
|
+
app.post("/api/sync/actors", async (c) => {
|
|
2303
|
+
const store = getStore();
|
|
2304
|
+
ensureLocalActorRecord(store);
|
|
2305
|
+
let body;
|
|
2306
|
+
try {
|
|
2307
|
+
body = await c.req.json();
|
|
2308
|
+
} catch {
|
|
2309
|
+
return c.json({ error: "invalid json" }, 400);
|
|
2310
|
+
}
|
|
2311
|
+
const displayName = String(body.display_name ?? "").trim();
|
|
2312
|
+
if (!displayName) return c.json({ error: "display_name required" }, 400);
|
|
2313
|
+
const d = drizzle(store.db, { schema });
|
|
2314
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2315
|
+
const actor = {
|
|
2316
|
+
actor_id: randomUUID(),
|
|
2317
|
+
display_name: displayName,
|
|
2318
|
+
is_local: 0,
|
|
2319
|
+
status: "active",
|
|
2320
|
+
merged_into_actor_id: null,
|
|
2321
|
+
created_at: now,
|
|
2322
|
+
updated_at: now
|
|
2323
|
+
};
|
|
2324
|
+
d.insert(schema.actors).values(actor).run();
|
|
2325
|
+
return c.json(actor);
|
|
2326
|
+
});
|
|
2327
|
+
app.post("/api/sync/actors/rename", async (c) => {
|
|
2328
|
+
const store = getStore();
|
|
2329
|
+
ensureLocalActorRecord(store);
|
|
2330
|
+
let body;
|
|
2331
|
+
try {
|
|
2332
|
+
body = await c.req.json();
|
|
2333
|
+
} catch {
|
|
2334
|
+
return c.json({ error: "invalid json" }, 400);
|
|
2335
|
+
}
|
|
2336
|
+
const actorId = String(body.actor_id ?? "").trim();
|
|
2337
|
+
const displayName = String(body.display_name ?? "").trim();
|
|
2338
|
+
if (!actorId) return c.json({ error: "actor_id required" }, 400);
|
|
2339
|
+
if (!displayName) return c.json({ error: "display_name required" }, 400);
|
|
2340
|
+
const d = drizzle(store.db, { schema });
|
|
2341
|
+
const actor = d.select().from(schema.actors).where(eq(schema.actors.actor_id, actorId)).get();
|
|
2342
|
+
if (!actor) return c.json({ error: "actor not found" }, 404);
|
|
2343
|
+
const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2344
|
+
d.update(schema.actors).set({
|
|
2345
|
+
display_name: displayName,
|
|
2346
|
+
updated_at: updatedAt
|
|
2347
|
+
}).where(eq(schema.actors.actor_id, actorId)).run();
|
|
2348
|
+
return c.json({
|
|
2349
|
+
...actor,
|
|
2350
|
+
display_name: displayName,
|
|
2351
|
+
updated_at: updatedAt
|
|
2352
|
+
});
|
|
2353
|
+
});
|
|
2354
|
+
app.post("/api/sync/actors/merge", async (c) => {
|
|
2355
|
+
const store = getStore();
|
|
2356
|
+
ensureLocalActorRecord(store);
|
|
2357
|
+
let body;
|
|
2358
|
+
try {
|
|
2359
|
+
body = await c.req.json();
|
|
2360
|
+
} catch {
|
|
2361
|
+
return c.json({ error: "invalid json" }, 400);
|
|
2362
|
+
}
|
|
2363
|
+
const primaryActorId = String(body.primary_actor_id ?? "").trim();
|
|
2364
|
+
const secondaryActorId = String(body.secondary_actor_id ?? "").trim();
|
|
2365
|
+
if (!primaryActorId) return c.json({ error: "primary_actor_id required" }, 400);
|
|
2366
|
+
if (!secondaryActorId) return c.json({ error: "secondary_actor_id required" }, 400);
|
|
2367
|
+
if (primaryActorId === secondaryActorId) return c.json({ error: "actor ids must differ" }, 400);
|
|
2368
|
+
const d = drizzle(store.db, { schema });
|
|
2369
|
+
const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
|
|
2370
|
+
const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
|
|
2371
|
+
if (!primary || !secondary) return c.json({ error: "actor not found" }, 404);
|
|
2372
|
+
if (primary.status !== "active") return c.json({ error: "primary actor not active" }, 409);
|
|
2373
|
+
if (secondary.status !== "active") return c.json({ error: "secondary actor not active" }, 409);
|
|
2374
|
+
if (secondary.is_local) return c.json({ error: "cannot merge local actor" }, 409);
|
|
2375
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2376
|
+
const mergedCount = store.db.transaction(() => {
|
|
2377
|
+
const peerUpdate = d.update(schema.syncPeers).set({ actor_id: primaryActorId }).where(eq(schema.syncPeers.actor_id, secondaryActorId)).run();
|
|
2378
|
+
if (primary.is_local) d.update(schema.syncPeers).set({ claimed_local_actor: 1 }).where(eq(schema.syncPeers.actor_id, primaryActorId)).run();
|
|
2379
|
+
d.update(schema.actors).set({
|
|
2380
|
+
status: "merged",
|
|
2381
|
+
merged_into_actor_id: primaryActorId,
|
|
2382
|
+
updated_at: now
|
|
2383
|
+
}).where(eq(schema.actors.actor_id, secondaryActorId)).run();
|
|
2384
|
+
return Number(peerUpdate.changes ?? 0);
|
|
2385
|
+
})();
|
|
2386
|
+
return c.json({ merged_count: mergedCount });
|
|
2387
|
+
});
|
|
2388
|
+
app.post("/api/sync/legacy-devices/claim", async (c) => {
|
|
2389
|
+
const store = getStore();
|
|
2390
|
+
let body;
|
|
2391
|
+
try {
|
|
2392
|
+
body = await c.req.json();
|
|
2393
|
+
} catch {
|
|
2394
|
+
return c.json({ error: "invalid json" }, 400);
|
|
2395
|
+
}
|
|
2396
|
+
const originDeviceId = String(body.origin_device_id ?? "").trim();
|
|
2397
|
+
if (!originDeviceId) return c.json({ error: "origin_device_id required" }, 400);
|
|
2398
|
+
const updated = claimLegacyDeviceAsSelf(store, originDeviceId);
|
|
2399
|
+
if (updated <= 0) return c.json({ error: "legacy device not found" }, 404);
|
|
2400
|
+
return c.json({
|
|
2401
|
+
ok: true,
|
|
2402
|
+
origin_device_id: originDeviceId,
|
|
2403
|
+
updated
|
|
2404
|
+
});
|
|
2405
|
+
});
|
|
1958
2406
|
app.post("/api/sync/peers/accept-discovered", async (c) => {
|
|
1959
2407
|
const store = getStore();
|
|
1960
2408
|
let body;
|
|
@@ -2132,7 +2580,9 @@ function createApp(opts) {
|
|
|
2132
2580
|
root: staticRoot,
|
|
2133
2581
|
rewriteRequestPath: (path) => path.replace(/^\/assets/, "")
|
|
2134
2582
|
}));
|
|
2135
|
-
const
|
|
2583
|
+
const indexPath = join(staticRoot, "index.html");
|
|
2584
|
+
if (!existsSync(indexPath)) throw new Error(`Viewer assets missing at ${indexPath}. Run \`pnpm build\` from the repo root before starting the viewer.`);
|
|
2585
|
+
const indexHtml = readFileSync(indexPath, "utf-8");
|
|
2136
2586
|
app.get("*", (c) => {
|
|
2137
2587
|
if (c.req.path.startsWith("/api/")) return c.json({ error: "not found" }, 404);
|
|
2138
2588
|
return c.html(indexHtml);
|
|
@@ -2151,8 +2601,18 @@ function createApp(opts) {
|
|
|
2151
2601
|
*/
|
|
2152
2602
|
function createSyncApp(opts) {
|
|
2153
2603
|
const storeFactory = opts?.storeFactory ?? getStore;
|
|
2604
|
+
const requestRateLimit = opts?.syncRequestRateLimit ?? {};
|
|
2605
|
+
const rateLimiter = requestRateLimit.limiter ?? createInMemoryRequestRateLimiter();
|
|
2606
|
+
const readLimit = Math.max(1, Math.trunc(requestRateLimit.readLimit ?? 120));
|
|
2607
|
+
const mutationLimit = Math.max(1, Math.trunc(requestRateLimit.mutationLimit ?? 30));
|
|
2154
2608
|
const app = new Hono();
|
|
2155
|
-
app.route("/", syncProtocolRoutes(storeFactory
|
|
2609
|
+
app.route("/", syncProtocolRoutes(storeFactory, { routeRateLimit: {
|
|
2610
|
+
limiter: rateLimiter,
|
|
2611
|
+
readLimit,
|
|
2612
|
+
mutationLimit,
|
|
2613
|
+
unauthenticatedReadLimit: Math.max(1, Math.trunc(requestRateLimit.unauthenticatedReadLimit ?? Math.min(20, readLimit))),
|
|
2614
|
+
unauthenticatedMutationLimit: Math.max(1, Math.trunc(requestRateLimit.unauthenticatedMutationLimit ?? Math.min(10, mutationLimit)))
|
|
2615
|
+
} }));
|
|
2156
2616
|
app.all("*", (c) => c.json({ error: "not found" }, 404));
|
|
2157
2617
|
return app;
|
|
2158
2618
|
}
|