neotoma 0.22.1 → 0.22.2

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.
Files changed (111) hide show
  1. package/dist/actions.d.ts +13 -0
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +236 -93
  4. package/dist/actions.js.map +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +11 -0
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/config.d.ts +13 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +90 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/docs/api/rest_api.md +81 -0
  13. package/dist/docs/developer/getting_started.md +45 -0
  14. package/dist/docs/developer/mcp/proxy.md +48 -28
  15. package/dist/docs/developer/npm_scripts.md +10 -4
  16. package/dist/docs/infrastructure/client_instance_deployment.md +50 -2
  17. package/dist/docs/operations/configuration.md +44 -1
  18. package/dist/docs/subsystems/agent_session_architecture.md +69 -0
  19. package/dist/docs/subsystems/usage_digest.md +127 -0
  20. package/dist/docs/testing/automated_test_catalog.md +49 -26
  21. package/dist/docs/testing/testing_standard.md +1 -1
  22. package/dist/middleware/aauth_admission.js +1 -1
  23. package/dist/middleware/aauth_admission.js.map +1 -1
  24. package/dist/middleware/db_abort_context.d.ts +24 -0
  25. package/dist/middleware/db_abort_context.d.ts.map +1 -0
  26. package/dist/middleware/db_abort_context.js +41 -0
  27. package/dist/middleware/db_abort_context.js.map +1 -0
  28. package/dist/proxy/mcp_stdio_proxy.d.ts +134 -6
  29. package/dist/proxy/mcp_stdio_proxy.d.ts.map +1 -1
  30. package/dist/proxy/mcp_stdio_proxy.js +373 -17
  31. package/dist/proxy/mcp_stdio_proxy.js.map +1 -1
  32. package/dist/repositories/sqlite/local_db_adapter.d.ts.map +1 -1
  33. package/dist/repositories/sqlite/local_db_adapter.js +20 -0
  34. package/dist/repositories/sqlite/local_db_adapter.js.map +1 -1
  35. package/dist/repositories/sqlite/sqlite_client.d.ts.map +1 -1
  36. package/dist/repositories/sqlite/sqlite_client.js +10 -0
  37. package/dist/repositories/sqlite/sqlite_client.js.map +1 -1
  38. package/dist/repositories/worker/worker_file_database.d.ts +135 -1
  39. package/dist/repositories/worker/worker_file_database.d.ts.map +1 -1
  40. package/dist/repositories/worker/worker_file_database.js +726 -27
  41. package/dist/repositories/worker/worker_file_database.js.map +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +182 -77
  44. package/dist/server.js.map +1 -1
  45. package/dist/services/attachment_resolution.d.ts +149 -0
  46. package/dist/services/attachment_resolution.d.ts.map +1 -0
  47. package/dist/services/attachment_resolution.js +206 -0
  48. package/dist/services/attachment_resolution.js.map +1 -0
  49. package/dist/services/attachment_resolution_sqlite.d.ts +57 -0
  50. package/dist/services/attachment_resolution_sqlite.d.ts.map +1 -0
  51. package/dist/services/attachment_resolution_sqlite.js +75 -0
  52. package/dist/services/attachment_resolution_sqlite.js.map +1 -0
  53. package/dist/services/entity_queries.d.ts.map +1 -1
  54. package/dist/services/entity_queries.js +85 -23
  55. package/dist/services/entity_queries.js.map +1 -1
  56. package/dist/services/entity_snapshot_at_time.d.ts.map +1 -1
  57. package/dist/services/entity_snapshot_at_time.js +23 -3
  58. package/dist/services/entity_snapshot_at_time.js.map +1 -1
  59. package/dist/services/file_input_diagnostics.d.ts +89 -0
  60. package/dist/services/file_input_diagnostics.d.ts.map +1 -0
  61. package/dist/services/file_input_diagnostics.js +174 -0
  62. package/dist/services/file_input_diagnostics.js.map +1 -0
  63. package/dist/services/interpretation.d.ts.map +1 -1
  64. package/dist/services/interpretation.js +22 -8
  65. package/dist/services/interpretation.js.map +1 -1
  66. package/dist/services/mcp_oauth.d.ts +14 -1
  67. package/dist/services/mcp_oauth.d.ts.map +1 -1
  68. package/dist/services/mcp_oauth.js +86 -1
  69. package/dist/services/mcp_oauth.js.map +1 -1
  70. package/dist/services/raw_storage.d.ts.map +1 -1
  71. package/dist/services/raw_storage.js +14 -2
  72. package/dist/services/raw_storage.js.map +1 -1
  73. package/dist/services/readiness.d.ts +85 -0
  74. package/dist/services/readiness.d.ts.map +1 -0
  75. package/dist/services/readiness.js +93 -0
  76. package/dist/services/readiness.js.map +1 -0
  77. package/dist/services/route_shadowing.d.ts +67 -0
  78. package/dist/services/route_shadowing.d.ts.map +1 -0
  79. package/dist/services/route_shadowing.js +126 -0
  80. package/dist/services/route_shadowing.js.map +1 -0
  81. package/dist/services/schema_definitions.d.ts.map +1 -1
  82. package/dist/services/schema_definitions.js +171 -0
  83. package/dist/services/schema_definitions.js.map +1 -1
  84. package/dist/services/schema_lag_repair.d.ts.map +1 -1
  85. package/dist/services/schema_lag_repair.js +32 -12
  86. package/dist/services/schema_lag_repair.js.map +1 -1
  87. package/dist/services/schema_registry.d.ts.map +1 -1
  88. package/dist/services/schema_registry.js +12 -5
  89. package/dist/services/schema_registry.js.map +1 -1
  90. package/dist/services/snapshot_computation.d.ts.map +1 -1
  91. package/dist/services/snapshot_computation.js +12 -8
  92. package/dist/services/snapshot_computation.js.map +1 -1
  93. package/dist/services/subscriptions/sse_hub.d.ts.map +1 -1
  94. package/dist/services/subscriptions/sse_hub.js +19 -1
  95. package/dist/services/subscriptions/sse_hub.js.map +1 -1
  96. package/dist/shared/action_handlers/entity_handlers.d.ts.map +1 -1
  97. package/dist/shared/action_handlers/entity_handlers.js +110 -26
  98. package/dist/shared/action_handlers/entity_handlers.js.map +1 -1
  99. package/dist/shared/action_schemas.d.ts +6 -6
  100. package/dist/shared/contract_mappings.d.ts.map +1 -1
  101. package/dist/shared/contract_mappings.js +8 -1
  102. package/dist/shared/contract_mappings.js.map +1 -1
  103. package/dist/shared/openapi_types.d.ts +108 -3
  104. package/dist/shared/openapi_types.d.ts.map +1 -1
  105. package/dist/tool_definitions.d.ts.map +1 -1
  106. package/dist/tool_definitions.js +18 -13
  107. package/dist/tool_definitions.js.map +1 -1
  108. package/openapi.yaml +83 -3
  109. package/package.json +2 -1
  110. package/skills/status/SKILL.md +0 -1
  111. package/skills/where/SKILL.md +315 -0
