diff --git a/docs/tasks/17-doc-viewer.md b/docs/tasks/17-doc-viewer.md index 039e6e5..e8b119f 100644 --- a/docs/tasks/17-doc-viewer.md +++ b/docs/tasks/17-doc-viewer.md @@ -11,15 +11,15 @@ references: Goal: publish `docs/reference/` as a public Phoenix documentation site with a Netscape Navigator-inspired shell and a web 1.0 design (with a sidebar & search) -- [ ] T17-01: Add a `Tempest.Docs` context with a fixed manifest for files under +- [x] T17-01: Add a `Tempest.Docs` context with a fixed manifest for files under `docs/reference/`. -- [ ] T17-02: Add safe document lookup by slug. Reject unknown slugs and any path +- [x] T17-02: Add safe document lookup by slug. Reject unknown slugs and any path traversal attempt. -- [ ] T17-03: Add frontmatter parsing for `title` and `updated`, falling back to +- [x] T17-03: Add frontmatter parsing for `title` and `updated`, falling back to manifest values when frontmatter is missing. -- [ ] T17-04: Add server-side Markdown rendering for trusted local reference docs +- [x] T17-04: Add server-side Markdown rendering for trusted local reference docs with `MDEx` -- [ ] T17-05: Add relative-link rewriting for links between known reference docs. +- [x] T17-05: Add relative-link rewriting for links between known reference docs. - [ ] T17-06: Add `TempestWeb.DocController` with `index` and `show` actions. - [ ] T17-07: Add public routes `GET /docs` and `GET /docs/:slug` under the browser pipeline. @@ -28,7 +28,7 @@ Netscape Navigator-inspired shell and a web 1.0 design (with a sidebar & search) location bar, bookmarks pane, document pane, and footer. - [ ] T17-10: Add responsive CSS through the existing vanilla CSS structure (`assets/css/app.css` plus component files such as - `assets/css/components/doc-viewer.css`). + `assets/css/components/docs.css`). - [ ] T17-11: Add accessible focus, contrast, heading, and navigation behavior. - [ ] T17-12: Add previous/next document links based on manifest order. - [ ] T17-13: Link the docs viewer from the home page and any relevant public @@ -38,8 +38,7 @@ Netscape Navigator-inspired shell and a web 1.0 design (with a sidebar & search) - [ ] T17-15: Add regression tests proving files outside `docs/reference/` cannot be rendered. - [ ] T17-16: Add Hurl smoke test `test/smoke/doc-viewer.hurl`. -- [ ] T17-17: Add production caching or explicitly document request-time rendering - if the first implementation skips caching. +- [ ] T17-17: Add production caching ## Integration Tests diff --git a/lib/tempest/docs.ex b/lib/tempest/docs.ex new file mode 100644 index 0000000..8df373a --- /dev/null +++ b/lib/tempest/docs.ex @@ -0,0 +1,215 @@ +defmodule Tempest.Docs do + @moduledoc """ + Reference documentation loaded from the fixed `docs/reference/` manifest. + + The public API accepts slugs, never paths. Markdown content is trusted local + project documentation, but file lookup remains constrained to the manifest. + """ + + @enforce_keys [:slug, :path, :title] + defstruct [:slug, :path, :title, :updated, :markdown, :html] + + @type document :: %__MODULE__{ + slug: String.t(), + path: String.t(), + title: String.t(), + updated: String.t() | nil, + markdown: String.t() | nil, + html: String.t() | nil + } + + @documents [ + %{slug: "reference", path: "README.md", title: "Reference Documentation"}, + %{slug: "account-migration", path: "account-migration.md", title: "Account Migration"}, + %{slug: "admin-operations", path: "admin-operations.md", title: "Admin and Operator Operations"}, + %{slug: "architecture", path: "architecture.md", title: "Architecture"}, + %{slug: "blobs", path: "blobs.md", title: "Blobs"}, + %{slug: "budget", path: "budget.md", title: "Budget"}, + %{slug: "car-drisl", path: "car-drisl.md", title: "CAR and DRISL"}, + %{slug: "deployment", path: "deployment.md", title: "Deployment Guide"}, + %{slug: "deployment-observability", path: "deployment-observability.md", title: "Deployment and Observability"}, + %{slug: "endpoints", path: "endpoints.md", title: "Endpoints"}, + %{slug: "identity-troubleshooting", path: "identity-troubleshooting.md", title: "Identity Troubleshooting"}, + %{slug: "interop-testing", path: "interop-testing.md", title: "Interop and Integration Testing"}, + %{slug: "lexicon-schemas", path: "lexicon-schemas.md", title: "Lexicon Schemas"}, + %{slug: "migration-lifecycle", path: "migration-lifecycle.md", title: "Migration and Account Lifecycle"}, + %{slug: "pds-compatibility", path: "pds-compatibility.md", title: "PDS Compatibility Matrix"}, + %{slug: "record-apis", path: "record-apis.md", title: "Record APIs"}, + %{slug: "release", path: "release.md", title: "Initial Release Readiness"}, + %{slug: "repo-core", path: "repo-core.md", title: "Repository Core"}, + %{slug: "security-oauth", path: "security-oauth.md", title: "Security, OAuth, and Delegated Access"}, + %{slug: "storage-sqlite", path: "storage-sqlite.md", title: "SQLite Storage"}, + %{slug: "sync-firehose", path: "sync-firehose.md", title: "Sync and Firehose"}, + %{slug: "tokens", path: "tokens.md", title: "Tokens"}, + %{slug: "xrpc", path: "xrpc.md", title: "XRPC HTTP Surface"} + ] + + @markdown_options [ + extension: [ + autolink: true, + strikethrough: true, + table: true + ] + ] + + @doc "Returns the fixed reference document manifest in display order." + @spec list_documents() :: [document()] + def list_documents do + Enum.map(@documents, &manifest_document/1) + end + + @doc "Fetches and renders a known reference document by slug." + @spec fetch_document(String.t()) :: {:ok, document()} | {:error, :not_found} + def fetch_document(slug) when is_binary(slug) do + with {:ok, entry} <- lookup_manifest(slug), + {:ok, markdown} <- read_manifest_file(entry) do + {frontmatter, body} = split_frontmatter(markdown) + title = Map.get(frontmatter, "title") || entry.title + updated = Map.get(frontmatter, "updated") + rewritten_body = rewrite_reference_links(body, entry) + html = MDEx.to_html!(rewritten_body, @markdown_options) + + {:ok, + %__MODULE__{ + slug: entry.slug, + path: entry.path, + title: title, + updated: updated, + markdown: rewritten_body, + html: html + }} + else + _ -> {:error, :not_found} + end + end + + @doc "Returns a known reference document by slug or raises `Ecto.NoResultsError`." + @spec get_document!(String.t()) :: document() + def get_document!(slug) do + case fetch_document(slug) do + {:ok, document} -> document + {:error, :not_found} -> raise Ecto.NoResultsError, queryable: __MODULE__ + end + end + + @doc "Returns the viewer route for a known document slug." + @spec document_path(document() | String.t()) :: String.t() + def document_path(%__MODULE__{slug: slug}), do: document_path(slug) + def document_path("reference"), do: "/docs" + def document_path(slug) when is_binary(slug), do: "/docs/" <> slug + + defp lookup_manifest(slug) do + if valid_slug?(slug) do + case Enum.find(@documents, &(&1.slug == slug)) do + nil -> {:error, :not_found} + entry -> {:ok, entry} + end + else + {:error, :not_found} + end + end + + defp valid_slug?(slug), do: Regex.match?(~r/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, slug) + + defp manifest_document(entry) do + %__MODULE__{slug: entry.slug, path: entry.path, title: entry.title} + end + + defp read_manifest_file(entry) do + root = reference_root() + path = Path.expand(entry.path, root) + + if inside_reference_root?(path, root) do + File.read(path) + else + {:error, :not_found} + end + end + + defp reference_root do + Path.expand("docs/reference", File.cwd!()) + end + + defp inside_reference_root?(path, root) do + path == root or String.starts_with?(path, root <> "/") + end + + defp split_frontmatter("---\n" <> rest) do + case :binary.split(rest, "\n---\n") do + [frontmatter, body] -> {parse_frontmatter(frontmatter), body} + _ -> {%{}, "---\n" <> rest} + end + end + + defp split_frontmatter(markdown), do: {%{}, markdown} + + defp parse_frontmatter(frontmatter) do + frontmatter + |> String.split("\n") + |> Enum.reduce(%{}, fn line, acc -> + case String.split(line, ":", parts: 2) do + [key, value] when key in ["title", "updated"] -> Map.put(acc, key, String.trim(value)) + _ -> acc + end + end) + end + + defp rewrite_reference_links(markdown, current_entry) do + Regex.replace(~r/(!?)\[([^\]]+)\]\(([^)\s]+)(\s+(?:"[^"]*"|'[^']*'|\([^)]+\)))?\)/, markdown, fn + full_match, "!", _text, _destination, _title -> + full_match + + full_match, _bang, _text, destination, title -> + rewrite_link_match(full_match, destination, title || "", current_entry) + end) + end + + defp rewrite_link_match(full_match, destination, title, current_entry) do + case reference_destination(destination, current_entry) do + {:ok, href} -> String.replace(full_match, "(" <> destination <> title <> ")", "(" <> href <> title <> ")") + :error -> full_match + end + end + + defp reference_destination(destination, current_entry) do + uri = URI.parse(destination) + + cond do + uri.scheme || uri.host || String.starts_with?(destination, "#") -> + :error + + uri.path && String.ends_with?(uri.path, ".md") -> + resolve_reference_destination(uri, current_entry) + + true -> + :error + end + end + + defp resolve_reference_destination(uri, current_entry) do + root = reference_root() + current_dir = Path.dirname(Path.expand(current_entry.path, root)) + path = Path.expand(uri.path, current_dir) + + with true <- inside_reference_root?(path, root), + relative_path <- Path.relative_to(path, root), + {:ok, entry} <- lookup_manifest_by_path(relative_path) do + suffix = + case uri.fragment do + nil -> "" + fragment -> "#" <> fragment + end + + {:ok, document_path(entry.slug) <> suffix} + else + _ -> :error + end + end + + defp lookup_manifest_by_path(path) do + case Enum.find(@documents, &(&1.path == path)) do + nil -> {:error, :not_found} + entry -> {:ok, entry} + end + end +end diff --git a/mix.exs b/mix.exs index 0827ddf..68d6634 100644 --- a/mix.exs +++ b/mix.exs @@ -52,6 +52,7 @@ defmodule Tempest.MixProject do {:gettext, "~> 1.0"}, {:jason, "~> 1.2"}, {:jose, "~> 1.11"}, + {:mdex, "~> 0.13.0"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"} ] diff --git a/mix.lock b/mix.lock index 2ecbcc8..f2ca85e 100644 --- a/mix.lock +++ b/mix.lock @@ -23,9 +23,12 @@ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, + "mdex": {:hex, :mdex, "0.13.0", "1e33bd571a49a778b4ebb1db2b0685834854cbfcdc8bbc20b9a656fc635e2bba", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.1.5", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "098f1bafee13d3137c5c0b70949f1d47cee70869e25a478d0cea6d1c21085362"}, + "mdex_native": {:hex, :mdex_native, "0.2.0", "f85ee3919bcd0a08e30895804b361e84532e001eefbb3391998fc11c8c44e06f", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "1b65964209afd8c8fcd5ea3d4909bdc741ae92d678e87eb6c6d30e3c15988ef9"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, @@ -39,6 +42,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, diff --git a/test/tempest/docs_test.exs b/test/tempest/docs_test.exs new file mode 100644 index 0000000..8603eca --- /dev/null +++ b/test/tempest/docs_test.exs @@ -0,0 +1,81 @@ +defmodule Tempest.DocsTest do + use ExUnit.Case, async: true + + alias Tempest.Docs + + describe "list_documents/0" do + test "uses a fixed manifest covering every reference markdown file" do + manifest_paths = + Docs.list_documents() + |> Enum.map(& &1.path) + |> Enum.sort() + + reference_paths = + "docs/reference/*.md" + |> Path.wildcard() + |> Enum.map(&Path.basename/1) + |> Enum.sort() + + assert manifest_paths == reference_paths + assert Enum.map(Docs.list_documents(), & &1.slug) == Enum.uniq(Enum.map(Docs.list_documents(), & &1.slug)) + end + end + + describe "fetch_document/1" do + test "fetches a known document by slug" do + assert {:ok, document} = Docs.fetch_document("architecture") + + assert document.slug == "architecture" + assert document.path == "architecture.md" + assert document.title == "Architecture" + assert document.updated == "2026-06-03" + assert document.markdown =~ "## Concepts" + end + + test "rejects unknown slugs and path traversal attempts" do + assert Docs.fetch_document("missing") == {:error, :not_found} + assert Docs.fetch_document("../config/prod.exs") == {:error, :not_found} + assert Docs.fetch_document("..%2F..%2Fconfig%2Fprod.exs") == {:error, :not_found} + assert Docs.fetch_document("architecture.md") == {:error, :not_found} + end + + test "uses frontmatter metadata when present" do + assert {:ok, document} = Docs.fetch_document("car-drisl") + + assert document.title == "CAR and DRISL" + assert document.updated == "2026-06-13" + refute document.markdown =~ "---" + end + + test "renders trusted local markdown to html with headings, code blocks, tables, and escaped raw html" do + assert {:ok, document} = Docs.fetch_document("architecture") + + assert document.html =~ "
"
+ assert document.html =~ ""
+
+ assert {:ok, security} = Docs.fetch_document("security-oauth")
+ refute security.html =~ "