@neat.is/core 0.3.7 → 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/index.d.cts CHANGED
@@ -114,6 +114,9 @@ interface BuildApiOptions {
114
114
  errorsPath?: string;
115
115
  staleEventsPath?: string;
116
116
  searchIndex?: SearchIndex;
117
+ authToken?: string;
118
+ trustProxy?: boolean;
119
+ publicRead?: boolean;
117
120
  }
118
121
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
119
122
 
@@ -128,6 +131,7 @@ interface ParsedSpan {
128
131
  endTimeUnixNano: string;
129
132
  startTimeIso?: string;
130
133
  durationNanos: bigint;
134
+ env: string;
131
135
  attributes: Record<string, AttributeValue>;
132
136
  dbSystem?: string;
133
137
  dbName?: string;
@@ -145,6 +149,8 @@ interface BuildOtelReceiverOptions {
145
149
  onSpan: SpanHandler;
146
150
  onErrorSpanSync?: (span: ParsedSpan) => Promise<void>;
147
151
  bodyLimit?: number;
152
+ authToken?: string;
153
+ trustProxy?: boolean;
148
154
  }
149
155
  interface OtlpKeyValue {
150
156
  key: string;
@@ -200,6 +206,8 @@ declare function logSpanHandler(span: ParsedSpan): void;
200
206
 
201
207
  interface BuildOtelGrpcReceiverOptions {
202
208
  onSpan: SpanHandler;
209
+ authToken?: string;
210
+ trustProxy?: boolean;
203
211
  }
204
212
  interface OtelGrpcReceiver {
205
213
  address: string;
package/dist/index.d.ts CHANGED
@@ -114,6 +114,9 @@ interface BuildApiOptions {
114
114
  errorsPath?: string;
115
115
  staleEventsPath?: string;
116
116
  searchIndex?: SearchIndex;
117
+ authToken?: string;
118
+ trustProxy?: boolean;
119
+ publicRead?: boolean;
117
120
  }
118
121
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
119
122
 
@@ -128,6 +131,7 @@ interface ParsedSpan {
128
131
  endTimeUnixNano: string;
129
132
  startTimeIso?: string;
130
133
  durationNanos: bigint;
134
+ env: string;
131
135
  attributes: Record<string, AttributeValue>;
132
136
  dbSystem?: string;
133
137
  dbName?: string;
@@ -145,6 +149,8 @@ interface BuildOtelReceiverOptions {
145
149
  onSpan: SpanHandler;
146
150
  onErrorSpanSync?: (span: ParsedSpan) => Promise<void>;
147
151
  bodyLimit?: number;
152
+ authToken?: string;
153
+ trustProxy?: boolean;
148
154
  }
149
155
  interface OtlpKeyValue {
150
156
  key: string;
@@ -200,6 +206,8 @@ declare function logSpanHandler(span: ParsedSpan): void;
200
206
 
201
207
  interface BuildOtelGrpcReceiverOptions {
202
208
  onSpan: SpanHandler;
209
+ authToken?: string;
210
+ trustProxy?: boolean;
203
211
  }
204
212
  interface OtelGrpcReceiver {
205
213
  address: string;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-EDHOCFOG.js";
4
+ } from "./chunk-N6RPINEJ.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -37,15 +37,15 @@ import {
37
37
  thresholdForEdgeType,
38
38
  touchLastSeen,
39
39
  writeAtomically
40
- } from "./chunk-ZU2RQRCN.js";
40
+ } from "./chunk-UPW4CMOH.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
- } from "./chunk-CY67YKNO.js";
43
+ } from "./chunk-IXIFJKMM.js";
44
44
  import {
45
45
  buildOtelReceiver,
46
46
  logSpanHandler,
47
47
  parseOtlpRequest
48
- } from "./chunk-75IBA4UL.js";
48
+ } from "./chunk-4V23KYOP.js";
49
49
  export {
50
50
  ProjectNameCollisionError,
51
51
  addProject,
package/dist/neatd.cjs CHANGED
@@ -40,6 +40,91 @@ var init_cjs_shims = __esm({
40
40
  }
41
41
  });