package/dist/actions.d.ts CHANGED
@@ -141,6 +141,19 @@ type GuestPrincipal = {
141
141
  accessToken?: string;
142
142
  };
143
143
  type RoutePrincipal = UserPrincipal | GuestPrincipal;
144
+ /**
145
+ * Static path segments under `/entities/` that are routes in their own right,
146
+ * not entity ids. The `/entities/:id` family of guest-eligible reads must never
147
+ * treat these as an id (issue #2208): `/entities/duplicates` matched the
148
+ * `:id`-shaped guest regex below, so a guest principal could be stamped onto a
149
+ * route whose handler calls `getAuthenticatedUserId()` and 500s.
150
+ *
151
+ * Keep in sync with the `app.get("/entities/<segment>")` registrations. The
152
+ * boot-time assertion in `assertNoShadowedRoutes()` fails the process if a new
153
+ * static `/entities/*` route is added without being registered ahead of
154
+ * `/entities/:id`, which is the other half of this invariant.
155
+ */
156
+ export declare const RESERVED_ENTITY_PATH_SEGMENTS: Set<string>;
144
157
  /** Exported for unit tests: routes where guest (AAuth / guest token) may be stamped before handlers run. */
145
158
  export declare function routeAcceptsGuestPrincipal(req: Pick<express.Request, "method" | "path">): boolean;
146
159
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AA2C9B,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,6BAA6B,CAAC;AAarC,OAAO,EAGL,KAAK,aAAa,EAGnB,MAAM,4BAA4B,CAAC;AAkCpC,OAAO,EAQL,KAAK,sBAAsB,EAC5B,MAAM,4BAA4B,CAAC;AAkDpC,OAAO,EAGL,oBAAoB,EACrB,MAAM,8BAA8B,CAAC;AAWtC,OAAO,EAiCL,KAAK,wBAAwB,EAE9B,MAAM,4BAA4B,CAAC;AA8CpC,6FAA6F;AAC7F,wBAAgB,qBAAqB,IAAI,sBAAsB,GAAG,IAAI,CAErE;AAED,eAAO,MAAM,GAAG,6CAAY,CAAC;AA2zB7B,kFAAkF;AAClF,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAMtE;AAoBD,uFAAuF;AACvF,eAAO,MAAM,wBAAwB,OAAQ,CAAC;AAE9C;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAMlE;AAID;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAA;CAAE,GAC7F,IAAI,CA4IN;AAuBD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CA+B1F;AAmCD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,GAAG,OAAO,CA2B5D;AAGD;+EAC+E;AAC/E,eAAO,MAAM,gBAAgB,sBAA6B,CAAC;AAE3D;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,QAAc,CAAC;AAE3D;;;gEAGgE;AAChE,eAAO,MAAM,2BAA2B,QAAgB,CAAC;AAEzD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAKzE;AA4OD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAgBR;AAED,gGAAgG;AAChG,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,GAAG,MAAM,GAAG,SAAS,CAKhF;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,CA2BhD;AA0qDD,KAAK,aAAa,GAAG;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;IACvB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,KAAK,cAAc,GAAG,aAAa,GAAG,cAAc,CAAC;AA2CrD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,aAAqD,CAAC;AAEhG,4GAA4G;AAC5G,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,GAAG,OAAO,CA0BjG;AAED;;;;;;;;GAQG;AACH,wBAAgB,kCAAkC,CAChD,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,GAC5C,OAAO,CAST;AAED,oHAAoH;AACpH,wBAAgB,6BAA6B,CAC3C,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,GAC5C,OAAO,CAWT;AAED,mGAAmG;AACnG,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,GAAG,MAAM,CAanE;AAoBD;;;;;;;;;;GAUG;AACH,wBAAgB,gCAAgC,CAC9C,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC,GAC5C,OAAO,CAIT;AAiHD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,SAAS,EAAE,cAAc,GACxB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAoCxB;AAycD;;;;;;;;;;;;;;;;GAgBG;AAIH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,MAAM,CAAC,CA4CjB;AAqpFD,KAAK,oBAAoB,GAAG;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAuDF,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IACpC,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,4BAA4B,EAAE,iBAAiB,CAAC;IAC3E,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACvC,cAAc,CAAC,EAAE,wBAAwB,CAAC;IAC1C,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;;;;;;;;;;;;;;;;qBAy/BgB,MAAM;eACZ,MAAM;2BACM,MAAM;;;;;;cAtBnB,MAAM;iBACH,MAAM;2BACI,MAAM;qBACZ,MAAM;mBACR,MAAM;kBAIP,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;cAnC3B,MAAM;gBACJ,MAAM;2BACK,MAAM;qBACZ,MAAM;mBACR,MAAM;wBACD,MAAM;uBACP,MAAM;;;;;mBAhSV,MAAM;qBACJ,MAAM;wBACH,MAAM,GAAG,IAAI;2BACV,MAAM;;wBAET,MAAM;uBACP,MAAM,EAAE;wBACP,MAAM;uBACP,MAAM;;kBAxYX,MAAM;oBACJ,MAAM;yBACD,MAAM;4BACH,MAAM;2BACP,MAAM;;;kBAQf,MAAM;yBACC,MAAM;iCACE,MAAM;sCACD,MAAM;;+BA4Xb,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;;;2BAwMlC,MAAM;0BACP,MAAM;0BACN,MAAM;;;;;;;;GA4e3B;AAklHD,wBAAsB,eAAe;;;eA+QpC"}
package/dist/actions.js CHANGED
@@ -18,11 +18,14 @@ import { encryptResponseMiddleware } from "./middleware/encrypt_response.js";
18
18
  import { unknownFieldsGuard } from "./middleware/unknown_fields_guard.js";
