logseq-graph-living-atlas 0.1.0
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/.env.example +29 -0
- package/CHANGELOG.md +38 -0
- package/CODE_OF_CONDUCT.md +33 -0
- package/CONTRIBUTING.md +116 -0
- package/GOVERNANCE.md +40 -0
- package/LICENSE +21 -0
- package/MAINTAINERS.md +34 -0
- package/README.md +370 -0
- package/ROADMAP.md +38 -0
- package/SECURITY.md +43 -0
- package/SUPPORT.md +31 -0
- package/dist/assets/AtlasCanvas-p-Pb_C3p.js +172 -0
- package/dist/assets/index-Cb0dgkF5.css +1 -0
- package/dist/assets/index-D-LUf-q7.js +12 -0
- package/dist/assets/three-DQCgX68K.js +3947 -0
- package/dist/index.html +13 -0
- package/docs/ADAPTERS.md +48 -0
- package/docs/API.md +145 -0
- package/docs/ARCHITECTURE.md +93 -0
- package/docs/CONCEPTS.md +62 -0
- package/docs/MCP.md +74 -0
- package/docs/RELEASE.md +65 -0
- package/docs/REPO_GUIDE.md +92 -0
- package/docs/TROUBLESHOOTING.md +138 -0
- package/docs/assets/living-atlas-demo.png +0 -0
- package/docs/assets/living-atlas-pathfinder.png +0 -0
- package/docs/assets/living-atlas-radar.png +0 -0
- package/docs/assets/living-atlas-source-detail.png +0 -0
- package/package.json +102 -0
- package/server/brain-service.mjs +201 -0
- package/server/contracts.mjs +346 -0
- package/server/fixture/create-fixture-graph.mjs +115 -0
- package/server/graph/pathfinding.mjs +155 -0
- package/server/graph/quality.mjs +11 -0
- package/server/graph/utils.mjs +25 -0
- package/server/graph-index.mjs +920 -0
- package/server/logseq/parser.mjs +121 -0
- package/server/logseq/source-adapter.mjs +147 -0
- package/server/redaction.mjs +89 -0
- package/server/service.mjs +777 -0
- package/server/source-adapter-contract.d.ts +46 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
buildSnapshot,
|
|
6
|
+
connectorCandidates,
|
|
7
|
+
createSnapshotRuntime,
|
|
8
|
+
DEFAULT_NODE_EDGE_LIMIT,
|
|
9
|
+
budgetSnapshot,
|
|
10
|
+
diffSnapshots,
|
|
11
|
+
focusSnapshot,
|
|
12
|
+
nodeDetail,
|
|
13
|
+
pathSnapshot,
|
|
14
|
+
searchSnapshot
|
|
15
|
+
} from "./graph-index.mjs";
|
|
16
|
+
import { createLogseqSourceAdapter } from "./logseq/source-adapter.mjs";
|
|
17
|
+
import {
|
|
18
|
+
createCacheEnvelope,
|
|
19
|
+
validateApiSnapshot,
|
|
20
|
+
validateCacheEnvelope,
|
|
21
|
+
validateConnectorResult,
|
|
22
|
+
validateDelta,
|
|
23
|
+
validateFocusResult,
|
|
24
|
+
validateHealth,
|
|
25
|
+
validateNodeDetail,
|
|
26
|
+
validatePathResult,
|
|
27
|
+
validateSearchResult,
|
|
28
|
+
validateSnapshot
|
|
29
|
+
} from "./contracts.mjs";
|
|
30
|
+
import { redactPayload } from "./redaction.mjs";
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_SNAPSHOT_NODE_BUDGET = 7200;
|
|
33
|
+
export const DEFAULT_SNAPSHOT_LINK_BUDGET = 18000;
|
|
34
|
+
const CACHE_PROPERTY_KEYS = new Set([
|
|
35
|
+
"type",
|
|
36
|
+
"tags",
|
|
37
|
+
"status",
|
|
38
|
+
"source",
|
|
39
|
+
"confidence",
|
|
40
|
+
"last-contacted",
|
|
41
|
+
"company",
|
|
42
|
+
"organization",
|
|
43
|
+
"owner",
|
|
44
|
+
"parent"
|
|
45
|
+
]);
|
|
46
|
+
const CACHE_RELATION_KINDS = new Set([
|
|
47
|
+
...CACHE_PROPERTY_KEYS,
|
|
48
|
+
"org",
|
|
49
|
+
"parent org",
|
|
50
|
+
"reports to",
|
|
51
|
+
"customer of",
|
|
52
|
+
"part of"
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
export function createBrainService(options) {
|
|
56
|
+
const root = path.resolve(requiredOption(options.root, "root"));
|
|
57
|
+
const sourceAdapter = options.sourceAdapter || createLogseqSourceAdapter(root);
|
|
58
|
+
const cachePath = path.resolve(requiredOption(options.cachePath, "cachePath"));
|
|
59
|
+
const staticDir = options.staticDir ? path.resolve(options.staticDir) : null;
|
|
60
|
+
const port = Number(options.port ?? 8787);
|
|
61
|
+
const bindHost = options.bindHost || "127.0.0.1";
|
|
62
|
+
const debugPaths = Boolean(options.debugPaths);
|
|
63
|
+
const localToken = String(options.token || "");
|
|
64
|
+
const allowUnauthenticatedRead = Boolean(options.allowUnauthenticatedRead);
|
|
65
|
+
if (options.requireToken === false && !allowUnauthenticatedRead) {
|
|
66
|
+
throw new Error("createBrainService({ requireToken: false }) requires allowUnauthenticatedRead: true.");
|
|
67
|
+
}
|
|
68
|
+
let requireToken;
|
|
69
|
+
if (options.requireToken === undefined) {
|
|
70
|
+
requireToken = !allowUnauthenticatedRead;
|
|
71
|
+
} else {
|
|
72
|
+
requireToken = Boolean(options.requireToken);
|
|
73
|
+
}
|
|
74
|
+
const allowUnauthenticatedReindex = Boolean(options.allowUnauthenticatedReindex);
|
|
75
|
+
const allowedOrigins = normalizeAllowedOrigins(options.allowedOrigins);
|
|
76
|
+
const watch = Boolean(options.watch);
|
|
77
|
+
const logger = options.logger || console;
|
|
78
|
+
|
|
79
|
+
if (!isLocalHostname(bindHost)) throw new Error("Living Atlas only binds to localhost. Use 127.0.0.1, localhost, or ::1.");
|
|
80
|
+
if (requireToken && !localToken) throw new Error("LIVING_ATLAS_REQUIRE_TOKEN requires LIVING_ATLAS_TOKEN or --token.");
|
|
81
|
+
assertCacheOutsideGraph(cachePath, root);
|
|
82
|
+
|
|
83
|
+
let state = loadState();
|
|
84
|
+
let snapshot = state.snapshot;
|
|
85
|
+
let runtime = createSnapshotRuntime(snapshot, state.records);
|
|
86
|
+
let previousSnapshot = null;
|
|
87
|
+
let eventSeq = 0;
|
|
88
|
+
const sseClients = new Set();
|
|
89
|
+
const watchers = [];
|
|
90
|
+
let watchTimer = null;
|
|
91
|
+
let watchedFingerprint = null;
|
|
92
|
+
|
|
93
|
+
const server = http.createServer(async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
if (!isAllowedLocalRequest(req)) return forbidden(res);
|
|
96
|
+
if (req.method === "OPTIONS") return noContent(req, res);
|
|
97
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
98
|
+
if (url.pathname.startsWith("/api/") && !isAuthorizedRead(req, url)) return unauthorized(req, res, "Living Atlas API token required.");
|
|
99
|
+
if (url.pathname === "/api/health") return sendJson(req, res, validateHealth(healthPayload()));
|
|
100
|
+
if (url.pathname === "/api/snapshot") {
|
|
101
|
+
const nodeBudget = snapshotBudgetParam(url, "nodeBudget", {
|
|
102
|
+
defaultValue: DEFAULT_SNAPSHOT_NODE_BUDGET,
|
|
103
|
+
min: 0,
|
|
104
|
+
max: 25000
|
|
105
|
+
});
|
|
106
|
+
const linkBudget = snapshotBudgetParam(url, "linkBudget", {
|
|
107
|
+
defaultValue: DEFAULT_SNAPSHOT_LINK_BUDGET,
|
|
108
|
+
min: 0,
|
|
109
|
+
max: 100000
|
|
110
|
+
});
|
|
111
|
+
return sendJson(req, res, validateApiSnapshot(budgetSnapshot(snapshot, {
|
|
112
|
+
nodeBudget,
|
|
113
|
+
linkBudget
|
|
114
|
+
}), "api.snapshot"));
|
|
115
|
+
}
|
|
116
|
+
if (url.pathname === "/api/delta") return sendJson(req, res, validateDelta(diffSnapshots(previousSnapshot || snapshot, snapshot)));
|
|
117
|
+
if (url.pathname === "/api/search") {
|
|
118
|
+
return sendJson(req, res, validateSearchResult(searchSnapshot(
|
|
119
|
+
snapshot,
|
|
120
|
+
url.searchParams.get("q") || "",
|
|
121
|
+
integerParam(url, "limit", { defaultValue: 8, min: 1, max: 50 }),
|
|
122
|
+
runtime
|
|
123
|
+
)));
|
|
124
|
+
}
|
|
125
|
+
if (url.pathname === "/api/focus") {
|
|
126
|
+
return sendJson(req, res, validateFocusResult(focusSnapshot(
|
|
127
|
+
snapshot,
|
|
128
|
+
url.searchParams.get("q") || "",
|
|
129
|
+
integerParam(url, "radius", { defaultValue: 2, min: 0, max: 8 }),
|
|
130
|
+
integerParam(url, "limit", { defaultValue: 1800, min: 1, max: 10000 }),
|
|
131
|
+
runtime
|
|
132
|
+
)));
|
|
133
|
+
}
|
|
134
|
+
if (url.pathname === "/api/node") {
|
|
135
|
+
return sendJson(req, res, validateNodeDetail(nodeDetail(snapshot, state.records, url.searchParams.get("q") || "", root, runtime, {
|
|
136
|
+
edgeLimit: integerParam(url, "edgeLimit", { defaultValue: DEFAULT_NODE_EDGE_LIMIT, min: 1, max: 1000 })
|
|
137
|
+
})));
|
|
138
|
+
}
|
|
139
|
+
if (url.pathname === "/api/bridges" || url.pathname === "/api/connectors") {
|
|
140
|
+
const headers = url.pathname === "/api/bridges" ? bridgeDeprecationHeaders() : {};
|
|
141
|
+
return sendJson(req, res, validateConnectorResult({
|
|
142
|
+
ok: true,
|
|
143
|
+
generatedAt: snapshot.generatedAt,
|
|
144
|
+
candidates: connectorCandidates(snapshot, integerParam(url, "limit", { defaultValue: 12, min: 1, max: 100 }))
|
|
145
|
+
}), 200, headers);
|
|
146
|
+
}
|
|
147
|
+
if (url.pathname === "/api/path") {
|
|
148
|
+
return sendJson(req, res, validatePathResult(pathSnapshot(
|
|
149
|
+
snapshot,
|
|
150
|
+
url.searchParams.get("from") || "",
|
|
151
|
+
url.searchParams.get("to") || "",
|
|
152
|
+
integerParam(url, "maxDepth", { defaultValue: 7, min: 1, max: 12 }),
|
|
153
|
+
runtime
|
|
154
|
+
)));
|
|
155
|
+
}
|
|
156
|
+
if (url.pathname === "/api/events") return attachSse(req, res);
|
|
157
|
+
if (url.pathname === "/api/reindex" && req.method === "POST") {
|
|
158
|
+
if (!isAuthorizedStateChange(req)) return unauthorized(req, res);
|
|
159
|
+
reindex("manual");
|
|
160
|
+
return sendJson(req, res, validateHealth(healthPayload()));
|
|
161
|
+
}
|
|
162
|
+
if (staticDir) return serveStatic(staticDir, url.pathname, res);
|
|
163
|
+
return notFound(res);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof RequestParamError) return badRequest(req, res, error.message);
|
|
166
|
+
logger.error("[living-atlas] request failed", error?.stack || error);
|
|
167
|
+
sendJson(req, res, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: debugPaths ? String(error?.stack || error) : "Internal server error"
|
|
170
|
+
}, 500);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
function listen() {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const fail = (error) => {
|
|
177
|
+
server.off("listening", ready);
|
|
178
|
+
reject(error);
|
|
179
|
+
};
|
|
180
|
+
const ready = () => {
|
|
181
|
+
server.off("error", fail);
|
|
182
|
+
if (watch) startWatchers();
|
|
183
|
+
resolve({
|
|
184
|
+
bindHost,
|
|
185
|
+
port: server.address()?.port || port,
|
|
186
|
+
root,
|
|
187
|
+
staticDir,
|
|
188
|
+
snapshot
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
server.once("error", fail);
|
|
192
|
+
server.once("listening", ready);
|
|
193
|
+
server.listen(port, bindHost);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function close() {
|
|
198
|
+
if (watchTimer) clearTimeout(watchTimer);
|
|
199
|
+
watchers.splice(0).forEach((watcher) => watcher.close());
|
|
200
|
+
for (const client of sseClients) client.end();
|
|
201
|
+
sseClients.clear();
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
if (!server.listening) return resolve();
|
|
204
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function startWatchers() {
|
|
209
|
+
watchedFingerprint = state.manifest.fingerprint;
|
|
210
|
+
const watchDirs = typeof sourceAdapter.watchDirectories === "function"
|
|
211
|
+
? sourceAdapter.watchDirectories()
|
|
212
|
+
: [{ sourceDir: "pages", path: path.join(root, "pages") }, { sourceDir: "journals", path: path.join(root, "journals") }].filter((entry) => fs.existsSync(entry.path));
|
|
213
|
+
for (const entry of watchDirs) {
|
|
214
|
+
watchers.push(fs.watch(entry.path, { persistent: true }, (_event, fileName) => {
|
|
215
|
+
if (!fileName || !String(fileName).endsWith(".md")) return;
|
|
216
|
+
scheduleWatchedReindex(`file:${entry.sourceDir || path.basename(entry.path)}/${fileName}`);
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
const pollIntervalMs = watchPollIntervalMs(state.manifest.pages);
|
|
220
|
+
const poll = setInterval(() => {
|
|
221
|
+
try {
|
|
222
|
+
const manifest = sourceAdapter.readManifest();
|
|
223
|
+
if (manifest.fingerprint === watchedFingerprint) return;
|
|
224
|
+
watchedFingerprint = manifest.fingerprint;
|
|
225
|
+
scheduleWatchedReindex("file:manifest");
|
|
226
|
+
} catch (error) {
|
|
227
|
+
logger.error("[living-atlas] watch poll failed", error?.message || error);
|
|
228
|
+
}
|
|
229
|
+
}, pollIntervalMs);
|
|
230
|
+
watchers.push({ close: () => clearInterval(poll) });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function scheduleWatchedReindex(reason) {
|
|
234
|
+
clearTimeout(watchTimer);
|
|
235
|
+
watchTimer = setTimeout(() => reindex(reason), 350);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function loadSnapshot() {
|
|
239
|
+
return loadState().snapshot;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function loadState() {
|
|
243
|
+
const manifest = sourceAdapter.readManifest();
|
|
244
|
+
const cached = readCache(cachePath);
|
|
245
|
+
if (cached?.manifest?.fingerprint === manifest.fingerprint) {
|
|
246
|
+
return {
|
|
247
|
+
snapshot: withGraphMetadata(cached.snapshot, manifest),
|
|
248
|
+
records: rehydrateCacheRecords(cached.records, root),
|
|
249
|
+
manifest,
|
|
250
|
+
cache: { configured: true, hit: true }
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const records = sourceAdapter.readRecords();
|
|
254
|
+
const built = {
|
|
255
|
+
snapshot: withGraphMetadata(buildSnapshot(records), manifest),
|
|
256
|
+
records,
|
|
257
|
+
manifest,
|
|
258
|
+
cache: { configured: true, hit: false }
|
|
259
|
+
};
|
|
260
|
+
writeCache(cachePath, built, root);
|
|
261
|
+
return built;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function reindex(reason) {
|
|
265
|
+
previousSnapshot = snapshot;
|
|
266
|
+
state = rebuildState();
|
|
267
|
+
snapshot = state.snapshot;
|
|
268
|
+
runtime = createSnapshotRuntime(snapshot, state.records);
|
|
269
|
+
watchedFingerprint = state.manifest.fingerprint;
|
|
270
|
+
const delta = validateDelta(diffSnapshots(previousSnapshot, snapshot));
|
|
271
|
+
const { events, eventsOmitted } = renderEventsFromDelta(delta, reason);
|
|
272
|
+
const eventSeqEnd = events.at(-1)?.seq || eventSeq;
|
|
273
|
+
broadcast({
|
|
274
|
+
...compactDeltaForStream(delta),
|
|
275
|
+
eventSeq: eventSeqEnd,
|
|
276
|
+
events,
|
|
277
|
+
eventsOmitted,
|
|
278
|
+
reason
|
|
279
|
+
});
|
|
280
|
+
logger.log(
|
|
281
|
+
`[living-atlas] reindexed ${snapshot.totals.nodes} nodes / ${snapshot.totals.links} links (${reason})`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function rebuildState() {
|
|
286
|
+
const manifest = sourceAdapter.readManifest();
|
|
287
|
+
const records = sourceAdapter.readRecords();
|
|
288
|
+
const built = {
|
|
289
|
+
snapshot: withGraphMetadata(buildSnapshot(records), manifest),
|
|
290
|
+
records,
|
|
291
|
+
manifest,
|
|
292
|
+
cache: { configured: true, hit: false }
|
|
293
|
+
};
|
|
294
|
+
writeCache(cachePath, built, root);
|
|
295
|
+
return built;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function withGraphMetadata(snapshotPayload, manifest) {
|
|
299
|
+
return validateApiSnapshot({
|
|
300
|
+
...snapshotPayload,
|
|
301
|
+
graph: {
|
|
302
|
+
id: manifest.graphId,
|
|
303
|
+
fingerprint: manifest.fingerprint,
|
|
304
|
+
pages: manifest.pages
|
|
305
|
+
}
|
|
306
|
+
}, "api.snapshot");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function attachSse(req, res) {
|
|
310
|
+
res.writeHead(200, {
|
|
311
|
+
"Content-Type": "text/event-stream",
|
|
312
|
+
"Cache-Control": "no-cache",
|
|
313
|
+
Connection: "keep-alive",
|
|
314
|
+
...corsHeaders(req)
|
|
315
|
+
});
|
|
316
|
+
res.write(`id: ${eventSeq}\n`);
|
|
317
|
+
res.write("event: snapshot\n");
|
|
318
|
+
res.write(`data: ${JSON.stringify({ generatedAt: snapshot.generatedAt, totals: snapshot.totals })}\n\n`);
|
|
319
|
+
sseClients.add(res);
|
|
320
|
+
req.on("close", () => sseClients.delete(res));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function broadcast(payload) {
|
|
324
|
+
const body = `id: ${payload.eventSeq || eventSeq}\nevent: graph_delta\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
325
|
+
for (const client of sseClients) client.write(body);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function compactDeltaForStream(delta) {
|
|
329
|
+
return {
|
|
330
|
+
type: delta.type,
|
|
331
|
+
generatedAt: delta.generatedAt,
|
|
332
|
+
changeCounts: {
|
|
333
|
+
addedNodes: delta.addedNodes?.length || 0,
|
|
334
|
+
changedNodes: delta.changedNodes?.length || 0,
|
|
335
|
+
removedNodes: delta.removedNodes?.length || 0,
|
|
336
|
+
addedLinks: delta.addedLinks?.length || 0,
|
|
337
|
+
removedLinks: delta.removedLinks?.length || 0
|
|
338
|
+
},
|
|
339
|
+
addedNodes: [],
|
|
340
|
+
changedNodes: [],
|
|
341
|
+
removedNodes: [],
|
|
342
|
+
addedLinks: [],
|
|
343
|
+
removedLinks: [],
|
|
344
|
+
insights: delta.insights,
|
|
345
|
+
totals: delta.totals
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderEventsFromDelta(delta, reason) {
|
|
350
|
+
const observedAt = delta.generatedAt || new Date().toISOString();
|
|
351
|
+
const actor = reason === "manual" ? "manual_reindex" : reason?.startsWith("file:") ? "filesystem_watch" : "brain_service";
|
|
352
|
+
const next = (kind, payload) => ({
|
|
353
|
+
id: `${Date.now().toString(36)}-${eventSeq + 1}-${kind}`,
|
|
354
|
+
seq: ++eventSeq,
|
|
355
|
+
kind,
|
|
356
|
+
reason,
|
|
357
|
+
observedAt,
|
|
358
|
+
actor,
|
|
359
|
+
...payload
|
|
360
|
+
});
|
|
361
|
+
const specs = [
|
|
362
|
+
["node.created", delta.addedNodes || [], 160, nodeEventPayload],
|
|
363
|
+
["node.updated", delta.changedNodes || [], 220, nodeEventPayload],
|
|
364
|
+
["node.removed", delta.removedNodes || [], 160, nodeEventPayload],
|
|
365
|
+
["link.created", delta.addedLinks || [], 260, linkEventPayload],
|
|
366
|
+
["link.removed", delta.removedLinks || [], 260, linkEventPayload]
|
|
367
|
+
];
|
|
368
|
+
let eventsOmitted = 0;
|
|
369
|
+
const events = [];
|
|
370
|
+
for (const [kind, items, limit, payloadFor] of specs) {
|
|
371
|
+
eventsOmitted += Math.max(0, items.length - limit);
|
|
372
|
+
events.push(...items.slice(0, limit).map((item) => next(kind, payloadFor(item))));
|
|
373
|
+
}
|
|
374
|
+
return { events, eventsOmitted };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function healthPayload() {
|
|
378
|
+
const payload = {
|
|
379
|
+
ok: true,
|
|
380
|
+
generatedAt: snapshot.generatedAt,
|
|
381
|
+
totals: snapshot.totals,
|
|
382
|
+
cache: state.cache,
|
|
383
|
+
manifest: state.manifest,
|
|
384
|
+
watch,
|
|
385
|
+
bindHost,
|
|
386
|
+
localOnly: true,
|
|
387
|
+
requireToken
|
|
388
|
+
};
|
|
389
|
+
if (debugPaths) {
|
|
390
|
+
payload.root = root;
|
|
391
|
+
payload.cache.path = cachePath;
|
|
392
|
+
payload.staticDir = staticDir;
|
|
393
|
+
}
|
|
394
|
+
return payload;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function sendJson(req, res, payload, status = 200, extraHeaders = {}) {
|
|
398
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
399
|
+
const responsePayload = url.searchParams.get("redact") === "1" ? redactPayload(payload) : payload;
|
|
400
|
+
res.writeHead(status, {
|
|
401
|
+
"Content-Type": "application/json",
|
|
402
|
+
"Cache-Control": "no-store",
|
|
403
|
+
"X-Content-Type-Options": "nosniff",
|
|
404
|
+
"Access-Control-Allow-Headers": "content-type,authorization,x-living-atlas-token,x-brain-atlas-token",
|
|
405
|
+
...corsHeaders(req),
|
|
406
|
+
...extraHeaders
|
|
407
|
+
});
|
|
408
|
+
res.end(JSON.stringify(responsePayload));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function noContent(req, res) {
|
|
412
|
+
res.writeHead(204, {
|
|
413
|
+
"Cache-Control": "no-store",
|
|
414
|
+
"X-Content-Type-Options": "nosniff",
|
|
415
|
+
"Access-Control-Allow-Headers": "content-type,authorization,x-living-atlas-token,x-brain-atlas-token",
|
|
416
|
+
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
417
|
+
...corsHeaders(req)
|
|
418
|
+
});
|
|
419
|
+
res.end();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function corsHeaders(req) {
|
|
423
|
+
const origin = req.headers.origin;
|
|
424
|
+
if (!origin || !isAllowedCorsOrigin(origin, req)) return {};
|
|
425
|
+
return {
|
|
426
|
+
"Access-Control-Allow-Origin": origin,
|
|
427
|
+
Vary: "Origin"
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isAllowedLocalRequest(req) {
|
|
432
|
+
return isLocalRemoteAddress(req.socket?.remoteAddress) && isLocalHostHeader(req.headers.host) && isAllowedOriginHeader(req.headers.origin, req.headers.host);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function isAllowedOriginHeader(origin, host) {
|
|
436
|
+
if (!origin) return true;
|
|
437
|
+
return isAllowedCorsOrigin(origin, { headers: { host } });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isAllowedCorsOrigin(origin, req = null) {
|
|
441
|
+
if (!isLocalUrl(origin)) return false;
|
|
442
|
+
if (allowedOrigins.has(origin)) return true;
|
|
443
|
+
if (req && isSameOrigin(origin, req.headers.host)) return true;
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isAuthorizedStateChange(req) {
|
|
448
|
+
if (allowUnauthenticatedReindex) return true;
|
|
449
|
+
if (!localToken) return false;
|
|
450
|
+
return requestToken(req) === localToken;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isAuthorizedRead(req, url) {
|
|
454
|
+
if (!requireToken) return true;
|
|
455
|
+
return requestToken(req, { url, allowQuery: url.pathname === "/api/events" }) === localToken;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
server,
|
|
460
|
+
listen,
|
|
461
|
+
close,
|
|
462
|
+
reindex,
|
|
463
|
+
loadSnapshot,
|
|
464
|
+
healthPayload,
|
|
465
|
+
get root() {
|
|
466
|
+
return root;
|
|
467
|
+
},
|
|
468
|
+
get staticDir() {
|
|
469
|
+
return staticDir;
|
|
470
|
+
},
|
|
471
|
+
get snapshot() {
|
|
472
|
+
return snapshot;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function watchPollIntervalMs(pageCount) {
|
|
478
|
+
const pages = Math.max(0, Number(pageCount) || 0);
|
|
479
|
+
if (pages >= 50000) return 15000;
|
|
480
|
+
if (pages >= 10000) return 7500;
|
|
481
|
+
if (pages >= 1000) return 3000;
|
|
482
|
+
return 1000;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function assertCacheOutsideGraph(filePath, graphRoot) {
|
|
486
|
+
if (!isInsideDirectory(graphRoot, filePath) && !isRealpathInsideDirectory(graphRoot, path.dirname(filePath))) return;
|
|
487
|
+
throw new Error("Refusing to write cache inside LOGSEQ_ROOT. Set LIVING_ATLAS_CACHE to a path outside the graph.");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function serveStatic(baseDir, requestPath, res) {
|
|
491
|
+
let clean = "";
|
|
492
|
+
try {
|
|
493
|
+
clean = decodeURIComponent(requestPath).replace(/^\/+/, "");
|
|
494
|
+
} catch {
|
|
495
|
+
return notFound(res);
|
|
496
|
+
}
|
|
497
|
+
let filePath = path.resolve(baseDir, clean || "index.html");
|
|
498
|
+
if (!isInsideDirectory(baseDir, filePath)) return notFound(res);
|
|
499
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
500
|
+
filePath = path.join(baseDir, "index.html");
|
|
501
|
+
}
|
|
502
|
+
const safePath = resolveStaticFile(baseDir, filePath);
|
|
503
|
+
if (!safePath) return notFound(res);
|
|
504
|
+
const ext = path.extname(safePath);
|
|
505
|
+
const type = {
|
|
506
|
+
".html": "text/html; charset=utf-8",
|
|
507
|
+
".js": "text/javascript",
|
|
508
|
+
".css": "text/css",
|
|
509
|
+
".svg": "image/svg+xml",
|
|
510
|
+
".png": "image/png",
|
|
511
|
+
".json": "application/json"
|
|
512
|
+
}[ext] || "application/octet-stream";
|
|
513
|
+
res.writeHead(200, staticHeaders(ext, type));
|
|
514
|
+
fs.createReadStream(safePath).pipe(res);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function staticHeaders(ext, type) {
|
|
518
|
+
const headers = {
|
|
519
|
+
"Content-Type": type,
|
|
520
|
+
"X-Content-Type-Options": "nosniff",
|
|
521
|
+
"Referrer-Policy": "no-referrer",
|
|
522
|
+
"Cache-Control": ext === ".html" ? "no-store" : "public, max-age=31536000, immutable"
|
|
523
|
+
};
|
|
524
|
+
if (ext === ".html") {
|
|
525
|
+
headers["Content-Security-Policy"] = [
|
|
526
|
+
"default-src 'self'",
|
|
527
|
+
"script-src 'self'",
|
|
528
|
+
"style-src 'self' 'unsafe-inline'",
|
|
529
|
+
"connect-src 'self'",
|
|
530
|
+
"img-src 'self' data:",
|
|
531
|
+
"object-src 'none'",
|
|
532
|
+
"base-uri 'none'",
|
|
533
|
+
"frame-ancestors 'none'"
|
|
534
|
+
].join("; ");
|
|
535
|
+
}
|
|
536
|
+
return headers;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function readCache(filePath) {
|
|
540
|
+
try {
|
|
541
|
+
return validateCacheEnvelope(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function writeCache(filePath, payload, root = "") {
|
|
548
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
549
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
550
|
+
fs.writeFileSync(tmp, JSON.stringify(createCacheEnvelope(serializeCachePayload(payload, root))), { mode: 0o600 });
|
|
551
|
+
fs.renameSync(tmp, filePath);
|
|
552
|
+
fs.chmodSync(filePath, 0o600);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function serializeCachePayload(payload, root) {
|
|
556
|
+
return {
|
|
557
|
+
...payload,
|
|
558
|
+
records: (payload.records || []).map((record) => sanitizeCacheRecord(record, root))
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function sanitizeCacheRecord(record, root) {
|
|
563
|
+
return {
|
|
564
|
+
id: record.id,
|
|
565
|
+
name: record.name,
|
|
566
|
+
path: path.relative(root, record.path),
|
|
567
|
+
type: record.type,
|
|
568
|
+
tags: record.tags || [],
|
|
569
|
+
status: record.status || "",
|
|
570
|
+
source: record.source || "",
|
|
571
|
+
confidence: record.confidence || "",
|
|
572
|
+
lastContacted: record.lastContacted || "",
|
|
573
|
+
updatedAt: record.updatedAt,
|
|
574
|
+
mtimeMs: record.mtimeMs,
|
|
575
|
+
out: record.out || [],
|
|
576
|
+
relations: (record.relations || [])
|
|
577
|
+
.filter((relation) => CACHE_RELATION_KINDS.has(relation.kind))
|
|
578
|
+
.map((relation) => ({
|
|
579
|
+
kind: relation.kind,
|
|
580
|
+
target: relation.target,
|
|
581
|
+
evidence: relation.evidence
|
|
582
|
+
})),
|
|
583
|
+
props: publicCacheProperties(record.props || {})
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function publicCacheProperties(props) {
|
|
588
|
+
const publicProps = {};
|
|
589
|
+
for (const [key, value] of Object.entries(props || {})) {
|
|
590
|
+
if (CACHE_PROPERTY_KEYS.has(key)) publicProps[key] = value;
|
|
591
|
+
}
|
|
592
|
+
return publicProps;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function rehydrateCacheRecords(records, root) {
|
|
596
|
+
return (records || []).map((record) => ({
|
|
597
|
+
...record,
|
|
598
|
+
path: path.isAbsolute(record.path) ? record.path : path.join(root, record.path)
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function nodeEventPayload(node) {
|
|
603
|
+
return {
|
|
604
|
+
nodeId: node.id,
|
|
605
|
+
nodeName: node.name,
|
|
606
|
+
cluster: node.cluster,
|
|
607
|
+
color: node.color,
|
|
608
|
+
x: node.x,
|
|
609
|
+
y: node.y,
|
|
610
|
+
z: node.z,
|
|
611
|
+
weight: node.heat
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function linkEventPayload(link) {
|
|
616
|
+
return {
|
|
617
|
+
linkId: link.id,
|
|
618
|
+
sourceId: link.source,
|
|
619
|
+
targetId: link.target,
|
|
620
|
+
weight: link.weight
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function forbidden(res) {
|
|
625
|
+
res.writeHead(403, errorJsonHeaders());
|
|
626
|
+
res.end(JSON.stringify({ ok: false, error: "Living Atlas only accepts localhost requests." }));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function unauthorized(req, res, message = "Living Atlas reindex token required.") {
|
|
630
|
+
res.writeHead(401, errorJsonHeaders(req));
|
|
631
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function badRequest(req, res, message) {
|
|
635
|
+
res.writeHead(400, errorJsonHeaders(req));
|
|
636
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function notFound(res) {
|
|
640
|
+
res.writeHead(404, {
|
|
641
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
642
|
+
"Cache-Control": "no-store",
|
|
643
|
+
"X-Content-Type-Options": "nosniff"
|
|
644
|
+
});
|
|
645
|
+
res.end("Not found");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function errorJsonHeaders(req = null) {
|
|
649
|
+
return {
|
|
650
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
651
|
+
"Cache-Control": "no-store",
|
|
652
|
+
"X-Content-Type-Options": "nosniff",
|
|
653
|
+
...(req ? corsHeadersForRequest(req) : {})
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function corsHeadersForRequest(req) {
|
|
658
|
+
const origin = req?.headers?.origin;
|
|
659
|
+
if (!origin || !isLocalUrl(origin)) return {};
|
|
660
|
+
return {
|
|
661
|
+
"Access-Control-Allow-Origin": origin,
|
|
662
|
+
Vary: "Origin"
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function isLocalHostHeader(host) {
|
|
667
|
+
if (!host) return true;
|
|
668
|
+
return isLocalUrl(`http://${host}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function isLocalRemoteAddress(address) {
|
|
672
|
+
if (!address) return true;
|
|
673
|
+
const normalized = String(address).toLowerCase();
|
|
674
|
+
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function isSameOrigin(origin, host) {
|
|
678
|
+
if (!host) return false;
|
|
679
|
+
try {
|
|
680
|
+
const url = new URL(origin);
|
|
681
|
+
return url.host === host;
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function isLocalUrl(value) {
|
|
688
|
+
try {
|
|
689
|
+
const url = new URL(value);
|
|
690
|
+
return isLocalHostname(url.hostname);
|
|
691
|
+
} catch {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function isLocalHostname(hostname) {
|
|
697
|
+
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
|
698
|
+
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function requestToken(req, options = {}) {
|
|
702
|
+
const headerToken = req.headers["x-living-atlas-token"] || req.headers["x-brain-atlas-token"];
|
|
703
|
+
const auth = String(req.headers.authorization || "");
|
|
704
|
+
if (auth.startsWith("Bearer ")) return auth.slice("Bearer ".length);
|
|
705
|
+
if (headerToken) return headerToken;
|
|
706
|
+
return options.allowQuery ? options.url?.searchParams.get("token") || "" : "";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
class RequestParamError extends Error {
|
|
710
|
+
constructor(message) {
|
|
711
|
+
super(message);
|
|
712
|
+
this.name = "RequestParamError";
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function integerParam(url, name, { defaultValue, min, max }) {
|
|
717
|
+
const raw = url.searchParams.get(name);
|
|
718
|
+
if (raw === null || raw === "") return defaultValue;
|
|
719
|
+
if (!/^-?\d+$/.test(raw)) {
|
|
720
|
+
throw new RequestParamError(`${name} must be an integer from ${min} to ${max}.`);
|
|
721
|
+
}
|
|
722
|
+
const value = Number(raw);
|
|
723
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
724
|
+
throw new RequestParamError(`${name} must be an integer from ${min} to ${max}.`);
|
|
725
|
+
}
|
|
726
|
+
return value;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function snapshotBudgetParam(url, name, options) {
|
|
730
|
+
const value = integerParam(url, name, options);
|
|
731
|
+
return value === 0 ? options.defaultValue : value;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function bridgeDeprecationHeaders() {
|
|
735
|
+
return {
|
|
736
|
+
Deprecation: "true",
|
|
737
|
+
Link: '</api/connectors>; rel="successor-version"'
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function isInsideDirectory(directory, targetPath) {
|
|
742
|
+
const relative = path.relative(path.resolve(directory), path.resolve(targetPath));
|
|
743
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function isRealpathInsideDirectory(directory, targetDirectory) {
|
|
747
|
+
try {
|
|
748
|
+
fs.mkdirSync(targetDirectory, { recursive: true });
|
|
749
|
+
const realDirectory = fs.realpathSync(directory);
|
|
750
|
+
const realTarget = fs.realpathSync(targetDirectory);
|
|
751
|
+
return isInsideDirectory(realDirectory, realTarget);
|
|
752
|
+
} catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function resolveStaticFile(baseDir, filePath) {
|
|
758
|
+
try {
|
|
759
|
+
const realBase = fs.realpathSync(baseDir);
|
|
760
|
+
const realFile = fs.realpathSync(filePath);
|
|
761
|
+
if (!isInsideDirectory(realBase, realFile)) return null;
|
|
762
|
+
if (!fs.statSync(realFile).isFile()) return null;
|
|
763
|
+
return realFile;
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function normalizeAllowedOrigins(value) {
|
|
770
|
+
if (value instanceof Set) return value;
|
|
771
|
+
return new Set(String(value || "").split(",").map((item) => item.trim()).filter(Boolean));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function requiredOption(value, name) {
|
|
775
|
+
if (value) return value;
|
|
776
|
+
throw new Error(`createBrainService requires ${name}`);
|
|
777
|
+
}
|