@neat.is/core 0.3.8 → 0.4.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/dist/server.cjs CHANGED
@@ -54,6 +54,7 @@ function mountBearerAuth(app, opts) {
54
54
  if (opts.trustProxy) return;
55
55
  const expected = Buffer.from(opts.token, "utf8");
56
56
  const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
57
+ const publicRead = opts.publicRead === true;
57
58
  app.addHook("preHandler", (req, reply, done) => {
58
59
  const path38 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
59
60
  for (const suffix of suffixes) {
@@ -62,6 +63,10 @@ function mountBearerAuth(app, opts) {
62
63
  return;
63
64
  }
64
65
  }
66
+ if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {
67
+ done();
68
+ return;
69
+ }
65
70
  const header = req.headers.authorization;
66
71
  if (typeof header !== "string" || !header.startsWith("Bearer ")) {
67
72
  void reply.code(401).send({ error: "unauthorized" });
@@ -75,16 +80,21 @@ function mountBearerAuth(app, opts) {
75
80
  done();
76
81
  });
77
82
  }
83
+ function parseBoolEnv(v) {
84
+ if (!v) return false;
85
+ return v === "true" || v === "1";
86
+ }
78
87
  function readAuthEnv(env = process.env) {
79
88
  const t = env.NEAT_AUTH_TOKEN;
80
89
  const ot = env.NEAT_OTEL_TOKEN;
81
90
  return {
82
91
  authToken: t && t.length > 0 ? t : void 0,
83
92
  otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
84
- trustProxy: env.NEAT_AUTH_PROXY === "true"
93
+ trustProxy: env.NEAT_AUTH_PROXY === "true",
94
+ publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
85
95
  };
86
96
  }
87
- var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
97
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
88
98
  var init_auth = __esm({
89
99
  "src/auth.ts"() {
90
100
  "use strict";
@@ -104,7 +114,13 @@ var init_auth = __esm({
104
114
  this.name = "BindAuthorityError";
105
115
  }
106
116
  };
107
- DEFAULT_UNAUTH_SUFFIXES = ["/health", "/healthz", "/readyz"];
117
+ PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
118
+ DEFAULT_UNAUTH_SUFFIXES = [
119
+ "/health",
120
+ "/healthz",
121
+ "/readyz",
122
+ "/api/config"
123
+ ];
108
124
  }
109
125
  });
110
126
 
@@ -307,6 +323,15 @@ function isoFromUnixNano(nanos) {
307
323
  return void 0;
308
324
  }
309
325
  }
326
+ function pickEnv(spanAttrs, resourceAttrs) {
327
+ for (const attrs of [spanAttrs, resourceAttrs]) {
328
+ for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
329
+ const v = attrs[key];
330
+ if (typeof v === "string" && v.length > 0) return v;
331
+ }
332
+ }
333
+ return ENV_FALLBACK;
334
+ }
310
335
  function parseOtlpRequest(body) {
311
336
  const out = [];
312
337
  for (const rs of body.resourceSpans ?? []) {
@@ -326,6 +351,7 @@ function parseOtlpRequest(body) {
326
351
  endTimeUnixNano: span.endTimeUnixNano ?? "0",
327
352
  startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
328
353
  durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
354
+ env: pickEnv(attrs, resourceAttrs),
329
355
  attributes: attrs,
330
356
  dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
331
357
  dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
@@ -464,7 +490,7 @@ async function buildOtelReceiver(opts) {
464
490
  };
465
491
  return decorated;
466
492
  }
467
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
493
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
468
494
  var init_otel = __esm({
469
495
  "src/otel.ts"() {
470
496
  "use strict";
@@ -474,6 +500,9 @@ var init_otel = __esm({
474
500
  import_fastify2 = __toESM(require("fastify"), 1);
475
501
  import_protobufjs = __toESM(require("protobufjs"), 1);
476
502
  init_auth();
503
+ ENV_ATTR_CANONICAL = "deployment.environment.name";
504
+ ENV_ATTR_COMPAT = "deployment.environment";
505
+ ENV_FALLBACK = "unknown";
477
506
  exportTraceServiceRequestType = null;
478
507
  exportTraceServiceResponseType = null;
479
508
  cachedProtobufResponseBody = null;
@@ -506,7 +535,7 @@ function getGraph(project = DEFAULT_PROJECT) {
506
535
  init_cjs_shims();
507
536
  var import_fastify = __toESM(require("fastify"), 1);
508
537
  var import_cors = __toESM(require("@fastify/cors"), 1);
509
- var import_types21 = require("@neat.is/types");
538
+ var import_types22 = require("@neat.is/types");
510
539
 
511
540
  // src/divergences.ts
512
541
  init_cjs_shims();
@@ -1763,52 +1792,64 @@ function cacheSpanService(span, now) {
1763
1792
  if (!span.traceId || !span.spanId) return;
1764
1793
  const key = parentSpanKey(span.traceId, span.spanId);
1765
1794
  parentSpanCache.delete(key);
1766
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1795
+ parentSpanCache.set(key, {
1796
+ service: span.service,
1797
+ env: span.env ?? "unknown",
1798
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1799
+ });
1767
1800
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1768
1801
  const oldest = parentSpanCache.keys().next().value;
1769
1802
  if (!oldest) break;
1770
1803
  parentSpanCache.delete(oldest);
1771
1804
  }
1772
1805
  }
1773
- function lookupParentSpanService(traceId, parentSpanId, now) {
1806
+ function lookupParentSpan(traceId, parentSpanId, now) {
1774
1807
  const entry = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1775
1808
  if (!entry) return null;
1776
1809
  if (entry.expiresAt <= now) {
1777
1810
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1778
1811
  return null;
1779
1812
  }
1780
- return entry.service;
1813
+ return { service: entry.service, env: entry.env };
1781
1814
  }
1782
- function resolveServiceId(graph, host) {
1783
- const direct = (0, import_types4.serviceId)(host);
1784
- if (graph.hasNode(direct)) return direct;
1785
- let found = null;
1815
+ function resolveServiceId(graph, host, env) {
1816
+ const envTagged = (0, import_types4.serviceId)(host, env);
1817
+ if (graph.hasNode(envTagged)) return envTagged;
1818
+ const envLess = (0, import_types4.serviceId)(host);
1819
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1820
+ let sameEnv = null;
1821
+ let envLessMatch = null;
1822
+ let anyMatch = null;
1786
1823
  graph.forEachNode((id, attrs) => {
1787
- if (found) return;
1824
+ if (sameEnv) return;
1788
1825
  const a = attrs;
1789
1826
  if (a.type !== import_types4.NodeType.ServiceNode) return;
1790
- if (a.name === host) {
1791
- found = id;
1827
+ const matchesByName = a.name === host;
1828
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1829
+ if (!matchesByName && !matchesByAlias) return;
1830
+ const nodeEnv = a.env ?? "unknown";
1831
+ if (nodeEnv === env) {
1832
+ sameEnv = id;
1792
1833
  return;
1793
1834
  }
1794
- if (a.aliases && a.aliases.includes(host)) {
1795
- found = id;
1796
- }
1835
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1836
+ else if (!anyMatch) anyMatch = id;
1797
1837
  });
1798
- return found;
1838
+ return sameEnv ?? envLessMatch ?? anyMatch;
1799
1839
  }
1800
1840
  function frontierIdFor(host) {
1801
1841
  return (0, import_types4.frontierId)(host);
1802
1842
  }
1803
- function ensureServiceNode(graph, serviceName) {
1804
- const id = (0, import_types4.serviceId)(serviceName);
1843
+ function ensureServiceNode(graph, serviceName, env) {
1844
+ const id = (0, import_types4.serviceId)(serviceName, env);
1805
1845
  if (graph.hasNode(id)) return id;
1806
1846
  const node = {
1807
1847
  id,
1808
1848
  type: import_types4.NodeType.ServiceNode,
1809
1849
  name: serviceName,
1810
1850
  language: "unknown",
1811
- discoveredVia: "otel"
1851
+ discoveredVia: "otel",
1852
+ ...env !== "unknown" ? { env } : {}
1812
1853
  };
1813
1854
  graph.addNode(id, node);
1814
1855
  return id;
@@ -1943,7 +1984,8 @@ function sanitizeAttributes(attrs) {
1943
1984
  async function handleSpan(ctx, span) {
1944
1985
  const ts = span.startTimeIso ?? nowIso(ctx);
1945
1986
  const nowMs = ctx.now ? ctx.now() : Date.now();
1946
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1987
+ const env = span.env ?? "unknown";
1988
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1947
1989
  const isError = span.statusCode === 2;
1948
1990
  cacheSpanService(span, nowMs);
1949
1991
  let affectedNode = sourceId;
@@ -1966,7 +2008,7 @@ async function handleSpan(ctx, span) {
1966
2008
  const host = pickAddress(span);
1967
2009
  let resolvedViaAddress = false;
1968
2010
  if (host && host !== span.service) {
1969
- const targetId = resolveServiceId(ctx.graph, host);
2011
+ const targetId = resolveServiceId(ctx.graph, host, env);
1970
2012
  if (targetId && targetId !== sourceId) {
1971
2013
  upsertObservedEdge(
1972
2014
  ctx.graph,
@@ -1993,9 +2035,9 @@ async function handleSpan(ctx, span) {
1993
2035
  }
1994
2036
  }
1995
2037
  if (!resolvedViaAddress && span.parentSpanId) {
1996
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1997
- if (parentService && parentService !== span.service) {
1998
- const parentId = ensureServiceNode(ctx.graph, parentService);
2038
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
2039
+ if (parent && parent.service !== span.service) {
2040
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1999
2041
  upsertObservedEdge(
2000
2042
  ctx.graph,
2001
2043
  import_types4.EdgeType.CALLS,
@@ -2191,6 +2233,28 @@ async function readErrorEvents(errorsPath) {
2191
2233
  throw err;
2192
2234
  }
2193
2235
  }
2236
+ function mergeSnapshot(graph, snapshot) {
2237
+ const exported = snapshot.graph;
2238
+ let nodesAdded = 0;
2239
+ let edgesAdded = 0;
2240
+ for (const node of exported.nodes ?? []) {
2241
+ if (graph.hasNode(node.key)) continue;
2242
+ if (!node.attributes) continue;
2243
+ graph.addNode(node.key, node.attributes);
2244
+ nodesAdded++;
2245
+ }
2246
+ for (const edge of exported.edges ?? []) {
2247
+ const attrs = edge.attributes;
2248
+ if (!attrs) continue;
2249
+ const id = edge.key ?? attrs.id;
2250
+ if (!id) continue;
2251
+ if (graph.hasEdge(id)) continue;
2252
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
2253
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
2254
+ edgesAdded++;
2255
+ }
2256
+ return { nodesAdded, edgesAdded };
2257
+ }
2194
2258
 
2195
2259
  // src/extract/services.ts
2196
2260
  init_cjs_shims();
@@ -2587,6 +2651,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2587
2651
  }
2588
2652
  return [...found];
2589
2653
  }
2654
+ function detectJsFramework(pkg) {
2655
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2656
+ if (deps["next"] !== void 0) return "next";
2657
+ if (deps["remix"] !== void 0) return "remix";
2658
+ for (const k of Object.keys(deps)) {
2659
+ if (k.startsWith("@remix-run/")) return "remix";
2660
+ }
2661
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2662
+ if (deps["nuxt"] !== void 0) return "nuxt";
2663
+ if (deps["astro"] !== void 0) return "astro";
2664
+ return void 0;
2665
+ }
2590
2666
  async function discoverNodeService(scanPath, dir) {
2591
2667
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2592
2668
  if (!await exists(pkgPath)) return null;
@@ -2598,6 +2674,7 @@ async function discoverNodeService(scanPath, dir) {
2598
2674
  return null;
2599
2675
  }
2600
2676
  if (!pkg.name) return null;
2677
+ const framework = detectJsFramework(pkg);
2601
2678
  const node = {
2602
2679
  id: (0, import_types6.serviceId)(pkg.name),
2603
2680
  type: import_types6.NodeType.ServiceNode,
@@ -2606,7 +2683,8 @@ async function discoverNodeService(scanPath, dir) {
2606
2683
  version: pkg.version,
2607
2684
  dependencies: pkg.dependencies ?? {},
2608
2685
  repoPath: import_node_path8.default.relative(scanPath, dir),
2609
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2686
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2687
+ ...framework ? { framework } : {}
2610
2688
  };
2611
2689
  return { pkg, dir, node };
2612
2690
  }
@@ -4461,25 +4539,138 @@ function canonicalJson(value) {
4461
4539
  });
4462
4540
  }
4463
4541
 
4464
- // src/projects.ts
4542
+ // src/persist.ts
4465
4543
  init_cjs_shims();
4544
+ var import_node_fs19 = require("fs");
4466
4545
  var import_node_path31 = __toESM(require("path"), 1);
4546
+ var import_types20 = require("@neat.is/types");
4547
+ var SCHEMA_VERSION = 4;
4548
+ function migrateV1ToV2(payload) {
4549
+ const nodes = payload.graph.nodes;
4550
+ if (Array.isArray(nodes)) {
4551
+ for (const node of nodes) {
4552
+ if (node.attributes && "pgDriverVersion" in node.attributes) {
4553
+ delete node.attributes.pgDriverVersion;
4554
+ }
4555
+ }
4556
+ }
4557
+ return { ...payload, schemaVersion: 2 };
4558
+ }
4559
+ function migrateV3ToV4(payload) {
4560
+ return { ...payload, schemaVersion: 4 };
4561
+ }
4562
+ function migrateV2ToV3(payload) {
4563
+ const edges = payload.graph.edges;
4564
+ if (Array.isArray(edges)) {
4565
+ for (const edge of edges) {
4566
+ const attrs = edge.attributes;
4567
+ if (!attrs || attrs.provenance !== "FRONTIER") continue;
4568
+ attrs.provenance = import_types20.Provenance.OBSERVED;
4569
+ const type = typeof attrs.type === "string" ? attrs.type : void 0;
4570
+ const source = typeof attrs.source === "string" ? attrs.source : void 0;
4571
+ const target = typeof attrs.target === "string" ? attrs.target : void 0;
4572
+ if (type && source && target) {
4573
+ const newId = (0, import_types20.observedEdgeId)(source, target, type);
4574
+ attrs.id = newId;
4575
+ if (edge.key) edge.key = newId;
4576
+ }
4577
+ }
4578
+ }
4579
+ return { ...payload, schemaVersion: 3 };
4580
+ }
4581
+ async function ensureDir(filePath) {
4582
+ await import_node_fs19.promises.mkdir(import_node_path31.default.dirname(filePath), { recursive: true });
4583
+ }
4584
+ async function saveGraphToDisk(graph, outPath) {
4585
+ await ensureDir(outPath);
4586
+ const payload = {
4587
+ schemaVersion: SCHEMA_VERSION,
4588
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4589
+ graph: graph.export()
4590
+ };
4591
+ const tmp = `${outPath}.tmp`;
4592
+ await import_node_fs19.promises.writeFile(tmp, JSON.stringify(payload), "utf8");
4593
+ await import_node_fs19.promises.rename(tmp, outPath);
4594
+ }
4595
+ async function loadGraphFromDisk(graph, outPath) {
4596
+ let raw;
4597
+ try {
4598
+ raw = await import_node_fs19.promises.readFile(outPath, "utf8");
4599
+ } catch (err) {
4600
+ if (err.code === "ENOENT") return;
4601
+ throw err;
4602
+ }
4603
+ let payload = JSON.parse(raw);
4604
+ if (payload.schemaVersion === 1) {
4605
+ payload = migrateV1ToV2(payload);
4606
+ }
4607
+ if (payload.schemaVersion === 2) {
4608
+ payload = migrateV2ToV3(payload);
4609
+ }
4610
+ if (payload.schemaVersion === 3) {
4611
+ payload = migrateV3ToV4(payload);
4612
+ }
4613
+ if (payload.schemaVersion !== SCHEMA_VERSION) {
4614
+ throw new Error(
4615
+ `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
4616
+ );
4617
+ }
4618
+ graph.clear();
4619
+ graph.import(payload.graph);
4620
+ }
4621
+ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
4622
+ let stopped = false;
4623
+ const tick = async () => {
4624
+ if (stopped) return;
4625
+ try {
4626
+ await saveGraphToDisk(graph, outPath);
4627
+ } catch (err) {
4628
+ console.error("persist: periodic save failed", err);
4629
+ }
4630
+ };
4631
+ const interval = setInterval(() => {
4632
+ void tick();
4633
+ }, intervalMs);
4634
+ const onSignal = (signal) => {
4635
+ void (async () => {
4636
+ try {
4637
+ await saveGraphToDisk(graph, outPath);
4638
+ } catch (err) {
4639
+ console.error(`persist: ${signal} save failed`, err);
4640
+ } finally {
4641
+ process.exit(0);
4642
+ }
4643
+ })();
4644
+ };
4645
+ process.on("SIGTERM", onSignal);
4646
+ process.on("SIGINT", onSignal);
4647
+ return () => {
4648
+ stopped = true;
4649
+ clearInterval(interval);
4650
+ process.off("SIGTERM", onSignal);
4651
+ process.off("SIGINT", onSignal);
4652
+ };
4653
+ }
4654
+
4655
+ // src/projects.ts
4656
+ init_cjs_shims();
4657
+ var import_node_path32 = __toESM(require("path"), 1);
4467
4658
  function pathsForProject(project, baseDir) {
4468
4659
  if (project === DEFAULT_PROJECT) {
4469
4660
  return {
4470
- snapshotPath: import_node_path31.default.join(baseDir, "graph.json"),
4471
- errorsPath: import_node_path31.default.join(baseDir, "errors.ndjson"),
4472
- staleEventsPath: import_node_path31.default.join(baseDir, "stale-events.ndjson"),
4473
- embeddingsCachePath: import_node_path31.default.join(baseDir, "embeddings.json"),
4474
- policyViolationsPath: import_node_path31.default.join(baseDir, "policy-violations.ndjson")
4661
+ snapshotPath: import_node_path32.default.join(baseDir, "graph.json"),
4662
+ errorsPath: import_node_path32.default.join(baseDir, "errors.ndjson"),
4663
+ staleEventsPath: import_node_path32.default.join(baseDir, "stale-events.ndjson"),
4664
+ embeddingsCachePath: import_node_path32.default.join(baseDir, "embeddings.json"),
4665
+ policyViolationsPath: import_node_path32.default.join(baseDir, "policy-violations.ndjson")
4475
4666
  };
4476
4667
  }
4477
4668
  return {
4478
- snapshotPath: import_node_path31.default.join(baseDir, `${project}.json`),
4479
- errorsPath: import_node_path31.default.join(baseDir, `errors.${project}.ndjson`),
4480
- staleEventsPath: import_node_path31.default.join(baseDir, `stale-events.${project}.ndjson`),
4481
- embeddingsCachePath: import_node_path31.default.join(baseDir, `embeddings.${project}.json`),
4482
- policyViolationsPath: import_node_path31.default.join(baseDir, `policy-violations.${project}.ndjson`)
4669
+ snapshotPath: import_node_path32.default.join(baseDir, `${project}.json`),
4670
+ errorsPath: import_node_path32.default.join(baseDir, `errors.${project}.ndjson`),
4671
+ staleEventsPath: import_node_path32.default.join(baseDir, `stale-events.${project}.ndjson`),
4672
+ embeddingsCachePath: import_node_path32.default.join(baseDir, `embeddings.${project}.json`),
4673
+ policyViolationsPath: import_node_path32.default.join(baseDir, `policy-violations.${project}.ndjson`)
4483
4674
  };
4484
4675
  }
4485
4676
  var Projects = class {
@@ -4519,23 +4710,23 @@ function parseExtraProjects(raw) {
4519
4710
 
4520
4711
  // src/registry.ts
4521
4712
  init_cjs_shims();
4522
- var import_node_fs19 = require("fs");
4713
+ var import_node_fs20 = require("fs");
4523
4714
  var import_node_os2 = __toESM(require("os"), 1);
4524
- var import_node_path32 = __toESM(require("path"), 1);
4525
- var import_types20 = require("@neat.is/types");
4715
+ var import_node_path33 = __toESM(require("path"), 1);
4716
+ var import_types21 = require("@neat.is/types");
4526
4717
  function neatHome() {
4527
4718
  const override = process.env.NEAT_HOME;
4528
- if (override && override.length > 0) return import_node_path32.default.resolve(override);
4529
- return import_node_path32.default.join(import_node_os2.default.homedir(), ".neat");
4719
+ if (override && override.length > 0) return import_node_path33.default.resolve(override);
4720
+ return import_node_path33.default.join(import_node_os2.default.homedir(), ".neat");
4530
4721
  }
4531
4722
  function registryPath() {
4532
- return import_node_path32.default.join(neatHome(), "projects.json");
4723
+ return import_node_path33.default.join(neatHome(), "projects.json");
4533
4724
  }
4534
4725
  async function readRegistry() {
4535
4726
  const file = registryPath();
4536
4727
  let raw;
4537
4728
  try {
4538
- raw = await import_node_fs19.promises.readFile(file, "utf8");
4729
+ raw = await import_node_fs20.promises.readFile(file, "utf8");
4539
4730
  } catch (err) {
4540
4731
  if (err.code === "ENOENT") {
4541
4732
  return { version: 1, projects: [] };
@@ -4543,7 +4734,7 @@ async function readRegistry() {
4543
4734
  throw err;
4544
4735
  }
4545
4736
  const parsed = JSON.parse(raw);
4546
- return import_types20.RegistryFileSchema.parse(parsed);
4737
+ return import_types21.RegistryFileSchema.parse(parsed);
4547
4738
  }
4548
4739
  async function getProject(name) {
4549
4740
  const reg = await readRegistry();
@@ -4734,11 +4925,11 @@ function registerRoutes(scope, ctx) {
4734
4925
  const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
4735
4926
  const parsed = [];
4736
4927
  for (const c of candidates) {
4737
- const r = import_types21.DivergenceTypeSchema.safeParse(c);
4928
+ const r = import_types22.DivergenceTypeSchema.safeParse(c);
4738
4929
  if (!r.success) {
4739
4930
  return reply.code(400).send({
4740
4931
  error: `unknown divergence type "${c}"`,
4741
- allowed: import_types21.DivergenceTypeSchema.options
4932
+ allowed: import_types22.DivergenceTypeSchema.options
4742
4933
  });
4743
4934
  }
4744
4935
  parsed.push(r.data);
@@ -4883,6 +5074,35 @@ function registerRoutes(scope, ctx) {
4883
5074
  }
4884
5075
  }
4885
5076
  );
5077
+ scope.post("/snapshot", async (req, reply) => {
5078
+ const proj = resolveProject(registry, req, reply);
5079
+ if (!proj) return;
5080
+ const body = req.body;
5081
+ if (!body || typeof body !== "object" || !body.snapshot) {
5082
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5083
+ }
5084
+ const snap = body.snapshot;
5085
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5086
+ return reply.code(400).send({
5087
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5088
+ });
5089
+ }
5090
+ try {
5091
+ const result = mergeSnapshot(proj.graph, snap);
5092
+ return {
5093
+ project: proj.name,
5094
+ nodesAdded: result.nodesAdded,
5095
+ edgesAdded: result.edgesAdded,
5096
+ nodeCount: proj.graph.order,
5097
+ edgeCount: proj.graph.size
5098
+ };
5099
+ } catch (err) {
5100
+ return reply.code(400).send({
5101
+ error: "snapshot merge failed",
5102
+ details: err.message
5103
+ });
5104
+ }
5105
+ });
4886
5106
  scope.post("/graph/scan", async (req, reply) => {
4887
5107
  const proj = resolveProject(registry, req, reply);
4888
5108
  if (!proj) return;
@@ -4922,7 +5142,7 @@ function registerRoutes(scope, ctx) {
4922
5142
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
4923
5143
  let violations = await log.readAll();
4924
5144
  if (req.query.severity) {
4925
- const sev = import_types21.PolicySeveritySchema.safeParse(req.query.severity);
5145
+ const sev = import_types22.PolicySeveritySchema.safeParse(req.query.severity);
4926
5146
  if (!sev.success) {
4927
5147
  return reply.code(400).send({
4928
5148
  error: "invalid severity",
@@ -4939,7 +5159,7 @@ function registerRoutes(scope, ctx) {
4939
5159
  scope.post("/policies/check", async (req, reply) => {
4940
5160
  const proj = resolveProject(registry, req, reply);
4941
5161
  if (!proj) return;
4942
- const parsed = import_types21.PoliciesCheckBodySchema.safeParse(req.body ?? {});
5162
+ const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req.body ?? {});
4943
5163
  if (!parsed.success) {
4944
5164
  return reply.code(400).send({
4945
5165
  error: "invalid /policies/check body",
@@ -4976,7 +5196,15 @@ function registerRoutes(scope, ctx) {
4976
5196
  async function buildApi(opts) {
4977
5197
  const app = (0, import_fastify.default)({ logger: false });
4978
5198
  await app.register(import_cors.default, { origin: true });
4979
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
5199
+ mountBearerAuth(app, {
5200
+ token: opts.authToken,
5201
+ trustProxy: opts.trustProxy,
5202
+ publicRead: opts.publicRead
5203
+ });
5204
+ app.get("/api/config", async () => ({
5205
+ publicRead: opts.publicRead === true,
5206
+ authProxy: opts.trustProxy === true
5207
+ }));
4980
5208
  const startedAt = opts.startedAt ?? Date.now();
4981
5209
  const registry = buildLegacyRegistry(opts);
4982
5210
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5038,113 +5266,6 @@ async function buildApi(opts) {
5038
5266
  return app;
5039
5267
  }
5040
5268
 
5041
- // src/persist.ts
5042
- init_cjs_shims();
5043
- var import_node_fs20 = require("fs");
5044
- var import_node_path33 = __toESM(require("path"), 1);
5045
- var import_types22 = require("@neat.is/types");
5046
- var SCHEMA_VERSION = 3;
5047
- function migrateV1ToV2(payload) {
5048
- const nodes = payload.graph.nodes;
5049
- if (Array.isArray(nodes)) {
5050
- for (const node of nodes) {
5051
- if (node.attributes && "pgDriverVersion" in node.attributes) {
5052
- delete node.attributes.pgDriverVersion;
5053
- }
5054
- }
5055
- }
5056
- return { ...payload, schemaVersion: 2 };
5057
- }
5058
- function migrateV2ToV3(payload) {
5059
- const edges = payload.graph.edges;
5060
- if (Array.isArray(edges)) {
5061
- for (const edge of edges) {
5062
- const attrs = edge.attributes;
5063
- if (!attrs || attrs.provenance !== "FRONTIER") continue;
5064
- attrs.provenance = import_types22.Provenance.OBSERVED;
5065
- const type = typeof attrs.type === "string" ? attrs.type : void 0;
5066
- const source = typeof attrs.source === "string" ? attrs.source : void 0;
5067
- const target = typeof attrs.target === "string" ? attrs.target : void 0;
5068
- if (type && source && target) {
5069
- const newId = (0, import_types22.observedEdgeId)(source, target, type);
5070
- attrs.id = newId;
5071
- if (edge.key) edge.key = newId;
5072
- }
5073
- }
5074
- }
5075
- return { ...payload, schemaVersion: 3 };
5076
- }
5077
- async function ensureDir(filePath) {
5078
- await import_node_fs20.promises.mkdir(import_node_path33.default.dirname(filePath), { recursive: true });
5079
- }
5080
- async function saveGraphToDisk(graph, outPath) {
5081
- await ensureDir(outPath);
5082
- const payload = {
5083
- schemaVersion: SCHEMA_VERSION,
5084
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5085
- graph: graph.export()
5086
- };
5087
- const tmp = `${outPath}.tmp`;
5088
- await import_node_fs20.promises.writeFile(tmp, JSON.stringify(payload), "utf8");
5089
- await import_node_fs20.promises.rename(tmp, outPath);
5090
- }
5091
- async function loadGraphFromDisk(graph, outPath) {
5092
- let raw;
5093
- try {
5094
- raw = await import_node_fs20.promises.readFile(outPath, "utf8");
5095
- } catch (err) {
5096
- if (err.code === "ENOENT") return;
5097
- throw err;
5098
- }
5099
- let payload = JSON.parse(raw);
5100
- if (payload.schemaVersion === 1) {
5101
- payload = migrateV1ToV2(payload);
5102
- }
5103
- if (payload.schemaVersion === 2) {
5104
- payload = migrateV2ToV3(payload);
5105
- }
5106
- if (payload.schemaVersion !== SCHEMA_VERSION) {
5107
- throw new Error(
5108
- `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
5109
- );
5110
- }
5111
- graph.clear();
5112
- graph.import(payload.graph);
5113
- }
5114
- function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5115
- let stopped = false;
5116
- const tick = async () => {
5117
- if (stopped) return;
5118
- try {
5119
- await saveGraphToDisk(graph, outPath);
5120
- } catch (err) {
5121
- console.error("persist: periodic save failed", err);
5122
- }
5123
- };
5124
- const interval = setInterval(() => {
5125
- void tick();
5126
- }, intervalMs);
5127
- const onSignal = (signal) => {
5128
- void (async () => {
5129
- try {
5130
- await saveGraphToDisk(graph, outPath);
5131
- } catch (err) {
5132
- console.error(`persist: ${signal} save failed`, err);
5133
- } finally {
5134
- process.exit(0);
5135
- }
5136
- })();
5137
- };
5138
- process.on("SIGTERM", onSignal);
5139
- process.on("SIGINT", onSignal);
5140
- return () => {
5141
- stopped = true;
5142
- clearInterval(interval);
5143
- process.off("SIGTERM", onSignal);
5144
- process.off("SIGINT", onSignal);
5145
- };
5146
- }
5147
-
5148
5269
  // src/server.ts
5149
5270
  init_otel();
5150
5271
  init_otel_grpc();
@@ -5489,7 +5610,8 @@ async function main() {
5489
5610
  const app = await buildApi({
5490
5611
  projects: registry,
5491
5612
  authToken: auth.authToken,
5492
- trustProxy: auth.trustProxy
5613
+ trustProxy: auth.trustProxy,
5614
+ publicRead: auth.publicRead
5493
5615
  });
5494
5616
  await app.listen({ port, host });
5495
5617
  console.log(`neat-core listening on http://${host}:${port}`);