42
42
 
43
+ // src/auth.ts
44
+ function isLoopbackHost(host) {
45
+ if (!host) return false;
46
+ return LOOPBACK_HOSTS.has(host);
47
+ }
48
+ function assertBindAuthority(host, token) {
49
+ if (token && token.length > 0) return;
50
+ if (isLoopbackHost(host)) return;
51
+ throw new BindAuthorityError(host);
52
+ }
53
+ function mountBearerAuth(app, opts) {
54
+ if (!opts.token || opts.token.length === 0) return;
55
+ if (opts.trustProxy) return;
56
+ const expected = Buffer.from(opts.token, "utf8");
57
+ const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
58
+ const publicRead = opts.publicRead === true;
59
+ app.addHook("preHandler", (req2, reply, done) => {
60
+ const path39 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
61
+ for (const suffix of suffixes) {
62
+ if (path39 === suffix || path39.endsWith(suffix)) {
63
+ done();
64
+ return;
65
+ }
66
+ }
67
+ if (publicRead && PUBLIC_READ_METHODS.has(req2.method)) {
68
+ done();
69
+ return;
70
+ }
71
+ const header = req2.headers.authorization;
72
+ if (typeof header !== "string" || !header.startsWith("Bearer ")) {
73
+ void reply.code(401).send({ error: "unauthorized" });
74
+ return;
75
+ }
76
+ const provided = Buffer.from(header.slice("Bearer ".length).trim(), "utf8");
77
+ if (provided.length !== expected.length || !(0, import_node_crypto.timingSafeEqual)(provided, expected)) {
78
+ void reply.code(401).send({ error: "unauthorized" });
79
+ return;
80
+ }
81
+ done();
82
+ });
83
+ }
84
+ function parseBoolEnv(v) {
85
+ if (!v) return false;
86
+ return v === "true" || v === "1";
87
+ }
88
+ function readAuthEnv(env = process.env) {
89
+ const t = env.NEAT_AUTH_TOKEN;
90
+ const ot = env.NEAT_OTEL_TOKEN;
91
+ return {
92
+ authToken: t && t.length > 0 ? t : void 0,
93
+ otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
94
+ trustProxy: env.NEAT_AUTH_PROXY === "true",
95
+ publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
96
+ };
97
+ }
98
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
99
+ var init_auth = __esm({
100
+ "src/auth.ts"() {
101
+ "use strict";
102
+ init_cjs_shims();
103
+ import_node_crypto = require("crypto");
104
+ LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
105
+ "127.0.0.1",
106
+ "localhost",
107
+ "::1",
108
+ "::ffff:127.0.0.1"
109
+ ]);
110
+ BindAuthorityError = class extends Error {
111
+ constructor(host) {
112
+ super(
113
+ `NEAT refuses to bind on a public interface without \`NEAT_AUTH_TOKEN\` set (host="${host}"). Set the token or bind to loopback only.`
114
+ );
115
+ this.name = "BindAuthorityError";
116
+ }
117
+ };
118
+ PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
119
+ DEFAULT_UNAUTH_SUFFIXES = [
120
+ "/health",
121
+ "/healthz",
122
+ "/readyz",
123
+ "/api/config"
124
+ ];
125
+ }
126
+ });
127
+
43
128
  // src/otel-grpc.ts
44
129
  var otel_grpc_exports = {};
