@neat.is/core 0.3.6 → 0.3.8

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,8 @@ interface BuildApiOptions {
114
114
  errorsPath?: string;
115
115
  staleEventsPath?: string;
116
116
  searchIndex?: SearchIndex;
117
+ authToken?: string;
118
+ trustProxy?: boolean;
117
119
  }
118
120
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
119
121
 
@@ -145,6 +147,8 @@ interface BuildOtelReceiverOptions {
145
147
  onSpan: SpanHandler;
146
148
  onErrorSpanSync?: (span: ParsedSpan) => Promise<void>;
147
149
  bodyLimit?: number;
150
+ authToken?: string;
151
+ trustProxy?: boolean;
148
152
  }
149
153
  interface OtlpKeyValue {
150
154
  key: string;
@@ -200,6 +204,8 @@ declare function logSpanHandler(span: ParsedSpan): void;
200
204
 
201
205
  interface BuildOtelGrpcReceiverOptions {
202
206
  onSpan: SpanHandler;
207
+ authToken?: string;
208
+ trustProxy?: boolean;
203
209
  }
204
210
  interface OtelGrpcReceiver {
205
211
  address: string;
@@ -358,6 +364,29 @@ interface DaemonHandle {
358
364
  *
359
365
  * Pure function. Daemon callers pass a snapshot of the registry to avoid
360
366
  * per-span fs reads.
367
+ *
368
+ * Matching order (ADR-072 — real-world `service.name` rarely equals project
369
+ * name; monorepos publish per-package names like `brief-api` under a
370
+ * project named `brief`):
371
+ *
372
+ * 1. Exact: `entry.name === serviceName`.
373
+ * 2. Hyphen/underscore-separated prefix: `entry.name` is a leading token
374
+ * of `serviceName` (`brief` matches `brief-api`, `brief_worker`).
375
+ * Longest-match wins so `brief-api` beats `brief` when both are
376
+ * registered.
377
+ * 3. Containment as a separator-delimited token (`api` inside
378
+ * `brief-api-staging`).
379
+ *
380
+ * Routing eligibility (ADR-071):
381
+ * - `active` matches at every pass (the steady-state path).
382
+ * - `broken` also matches — the daemon needs the span to reach the broken
383
+ * slot so the ingest-time auto-recover path can attempt a bootstrap and
384
+ * lift the project back to `active`. The router only chooses the target;
385
+ * whether the span actually lands is the ingest handler's decision.
386
+ * - `paused` is intentionally not routed; the operator paused it on
387
+ * purpose, so the span falls through to the default-project flow.
388
+ *
389
+ * Falls back to `DEFAULT_PROJECT` when nothing matches.
361
390
  */
362
391
  declare function routeSpanToProject(serviceName: string | undefined, projects: ReadonlyArray<RegistryEntry>): string;
363
392
  declare function startDaemon(opts?: DaemonOptions): Promise<DaemonHandle>;
package/dist/index.d.ts CHANGED
@@ -114,6 +114,8 @@ interface BuildApiOptions {
114
114
  errorsPath?: string;
115
115
  staleEventsPath?: string;
116
116
  searchIndex?: SearchIndex;
117
+ authToken?: string;
118
+ trustProxy?: boolean;
117
119
  }
118
120
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
119
121
 
@@ -145,6 +147,8 @@ interface BuildOtelReceiverOptions {
145
147
  onSpan: SpanHandler;
146
148
  onErrorSpanSync?: (span: ParsedSpan) => Promise<void>;
147
149
  bodyLimit?: number;
150
+ authToken?: string;
151
+ trustProxy?: boolean;
148
152
  }
149
153
  interface OtlpKeyValue {
150
154
  key: string;
@@ -200,6 +204,8 @@ declare function logSpanHandler(span: ParsedSpan): void;
200
204
 
201
205
  interface BuildOtelGrpcReceiverOptions {
202
206
  onSpan: SpanHandler;
207
+ authToken?: string;
208
+ trustProxy?: boolean;
203
209
  }
204
210
  interface OtelGrpcReceiver {
205
211
  address: string;
@@ -358,6 +364,29 @@ interface DaemonHandle {
358
364
  *
359
365
  * Pure function. Daemon callers pass a snapshot of the registry to avoid
360
366
  * per-span fs reads.
367
+ *
368
+ * Matching order (ADR-072 — real-world `service.name` rarely equals project
369
+ * name; monorepos publish per-package names like `brief-api` under a
370
+ * project named `brief`):
371
+ *
372
+ * 1. Exact: `entry.name === serviceName`.
373
+ * 2. Hyphen/underscore-separated prefix: `entry.name` is a leading token
374
+ * of `serviceName` (`brief` matches `brief-api`, `brief_worker`).
375
+ * Longest-match wins so `brief-api` beats `brief` when both are
376
+ * registered.
377
+ * 3. Containment as a separator-delimited token (`api` inside
378
+ * `brief-api-staging`).
379
+ *
380
+ * Routing eligibility (ADR-071):
381
+ * - `active` matches at every pass (the steady-state path).
382
+ * - `broken` also matches — the daemon needs the span to reach the broken
383
+ * slot so the ingest-time auto-recover path can attempt a bootstrap and
384
+ * lift the project back to `active`. The router only chooses the target;
385
+ * whether the span actually lands is the ingest handler's decision.
386
+ * - `paused` is intentionally not routed; the operator paused it on
387
+ * purpose, so the span falls through to the default-project flow.
388
+ *
389
+ * Falls back to `DEFAULT_PROJECT` when nothing matches.
361
390
  */
362
391
  declare function routeSpanToProject(serviceName: string | undefined, projects: ReadonlyArray<RegistryEntry>): string;
363
392
  declare function startDaemon(opts?: DaemonOptions): Promise<DaemonHandle>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-YHQYHFI3.js";
4
+ } from "./chunk-LQ3JFBTX.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-CZ3T6TE2.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
- } from "./chunk-G3PDTGOW.js";
43
+ } from "./chunk-V4TU7OKZ.js";
44
44
  import {
45
45
  buildOtelReceiver,
46
46
  logSpanHandler,
47
47
  parseOtlpRequest
48
- } from "./chunk-4ASCXBZF.js";
48
+ } from "./chunk-7TYESDAI.js";
49
49
  export {
50
50
  ProjectNameCollisionError,
51
51
  addProject,
package/dist/neatd.cjs CHANGED
@@ -40,6 +40,75 @@ 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
+ app.addHook("preHandler", (req2, reply, done) => {
59
+ const path39 = (req2.url.split("?")[0] ?? "").replace(/\/+$/, "");
60
+ for (const suffix of suffixes) {
61
+ if (path39 === suffix || path39.endsWith(suffix)) {
62
+ done();
63
+ return;
64
+ }
65
+ }
66
+ const header = req2.headers.authorization;
67
+ if (typeof header !== "string" || !header.startsWith("Bearer ")) {
68
+ void reply.code(401).send({ error: "unauthorized" });
69
+ return;
70
+ }
71
+ const provided = Buffer.from(header.slice("Bearer ".length).trim(), "utf8");
72
+ if (provided.length !== expected.length || !(0, import_node_crypto.timingSafeEqual)(provided, expected)) {
73
+ void reply.code(401).send({ error: "unauthorized" });
74
+ return;
75
+ }
76
+ done();
77
+ });
78
+ }
79
+ function readAuthEnv(env = process.env) {
80
+ const t = env.NEAT_AUTH_TOKEN;
81
+ const ot = env.NEAT_OTEL_TOKEN;
82
+ return {
83
+ authToken: t && t.length > 0 ? t : void 0,
84
+ otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
85
+ trustProxy: env.NEAT_AUTH_PROXY === "true"
86
+ };
87
+ }
88
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
89
+ var init_auth = __esm({
90
+ "src/auth.ts"() {
91
+ "use strict";
92
+ init_cjs_shims();
93
+ import_node_crypto = require("crypto");
94
+ LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
95
+ "127.0.0.1",
96
+ "localhost",
97
+ "::1",
98
+ "::ffff:127.0.0.1"
99
+ ]);
100
+ BindAuthorityError = class extends Error {
101
+ constructor(host) {
102
+ super(
103
+ `NEAT refuses to bind on a public interface without \`NEAT_AUTH_TOKEN\` set (host="${host}"). Set the token or bind to loopback only.`
104
+ );
105
+ this.name = "BindAuthorityError";
106
+ }
107
+ };
108
+ DEFAULT_UNAUTH_SUFFIXES = ["/health", "/healthz", "/readyz"];
109
+ }
110
+ });
111
+
43
112
  // src/otel-grpc.ts
