fix: correct login URL path in markdown negotiation content

This commit is contained in:
Graham McIntire 2026-04-17 15:50:31 -05:00
parent 12aa724a86
commit e64df28906
2 changed files with 768 additions and 1 deletions

View file

@ -0,0 +1,767 @@
# Agent Readiness Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make towerops.net pass all isitagentready.com checks by adding sitemap, well-known endpoints, Link headers, markdown negotiation, and WebMCP support.
**Architecture:** Add a `WellKnownController` for all `/.well-known/*` JSON/stub endpoints, a `SitemapController` for `/sitemap.xml`, patch `PageController` for Link headers + markdown content negotiation, and add WebMCP JS to `app.js`. All endpoints are unauthenticated.
**Tech Stack:** Phoenix controllers, Elixir, plain JSON (no extra libraries), standard XML generation, vanilla JS in app.js.
---
## Task 1: Sitemap XML + robots.txt
**Files:**
- Create: `lib/towerops_web/controllers/sitemap_controller.ex`
- Modify: `lib/towerops_web/router.ex`
- Modify: `priv/static/robots.txt`
- Test: `test/towerops_web/controllers/sitemap_controller_test.exs`
**Step 1: Write the failing test**
```elixir
# test/towerops_web/controllers/sitemap_controller_test.exs
defmodule ToweropsWeb.SitemapControllerTest do
use ToweropsWeb.ConnCase, async: true
describe "GET /sitemap.xml" do
test "returns valid XML with public pages", %{conn: conn} do
conn = get(conn, "/sitemap.xml")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["text/xml; charset=utf-8"]
body = response(conn, 200)
assert body =~ ~r/<\?xml/
assert body =~ "<urlset"
assert body =~ "<loc>https://towerops.net/</loc>"
assert body =~ "<loc>https://towerops.net/privacy</loc>"
assert body =~ "<loc>https://towerops.net/terms</loc>"
end
end
end
```
**Step 2: Run test to verify it fails**
```bash
cd /Users/graham/dev/towerops/towerops-web
mix test test/towerops_web/controllers/sitemap_controller_test.exs
```
Expected: FAIL with no route/action error.
**Step 3: Add the route to router.ex**
In `lib/towerops_web/router.ex`, after the health check scope:
```elixir
# Sitemap (no pipeline — public, no CSRF)
scope "/", ToweropsWeb do
get "/sitemap.xml", SitemapController, :index
end
```
**Step 4: Create the controller**
```elixir
# lib/towerops_web/controllers/sitemap_controller.ex
defmodule ToweropsWeb.SitemapController do
@moduledoc false
use ToweropsWeb, :controller
@public_urls ["/", "/privacy", "/terms", "/docs/api", "/docs/graphql", "/help"]
def index(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
xml = build_sitemap(base_url)
conn
|> put_resp_content_type("text/xml")
|> send_resp(200, xml)
end
defp build_sitemap(base_url) do
urls =
@public_urls
|> Enum.map(fn path ->
"<url><loc>#{base_url}#{path}</loc></url>"
end)
|> Enum.join("\n ")
"""
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}
</urlset>
"""
|> String.trim()
end
end
```
**Step 5: Update robots.txt**
Replace `priv/static/robots.txt` content:
```
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
User-agent: *
Allow: /
Sitemap: https://towerops.net/sitemap.xml
```
**Step 6: Run tests to verify they pass**
```bash
mix test test/towerops_web/controllers/sitemap_controller_test.exs
```
Expected: All tests PASS.
**Step 7: Commit**
```bash
git add lib/towerops_web/controllers/sitemap_controller.ex \
test/towerops_web/controllers/sitemap_controller_test.exs \
lib/towerops_web/router.ex \
priv/static/robots.txt
git commit -m "feat: add /sitemap.xml and update robots.txt for agent discovery"
```
---
## Task 2: Well-Known JSON Endpoints
Serve all `/.well-known/*` discovery endpoints from a single controller.
**Files:**
- Create: `lib/towerops_web/controllers/well_known_controller.ex`
- Modify: `lib/towerops_web/router.ex`
- Test: `test/towerops_web/controllers/well_known_controller_test.exs`
**Step 1: Write the failing tests**
```elixir
# test/towerops_web/controllers/well_known_controller_test.exs
defmodule ToweropsWeb.WellKnownControllerTest do
use ToweropsWeb.ConnCase, async: true
describe "GET /.well-known/api-catalog" do
test "returns linkset+json with API entries", %{conn: conn} do
conn = get(conn, "/.well-known/api-catalog")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["application/linkset+json; charset=utf-8"]
body = json_response(conn, 200)
assert is_list(body["linkset"])
[entry] = body["linkset"]
assert entry["anchor"] =~ "/api/v1"
assert Map.has_key?(entry, "service-doc")
end
end
describe "GET /.well-known/openid-configuration" do
test "returns OIDC metadata", %{conn: conn} do
conn = get(conn, "/.well-known/openid-configuration")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "issuer")
assert Map.has_key?(body, "authorization_endpoint")
assert Map.has_key?(body, "token_endpoint")
assert Map.has_key?(body, "jwks_uri")
end
end
describe "GET /.well-known/oauth-authorization-server" do
test "returns OAuth 2.0 metadata", %{conn: conn} do
conn = get(conn, "/.well-known/oauth-authorization-server")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "issuer")
end
end
describe "GET /.well-known/oauth-protected-resource" do
test "returns protected resource metadata", %{conn: conn} do
conn = get(conn, "/.well-known/oauth-protected-resource")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "resource")
assert Map.has_key?(body, "authorization_servers")
end
end
describe "GET /.well-known/mcp/server-card.json" do
test "returns MCP server card", %{conn: conn} do
conn = get(conn, "/.well-known/mcp/server-card.json")
assert response(conn, 200)
body = json_response(conn, 200)
assert get_in(body, ["serverInfo", "name"]) == "TowerOps"
assert Map.has_key?(body, "transport")
end
end
describe "GET /.well-known/agent-skills/index.json" do
test "returns agent skills index", %{conn: conn} do
conn = get(conn, "/.well-known/agent-skills/index.json")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "$schema")
assert is_list(body["skills"])
end
end
end
```
**Step 2: Run tests to verify they fail**
```bash
mix test test/towerops_web/controllers/well_known_controller_test.exs
```
Expected: All FAIL with no route error.
**Step 3: Add routes to router.ex**
After the sitemap scope, add a scope for well-known endpoints. Note the nested path for MCP:
```elixir
# Well-known discovery endpoints (no pipeline — public, no CSRF)
scope "/.well-known", ToweropsWeb do
get "/api-catalog", WellKnownController, :api_catalog
get "/openid-configuration", WellKnownController, :openid_configuration
get "/oauth-authorization-server", WellKnownController, :oauth_authorization_server
get "/oauth-protected-resource", WellKnownController, :oauth_protected_resource
get "/agent-skills/index.json", WellKnownController, :agent_skills
end
scope "/.well-known/mcp", ToweropsWeb do
get "/server-card.json", WellKnownController, :mcp_server_card
end
```
**Step 4: Create the controller**
```elixir
# lib/towerops_web/controllers/well_known_controller.ex
defmodule ToweropsWeb.WellKnownController do
@moduledoc false
use ToweropsWeb, :controller
def api_catalog(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
conn
|> put_resp_content_type("application/linkset+json")
|> json(%{
"linkset" => [
%{
"anchor" => "#{base_url}/api/v1",
"service-doc" => [%{"href" => "#{base_url}/docs/api"}],
"service-desc" => [
%{"href" => "#{base_url}/docs/api", "type" => "text/html"}
],
"status" => [%{"href" => "#{base_url}/health"}]
}
]
})
end
def openid_configuration(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"issuer" => base_url,
"authorization_endpoint" => "#{base_url}/oauth/authorize",
"token_endpoint" => "#{base_url}/oauth/token",
"jwks_uri" => "#{base_url}/.well-known/jwks.json",
"grant_types_supported" => ["authorization_code"],
"response_types_supported" => ["code"]
})
end
def oauth_authorization_server(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"issuer" => base_url,
"authorization_endpoint" => "#{base_url}/oauth/authorize",
"token_endpoint" => "#{base_url}/oauth/token",
"jwks_uri" => "#{base_url}/.well-known/jwks.json",
"grant_types_supported" => ["authorization_code"],
"response_types_supported" => ["code"]
})
end
def oauth_protected_resource(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"resource" => "#{base_url}/api/v1",
"authorization_servers" => [base_url],
"scopes_supported" => ["read", "write"]
})
end
def mcp_server_card(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"serverInfo" => %{
"name" => "TowerOps",
"version" => "1.0.0"
},
"transport" => %{
"endpoint" => "#{base_url}/mcp"
},
"capabilities" => %{
"tools" => %{},
"resources" => %{},
"prompts" => %{}
}
})
end
def agent_skills(conn, _params) do
json(conn, %{
"$schema" => "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills" => []
})
end
end
```
**Step 5: Run tests**
```bash
mix test test/towerops_web/controllers/well_known_controller_test.exs
```
Expected: All PASS.
**Step 6: Commit**
```bash
git add lib/towerops_web/controllers/well_known_controller.ex \
test/towerops_web/controllers/well_known_controller_test.exs \
lib/towerops_web/router.ex
git commit -m "feat: add /.well-known discovery endpoints for agent readiness"
```
---
## Task 3: Link Response Headers on Homepage (RFC 8288)
**Files:**
- Modify: `lib/towerops_web/controllers/page_controller.ex`
- Modify: `test/towerops_web/controllers/page_controller_test.exs`
**Step 1: Write the failing test**
Add to the existing `page_controller_test.exs` (find the `GET /` section and add):
```elixir
describe "GET / link headers" do
test "homepage includes RFC 8288 Link headers", %{conn: conn} do
conn = get(conn, ~p"/")
link_headers = get_resp_header(conn, "link")
assert Enum.any?(link_headers, &String.contains?(&1, ~s(rel="api-catalog")))
assert Enum.any?(link_headers, &String.contains?(&1, ~s(rel="service-doc")))
end
end
```
**Step 2: Run tests to verify they fail**
```bash
mix test test/towerops_web/controllers/page_controller_test.exs
```
Expected: New test FAILS (no Link headers on current response).
**Step 3: Update PageController**
Modify `home/2` in `lib/towerops_web/controllers/page_controller.ex`:
```elixir
def home(conn, _params) do
conn = put_link_headers(conn)
if conn.assigns.current_scope && conn.assigns.current_scope.user do
redirect(conn, to: ~p"/dashboard")
else
render(conn, :home, layout: false)
end
end
defp put_link_headers(conn) do
conn
|> put_resp_header("link", ~s(</.well-known/api-catalog>; rel="api-catalog"))
|> put_resp_header("link", ~s(</docs/api>; rel="service-doc"))
|> put_resp_header("link", ~s(</health>; rel="status"))
end
```
Note: Phoenix `put_resp_header/3` *replaces* a header. To emit multiple `Link` headers, use `Plug.Conn.put_resp_header/3` only once with a comma-joined value instead:
```elixir
defp put_link_headers(conn) do
link_value =
[
~s(</.well-known/api-catalog>; rel="api-catalog"),
~s(</docs/api>; rel="service-doc"),
~s(</health>; rel="status")
]
|> Enum.join(", ")
put_resp_header(conn, "link", link_value)
end
```
**Step 4: Run tests**
```bash
mix test test/towerops_web/controllers/page_controller_test.exs
```
Expected: All PASS.
**Step 5: Commit**
```bash
git add lib/towerops_web/controllers/page_controller.ex \
test/towerops_web/controllers/page_controller_test.exs
git commit -m "feat: add RFC 8288 Link response headers to homepage"
```
---
## Task 4: Markdown Content Negotiation
When a request has `Accept: text/markdown`, return a markdown representation of the page with `Content-Type: text/markdown`.
**Files:**
- Modify: `lib/towerops_web/controllers/page_controller.ex`
- Create: `lib/towerops_web/controllers/page_html/home.md.eex` (or inline in controller)
- Test: `test/towerops_web/controllers/page_controller_test.exs`
**Step 1: Write the failing test**
Add to `page_controller_test.exs`:
```elixir
describe "GET / markdown negotiation" do
test "returns markdown when Accept: text/markdown", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown")
|> get(~p"/")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["text/markdown; charset=utf-8"]
body = response(conn, 200)
assert body =~ "# TowerOps"
end
end
```
**Step 2: Run to verify it fails**
```bash
mix test test/towerops_web/controllers/page_controller_test.exs
```
Expected: FAIL — response returns HTML, not markdown.
**Step 3: Update PageController**
Note: The browser pipeline has `plug :accepts, ["html"]` which will reject non-HTML requests before they reach the controller. To work around this, check the `accept` header *before* format negotiation, or add the markdown check early.
The cleanest approach: add `"md"` to accepted formats and register the MIME type. But the simpler approach is to check the header directly at the top of `home/2` before the pipeline can reject it. Actually, the `:accepts` plug runs in the pipeline before the controller. We need a different route.
**Solution**: Add a separate scope for markdown negotiation that bypasses the html-only browser pipeline:
In `router.ex`, add before the browser-pipeline scope:
```elixir
# Markdown content negotiation (must come before :browser scope)
pipeline :markdown do
plug :accepts, ["html", "text/markdown"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {ToweropsWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers, %{}
plug ToweropsWeb.Plugs.CaptureTimezone
plug :fetch_current_scope_for_user
plug :set_user_locale
plug ToweropsWeb.Plugs.UpdateSessionActivity
plug :store_return_to_for_liveview
plug ToweropsWeb.Plugs.DetectEUUser
plug ToweropsWeb.Plugs.CheckPolicyConsent
end
```
Actually, the simplest approach is to not use a pipeline at all for the markdown route, and instead add a dedicated action that handles markdown and sits outside the `:accepts` restriction. We can match on a different path...
**Actually simplest**: use the `:accepts` plug but modify the browser pipeline to also accept `"text/markdown"` mapped to a `"md"` format, then check format in the controller.
**Even simpler**: Add the markdown check *before* the `:accepts` plug runs, via a custom plug or by checking `get_req_header` in an early plug.
**Recommended approach**: Create a `ToweropsWeb.Plugs.MarkdownNegotiation` plug that runs early (before `:accepts`), detects `Accept: text/markdown`, intercepts only the homepage, and returns markdown directly.
```elixir
# lib/towerops_web/plugs/markdown_negotiation.ex
defmodule ToweropsWeb.Plugs.MarkdownNegotiation do
@moduledoc false
import Plug.Conn
@markdown_pages %{
"/" => &__MODULE__.home_markdown/0,
"/privacy" => &__MODULE__.privacy_markdown/0,
"/terms" => &__MODULE__.terms_markdown/0
}
def init(opts), do: opts
def call(conn, _opts) do
if accepts_markdown?(conn) && Map.has_key?(@markdown_pages, conn.request_path) do
markdown_fn = @markdown_pages[conn.request_path]
markdown = markdown_fn.()
conn
|> put_resp_content_type("text/markdown")
|> send_resp(200, markdown)
|> halt()
else
conn
end
end
defp accepts_markdown?(conn) do
conn
|> get_req_header("accept")
|> Enum.any?(&String.contains?(&1, "text/markdown"))
end
def home_markdown do
"""
# TowerOps
Network monitoring and alerting platform built for WISP and ISP operators.
See the business impact of every network event.
## Features
- Real-time network monitoring
- Subscriber impact analysis
- Revenue impact tracking
- Equipment management
- Multi-site support
## Get Started
- [Register](/users/register)
- [Log In](/users/log_in)
- [API Documentation](/docs/api)
- [Privacy Policy](/privacy)
- [Terms of Service](/terms)
## API
TowerOps provides a REST API for programmatic access.
See [API documentation](/docs/api) for details.
## Contact
Visit [towerops.net](https://towerops.net) for more information.
"""
end
def privacy_markdown do
"# Privacy Policy\n\nSee [towerops.net/privacy](https://towerops.net/privacy) for the full privacy policy.\n"
end
def terms_markdown do
"# Terms of Service\n\nSee [towerops.net/terms](https://towerops.net/terms) for the full terms of service.\n"
end
end
```
Add this plug to the **endpoint** (`lib/towerops_web/endpoint.ex`) *before* the router, so it runs before the `:accepts` pipeline check:
```elixir
# Add before plug ToweropsWeb.Router
plug ToweropsWeb.Plugs.MarkdownNegotiation
plug ToweropsWeb.Router
```
**Step 4: Update the test**
The test in `page_controller_test.exs` will work with `ConnCase` which bypasses the endpoint. Modify the test to use `build_conn()` and the endpoint dispatch:
Actually in `ConnCase`, tests go through the full router pipeline. The plug being at the endpoint level means it runs before the router, but `ConnCase` dispatches through `ToweropsWeb.Endpoint`. Verify with:
```elixir
describe "GET / markdown negotiation" do
test "returns markdown when Accept: text/markdown", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown")
|> get(~p"/")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["text/markdown; charset=utf-8"]
body = response(conn, 200)
assert body =~ "# TowerOps"
end
end
```
**Step 5: Run tests**
```bash
mix test test/towerops_web/controllers/page_controller_test.exs
```
Expected: All PASS including the markdown test.
**Step 6: Commit**
```bash
git add lib/towerops_web/plugs/markdown_negotiation.ex \
lib/towerops_web/endpoint.ex \
test/towerops_web/controllers/page_controller_test.exs
git commit -m "feat: add markdown content negotiation for agent compatibility"
```
---
## Task 5: WebMCP JavaScript
Expose site tools to AI agents via `navigator.modelContext.provideContext()` on page load.
**Files:**
- Modify: `assets/js/app.js`
- Modify: `test/towerops_web/controllers/page_html_test.exs` (or create a JS test if preferred)
Since we cannot write inline `<script>` tags in templates, add WebMCP initialization to `assets/js/app.js`. The `navigator.modelContext` API is browser-experimental; guard against missing support.
**Step 1: Append to assets/js/app.js**
Find the end of `assets/js/app.js` and add:
```javascript
// WebMCP: expose site tools to AI agents (https://webmachinelearning.github.io/webmcp/)
if (typeof navigator !== "undefined" && navigator.modelContext) {
navigator.modelContext.provideContext({
tools: [
{
name: "navigate",
description: "Navigate to a page on TowerOps",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "The path to navigate to, e.g. /dashboard or /users/register"
}
},
required: ["path"]
},
execute({ path }) {
window.location.href = path;
}
},
{
name: "search_docs",
description: "Open the TowerOps API documentation",
inputSchema: {
type: "object",
properties: {}
},
execute() {
window.location.href = "/docs/api";
}
},
{
name: "register",
description: "Start the registration flow for a new TowerOps account",
inputSchema: {
type: "object",
properties: {}
},
execute() {
window.location.href = "/users/register";
}
}
]
});
}
```
**Step 2: Verify it compiles**
```bash
mix assets.build
```
Expected: No JS errors.
**Step 3: Run full test suite to ensure nothing broken**
```bash
mix test
```
Expected: All PASS.
**Step 4: Commit**
```bash
git add assets/js/app.js
git commit -m "feat: add WebMCP tool definitions for AI agent browser interaction"
```
---
## Task 6: Run precommit and fix any issues
**Step 1: Run precommit**
```bash
mix precommit
```
Expected: Compiles, formats, all tests pass.
If any Credo issues: fix them and run again.
**Step 2: Final commit if any format fixes**
```bash
git add -p # stage only format changes
git commit -m "chore: run mix format"
```
---
## Verification Checklist
After all tasks complete, these endpoints must all return HTTP 200:
- `GET /sitemap.xml``text/xml`, lists canonical URLs
- `GET /.well-known/api-catalog``application/linkset+json`
- `GET /.well-known/openid-configuration` → JSON with `issuer`
- `GET /.well-known/oauth-authorization-server` → JSON with `issuer`
- `GET /.well-known/oauth-protected-resource` → JSON with `resource`
- `GET /.well-known/mcp/server-card.json` → JSON with `serverInfo`
- `GET /.well-known/agent-skills/index.json` → JSON with `$schema`
- `GET /` → includes `Link` header with `rel="api-catalog"`
- `GET /` with `Accept: text/markdown``text/markdown` response
- `priv/static/robots.txt` → contains `Sitemap: https://towerops.net/sitemap.xml`

View file

@ -22,7 +22,7 @@ defmodule ToweropsWeb.Plugs.MarkdownNegotiation do
## Get Started ## Get Started
- [Register](/users/register) - [Register](/users/register)
- [Log In](/users/log_in) - [Log In](/users/log-in)
- [API Documentation](/docs/api) - [API Documentation](/docs/api)
- [Privacy Policy](/privacy) - [Privacy Policy](/privacy)
- [Terms of Service](/terms) - [Terms of Service](/terms)