45
130
  __export(otel_grpc_exports, {
@@ -122,8 +207,21 @@ function loadTraceService() {
122
207
  async function startOtelGrpcReceiver(opts) {
123
208
  const server = new grpc.Server();
124
209
  const service = loadTraceService();
210
+ const requiresAuth = !opts.trustProxy && !!opts.authToken && opts.authToken.length > 0;
211
+ const expectedHeader = requiresAuth ? `Bearer ${opts.authToken}` : "";
125
212
  server.addService(service, {
126
213
  Export: (call, callback) => {
214
+ if (requiresAuth) {
215
+ const meta = call.metadata.get("authorization");
216
+ const got = meta.length > 0 ? String(meta[0]) : "";
217
+ const a = Buffer.from(got, "utf8");
218
+ const b = Buffer.from(expectedHeader, "utf8");
219
+ const ok = a.length === b.length && (0, import_node_crypto2.timingSafeEqual)(a, b);
220
+ if (!ok) {
221
+ callback({ code: grpc.status.UNAUTHENTICATED, message: "unauthorized" });
222
+ return;
223
+ }
224
+ }
127
225
  void (async () => {
128
226
  try {
129
227
  const reshaped = reshapeGrpcRequest(call.request ?? {});
@@ -156,13 +254,14 @@ async function startOtelGrpcReceiver(opts) {
156
254
  })
157
255
  };
158
256
  }
159
- var import_node_url, import_node_path34, grpc, protoLoader;
257
+ var import_node_url, import_node_path34, import_node_crypto2, grpc, protoLoader;
160
258
  var init_otel_grpc = __esm({
161
259
  "src/otel-grpc.ts"() {
162
260
  "use strict";
163
261
  init_cjs_shims();
164
262
  import_node_url = require("url");
165
263
  import_node_path34 = __toESM(require("path"), 1);
264
+ import_node_crypto2 = require("crypto");
166
265
  grpc = __toESM(require("@grpc/grpc-js"), 1);
167
266
  protoLoader = __toESM(require("@grpc/proto-loader"), 1);
168
267
  init_otel();
@@ -225,6 +324,15 @@ function isoFromUnixNano(nanos) {
225
324
  return void 0;
226
325
  }
227
326
  }
327
+ function pickEnv(spanAttrs, resourceAttrs) {
328
+ for (const attrs of [spanAttrs, resourceAttrs]) {
329
+ for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
330
+ const v = attrs[key];
331
+ if (typeof v === "string" && v.length > 0) return v;
332
+ }
333
+ }
334
+ return ENV_FALLBACK;
335
+ }
228
336
  function parseOtlpRequest(body) {
229
337
  const out = [];
230
338
  for (const rs of body.resourceSpans ?? []) {
@@ -244,6 +352,7 @@ function parseOtlpRequest(body) {
244
352
  endTimeUnixNano: span.endTimeUnixNano ?? "0",
245
353
  startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
246
354
  durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
355
+ env: pickEnv(attrs, resourceAttrs),
247
356
  attributes: attrs,
248
357
  dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
249
358
  dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
@@ -303,6 +412,7 @@ async function buildOtelReceiver(opts) {
303
412
  logger: false,
304
413
  bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024
305
414
  });
415
+ mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
306
416
  const queue = [];
307
417
  let draining = false;
308
418
  let drainPromise = Promise.resolve();
@@ -381,7 +491,7 @@ async function buildOtelReceiver(opts) {
381
491
  };
382
492
  return decorated;
383
493
  }
384
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
494
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
385
495
  var init_otel = __esm({
386
496
  "src/otel.ts"() {
387
497
  "use strict";
@@ -390,6 +500,10 @@ var init_otel = __esm({
390
500
  import_node_url2 = require("url");
391
501
  import_fastify2 = __toESM(require("fastify"), 1);
392
502
  import_protobufjs = __toESM(require("protobufjs"), 1);
503
+ init_auth();
504
+ ENV_ATTR_CANONICAL = "deployment.environment.name";
505
+ ENV_ATTR_COMPAT = "deployment.environment";
506
+ ENV_FALLBACK = "unknown";
393
507
  exportTraceServiceRequestType = null;
394
508
  exportTraceServiceResponseType = null;
395
509
  cachedProtobufResponseBody = null;
@@ -1428,52 +1542,64 @@ function cacheSpanService(span, now) {
1428
1542
  if (!span.traceId || !span.spanId) return;
1429
1543
  const key = parentSpanKey(span.traceId, span.spanId);
1430
1544
  parentSpanCache.delete(key);
1431
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1545
+ parentSpanCache.set(key, {
1546
+ service: span.service,
1547
+ env: span.env ?? "unknown",
1548
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1549
+ });
1432
1550
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1433
1551
  const oldest = parentSpanCache.keys().next().value;
1434
1552
  if (!oldest) break;
1435
1553
  parentSpanCache.delete(oldest);
1436
1554
  }
1437
1555
  }
1438
- function lookupParentSpanService(traceId, parentSpanId, now) {
1556
+ function lookupParentSpan(traceId, parentSpanId, now) {
1439
1557
  const entry2 = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1440
1558
  if (!entry2) return null;
1441
1559
  if (entry2.expiresAt <= now) {
1442
1560
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1443
1561
  return null;
1444
1562
  }
1445
- return entry2.service;
1563
+ return { service: entry2.service, env: entry2.env };
1446
1564
  }
1447
- function resolveServiceId(graph, host) {
1448
- const direct = (0, import_types3.serviceId)(host);
1449
- if (graph.hasNode(direct)) return direct;
1450
- let found = null;
1565
+ function resolveServiceId(graph, host, env) {
1566
+ const envTagged = (0, import_types3.serviceId)(host, env);
1567
+ if (graph.hasNode(envTagged)) return envTagged;
1568
+ const envLess = (0, import_types3.serviceId)(host);
1569
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1570
+ let sameEnv = null;
1571
+ let envLessMatch = null;
1572
+ let anyMatch = null;
1451
1573
  graph.forEachNode((id, attrs) => {
1452
- if (found) return;
1574
+ if (sameEnv) return;
1453
1575
  const a = attrs;
1454
1576
  if (a.type !== import_types3.NodeType.ServiceNode) return;
1455
- if (a.name === host) {
1456
- found = id;
1577
+ const matchesByName = a.name === host;
1578
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1579
+ if (!matchesByName && !matchesByAlias) return;
1580
+ const nodeEnv = a.env ?? "unknown";
1581
+ if (nodeEnv === env) {
1582
+ sameEnv = id;
1457
1583
  return;
1458
1584
  }
1459
- if (a.aliases && a.aliases.includes(host)) {
1460
- found = id;
1461
- }
1585
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1586
+ else if (!anyMatch) anyMatch = id;
1462
1587
  });
1463
- return found;
1588
+ return sameEnv ?? envLessMatch ?? anyMatch;
1464
1589
  }
1465
1590
  function frontierIdFor(host) {
1466
1591
  return (0, import_types3.frontierId)(host);
1467
1592
  }
1468
- function ensureServiceNode(graph, serviceName) {
1469
- const id = (0, import_types3.serviceId)(serviceName);
1593
+ function ensureServiceNode(graph, serviceName, env) {
1594
+ const id = (0, import_types3.serviceId)(serviceName, env);
1470
1595
  if (graph.hasNode(id)) return id;
1471
1596
  const node = {
1472
1597
  id,
1473
1598
  type: import_types3.NodeType.ServiceNode,
1474
1599
  name: serviceName,
1475
1600
  language: "unknown",
1476
- discoveredVia: "otel"
1601
+ discoveredVia: "otel",
1602
+ ...env !== "unknown" ? { env } : {}
1477
1603
  };
1478
1604
  graph.addNode(id, node);
1479
1605
  return id;
@@ -1619,7 +1745,7 @@ function buildErrorEventForReceiver(span) {
1619
1745
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1620
1746
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1621
1747
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
1622
- affectedNode: (0, import_types3.serviceId)(span.service)
1748
+ affectedNode: (0, import_types3.serviceId)(span.service, span.env)
1623
1749
  };
1624
1750
  }
1625
1751
  function makeErrorSpanWriter(errorsPath) {
@@ -1633,7 +1759,8 @@ function makeErrorSpanWriter(errorsPath) {
1633
1759
  async function handleSpan(ctx, span) {
1634
1760
  const ts = span.startTimeIso ?? nowIso(ctx);
1635
1761
  const nowMs = ctx.now ? ctx.now() : Date.now();
1636
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1762
+ const env = span.env ?? "unknown";
1763
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1637
1764
  const isError = span.statusCode === 2;
1638
1765
  cacheSpanService(span, nowMs);
1639
1766
  let affectedNode = sourceId;
@@ -1656,7 +1783,7 @@ async function handleSpan(ctx, span) {
1656
1783
  const host = pickAddress(span);
1657
1784
  let resolvedViaAddress = false;
1658
1785
  if (host && host !== span.service) {
1659
- const targetId = resolveServiceId(ctx.graph, host);
1786
+ const targetId = resolveServiceId(ctx.graph, host, env);
1660
1787
  if (targetId && targetId !== sourceId) {
1661
1788
  upsertObservedEdge(
1662
1789
  ctx.graph,
@@ -1683,9 +1810,9 @@ async function handleSpan(ctx, span) {
1683
1810
  }
1684
1811
  }
1685
1812
  if (!resolvedViaAddress && span.parentSpanId) {
1686
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1687
- if (parentService && parentService !== span.service) {
1688
- const parentId = ensureServiceNode(ctx.graph, parentService);
1813
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
1814
+ if (parent && parent.service !== span.service) {
1815
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1689
1816
  upsertObservedEdge(
1690
1817
  ctx.graph,
1691
1818
  import_types3.EdgeType.CALLS,
@@ -1808,6 +1935,28 @@ async function readErrorEvents(errorsPath) {
1808
1935
  throw err;
1809
1936
  }
1810
1937
  }
1938
+ function mergeSnapshot(graph, snapshot) {
1939
+ const exported = snapshot.graph;
1940
+ let nodesAdded = 0;
1941
+ let edgesAdded = 0;
1942
+ for (const node of exported.nodes ?? []) {
1943
+ if (graph.hasNode(node.key)) continue;
1944
+ if (!node.attributes) continue;
1945
+ graph.addNode(node.key, node.attributes);
1946
+ nodesAdded++;
1947
+ }
1948
+ for (const edge of exported.edges ?? []) {
1949
+ const attrs = edge.attributes;
1950
+ if (!attrs) continue;
1951
+ const id = edge.key ?? attrs.id;
1952
+ if (!id) continue;
1953
+ if (graph.hasEdge(id)) continue;
1954
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
1955
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
1956
+ edgesAdded++;
1957
+ }
1958
+ return { nodesAdded, edgesAdded };
1959
+ }
1811
1960
 
1812
1961
  // src/extract/services.ts
1813
1962
  init_cjs_shims();
@@ -2204,6 +2353,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2204
2353
  }
2205
2354
  return [...found];
2206
2355
  }
2356
+ function detectJsFramework(pkg) {
2357
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2358
+ if (deps["next"] !== void 0) return "next";
2359
+ if (deps["remix"] !== void 0) return "remix";
2360
+ for (const k of Object.keys(deps)) {
2361
+ if (k.startsWith("@remix-run/")) return "remix";
2362
+ }
2363
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2364
+ if (deps["nuxt"] !== void 0) return "nuxt";
2365
+ if (deps["astro"] !== void 0) return "astro";
2366
+ return void 0;
2367
+ }
2207
2368
  async function discoverNodeService(scanPath, dir) {
2208
2369
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2209
2370
  if (!await exists(pkgPath)) return null;
@@ -2215,6 +2376,7 @@ async function discoverNodeService(scanPath, dir) {
2215
2376
  return null;
2216
2377
  }
2217
2378
  if (!pkg.name) return null;
2379
+ const framework = detectJsFramework(pkg);
2218
2380
  const node = {
2219
2381
  id: (0, import_types5.serviceId)(pkg.name),
2220
2382
  type: import_types5.NodeType.ServiceNode,
@@ -2223,7 +2385,8 @@ async function discoverNodeService(scanPath, dir) {
2223
2385
  version: pkg.version,
2224
2386
  dependencies: pkg.dependencies ?? {},
2225
2387
  repoPath: import_node_path8.default.relative(scanPath, dir),
2226
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2388
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2389
+ ...framework ? { framework } : {}
2227
2390
  };
2228
2391
  return { pkg, dir, node };
2229
2392
  }
@@ -4006,7 +4169,7 @@ init_cjs_shims();
4006
4169
  var import_node_fs18 = require("fs");
4007
4170
  var import_node_path31 = __toESM(require("path"), 1);
4008
4171
  var import_types19 = require("@neat.is/types");
4009
- var SCHEMA_VERSION = 3;
4172
+ var SCHEMA_VERSION = 4;
4010
4173
  function migrateV1ToV2(payload) {
4011
4174
  const nodes = payload.graph.nodes;
4012
4175
  if (Array.isArray(nodes)) {
@@ -4018,6 +4181,9 @@ function migrateV1ToV2(payload) {
4018
4181
  }
4019
4182
  return { ...payload, schemaVersion: 2 };
4020
4183
  }
4184
+ function migrateV3ToV4(payload) {
4185
+ return { ...payload, schemaVersion: 4 };
4186
+ }
4021
4187
  function migrateV2ToV3(payload) {
4022
4188
  const edges = payload.graph.edges;
4023
4189
  if (Array.isArray(edges)) {
@@ -4066,6 +4232,9 @@ async function loadGraphFromDisk(graph, outPath) {
4066
4232
  if (payload.schemaVersion === 2) {
4067
4233
  payload = migrateV2ToV3(payload);
4068
4234
  }
4235
+ if (payload.schemaVersion === 3) {
4236
+ payload = migrateV3ToV4(payload);
4237
+ }
4069
4238
  if (payload.schemaVersion !== SCHEMA_VERSION) {
4070
4239
  throw new Error(
4071
4240
  `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
@@ -4646,6 +4815,7 @@ data: ${JSON.stringify(envelope.payload)}
4646
4815
  }
4647
4816
 
4648
4817
  // src/api.ts
4818
+ init_auth();
4649
4819
  function serializeGraph(graph) {
4650
4820
  const nodes = [];
4651
4821
  graph.forEachNode((_id, attrs) => {
@@ -4918,6 +5088,35 @@ function registerRoutes(scope, ctx) {
4918
5088
  }
4919
5089
  }
4920
5090
  );
5091
+ scope.post("/snapshot", async (req2, reply) => {
5092
+ const proj = resolveProject(registry, req2, reply);
5093
+ if (!proj) return;
5094
+ const body = req2.body;
5095
+ if (!body || typeof body !== "object" || !body.snapshot) {
5096
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5097
+ }
5098
+ const snap = body.snapshot;
5099
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5100
+ return reply.code(400).send({
5101
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5102
+ });
5103
+ }
5104
+ try {
5105
+ const result = mergeSnapshot(proj.graph, snap);
5106
+ return {
5107
+ project: proj.name,
5108
+ nodesAdded: result.nodesAdded,
5109
+ edgesAdded: result.edgesAdded,
5110
+ nodeCount: proj.graph.order,
5111
+ edgeCount: proj.graph.size
5112
+ };
5113
+ } catch (err) {
5114
+ return reply.code(400).send({
5115
+ error: "snapshot merge failed",
5116
+ details: err.message
5117
+ });
5118
+ }
5119
+ });
4921
5120
  scope.post("/graph/scan", async (req2, reply) => {
4922
5121
  const proj = resolveProject(registry, req2, reply);
4923
5122
  if (!proj) return;
@@ -5011,6 +5210,15 @@ function registerRoutes(scope, ctx) {
5011
5210
  async function buildApi(opts) {
5012
5211
  const app = (0, import_fastify.default)({ logger: false });
5013
5212
  await app.register(import_cors.default, { origin: true });
5213
+ mountBearerAuth(app, {
5214
+ token: opts.authToken,
5215
+ trustProxy: opts.trustProxy,
5216
+ publicRead: opts.publicRead
5217
+ });
5218
+ app.get("/api/config", async () => ({
5219
+ publicRead: opts.publicRead === true,
5220
+ authProxy: opts.trustProxy === true
5221
+ }));
5014
5222
  const startedAt = opts.startedAt ?? Date.now();
5015
5223
  const registry = buildLegacyRegistry(opts);
5016
5224
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5074,6 +5282,7 @@ async function buildApi(opts) {
5074
5282
 
5075
5283
  // src/daemon.ts
5076
5284
  init_otel();
5285
+ init_auth();
5077
5286
  function neatHomeFor(opts) {
5078
5287
  if (opts.neatHome && opts.neatHome.length > 0) return import_node_path36.default.resolve(opts.neatHome);
5079
5288
  const env = process.env.NEAT_HOME;
@@ -5280,8 +5489,15 @@ async function startDaemon(opts = {}) {
5280
5489
  const host = resolveHost(opts);
5281
5490
  const restPort = resolveRestPort(opts);
5282
5491
  const otlpPort = resolveOtlpPort(opts);
5492
+ const auth = readAuthEnv();
5493
+ assertBindAuthority(host, auth.authToken);
5283
5494
  try {
5284
- restApp = await buildApi({ projects: registry });
5495
+ restApp = await buildApi({
5496
+ projects: registry,
5497
+ authToken: auth.authToken,
5498
+ trustProxy: auth.trustProxy,
5499
+ publicRead: auth.publicRead
5500
+ });
5285
5501
  restAddress = await restApp.listen({ port: restPort, host });
5286
5502
  console.log(`neatd: REST listening on ${restAddress}`);
5287
5503
  } catch (err) {
@@ -5318,6 +5534,8 @@ async function startDaemon(opts = {}) {
5318
5534
  }
5319
5535
  try {
5320
5536
  otlpApp = await buildOtelReceiver({
5537
+ authToken: auth.otelToken,
5538
+ trustProxy: auth.trustProxy,
5321
5539
  onSpan: async (span) => {
5322
5540
  const slot = await resolveTargetSlot(span.service);
5323
5541
  if (!slot) return;
@@ -5397,6 +5615,9 @@ async function startDaemon(opts = {}) {
5397
5615
  return { slots, reload, stop, pidPath, restAddress, otlpAddress };
5398
5616
  }
5399
5617
 
5618
+ // src/neatd.ts
5619
+ init_auth();
5620
+
5400
5621
  // src/web-spawn.ts
5401
5622
  init_cjs_shims();
5402
5623
  var import_node_child_process = require("child_process");
@@ -5592,7 +5813,16 @@ function restPortFromEnv() {
5592
5813
  return raw && raw.length > 0 ? Number.parseInt(raw, 10) : 8080;
5593
5814
  }
5594
5815
  async function cmdStart() {
5595
- const handle = await startDaemon();
5816
+ let handle;
5817
+ try {
5818
+ handle = await startDaemon();
5819
+ } catch (err) {
5820
+ if (err instanceof BindAuthorityError) {
5821
+ console.error(`neatd: ${err.message}`);
5822
+ process.exit(1);
5823
+ }
5824
+ throw err;
5825
+ }
5596
5826
  console.log(`neatd: started, PID ${process.pid}, ${handle.slots.size} project(s)`);
5597
5827
  console.log(`neatd: registry at ${registryPath()}`);
5598
5828
  if (handle.restAddress) console.log(`neatd: REST \u2192 ${handle.restAddress}`);