44
113
  var otel_grpc_exports = {};
45
114
  __export(otel_grpc_exports, {
@@ -122,8 +191,21 @@ function loadTraceService() {
122
191
  async function startOtelGrpcReceiver(opts) {
123
192
  const server = new grpc.Server();
124
193
  const service = loadTraceService();
194
+ const requiresAuth = !opts.trustProxy && !!opts.authToken && opts.authToken.length > 0;
195
+ const expectedHeader = requiresAuth ? `Bearer ${opts.authToken}` : "";
125
196
  server.addService(service, {
126
197
  Export: (call, callback) => {
198
+ if (requiresAuth) {
199
+ const meta = call.metadata.get("authorization");
200
+ const got = meta.length > 0 ? String(meta[0]) : "";
201
+ const a = Buffer.from(got, "utf8");
202
+ const b = Buffer.from(expectedHeader, "utf8");
203
+ const ok = a.length === b.length && (0, import_node_crypto2.timingSafeEqual)(a, b);
204
+ if (!ok) {
205
+ callback({ code: grpc.status.UNAUTHENTICATED, message: "unauthorized" });
206
+ return;
207
+ }
208
+ }
127
209
  void (async () => {
128
210
  try {
129
211
  const reshaped = reshapeGrpcRequest(call.request ?? {});
@@ -156,13 +238,14 @@ async function startOtelGrpcReceiver(opts) {
156
238
  })
157
239
  };
158
240
  }
159
- var import_node_url, import_node_path34, grpc, protoLoader;
241
+ var import_node_url, import_node_path34, import_node_crypto2, grpc, protoLoader;
160
242
  var init_otel_grpc = __esm({
161
243
  "src/otel-grpc.ts"() {
162
244
  "use strict";
163
245
  init_cjs_shims();
164
246
  import_node_url = require("url");
165
247
  import_node_path34 = __toESM(require("path"), 1);
248
+ import_node_crypto2 = require("crypto");
166
249
  grpc = __toESM(require("@grpc/grpc-js"), 1);
167
250
  protoLoader = __toESM(require("@grpc/proto-loader"), 1);
168
251
  init_otel();
@@ -257,8 +340,7 @@ function parseOtlpRequest(body) {
257
340
  }
258
341
  return out;
259
342
  }
260
- function loadProtobufDecoder() {
261
- if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
343
+ function loadProtoRoot() {
262
344
  const here = import_node_path35.default.dirname((0, import_node_url2.fileURLToPath)(importMetaUrl));
263
345
  const protoRoot = import_node_path35.default.resolve(here, "..", "proto");
264
346
  const root = new import_protobufjs.default.Root();
@@ -267,11 +349,32 @@ function loadProtobufDecoder() {
267
349
  "opentelemetry/proto/collector/trace/v1/trace_service.proto",
268
350
  { keepCase: true }
269
351
  );
352
+ return root;
353
+ }
354
+ function loadProtobufDecoder() {
355
+ if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
356
+ const root = loadProtoRoot();
270
357
  exportTraceServiceRequestType = root.lookupType(
271
358
  "opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"
272
359
  );
273
360
  return exportTraceServiceRequestType;
274
361
  }
362
+ function loadProtobufResponseEncoder() {
363
+ if (exportTraceServiceResponseType) return exportTraceServiceResponseType;
364
+ const root = loadProtoRoot();
365
+ exportTraceServiceResponseType = root.lookupType(
366
+ "opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse"
367
+ );
368
+ return exportTraceServiceResponseType;
369
+ }
370
+ function encodeProtobufResponseBody() {
371
+ if (cachedProtobufResponseBody) return cachedProtobufResponseBody;
372
+ const Type = loadProtobufResponseEncoder();
373
+ const msg = Type.create({});
374
+ const encoded = Type.encode(msg).finish();
375
+ cachedProtobufResponseBody = Buffer.from(encoded);
376
+ return cachedProtobufResponseBody;
377
+ }
275
378
  async function decodeProtobufBody(buf) {
276
379
  const Type = loadProtobufDecoder();
277
380
  const decoded = Type.decode(buf).toJSON();
@@ -283,6 +386,7 @@ async function buildOtelReceiver(opts) {
283
386
  logger: false,
284
387
  bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024
285
388
  });
389
+ mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
286
390
  const queue = [];
287
391
  let draining = false;
288
392
  let drainPromise = Promise.resolve();
@@ -318,7 +422,9 @@ async function buildOtelReceiver(opts) {
318
422
  app.post("/v1/traces", async (req2, reply) => {
319
423
  const ct = (req2.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
320
424
  let body;
425
+ let responseFlavor;
321
426
  if (ct === "application/x-protobuf") {
427
+ responseFlavor = "protobuf";
322
428
  try {
323
429
  body = await decodeProtobufBody(req2.body);
324
430
  } catch (err) {
@@ -327,6 +433,7 @@ async function buildOtelReceiver(opts) {
327
433
  });
328
434
  }
329
435
  } else if (!ct || ct === "application/json") {
436
+ responseFlavor = "json";
330
437
  body = req2.body ?? {};
331
438
  } else {
332
439
  return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
@@ -344,7 +451,11 @@ async function buildOtelReceiver(opts) {
344
451
  }
345
452
  }
346
453
  enqueue(spans);
347
- return reply.code(200).send({ partialSuccess: {} });
454
+ if (responseFlavor === "protobuf") {
455
+ const buf = encodeProtobufResponseBody();
456
+ return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
457
+ }
458
+ return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
348
459
  });
349
460
  const decorated = app;
350
461
  decorated.flushPending = async () => {
@@ -354,7 +465,7 @@ async function buildOtelReceiver(opts) {
354
465
  };
355
466
  return decorated;
356
467
  }
357
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType;
468
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
358
469
  var init_otel = __esm({
359
470
  "src/otel.ts"() {
360
471
  "use strict";
@@ -363,7 +474,10 @@ var init_otel = __esm({
363
474
  import_node_url2 = require("url");
364
475
  import_fastify2 = __toESM(require("fastify"), 1);
365
476
  import_protobufjs = __toESM(require("protobufjs"), 1);
477
+ init_auth();
366
478
  exportTraceServiceRequestType = null;
479
+ exportTraceServiceResponseType = null;
480
+ cachedProtobufResponseBody = null;
367
481
  }
368
482
  });
369
483
 
@@ -4617,6 +4731,7 @@ data: ${JSON.stringify(envelope.payload)}
4617
4731
  }
4618
4732
 
4619
4733
  // src/api.ts
4734
+ init_auth();
4620
4735
  function serializeGraph(graph) {
4621
4736
  const nodes = [];
4622
4737
  graph.forEachNode((_id, attrs) => {
@@ -4982,6 +5097,7 @@ function registerRoutes(scope, ctx) {
4982
5097
  async function buildApi(opts) {
4983
5098
  const app = (0, import_fastify.default)({ logger: false });
4984
5099
  await app.register(import_cors.default, { origin: true });
5100
+ mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
4985
5101
  const startedAt = opts.startedAt ?? Date.now();
4986
5102
  const registry = buildLegacyRegistry(opts);
4987
5103
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5045,6 +5161,7 @@ async function buildApi(opts) {
5045
5161
 
5046
5162
  // src/daemon.ts
5047
5163
  init_otel();
5164
+ init_auth();
5048
5165
  function neatHomeFor(opts) {
5049
5166
  if (opts.neatHome && opts.neatHome.length > 0) return import_node_path36.default.resolve(opts.neatHome);
5050
5167
  const env = process.env.NEAT_HOME;
@@ -5055,13 +5172,35 @@ function neatHomeFor(opts) {
5055
5172
  function routeSpanToProject(serviceName, projects) {
5056
5173
  if (!serviceName) return DEFAULT_PROJECT;
5057
5174
  for (const entry2 of projects) {
5058
- if (entry2.status !== "active") continue;
5059
- if (entry2.languages.length === 0) {
5060
- }
5175
+ if (entry2.status === "paused") continue;
5061
5176
  if (entry2.name === serviceName) return entry2.name;
5062
5177
  }
5178
+ const candidates = [];
5179
+ for (const entry2 of projects) {
5180
+ if (entry2.status === "paused") continue;
5181
+ if (isTokenPrefix(entry2.name, serviceName)) candidates.push(entry2);
5182
+ }
5183
+ if (candidates.length > 0) {
5184
+ candidates.sort((a, b) => b.name.length - a.name.length);
5185
+ return candidates[0].name;
5186
+ }
5187
+ for (const entry2 of projects) {
5188
+ if (entry2.status === "paused") continue;
5189
+ if (isTokenContained(entry2.name, serviceName)) return entry2.name;
5190
+ }
5063
5191
  return DEFAULT_PROJECT;
5064
5192
  }
5193
+ function isTokenPrefix(prefix, full) {
5194
+ if (prefix.length >= full.length) return false;
5195
+ if (!full.startsWith(prefix)) return false;
5196
+ const sep = full.charAt(prefix.length);
5197
+ return sep === "-" || sep === "_";
5198
+ }
5199
+ function isTokenContained(needle, haystack) {
5200
+ if (!haystack.includes(needle)) return false;
5201
+ const tokens = haystack.split(/[-_]/);
5202
+ return tokens.includes(needle);
5203
+ }
5065
5204
  async function bootstrapProject(entry2) {
5066
5205
  const paths = pathsForProject(entry2.name, import_node_path36.default.join(entry2.path, "neat-out"));
5067
5206
  try {
@@ -5141,6 +5280,17 @@ async function startDaemon(opts = {}) {
5141
5280
  `);
5142
5281
  const slots = /* @__PURE__ */ new Map();
5143
5282
  const registry = new Projects();
5283
+ const DROP_WARN_INTERVAL_MS = 6e4;
5284
+ const lastDropWarnAt = /* @__PURE__ */ new Map();
5285
+ function warnDroppedSpan(project, reason) {
5286
+ const now = Date.now();
5287
+ const prev = lastDropWarnAt.get(project) ?? 0;
5288
+ if (now - prev < DROP_WARN_INTERVAL_MS) return;
5289
+ lastDropWarnAt.set(project, now);
5290
+ console.warn(
5291
+ `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
5292
+ );
5293
+ }
5144
5294
  function upsertRegistryFromSlot(slot) {
5145
5295
  if (slot.status !== "active") return;
5146
5296
  registry.set(slot.entry.name, {
@@ -5149,12 +5299,38 @@ async function startDaemon(opts = {}) {
5149
5299
  graph: slot.graph
5150
5300
  });
5151
5301
  }
5302
+ async function tryRecoverSlot(entry2) {
5303
+ try {
5304
+ const fresh = await bootstrapProject(entry2);
5305
+ slots.set(entry2.name, fresh);
5306
+ upsertRegistryFromSlot(fresh);
5307
+ if (fresh.status === "active") {
5308
+ await setStatus(entry2.name, "active").catch(() => {
5309
+ });
5310
+ console.log(
5311
+ `neatd: project "${entry2.name}" recovered from broken \u2014 active`
5312
+ );
5313
+ }
5314
+ return fresh;
5315
+ } catch (err) {
5316
+ console.warn(
5317
+ `neatd: project "${entry2.name}" still broken after recovery attempt \u2014 ${err.message}`
5318
+ );
5319
+ return slots.get(entry2.name);
5320
+ }
5321
+ }
5152
5322
  async function loadAll() {
5153
5323
  const projects = await listProjects();
5154
5324
  const seen = /* @__PURE__ */ new Set();
5155
5325
  for (const entry2 of projects) {
5156
5326
  seen.add(entry2.name);
5157
- if (slots.has(entry2.name)) continue;
5327
+ const existing = slots.get(entry2.name);
5328
+ if (existing) {
5329
+ if (existing.status === "broken") {
5330
+ await tryRecoverSlot(entry2);
5331
+ }
5332
+ continue;
5333
+ }
5158
5334
  try {
5159
5335
  const slot = await bootstrapProject(entry2);
5160
5336
  slots.set(entry2.name, slot);
@@ -5192,8 +5368,14 @@ async function startDaemon(opts = {}) {
5192
5368
  const host = resolveHost(opts);
5193
5369
  const restPort = resolveRestPort(opts);
5194
5370
  const otlpPort = resolveOtlpPort(opts);
5371
+ const auth = readAuthEnv();
5372
+ assertBindAuthority(host, auth.authToken);
5195
5373
  try {
5196
- restApp = await buildApi({ projects: registry });
5374
+ restApp = await buildApi({
5375
+ projects: registry,
5376
+ authToken: auth.authToken,
5377
+ trustProxy: auth.trustProxy
5378
+ });
5197
5379
  restAddress = await restApp.listen({ port: restPort, host });
5198
5380
  console.log(`neatd: REST listening on ${restAddress}`);
5199
5381
  } catch (err) {
@@ -5211,13 +5393,30 @@ async function startDaemon(opts = {}) {
5211
5393
  `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5212
5394
  );
5213
5395
  }
5396
+ async function resolveTargetSlot(serviceName) {
5397
+ const liveEntries = await listProjects().catch(() => []);
5398
+ const target = routeSpanToProject(serviceName, liveEntries);
5399
+ let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5400
+ if (!slot) return null;
5401
+ if (slot.status === "broken") {
5402
+ const entry2 = liveEntries.find((e) => e.name === slot.entry.name);
5403
+ if (entry2) {
5404
+ slot = await tryRecoverSlot(entry2);
5405
+ }
5406
+ if (slot.status !== "active") {
5407
+ warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
5408
+ return null;
5409
+ }
5410
+ }
5411
+ return slot.status === "active" ? slot : null;
5412
+ }
5214
5413
  try {
5215
5414
  otlpApp = await buildOtelReceiver({
5415
+ authToken: auth.otelToken,
5416
+ trustProxy: auth.trustProxy,
5216
5417
  onSpan: async (span) => {
5217
- const liveEntries = await listProjects().catch(() => []);
5218
- const target = routeSpanToProject(span.service, liveEntries);
5219
- const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5220
- if (!slot || slot.status !== "active") return;
5418
+ const slot = await resolveTargetSlot(span.service);
5419
+ if (!slot) return;
5221
5420
  await handleSpan(
5222
5421
  {
5223
5422
  graph: slot.graph,
@@ -5230,10 +5429,8 @@ async function startDaemon(opts = {}) {
5230
5429
  );
5231
5430
  },
5232
5431
  onErrorSpanSync: async (span) => {
5233
- const liveEntries = await listProjects().catch(() => []);
5234
- const target = routeSpanToProject(span.service, liveEntries);
5235
- const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5236
- if (!slot || slot.status !== "active") return;
5432
+ const slot = await resolveTargetSlot(span.service);
5433
+ if (!slot) return;
5237
5434
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5238
5435
  }
5239
5436
  });
@@ -5296,6 +5493,9 @@ async function startDaemon(opts = {}) {
5296
5493
  return { slots, reload, stop, pidPath, restAddress, otlpAddress };
5297
5494
  }
5298
5495
 
5496
+ // src/neatd.ts
5497
+ init_auth();
5498
+
5299
5499
  // src/web-spawn.ts
5300
5500
  init_cjs_shims();
5301
5501
  var import_node_child_process = require("child_process");
@@ -5491,7 +5691,16 @@ function restPortFromEnv() {
5491
5691
  return raw && raw.length > 0 ? Number.parseInt(raw, 10) : 8080;
5492
5692
  }
5493
5693
  async function cmdStart() {
5494
- const handle = await startDaemon();
5694
+ let handle;
5695
+ try {
5696
+ handle = await startDaemon();
5697
+ } catch (err) {
5698
+ if (err instanceof BindAuthorityError) {
5699
+ console.error(`neatd: ${err.message}`);
5700
+ process.exit(1);
5701
+ }
5702
+ throw err;
5703
+ }
5495
5704
  console.log(`neatd: started, PID ${process.pid}, ${handle.slots.size} project(s)`);
5496
5705
  console.log(`neatd: registry at ${registryPath()}`);
5497
5706
  if (handle.restAddress) console.log(`neatd: REST \u2192 ${handle.restAddress}`);