19
19
  import { aauthVerify, getAAuthContextFromRequest, getAttributionDecisionFromRequest, } from "./middleware/aauth_verify.js";
20
20
  import { attributionContext } from "./middleware/attribution_context.js";
21
+ import { dbAbortContext } from "./middleware/db_abort_context.js";
21
22
  import { aauthAdmission, getAAuthAdmissionFromRequest } from "./middleware/aauth_admission.js";
22
23
  import { buildSessionInfo, normalizeSessionOrigin } from "./services/session_info.js";
24
+ import { probeReadiness } from "./services/readiness.js";
23
25
  import { AttributionPolicyError, enforceAttributionPolicy } from "./services/attribution_policy.js";
24
26
  import { OverridePolicyViolationError } from "./services/override_validation.js";
25
27
  import { CursorError } from "./services/entity_cursor.js";
28
+ import { assertNoShadowedRoutes } from "./services/route_shadowing.js";
26
29
  import { StorePolicyUnavailableError } from "./services/instance_policy.js";
27
30
  import { AgentCapabilityError, contextFromAgentIdentity, enforceAgentCapability, } from "./services/agent_capabilities.js";
28
31
  import { assertGuestWriteAllowed, AccessPolicyError, } from "./services/access_policy.js";
@@ -34,6 +37,7 @@ import { createAgentIdentity as buildAgentIdentity, getAgentIdentityFromRequest,
34
37
  import { initServerKeys } from "./services/encryption_service.js";
35
38
  import { storeRawContent, downloadRawContent, resolveLocalSourceFilePath, SourceFileNotFoundError, } from "./services/raw_storage.js";
36
39
  import { attachSourceLabelsToObservations } from "./services/observation_source_label.js";
40
+ import { isFilesystemLocalToCaller, buildFilePathServerLocalError, decodeFileContent, FileInputError, } from "./services/file_input_diagnostics.js";
37
41
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
38
42
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
39
43
  import { NeotomaServer } from "./server.js";
@@ -1517,6 +1521,10 @@ app.use(aauthVerify({
1517
1521
  // may nest a more specific `runWithRequestContext` — nested scopes
1518
1522
  // shadow outer ones exactly as intended.
1519
1523
  app.use(attributionContext());
1524
+ // Bind a per-request AbortSignal so DB reads stop consuming a reader-pool slot
1525
+ // when the client hangs up (#2217). Reads only — a write must not be abandoned
1526
+ // half-applied. No-op on backends without a worker pool.
1527
+ app.use(dbAbortContext());
1520
1528
  // Stronger AAuth Admission plan: resolve verified AAuth identities to
1521
1529
  // `agent_grant` entities and stamp `req.aauthAdmission` /
1522
1530
  // `req.authenticatedUserId`. Runs after attributionContext so the
@@ -2024,6 +2032,10 @@ function sendValidationError(res, issues) {
2024
2032
  }));
2025
2033
  }
2026
2034
  // Public health endpoint (no auth)
2035
+ //
2036
+ // LIVENESS ONLY. This reads package.json off disk and never touches the
2037
+ // database, so it returns 200 throughout a total read outage. Do not use it as
2038
+ // a Fly health check or as a watchdog's healthy verdict — use /ready below.
2027
2039
  app.get("/health", (_req, res) => {
2028
2040
  let version = "0.0.0";
2029
2041
  try {
@@ -2036,6 +2048,21 @@ app.get("/health", (_req, res) => {
2036
2048
  }
2037
2049
  return res.json({ ok: true, version });
2038
2050
  });
2051
+ // Public readiness endpoint (no auth). This is what Fly's health check hits —
2052
+ // see the [[http_service.checks]] block in fly.toml.
2053
+ //
2054
+ // Unlike /health above, it exercises the real read path and returns 503 when
2055
+ // the database is unreachable, erroring, or too slow. See
2056
+ // src/services/readiness.ts for exactly what a passing result does and does
2057
+ // not prove.
2058
+ app.get("/ready", async (_req, res) => {
2059
+ const result = await probeReadiness(db);
2060
+ if (!result.ok) {
2061
+ logger.warn(`[Ready] database probe failed after ${result.latency_ms}ms: ${result.error}`);
2062
+ return res.status(503).json(result);
2063
+ }
2064
+ return res.json(result);
2065
+ });
2039
2066
  // ============================================================================
2040
2067
  // Sandbox endpoints
2041
2068
  //
@@ -2467,6 +2494,24 @@ app.get("/mcp/oauth/google/callback", async (req, res) => {
2467
2494
  return failWith(error?.message ?? "Google sign-in failed.");
2468
2495
  }
2469
2496
  });
2497
+ /**
2498
+ * Send an authorize-endpoint refusal as plain text.
2499
+ *
2500
+ * `res.send(string)` defaults to `text/html`, which makes the body an HTML sink.
2501
+ * That matters here because this endpoint is reached by a BROWSER, before any
2502
+ * authentication, with a `redirect_uri` supplied by whoever crafted the link —
2503
+ * so anything echoed from the request into an HTML body would execute on this
2504
+ * instance's own origin. Setting `text/plain` removes the sink rather than
2505
+ * filtering it, which is what makes it safe to name the rejected redirect_uri
2506
+ * in the body below.
2507
+ *
2508
+ * Plain text (rather than the canonical JSON ErrorEnvelope) is also the right
2509
+ * shape for the reader: this renders in a browser address bar mid-redirect, for
2510
+ * a human. Declared as `text/plain` in openapi.yaml under `mcpOAuthAuthorize`.
2511
+ */
2512
+ function sendAuthorizeRefusal(res, status, message) {
2513
+ return res.status(status).type("text/plain").send(message);
2514
+ }
2470
2515
  // RFC 8414 authorization endpoint (GET) for Cursor and other OAuth clients
2471
2516
  app.get("/mcp/oauth/authorize", async (req, res) => {
2472
2517
  try {
@@ -2485,11 +2530,11 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
2485
2530
  });
2486
2531
  if (!redirect_uri) {
2487
2532
  logger.warn("[MCP OAuth] Authorize rejected: missing redirect_uri");
2488
- return res.status(400).send("redirect_uri is required");
2533
+ return sendAuthorizeRefusal(res, 400, "redirect_uri is required");
2489
2534
  }
2490
2535
  if (!state) {
2491
2536
  logger.warn("[MCP OAuth] Authorize rejected: missing state");
2492
- return res.status(400).send("state is required");
2537
+ return sendAuthorizeRefusal(res, 400, "state is required");
2493
2538
  }
2494
2539
  const isOpenAiCustomGptRedirect = redirect_uri &&
2495
2540
  (redirect_uri.includes("chatgpt.com") || redirect_uri.includes("chat.openai.com"));
@@ -2498,7 +2543,7 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
2498
2543
  logger.warn("[MCP OAuth] Authorize rejected: missing PKCE for non-OpenAI redirect", {
2499
2544
  redirect_uri: sanitizeRedirectUriForLog(redirect_uri),
2500
2545
  });
2501
- return res.status(400).send("code_challenge and code_challenge_method=S256 are required");
2546
+ return sendAuthorizeRefusal(res, 400, "code_challenge and code_challenge_method=S256 are required");
2502
2547
  }
2503
2548
  if (!hasPkce && isOpenAiCustomGptRedirect) {
2504
2549
  // Allow OAuth without client PKCE for OpenAI Custom GPT only (weaker security; see docs).
@@ -2509,9 +2554,7 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
2509
2554
  return res.redirect(`/mcp/oauth/key-auth?next=${encodeURIComponent(nextPath)}`);
2510
2555
  }
2511
2556
  if (dev_stub === "1" || dev_stub === "true") {
2512
- return res
2513
- .status(400)
2514
- .send("dev_stub is disabled. OAuth requires key authentication via /mcp/oauth/key-auth.");
2557
+ return sendAuthorizeRefusal(res, 400, "dev_stub is disabled. OAuth requires key authentication via /mcp/oauth/key-auth.");
2515
2558
  }
2516
2559
  if (config.storageBackend === "local") {
2517
2560
  // When reached via tunnel, only allow redirect_uri to localhost, app schemes, or OpenAI Custom GPT
@@ -2521,12 +2564,31 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
2521
2564
  // proxy) so an Inspector callback on this instance's own origin is allowed.
2522
2565
  const selfHost = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.get("host");
2523
2566
  if (!isRedirectUriAllowedForTunnel(redirect_uri, selfHost)) {
2567
+ // Name the config variable and whether it is even set: an operator who
2568
+ // has configured a callback and still gets refused otherwise has no way
2569
+ // to tell a typo from an unset variable from an exact-match miss.
2570
+ const trustedCount = config.oauthTrustedCallbackUrls.length;
2571
+ const sanitizedRedirectUri = sanitizeRedirectUriForLog(redirect_uri);
2524
2572
  logger.warn("[MCP OAuth] Authorize rejected: redirect_uri not allowed for tunnel", {
2525
- redirect_uri: sanitizeRedirectUriForLog(redirect_uri),
2573
+ redirect_uri: sanitizedRedirectUri,
2574
+ trusted_callback_urls_configured: trustedCount,
2526
2575
  });
2527
- return res
2528
- .status(400)
2529
- .send("redirect_uri is not allowed when connecting via a tunnel. Use cursor://, localhost, loopback, or trusted callback URLs (OpenAI/Claude).");
2576
+ return sendAuthorizeRefusal(res, 400, "redirect_uri is not allowed when connecting via a tunnel. Use cursor://, localhost, loopback, or trusted callback URLs (OpenAI/Claude). " +
2577
+ // Echo what the server actually compared. An operator on a hosted
2578
+ // deploy has no log access, and every likely failure here is a
2579
+ // NEAR miss (trailing slash, port, case, a dot segment that
2580
+ // resolved) — indistinguishable from a typo or a client-side bug
2581
+ // without seeing the value the server saw. Safe to echo because
2582
+ // this response is text/plain (see sendAuthorizeRefusal) and
2583
+ // because the value is sanitized: scheme, host and path only,
2584
+ // with query and fragment — where the code and state live —
2585
+ // already stripped.
2586
+ (sanitizedRedirectUri
2587
+ ? `The server compared: ${sanitizedRedirectUri} (query and fragment ignored). `
2588
+ : "") +
2589
+ (trustedCount > 0
2590
+ ? `This instance has ${trustedCount} operator-configured callback URL(s) in NEOTOMA_OAUTH_TRUSTED_CALLBACK_URLS; none matched. That list is matched on the EXACT full callback URL: scheme, host, port and path must all agree. The host is compared case-insensitively and the path case-sensitively; a trailing slash and a default port (:443) are insignificant; query and fragment are ignored; and plaintext http: is only honoured for loopback hosts.`
2591
+ : "To trust a self-hosted app's callback, set NEOTOMA_OAUTH_TRUSTED_CALLBACK_URLS to its exact full callback URL (for example https://app.example.com/auth/callback)."));
2530
2592
  }
2531
2593
  }
2532
2594
  const { randomUUID } = await import("node:crypto");
@@ -2581,7 +2643,7 @@ app.get("/mcp/oauth/authorize", async (req, res) => {
2581
2643
  }
2582
2644
  catch (error) {
2583
2645
  logError("MCPOAuthAuthorize", req, error);
2584
- return res.status(500).send(error.message ?? "Authorization failed");
2646
+ return sendAuthorizeRefusal(res, 500, error.message ?? "Authorization failed");
2585
2647
  }
2586
2648
  });
2587
2649
  // Local OAuth login page (local backend only)
@@ -2919,10 +2981,23 @@ function explicitGuestAccessTokenFromRequest(req) {
2919
2981
  }
2920
2982
  return undefined;
2921
2983
  }
