diff --git a/lib/microwaveprop_web/controllers/agent_skills_controller.ex b/lib/microwaveprop_web/controllers/agent_skills_controller.ex new file mode 100644 index 00000000..3c7665aa --- /dev/null +++ b/lib/microwaveprop_web/controllers/agent_skills_controller.ex @@ -0,0 +1,72 @@ +defmodule MicrowavepropWeb.AgentSkillsController do + @moduledoc """ + Publishes an Agent Skills Discovery (v0.2.0) index at + `/.well-known/agent-skills/index.json` plus the referenced skill + documents. Each document is a short markdown file describing one + concrete task an agent can perform against this service's public + endpoints. Digests are computed at compile time from the source files. + """ + use MicrowavepropWeb, :controller + + @schema_url "https://agentskills.io/schemas/v0.2.0/skills.schema.json" + + @skill_sources [ + %{ + name: "fetch-contacts", + description: + "Retrieve every amateur-radio contact in the propagation calibration dataset as a JSON array of compact tuples.", + path: "priv/agent-skills/fetch-contacts.md" + }, + %{ + name: "read-algorithm", + description: + "Obtain the full NTMS propagation scoring algorithm documentation, including factor weights and ITU-R model references, as markdown.", + path: "priv/agent-skills/read-algorithm.md" + } + ] + + for %{path: path} <- @skill_sources do + @external_resource path + end + + @skills Enum.map(@skill_sources, fn %{name: name, description: desc, path: path} -> + body = File.read!(path) + sha = :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower) + %{name: name, description: desc, body: body, sha256: sha} + end) + + @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() + def index(conn, _params) do + entries = + Enum.map(@skills, fn s -> + %{ + name: s.name, + type: "text/markdown", + description: s.description, + url: url(conn, ~p"/.well-known/agent-skills/#{s.name}/SKILL.md"), + sha256: s.sha256 + } + end) + + body = Jason.encode!(%{"$schema" => @schema_url, skills: entries}) + + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "public, max-age=3600") + |> send_resp(200, body) + end + + @spec skill(Plug.Conn.t(), map()) :: Plug.Conn.t() + def skill(conn, %{"name" => name}) do + case Enum.find(@skills, &(&1.name == name)) do + nil -> + send_resp(conn, 404, "") + + s -> + conn + |> put_resp_content_type("text/markdown", "utf-8") + |> put_resp_header("cache-control", "public, max-age=3600") + |> send_resp(200, s.body) + end + end +end diff --git a/lib/microwaveprop_web/controllers/api_catalog_controller.ex b/lib/microwaveprop_web/controllers/api_catalog_controller.ex new file mode 100644 index 00000000..e61b218d --- /dev/null +++ b/lib/microwaveprop_web/controllers/api_catalog_controller.ex @@ -0,0 +1,83 @@ +defmodule MicrowavepropWeb.ApiCatalogController do + @moduledoc """ + RFC 9727 API catalog + companion OpenAPI spec for automated API discovery. + + `/api/contacts/map` is currently the only public JSON endpoint. The + catalog lists it with link relations for `service-desc` (OpenAPI) and + `status` (health check). If more endpoints are added, extend + `linkset_entries/1` and the OpenAPI `paths` map. + """ + use MicrowavepropWeb, :controller + + @openapi_type "application/vnd.oai.openapi+json;version=3.0" + + @spec catalog(Plug.Conn.t(), map()) :: Plug.Conn.t() + def catalog(conn, _params) do + body = Jason.encode!(%{linkset: linkset_entries(conn)}) + + conn + |> put_resp_content_type("application/linkset+json") + |> put_resp_header("cache-control", "public, max-age=3600") + |> send_resp(200, body) + end + + @spec openapi(Plug.Conn.t(), map()) :: Plug.Conn.t() + def openapi(conn, _params) do + body = Jason.encode!(openapi_spec(conn)) + + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "public, max-age=3600") + |> send_resp(200, body) + end + + defp linkset_entries(conn) do + [ + %{ + anchor: url(conn, ~p"/api/contacts/map"), + "service-desc": [%{href: url(conn, ~p"/openapi.json"), type: @openapi_type}], + status: [%{href: url(conn, ~p"/health")}] + } + ] + end + + defp openapi_spec(conn) do + %{ + openapi: "3.0.3", + info: %{ + title: "Microwaveprop Public API", + description: "Public JSON endpoints for the NTMS microwave propagation prediction service.", + version: "1.0.0" + }, + servers: [%{url: url(conn, ~p"/")}], + paths: %{ + "/api/contacts/map" => %{ + get: %{ + summary: "All recorded amateur-radio contacts as compact tuples", + description: + "Returns every contact in the calibration dataset as an array of fixed-shape tuples. " <> + "Response is pre-gzipped when the client sends Accept-Encoding: gzip. " <> + "Tuple shape: [lat1, lon1, lat2, lon2, band_mhz, callsign1, callsign2, mode, distance_km, qso_timestamp, id].", + responses: %{ + "200" => %{ + description: "Array of contact tuples", + content: %{ + "application/json" => %{ + schema: %{ + type: "array", + items: %{ + type: "array", + minItems: 11, + maxItems: 11 + } + } + } + } + } + } + } + } + } + } + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 5427e9b7..a5522b24 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Router do import Phoenix.LiveDashboard.Router pipeline :browser do + plug :serve_markdown_if_requested plug :accepts, ["html"] plug :fetch_session plug :store_remote_ip @@ -14,9 +15,97 @@ defmodule MicrowavepropWeb.Router do plug :put_root_layout, html: {MicrowavepropWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :put_agent_link_headers plug :fetch_current_scope_for_user end + # RFC 8288 Link headers advertising resources for agent/crawler discovery. + # `/algo` is the human-readable algorithm/service documentation, rendered + # from `algo.md`. `/sitemap.xml` is the registered "sitemap" rel. About + # and privacy use their IANA-registered rels. + @agent_link_header Enum.join( + [ + ~s(; rel="service-doc"), + ~s(; rel="about"), + ~s(; rel="privacy-policy"), + ~s(; rel="sitemap") + ], + ", " + ) + + defp put_agent_link_headers(conn, _opts) do + Plug.Conn.put_resp_header(conn, "link", @agent_link_header) + end + + # Markdown for Agents: if the client prefers `text/markdown`, serve real + # markdown for the paths where we have markdown source. Other paths fall + # through to the `:accepts` plug and 406 — we don't synthesize markdown + # from LiveView HTML on the fly, since a lossy conversion would mislead + # agents about site content. + @external_resource "algo.md" + @algo_markdown File.read!("algo.md") + + @homepage_markdown """ + # NTMS Propagation Prediction + + The North Texas Microwave Society's propagation prediction service for + amateur radio bands 10–241 GHz. Scores propagation conditions across + CONUS using HRRR numerical weather prediction, sounding data, ITU-R + atmospheric models, and calibration against 57,000+ recorded QSOs. + + ## Primary Resources + + - [Propagation map](/map) — real-time CONUS propagation scores + 18-hour forecast. + - [Algorithm documentation](/algo) — full methodology, factor weights, ITU-R references (also available as `text/markdown`). + - [Contact database](/contacts) — 58,000+ historical QSOs used for calibration. + - [Contact map](/contacts/map) — geographic view of all recorded contacts. + - [Path calculator](/path) — point-to-point propagation prediction. + - [Beacons](/beacons) — microwave beacon directory. + - [Submit a contact](/submit) — add a QSO to the calibration dataset. + + ## Meta + + - [About](/about) + - [Privacy](/privacy) + - [Sitemap](/sitemap.xml) + + ## API + + - `GET /api/contacts/map` — JSON tuples of every contact on the map (lat/lon/band/callsigns/mode/distance/timestamp/id). Pre-gzipped, ~5 MB decompressed. + """ + + defp serve_markdown_if_requested(conn, _opts) do + if wants_markdown?(conn) do + case markdown_for_path(conn.request_path) do + nil -> + conn + + body -> + conn + |> Plug.Conn.put_resp_content_type("text/markdown", "utf-8") + |> Plug.Conn.put_resp_header("x-markdown-tokens", estimate_tokens(body)) + |> Plug.Conn.send_resp(200, body) + |> Plug.Conn.halt() + end + else + conn + end + end + + defp wants_markdown?(conn) do + conn + |> Plug.Conn.get_req_header("accept") + |> Enum.any?(&String.contains?(&1, "text/markdown")) + end + + defp markdown_for_path("/"), do: @homepage_markdown + defp markdown_for_path("/algo"), do: @algo_markdown + defp markdown_for_path(_), do: nil + + # Rough token estimate: ~4 chars per token is the widely-used OpenAI + # heuristic. Good enough for the `x-markdown-tokens` hint. + defp estimate_tokens(body), do: body |> byte_size() |> div(4) |> Integer.to_string() + defp store_remote_ip(conn, _opts) do ip_str = conn.remote_ip |> :inet.ntoa() |> to_string() Plug.Conn.put_session(conn, :remote_ip, ip_str) @@ -46,6 +135,18 @@ defmodule MicrowavepropWeb.Router do # Health check — no pipeline, minimal overhead get "/health", MicrowavepropWeb.HealthController, :check, log: false + # API discovery (RFC 9727 / RFC 9264). These are JSON-only, no pipeline, + # no session — agents and crawlers fetch them without auth. + get "/.well-known/api-catalog", MicrowavepropWeb.ApiCatalogController, :catalog + get "/openapi.json", MicrowavepropWeb.ApiCatalogController, :openapi + + # Agent Skills Discovery (v0.2.0). Index + individual skill documents. + get "/.well-known/agent-skills/index.json", MicrowavepropWeb.AgentSkillsController, :index + + get "/.well-known/agent-skills/:name/SKILL.md", + MicrowavepropWeb.AgentSkillsController, + :skill + # Admin-only routes must come first so that literal segments like # `/beacons/:id/edit` are registered before the public `/beacons/:id`. scope "/", MicrowavepropWeb do diff --git a/priv/agent-skills/fetch-contacts.md b/priv/agent-skills/fetch-contacts.md new file mode 100644 index 00000000..ca46c7f3 --- /dev/null +++ b/priv/agent-skills/fetch-contacts.md @@ -0,0 +1,32 @@ +# Fetch contacts dataset + +The NTMS microwave propagation service publishes its full amateur-radio contact (QSO) calibration dataset as a single public JSON endpoint. No authentication is required. + +## Endpoint + +``` +GET https://prop.w5isp.com/api/contacts/map +``` + +The response is pre-gzipped when the client sends `Accept-Encoding: gzip` (roughly 1 MB compressed, 5 MB decompressed). Cached at the origin for 60 s. + +## Response shape + +A single JSON array where each element is a fixed-length tuple: + +``` +[lat1, lon1, lat2, lon2, band_mhz, callsign1, callsign2, mode, distance_km, qso_timestamp_iso8601, id_uuid] +``` + +- `lat1/lon1` and `lat2/lon2` are the two station locations (WGS84 degrees). +- `band_mhz` is an integer in MHz (e.g. `1296`, `10000`, `24000`). +- `callsign1/callsign2` are uppercase amateur callsigns or `null` if unknown. +- `mode` is a string (`CW`, `SSB`, `FT8`, …) or `null`. +- `distance_km` is a number or `null`. +- `qso_timestamp_iso8601` is `YYYY-MM-DDTHH:MM:SSZ`. +- `id_uuid` is the stable UUID for the contact; fetch details at `/contacts/{id_uuid}`. + +## Machine-readable specs + +- OpenAPI 3.0 spec: `GET /openapi.json` +- API catalog (RFC 9727): `GET /.well-known/api-catalog` diff --git a/priv/agent-skills/read-algorithm.md b/priv/agent-skills/read-algorithm.md new file mode 100644 index 00000000..b438e6e6 --- /dev/null +++ b/priv/agent-skills/read-algorithm.md @@ -0,0 +1,27 @@ +# Read propagation algorithm documentation + +The scoring algorithm for amateur-radio microwave propagation (902 MHz – 241 GHz) is documented in long-form markdown, covering factor weights, band-specific configurations, ITU-R atmospheric models, and calibration dataset composition. + +## Endpoint + +``` +GET https://prop.w5isp.com/algo +``` + +The default response is HTML. Send `Accept: text/markdown` to receive the raw markdown source directly: + +``` +GET https://prop.w5isp.com/algo +Accept: text/markdown +``` + +Response: `Content-Type: text/markdown; charset=utf-8`. An `x-markdown-tokens` header gives a rough token-count estimate for context-window planning. + +## Content summary + +- Scoring methodology: 10 weighted factors combining to a 0–100 composite score per grid point per band. +- Factor weights (humidity 13.6 %, time of day 5.0 %, T-Td depression 9.8 %, refractivity 10.5 %, sky 8.0 %, season 11.1 %, wind 8.0 %, rain 13.6 %, pressure 10.3 %, PWAT 11.3 %) recalibrated 2026-04-11 via gradient descent against 57,488 tropospheric QSOs. +- Band-dependent humidity effect: beneficial at 10 GHz (refractivity gradients), harmful at 24 GHz+ (gaseous absorption). +- ITU-R model references: P.838-3 (rain attenuation), P.526-16 (diffraction), P.676 (gaseous absorption). +- Terrain analysis: 97.2 % of all analysed QSO paths are terrain-blocked; atmospheric ducting and refraction are the primary enabling mechanisms. +- Calibration dataset: 58 k+ QSOs, HRRR atmospheric profiles, RAOB soundings, IEMRE gridded observations, SRTM terrain, and commercial link SNMP polling at 11/24/68 GHz. diff --git a/priv/static/robots.txt b/priv/static/robots.txt index 1525ed00..c2346eef 100644 --- a/priv/static/robots.txt +++ b/priv/static/robots.txt @@ -4,5 +4,6 @@ Disallow: /backfill Disallow: /dev/ Disallow: /dashboard Disallow: /health +Content-Signal: search=yes, ai-train=no, ai-input=no Sitemap: https://prop.w5isp.com/sitemap.xml diff --git a/test/microwaveprop_web/controllers/agent_skills_controller_test.exs b/test/microwaveprop_web/controllers/agent_skills_controller_test.exs new file mode 100644 index 00000000..278c1124 --- /dev/null +++ b/test/microwaveprop_web/controllers/agent_skills_controller_test.exs @@ -0,0 +1,60 @@ +defmodule MicrowavepropWeb.AgentSkillsControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + describe "GET /.well-known/agent-skills/index.json" do + test "returns the agent-skills discovery index", %{conn: conn} do + conn = get(conn, "/.well-known/agent-skills/index.json") + + assert conn.status == 200 + assert ["application/json" <> _] = get_resp_header(conn, "content-type") + + body = Jason.decode!(conn.resp_body) + assert body["$schema"] =~ "agentskills" + assert is_list(body["skills"]) + assert body["skills"] != [] + + for skill <- body["skills"] do + assert is_binary(skill["name"]) + assert is_binary(skill["type"]) + assert is_binary(skill["description"]) + assert is_binary(skill["url"]) + assert String.match?(skill["sha256"], ~r/^[0-9a-f]{64}$/) + end + end + + test "index lists at least fetch-contacts and read-algorithm", %{conn: conn} do + conn = get(conn, "/.well-known/agent-skills/index.json") + %{"skills" => skills} = Jason.decode!(conn.resp_body) + names = Enum.map(skills, & &1["name"]) + + assert "fetch-contacts" in names + assert "read-algorithm" in names + end + end + + describe "GET /.well-known/agent-skills/:name/SKILL.md" do + test "serves a skill document as markdown", %{conn: conn} do + conn = get(conn, "/.well-known/agent-skills/fetch-contacts/SKILL.md") + + assert conn.status == 200 + assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type") + assert conn.resp_body =~ "Fetch contacts dataset" + end + + test "sha256 in the index matches the served document bytes", %{conn: conn} do + %{"skills" => skills} = + conn |> get("/.well-known/agent-skills/index.json") |> Map.get(:resp_body) |> Jason.decode!() + + for %{"name" => name, "sha256" => expected} <- skills do + doc = get(conn, "/.well-known/agent-skills/#{name}/SKILL.md").resp_body + actual = :sha256 |> :crypto.hash(doc) |> Base.encode16(case: :lower) + assert actual == expected, "sha256 mismatch for skill #{name}" + end + end + + test "returns 404 for unknown skill names", %{conn: conn} do + conn = get(conn, "/.well-known/agent-skills/does-not-exist/SKILL.md") + assert conn.status == 404 + end + end +end diff --git a/test/microwaveprop_web/controllers/api_catalog_controller_test.exs b/test/microwaveprop_web/controllers/api_catalog_controller_test.exs new file mode 100644 index 00000000..51f1694a --- /dev/null +++ b/test/microwaveprop_web/controllers/api_catalog_controller_test.exs @@ -0,0 +1,46 @@ +defmodule MicrowavepropWeb.ApiCatalogControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + describe "GET /.well-known/api-catalog (RFC 9727)" do + test "returns application/linkset+json with a linkset array", %{conn: conn} do + conn = get(conn, "/.well-known/api-catalog") + + assert conn.status == 200 + assert ["application/linkset+json" <> _] = get_resp_header(conn, "content-type") + + body = Jason.decode!(conn.resp_body) + assert is_list(body["linkset"]) + assert body["linkset"] != [] + end + + test "first entry anchors the contacts map endpoint with status and service-desc", %{conn: conn} do + conn = get(conn, "/.well-known/api-catalog") + %{"linkset" => [entry | _]} = Jason.decode!(conn.resp_body) + + assert entry["anchor"] =~ "/api/contacts/map" + + assert [%{"href" => status_href}] = entry["status"] + assert status_href =~ "/health" + + assert [%{"href" => desc_href, "type" => desc_type}] = entry["service-desc"] + assert desc_href =~ "/openapi.json" + assert desc_type == "application/vnd.oai.openapi+json;version=3.0" + end + end + + describe "GET /openapi.json" do + test "returns a valid OpenAPI 3.x document describing the public API", %{conn: conn} do + conn = get(conn, "/openapi.json") + + assert conn.status == 200 + assert ["application/json" <> _] = get_resp_header(conn, "content-type") + + spec = Jason.decode!(conn.resp_body) + assert spec["openapi"] =~ ~r/^3\./ + assert is_map(spec["info"]) + assert is_binary(spec["info"]["title"]) + assert is_binary(spec["info"]["version"]) + assert Map.has_key?(spec["paths"], "/api/contacts/map") + end + end +end diff --git a/test/microwaveprop_web/controllers/page_controller_test.exs b/test/microwaveprop_web/controllers/page_controller_test.exs index dad2f6bf..103a351e 100644 --- a/test/microwaveprop_web/controllers/page_controller_test.exs +++ b/test/microwaveprop_web/controllers/page_controller_test.exs @@ -5,4 +5,71 @@ defmodule MicrowavepropWeb.PageControllerTest do conn = get(conn, ~p"/") assert redirected_to(conn) == "/map" end + + # RFC 8288 Link headers for agent discovery. These advertise resources + # that actually exist in this app to crawlers/agents that look at + # response headers before parsing HTML. + describe "Link headers (RFC 8288)" do + test "homepage response carries Link headers", %{conn: conn} do + conn = get(conn, ~p"/") + [link] = get_resp_header(conn, "link") + + assert link =~ ~s(; rel="service-doc") + assert link =~ ~s(; rel="about") + assert link =~ ~s(; rel="privacy-policy") + assert link =~ ~s(; rel="sitemap") + end + + test "browser-pipeline pages carry the same Link headers", %{conn: conn} do + conn = get(conn, ~p"/about") + [link] = get_resp_header(conn, "link") + assert link =~ ~s(rel="service-doc") + assert link =~ ~s(rel="about") + end + end + + # Markdown for Agents: content negotiation so agents that send + # `Accept: text/markdown` get a real markdown document instead of a 406. + # We only serve markdown for paths where we actually have markdown source + # (the homepage summary and /algo). Other paths still 406 rather than lie + # with a converted-on-the-fly body. + describe "Markdown for Agents" do + test "GET / with Accept: text/markdown returns a markdown document", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/markdown") + |> get(~p"/") + + assert conn.status == 200 + assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type") + [tokens] = get_resp_header(conn, "x-markdown-tokens") + assert String.to_integer(tokens) > 0 + assert conn.resp_body =~ "# " + end + + test "GET /algo with Accept: text/markdown returns algo.md", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/markdown") + |> get(~p"/algo") + + assert conn.status == 200 + assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type") + assert conn.resp_body =~ "Microwave Propagation Algorithm" + end + + test "GET / without markdown Accept still redirects to /map", %{conn: conn} do + conn = get(conn, ~p"/") + assert redirected_to(conn) == "/map" + end + + test "Accept lists with both html and markdown prefer markdown when specified", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/markdown, text/html;q=0.9") + |> get(~p"/") + + assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type") + end + end end