diff --git a/appview/lib/atvouch/graph.ex b/appview/lib/atvouch/graph.ex new file mode 100644 index 0000000..6dde56c --- /dev/null +++ b/appview/lib/atvouch/graph.ex @@ -0,0 +1,100 @@ +defmodule Atvouch.Graph do + @moduledoc """ + Graph routing algorithm: finds all vouch paths (up to 3 hops) + from a source DID to a target DID using the indexed vouch data. + + Port of the Go CLI's checkWithDeps algorithm. + """ + + import Ecto.Query + + @doc """ + Find routes from `source_did` to `target_did`. + + Returns `{:direct, target_did}` if source directly vouches for target, + or `{:routes, target_did, paths}` where paths is a list of DID lists. + """ + def find_routes(source_did, target_did) do + my_vouches = vouched_by(source_did) + my_vouch_set = MapSet.new(my_vouches) + + # Direct vouch check + if MapSet.member?(my_vouch_set, target_did) do + {:direct, target_did} + else + # Build reverse graph from target (who vouches for X?) + # Level 1: who vouches for target + level1 = vouchers_of(target_did) + reverse_graph = %{target_did => MapSet.new(level1)} + + # Level 2: who vouches for each level-1 voucher + {reverse_graph, level2_dids} = + Enum.reduce(level1, {reverse_graph, []}, fn did, {rg, l2} -> + vouchers = vouchers_of(did) + {Map.put(rg, did, MapSet.new(vouchers)), l2 ++ vouchers} + end) + + # Level 3: who vouches for each level-2 voucher + reverse_graph = + Enum.reduce(level2_dids, reverse_graph, fn did, rg -> + if Map.has_key?(rg, did) do + rg + else + vouchers = vouchers_of(did) + Map.put(rg, did, MapSet.new(vouchers)) + end + end) + + # Find all paths + paths = [] + + # Depth 2: source -> X -> target (X vouches for target, source vouches for X) + paths = + reverse_graph + |> Map.get(target_did, MapSet.new()) + |> Enum.reduce(paths, fn voucher, acc -> + if MapSet.member?(my_vouch_set, voucher) do + [[source_did, voucher, target_did] | acc] + else + acc + end + end) + + # Depth 3: source -> X -> Y -> target + paths = + reverse_graph + |> Map.get(target_did, MapSet.new()) + |> Enum.reduce(paths, fn y_did, acc -> + reverse_graph + |> Map.get(y_did, MapSet.new()) + |> Enum.reduce(acc, fn x_did, inner_acc -> + if MapSet.member?(my_vouch_set, x_did) do + [[source_did, x_did, y_did, target_did] | inner_acc] + else + inner_acc + end + end) + end) + + {:routes, target_did, paths} + end + end + + # Returns DIDs that `source_did` has vouched for (outgoing edges) + defp vouched_by(source_did) do + from(v in Atvouch.Vouch, + where: v.creator_did == ^source_did, + select: v.target_did + ) + |> Atvouch.Repo.replica().all() + end + + # Returns DIDs that vouch for `target_did` (incoming edges / reverse graph) + defp vouchers_of(target_did) do + from(v in Atvouch.Vouch, + where: v.target_did == ^target_did, + select: v.creator_did + ) + |> Atvouch.Repo.replica().all() + end +end diff --git a/appview/lib/atvouch/lexicons/get_routes.ex b/appview/lib/atvouch/lexicons/get_routes.ex new file mode 100644 index 0000000..67b7d3b --- /dev/null +++ b/appview/lib/atvouch/lexicons/get_routes.ex @@ -0,0 +1,11 @@ +defmodule Atvouch.Lexicons.Graph.GetRoutes do + use Atex.Lexicon + + deflexicon( + Jason.decode!( + File.read!( + Path.join([File.cwd!(), "..", "lexicons", "dev", "atvouch", "graph", "getRoutes.json"]) + ) + ) + ) +end diff --git a/appview/lib/atvouch/xrpc_router.ex b/appview/lib/atvouch/xrpc_router.ex index f18e497..4cc5466 100644 --- a/appview/lib/atvouch/xrpc_router.ex +++ b/appview/lib/atvouch/xrpc_router.ex @@ -78,6 +78,41 @@ defmodule Atvouch.XrpcRouter do end end + get "/dev.atvouch.graph.getRoutes" do + conn = fetch_query_params(conn) + params = conn.query_params + + with {:ok, did} <- require_auth(conn), + {:ok, target} <- parse_target(params) do + {direct_vouch, routes} = + case Atvouch.Graph.find_routes(did, target) do + {:direct, _} -> + {true, []} + + {:routes, _, paths} -> + {false, Enum.map(paths, fn path -> %{path: path} end)} + end + + output = %{ + target: target, + directVouch: direct_vouch, + routes: routes + } + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(output)) + else + {:error, message} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(400, Jason.encode!(%{error: "InvalidRequest", message: message})) + + %Plug.Conn{halted: true} = halted_conn -> + halted_conn + end + end + options "/dev.atvouch.graph.getEntireGraph" do conn |> put_resp_header("access-control-allow-origin", "*") @@ -118,7 +153,10 @@ defmodule Atvouch.XrpcRouter do match _ do conn |> put_resp_content_type("application/json") - |> send_resp(404, Jason.encode!(%{error: "MethodNotImplemented", message: "XRPC method not found"})) + |> send_resp( + 404, + Jason.encode!(%{error: "MethodNotImplemented", message: "XRPC method not found"}) + ) end defp require_auth(conn) do @@ -126,7 +164,10 @@ defmodule Atvouch.XrpcRouter do nil -> conn |> put_resp_content_type("application/json") - |> send_resp(401, Jason.encode!(%{error: "AuthRequired", message: "Authentication required"})) + |> send_resp( + 401, + Jason.encode!(%{error: "AuthRequired", message: "Authentication required"}) + ) |> halt() did -> @@ -134,6 +175,11 @@ defmodule Atvouch.XrpcRouter do end end + defp parse_target(%{"target" => target}) when is_binary(target) and target != "", + do: {:ok, target} + + defp parse_target(_), do: {:error, "target parameter is required"} + defp parse_limit(%{"limit" => limit_str}) do case Integer.parse(limit_str) do {limit, ""} when limit >= 1 and limit <= 1000 -> {:ok, limit} diff --git a/appview/test/atvouch/xrpc_get_routes_test.exs b/appview/test/atvouch/xrpc_get_routes_test.exs new file mode 100644 index 0000000..df4b859 --- /dev/null +++ b/appview/test/atvouch/xrpc_get_routes_test.exs @@ -0,0 +1,205 @@ +defmodule Atvouch.XrpcGetRoutesTest do + use ExUnit.Case + import Plug.Test + import Plug.Conn + + @opts Atvouch.Router.init([]) + + setup do + Ecto.Adapters.SQL.Sandbox.checkout(Atvouch.Repo) + Ecto.Adapters.SQL.Sandbox.mode(Atvouch.Repo, {:shared, self()}) + + {_server_pid, auth_port} = Atvouch.Test.FakeAuthServer.start() + auth_url = "http://127.0.0.1:#{auth_port}" + prev_auth_url = Application.get_env(:atvouch, :auth_url) + Application.put_env(:atvouch, :auth_url, auth_url) + + on_exit(fn -> + Application.put_env(:atvouch, :auth_url, prev_auth_url) + end) + + # Create identities + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:alice", handle: "alice.test"}) + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:bob", handle: "bob.test"}) + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:carol", handle: "carol.test"}) + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:dave", handle: "dave.test"}) + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:eve", handle: "eve.test"}) + + :ok + end + + defp create_vouch(creator_did, target_did, created_at \\ "2026-03-01T00:00:00Z") do + {:ok, _} = + Atvouch.Vouch.create(%{ + at_uri: "at://#{creator_did}/dev.atvouch.graph.vouch/#{target_did}", + creator_did: creator_did, + target_did: target_did, + original_created_at: created_at, + remote_created_at: created_at, + at_cid: "bafytest#{:erlang.phash2({creator_did, target_did})}", + live: true + }) + end + + defp get_routes(target_did, auth_did) do + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes?target=#{target_did}") + |> put_req_header("authorization", "Bearer valid-token:#{auth_did}") + |> Atvouch.Router.call(@opts) + end + + defp route_paths(body) do + Enum.map(body["routes"], fn r -> r["path"] end) + end + + describe "GET /xrpc/dev.atvouch.graph.getRoutes" do + test "returns 401 without authentication" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes?target=did:plc:bob") + |> Atvouch.Router.call(@opts) + + assert conn.status == 401 + end + + test "returns 401 with invalid token" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes?target=did:plc:bob") + |> put_req_header("authorization", "Bearer bad-token") + |> Atvouch.Router.call(@opts) + + assert conn.status == 401 + end + + test "returns 400 when target parameter is missing" do + conn = + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes") + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") + |> Atvouch.Router.call(@opts) + + assert conn.status == 400 + body = Jason.decode!(conn.resp_body) + assert body["error"] == "InvalidRequest" + end + + test "detects direct vouch" do + create_vouch("did:plc:alice", "did:plc:bob") + + conn = get_routes("did:plc:bob", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["target"] == "did:plc:bob" + assert body["directVouch"] == true + assert body["routes"] == [] + end + + test "returns empty routes when no connection exists" do + conn = get_routes("did:plc:bob", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["target"] == "did:plc:bob" + assert body["directVouch"] == false + assert body["routes"] == [] + end + + test "finds two-hop path: alice -> bob -> carol" do + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:bob", "did:plc:carol") + + conn = get_routes("did:plc:carol", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["target"] == "did:plc:carol" + assert body["directVouch"] == false + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol"]] + end + + test "finds three-hop path: alice -> bob -> carol -> dave" do + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:bob", "did:plc:carol") + create_vouch("did:plc:carol", "did:plc:dave") + + conn = get_routes("did:plc:dave", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["target"] == "did:plc:dave" + assert body["directVouch"] == false + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol", "did:plc:dave"]] + end + + test "does not find four-hop paths (beyond depth limit)" do + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:bob", "did:plc:carol") + create_vouch("did:plc:carol", "did:plc:dave") + create_vouch("did:plc:dave", "did:plc:eve") + + conn = get_routes("did:plc:eve", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["directVouch"] == false + assert body["routes"] == [] + end + + test "finds multiple two-hop paths" do + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:alice", "did:plc:carol") + create_vouch("did:plc:bob", "did:plc:dave") + create_vouch("did:plc:carol", "did:plc:dave") + + conn = get_routes("did:plc:dave", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + paths = route_paths(body) + assert length(paths) == 2 + + assert Enum.all?(paths, fn p -> length(p) == 3 end) + assert Enum.all?(paths, fn [first | _] -> first == "did:plc:alice" end) + assert Enum.all?(paths, fn p -> List.last(p) == "did:plc:dave" end) + + middles = Enum.map(paths, fn [_, mid, _] -> mid end) |> MapSet.new() + assert middles == MapSet.new(["did:plc:bob", "did:plc:carol"]) + end + + test "handles cyclic vouches without infinite loops" do + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:bob", "did:plc:carol") + create_vouch("did:plc:carol", "did:plc:alice") + + conn = get_routes("did:plc:carol", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol"]] + end + + test "handles mutual vouches without spurious paths" do + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:bob", "did:plc:alice") + create_vouch("did:plc:bob", "did:plc:carol") + create_vouch("did:plc:carol", "did:plc:bob") + + conn = get_routes("did:plc:carol", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol"]] + end + + test "direct vouch takes priority even when indirect paths exist" do + create_vouch("did:plc:alice", "did:plc:carol") + create_vouch("did:plc:alice", "did:plc:bob") + create_vouch("did:plc:bob", "did:plc:carol") + + conn = get_routes("did:plc:carol", "did:plc:alice") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["directVouch"] == true + assert body["routes"] == [] + end + end +end diff --git a/lexicons/dev/atvouch/graph/defs.json b/lexicons/dev/atvouch/graph/defs.json index b96948e..7b08a55 100644 --- a/lexicons/dev/atvouch/graph/defs.json +++ b/lexicons/dev/atvouch/graph/defs.json @@ -2,6 +2,17 @@ "lexicon": 1, "id": "dev.atvouch.graph.defs", "defs": { + "routeView": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "array", + "description": "List of DIDs forming a vouch path from source to target.", + "items": { "type": "string", "format": "did" } + } + } + }, "vouchView": { "type": "object", "required": ["uri", "creatorDid", "targetDid", "createdAt"], diff --git a/lexicons/dev/atvouch/graph/getRoutes.json b/lexicons/dev/atvouch/graph/getRoutes.json new file mode 100644 index 0000000..976df22 --- /dev/null +++ b/lexicons/dev/atvouch/graph/getRoutes.json @@ -0,0 +1,46 @@ +{ + "lexicon": 1, + "id": "dev.atvouch.graph.getRoutes", + "defs": { + "main": { + "type": "query", + "description": "Find all vouch paths (up to 3 hops) from the authenticated user to a target DID.", + "parameters": { + "type": "params", + "required": ["target"], + "properties": { + "target": { + "type": "string", + "format": "did", + "description": "The DID to find vouch routes to." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["routes", "target", "directVouch"], + "properties": { + "target": { + "type": "string", + "format": "did" + }, + "directVouch": { + "type": "boolean", + "description": "True if the authenticated user directly vouches for the target." + }, + "routes": { + "type": "array", + "description": "List of vouch paths from the authenticated user to the target.", + "items": { + "type": "ref", + "ref": "dev.atvouch.graph.defs#routeView" + } + } + } + } + } + } + } +}