2984
+ /**
2985
+ * Static path segments under `/entities/` that are routes in their own right,
2986
+ * not entity ids. The `/entities/:id` family of guest-eligible reads must never
2987
+ * treat these as an id (issue #2208): `/entities/duplicates` matched the
2988
+ * `:id`-shaped guest regex below, so a guest principal could be stamped onto a
2989
+ * route whose handler calls `getAuthenticatedUserId()` and 500s.
2990
+ *
2991
+ * Keep in sync with the `app.get("/entities/<segment>")` registrations. The
2992
+ * boot-time assertion in `assertNoShadowedRoutes()` fails the process if a new
2993
+ * static `/entities/*` route is added without being registered ahead of
2994
+ * `/entities/:id`, which is the other half of this invariant.
2995
+ */
2996
+ export const RESERVED_ENTITY_PATH_SEGMENTS = new Set(["duplicates", "merge", "split", "query"]);
2922
2997
  /** Exported for unit tests: routes where guest (AAuth / guest token) may be stamped before handlers run. */
2923
2998
  export function routeAcceptsGuestPrincipal(req) {
2924
2999
  const path = req.path;
2925
- if ((req.method === "POST" &&
3000
+ if (req.method === "POST" &&
2926
3001
  (path === "/issues/submit" ||
2927
3002
  path === "/api/issues/submit" ||
2928
3003
  path === "/issues/status" ||
@@ -2932,12 +3007,19 @@ export function routeAcceptsGuestPrincipal(req) {
2932
3007
  path === "/subscribe" ||
2933
3008
  path === "/unsubscribe" ||
2934
3009
  path === "/list_subscriptions" ||
2935
- path === "/get_subscription_status")) ||
2936
- (req.method === "GET" &&
2937
- (/^\/entities\/[^/]+(?:\/(?:observations|relationships|html))?$/.test(path) ||
2938
- path === "/events/stream"))) {
3010
+ path === "/get_subscription_status")) {
2939
3011
  return true;
2940
3012
  }
3013
+ if (req.method === "GET") {
3014
+ if (path === "/events/stream")
3015
+ return true;
3016
+ const match = /^\/entities\/([^/]+)(?:\/(?:observations|relationships|html))?$/.exec(path);
3017
+ if (!match)
3018
+ return false;
3019
+ // Fail closed: a reserved static segment is a route, not an entity id, so it
3020
+ // is not guest-eligible unless it is explicitly listed as such above.
3021
+ return !RESERVED_ENTITY_PATH_SEGMENTS.has(match[1]);
3022
+ }
2941
3023
  return false;
2942
3024
  }
2943
3025
  /**
@@ -3262,7 +3344,8 @@ app.use(async (req, res, next) => {
3262
3344
  (req.method === "GET" &&
3263
3345
  (req.path === "/openapi.yaml" ||
3264
3346
  req.path === "/openapi_actions.yaml" ||
3265
- req.path === "/health")) ||
3347
+ req.path === "/health" ||
3348
+ req.path === "/ready")) ||
3266
3349
  (req.method === "POST" && req.path === "/auth/dev-signin")) {
3267
3350
  return next();
3268
3351
  }
@@ -3846,6 +3929,53 @@ app.all("/entities", (req, res) => {
3846
3929
  supported: ["GET /entities", "POST /entities/query"],
3847
3930
  });
3848
3931
  });
3932
+ // IMPORTANT (issue #2208): this static route MUST stay registered before
3933
+ // `GET /entities/:id` below. Express matches first-wins, so a param route
3934
+ // registered earlier would capture `id = "duplicates"` and 404. The
3935
+ // boot-time assertion in assertNoShadowedRoutes() enforces this invariant.
3936
+ // GET /entities/duplicates - List candidate duplicate entity pairs (R5).
3937
+ // Read-only fuzzy post-hoc detector. Never auto-merges. Hands off to
3938
+ // /entities/merge once an operator or agent confirms a pair.
3939
+ app.get("/entities/duplicates", async (req, res) => {
3940
+ try {
3941
+ const entityType = typeof req.query.entity_type === "string" ? req.query.entity_type : undefined;
3942
+ if (!entityType) {
3943
+ return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "entity_type query parameter is required");
3944
+ }
3945
+ const providedUserId = typeof req.query.user_id === "string" ? req.query.user_id : undefined;
3946
+ const authenticatedUserId = await getAuthenticatedUserId(req, providedUserId);
3947
+ if (providedUserId && providedUserId !== authenticatedUserId) {
3948
+ return sendError(res, 403, "FORBIDDEN", "user_id does not match authenticated user.");
3949
+ }
3950
+ const thresholdRaw = typeof req.query.threshold === "string" ? req.query.threshold : undefined;
3951
+ const limitRaw = typeof req.query.limit === "string" ? req.query.limit : undefined;
3952
+ const threshold = thresholdRaw ? Number(thresholdRaw) : undefined;
3953
+ if (threshold !== undefined && (Number.isNaN(threshold) || threshold <= 0 || threshold > 1)) {
3954
+ return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "threshold must be a number in (0, 1]");
3955
+ }
3956
+ const limit = limitRaw ? Number(limitRaw) : undefined;
3957
+ if (limit !== undefined && (!Number.isFinite(limit) || limit < 1 || limit > 200)) {
3958
+ return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "limit must be an integer in [1, 200]");
3959
+ }
3960
+ const { findDuplicateCandidates } = await import("./services/duplicate_detection.js");
3961
+ const candidates = await findDuplicateCandidates({
3962
+ entityType,
3963
+ userId: authenticatedUserId,
3964
+ threshold,
3965
+ limit,
3966
+ });
3967
+ return res.json({
3968
+ candidates,
3969
+ entity_type: entityType,
3970
+ threshold: threshold ?? null,
3971
+ });
3972
+ }
3973
+ catch (error) {
3974
+ logError("APIError:entities_duplicates", req, error);
3975
+ const message = error instanceof Error ? error.message : "Failed to list potential duplicates";
3976
+ return sendError(res, 500, "DB_QUERY_FAILED", message);
3977
+ }
3978
+ });
3849
3979
  // GET /api/entities/:id - Get entity detail with snapshot and provenance (FU-601)
3850
3980
  // REQUIRES AUTHENTICATION - verifies entity belongs to authenticated user
3851
3981
  app.get("/entities/:id", async (req, res) => {
@@ -4362,27 +4492,30 @@ app.get("/schemas/:entity_type", async (req, res) => {
4362
4492
  const entityType = decodeURIComponent(req.params.entity_type);
4363
4493
  const { SchemaRegistryService } = await import("./services/schema_registry.js");
4364
4494
  const schemaRegistry = new SchemaRegistryService();
4365
- // Handle authentication - support both Ed25519 and session tokens
4366
- const headerAuth = req.headers.authorization || "";
4367
- let userId = req.query.user_id;
4368
- if (headerAuth.startsWith("Bearer ")) {
4369
- const token = headerAuth.slice("Bearer ".length).trim();
4370
- // Try to validate as Ed25519 bearer token first
4371
- const registered = ensurePublicKeyRegistered(token);
4372
- if (!registered || !isBearerTokenValid(token)) {
4373
- // Try to validate as session token
4374
- try {
4375
- const { validateSessionToken } = await import("./services/mcp_auth.js");
4376
- const validated = await validateSessionToken(token);
4377
- // Use validated user ID if not provided in query
4378
- userId = userId || validated.userId;
4379
- }
4380
- catch {
4381
- // Not a valid token - continue without user_id (will try global schema)
4382
- }
4383
- }
4384
- // If Ed25519 token is valid, use user_id from query (Ed25519 tokens don't contain user info)
4385
- }
4495
+ // SECURITY (GHSA-f48j-993h-g6qr): `user_id` selects the schema SCOPE that
4496
+ // loadActiveSchema reads — a user-scoped row shadows the global one — so it
4497
+ // carries the safety meaning for this route and must be validated against
4498
+ // the authenticated principal, never used as supplied.
4499
+ //
4500
+ // This route previously initialised `userId` straight from
4501
+ // `req.query.user_id` and ran its own Bearer/session handling, so the query
4502
+ // value became the scope rather than a request for one. That bespoke block
4503
+ // is removed in favour of the shared guard its sibling GET /schemas already
4504
+ // uses. Nothing is lost by removing it: the app-wide auth middleware runs
4505
+ // ahead of this route (this path is not on the public allowlist, which is
4506
+ // only /openapi*.yaml, /health and /ready), validates Ed25519 bearers
4507
+ // *including the request signature*, falls through to the same
4508
+ // validateSessionToken for session bearers, stamps the principal, and 401s
4509
+ // an unresolvable token. The route-level copy did strictly less — no
4510
+ // signature verification, and a silent fall-through that kept the
4511
+ // caller-supplied id when validation failed.
4512
+ //
4513
+ // Legitimate callers are unaffected: the CLI's optional --user-id and the
4514
+ // eval harness pass nothing by default, so the principal resolves itself,
4515
+ // and getAuthenticatedUserId still honours the local-dev override for CLI
4516
+ // and dev flows. Built-in/code-defined schema discovery is unchanged — it
4517
+ // is the fallback below, reached the same way once the scope is resolved.
4518
+ const userId = await getAuthenticatedUserId(req, req.query.user_id);
4386
4519
  // Try to load active schema (global or user-specific), then fallback to code-defined schemas
4387
4520
  let schema = await schemaRegistry.loadActiveSchema(entityType, userId);
4388
4521
  if (!schema) {
@@ -4407,9 +4540,11 @@ app.get("/schemas/:entity_type", async (req, res) => {
4407
4540
  return res.json(schema);
4408
4541
  }
4409
4542
  catch (error) {
4410
- logError("APIError:schema_detail", req, error);
4411
- const message = error instanceof Error ? error.message : "Failed to get schema";
4412
- return sendError(res, 500, "DB_QUERY_FAILED", message);
4543
+ // Routed through handleApiError so a rejected user_id surfaces as 403 and an
4544
+ // unresolved principal as 401 — the same mapping GET /schemas gets. The
4545
+ // previous bespoke catch turned every failure into a 500, which would have
4546
+ // reported the guard's refusal as a server fault.
4547
+ return handleApiError(req, res, error, "Failed to get schema", "DB_QUERY_FAILED", "APIError:schema_detail");
4413
4548
  }
4414
4549
  });
4415
4550
  /**
@@ -6862,7 +6997,9 @@ export async function storeStructuredForApi(params) {
6862
6997
  // boundary" section in src/services/instance_policy.ts's docblock.
6863
6998
  async function storeUnstructuredForApi(params) {
6864
6999
  const { fileContent, fileBuffer, filePath, mimeType, idempotencyKey, originalFilename, sourceType, userId, storageMode = "inline", } = params;
6865
- const resolvedFileBuffer = fileBuffer ?? (fileContent !== undefined ? Buffer.from(fileContent, "base64") : undefined);
7000
+ // decodeFileContent rejects non-base64 instead of letting Node discard the
7001
+ // invalid characters and store corrupted bytes (#2325).
7002
+ const resolvedFileBuffer = fileBuffer ?? (fileContent !== undefined ? decodeFileContent(fileContent) : undefined);
6866
7003
  if (!resolvedFileBuffer && storageMode !== "reference") {
6867
7004
  throw new Error("fileContent or fileBuffer is required for inline storage");
6868
7005
  }
@@ -6939,9 +7076,24 @@ async function handleStorePost(req, res) {
6939
7076
  let originalFilename = parsed.data.original_filename;
6940
7077
  let resolvedFileBuffer;
6941
7078
  if (hasFilePath) {
7079
+ // `file_path` resolves against *this* process's filesystem. On a remote
7080
+ // instance it cannot mean the caller's disk, and the old failure here
7081
+ // was a bare Node ENOENT — worse than MCP's, which at least named the
7082
+ // path. Both transports now fail identically (#2325).
7083
+ if (!isFilesystemLocalToCaller()) {
7084
+ const serverLocal = buildFilePathServerLocalError(parsed.data.file_path);
7085
+ sendError(res, 400, serverLocal.code, serverLocal.message, serverLocal.details);
7086
+ return null;
7087
+ }
6942
7088
  const resolvedPath = path.isAbsolute(parsed.data.file_path)
6943
7089
  ? parsed.data.file_path
6944
7090
  : path.resolve(process.cwd(), parsed.data.file_path);
7091
+ if (!fs.existsSync(resolvedPath)) {
7092
+ sendError(res, 400, "ERR_FILE_NOT_FOUND", `File not found: ${resolvedPath}`, {
7093
+ file_path: parsed.data.file_path,
7094
+ });
7095
+ return null;
7096
+ }
6945
7097
  if (parsed.data.source_storage === "reference") {
6946
7098
  // For reference mode, don't read the file buffer, just use the path
6947
7099
  if (!mimeType) {
@@ -7038,6 +7190,13 @@ async function handleStorePost(req, res) {
7038
7190
  if (error instanceof Error && error.message.includes("Not authenticated")) {
7039
7191
  return sendError(res, 401, "AUTH_REQUIRED", error.message);
7040
7192
  }
7193
+ // A bad file input is the caller's payload, not a server fault: 400 with
7194
+ // the code, never the generic 500 below. Falling through would have made a
7195
+ // non-base64 `file_content` look like a server outage (#2325).
7196
+ if (error instanceof FileInputError) {
7197
+ logWarn("FileInputError:store", req, { code: error.code });
7198
+ return sendError(res, 400, error.code, error.message, error.details);
7199
+ }
7041
7200
  const errCode = error && typeof error === "object" ? error.code : undefined;
7042
7201
  if (errCode === "ERR_FORBIDDEN_ENTITY_TYPE" || errCode === "ERR_PLURAL_ENTITY_TYPE") {
7043
7202
  const message = error instanceof Error ? error.message : String(error);
@@ -7480,49 +7639,9 @@ app.post("/observations/query", async (req, res) => {
7480
7639
  return sendError(res, 500, "DB_QUERY_FAILED", message);
7481
7640
  }
7482
7641
  });
7483
- // GET /entities/duplicates - List candidate duplicate entity pairs (R5).
7484
- // Read-only fuzzy post-hoc detector. Never auto-merges. Hands off to
7485
- // /entities/merge once an operator or agent confirms a pair.
7486
- app.get("/entities/duplicates", async (req, res) => {
7487
- try {
7488
- const entityType = typeof req.query.entity_type === "string" ? req.query.entity_type : undefined;
7489
- if (!entityType) {
7490
- return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "entity_type query parameter is required");
7491
- }
7492
- const providedUserId = typeof req.query.user_id === "string" ? req.query.user_id : undefined;
7493
- const authenticatedUserId = await getAuthenticatedUserId(req, providedUserId);
7494
- if (providedUserId && providedUserId !== authenticatedUserId) {
7495
- return sendError(res, 403, "FORBIDDEN", "user_id does not match authenticated user.");
7496
- }
7497
- const thresholdRaw = typeof req.query.threshold === "string" ? req.query.threshold : undefined;
7498
- const limitRaw = typeof req.query.limit === "string" ? req.query.limit : undefined;
7499
- const threshold = thresholdRaw ? Number(thresholdRaw) : undefined;
7500
- if (threshold !== undefined && (Number.isNaN(threshold) || threshold <= 0 || threshold > 1)) {
7501
- return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "threshold must be a number in (0, 1]");
7502
- }
7503
- const limit = limitRaw ? Number(limitRaw) : undefined;
7504
- if (limit !== undefined && (!Number.isFinite(limit) || limit < 1 || limit > 200)) {
7505
- return sendError(res, 400, "VALIDATION_INVALID_FORMAT", "limit must be an integer in [1, 200]");
7506
- }
7507
- const { findDuplicateCandidates } = await import("./services/duplicate_detection.js");
7508
- const candidates = await findDuplicateCandidates({
7509
- entityType,
7510
- userId: authenticatedUserId,
7511
- threshold,
7512
- limit,
7513
- });
7514
- return res.json({
7515
- candidates,
7516
- entity_type: entityType,
7517
- threshold: threshold ?? null,
7518
- });
7519
- }
7520
- catch (error) {
7521
- logError("APIError:entities_duplicates", req, error);
7522
- const message = error instanceof Error ? error.message : "Failed to list potential duplicates";
7523
- return sendError(res, 500, "DB_QUERY_FAILED", message);
7524
- }
7525
- });
7642
+ // NOTE: `GET /entities/duplicates` is registered earlier in this file,
7643
+ // immediately before `GET /entities/:id`. A static path must be registered
7644
+ // before any param route that would also match it (issue #2208).
7526
7645
  // POST /api/entities/merge - Merge duplicate entities
7527
7646
  // REQUIRES AUTHENTICATION - validates user_id matches authenticated user and entities belong to user
7528
7647
  app.post("/entities/merge", async (req, res) => {
@@ -9484,18 +9603,27 @@ app.post("/health_check_snapshots", async (req, res) => {
9484
9603
  }
9485
9604
  }
9486
9605
  let fixedCount = 0;
9606
+ let redirectedCount = 0;
9487
9607
  if (auto_fix && staleEntities.length > 0) {
9488
9608
  // Recompute snapshots for stale entities using observationReducer
9489
9609
  const { observationReducer } = await import("./reducers/observation_reducer.js");
9610
+ const { resolveOwnedObservations } = await import("./services/attachment_resolution.js");
9490
9611
  for (const entity of staleEntities) {
9491
9612
  try {
9492
- // Get all observations for this entity
9493
- const { data: observations } = await db
9494
- .from("observations")
9495
- .select("*")
9496
- .eq("entity_id", entity.entity_id)
9497
- .order("observed_at", { ascending: false });
9498
- if (!observations || observations.length === 0)
9613
+ // #2343: the observations ATTACHED to this entity, resolved through
9614
+ // the declared layer, and null when the id is redirected — a
9615
+ // tombstone owns no snapshot, so auto-fix must skip it rather than
9616
+ // upsert the survivor's snapshot under the tombstone's id.
9617
+ // `null` scope, deliberately: this endpoint's observation fetch was
9618
+ // unscoped before #2343, and adding a user_id filter here would
9619
+ // change which rows the reducer sees. Routing the fetch through the
9620
+ // seam is this PR's job; tightening this endpoint's tenancy is not.
9621
+ const observations = await resolveOwnedObservations(entity.entity_id, null);
9622
+ if (observations === null) {
9623
+ redirectedCount++;
9624
+ continue;
9625
+ }
9626
+ if (observations.length === 0)
9499
9627
  continue;
9500
9628
  // Recompute snapshot
9501
9629
  const newSnapshot = await observationReducer.computeSnapshot(entity.entity_id, observations);
@@ -9529,11 +9657,15 @@ app.post("/health_check_snapshots", async (req, res) => {
9529
9657
  message: staleEntities.length === 0
9530
9658
  ? "All snapshots healthy"
9531
9659
  : auto_fix
9532
- ? `Found ${staleEntities.length} stale snapshots, fixed ${fixedCount}`
9660
+ ? `Found ${staleEntities.length} stale snapshots, fixed ${fixedCount}` +
9661
+ (redirectedCount > 0
9662
+ ? `, skipped ${redirectedCount} redirected (merged-away) id(s)`
9663
+ : "")
9533
9664
  : `Found ${staleEntities.length} stale snapshots`,
9534
9665
  checked: staleSnapshots?.length || 0,
9535
9666
  stale: staleEntities.length,
9536
9667
  fixed: auto_fix ? fixedCount : undefined,
9668
+ skipped_redirected: auto_fix ? redirectedCount : undefined,
9537
9669
  stale_snapshots: staleEntities,
9538
9670
  });
9539
9671
  }
@@ -9747,9 +9879,20 @@ function emitSandboxBootBanner(mode, reason, shouldRefuseBoot, refusePolicy, pro
9747
9879
  });
9748
9880
  process.stderr.write(lines.join("\n"));
9749
9881
  }
9882
+ /**
9883
+ * Fail the boot if any static route is unreachable because an earlier param
9884
+ * route matches it first (issue #2208). Runs after every route registration
9885
+ * and before the process binds a port: an unreachable route table is not a
9886
+ * runnable state, and a warning is what let `GET /entities/duplicates` ship
9887
+ * broken for months.
9888
+ */
9889
+ function assertRouteTableIsReachable() {
9890
+ assertNoShadowedRoutes(app);
9891
+ }
9750
9892
  /** Try to bind on a port; resolves with server and port, or rejects on error (e.g. EADDRINUSE). */
9751
9893
  function tryListen(port) {
9752
9894
  return new Promise((resolve, reject) => {
9895
+ assertRouteTableIsReachable();
9753
9896
  const server = app.listen(port, () => {
9754
9897
  // When `port === 0` the OS assigns an ephemeral port. We must report
9755
9898
  // the actually-bound port back to callers (the eval harness's