dev vouch dev on at. thats about it
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview: add getRoutes xrpc

Luna bf93de6e 5143d514

+421 -2
+100
appview/lib/atvouch/graph.ex
··· 1 + defmodule Atvouch.Graph do 2 + @moduledoc """ 3 + Graph routing algorithm: finds all vouch paths (up to 3 hops) 4 + from a source DID to a target DID using the indexed vouch data. 5 + 6 + Port of the Go CLI's checkWithDeps algorithm. 7 + """ 8 + 9 + import Ecto.Query 10 + 11 + @doc """ 12 + Find routes from `source_did` to `target_did`. 13 + 14 + Returns `{:direct, target_did}` if source directly vouches for target, 15 + or `{:routes, target_did, paths}` where paths is a list of DID lists. 16 + """ 17 + def find_routes(source_did, target_did) do 18 + my_vouches = vouched_by(source_did) 19 + my_vouch_set = MapSet.new(my_vouches) 20 + 21 + # Direct vouch check 22 + if MapSet.member?(my_vouch_set, target_did) do 23 + {:direct, target_did} 24 + else 25 + # Build reverse graph from target (who vouches for X?) 26 + # Level 1: who vouches for target 27 + level1 = vouchers_of(target_did) 28 + reverse_graph = %{target_did => MapSet.new(level1)} 29 + 30 + # Level 2: who vouches for each level-1 voucher 31 + {reverse_graph, level2_dids} = 32 + Enum.reduce(level1, {reverse_graph, []}, fn did, {rg, l2} -> 33 + vouchers = vouchers_of(did) 34 + {Map.put(rg, did, MapSet.new(vouchers)), l2 ++ vouchers} 35 + end) 36 + 37 + # Level 3: who vouches for each level-2 voucher 38 + reverse_graph = 39 + Enum.reduce(level2_dids, reverse_graph, fn did, rg -> 40 + if Map.has_key?(rg, did) do 41 + rg 42 + else 43 + vouchers = vouchers_of(did) 44 + Map.put(rg, did, MapSet.new(vouchers)) 45 + end 46 + end) 47 + 48 + # Find all paths 49 + paths = [] 50 + 51 + # Depth 2: source -> X -> target (X vouches for target, source vouches for X) 52 + paths = 53 + reverse_graph 54 + |> Map.get(target_did, MapSet.new()) 55 + |> Enum.reduce(paths, fn voucher, acc -> 56 + if MapSet.member?(my_vouch_set, voucher) do 57 + [[source_did, voucher, target_did] | acc] 58 + else 59 + acc 60 + end 61 + end) 62 + 63 + # Depth 3: source -> X -> Y -> target 64 + paths = 65 + reverse_graph 66 + |> Map.get(target_did, MapSet.new()) 67 + |> Enum.reduce(paths, fn y_did, acc -> 68 + reverse_graph 69 + |> Map.get(y_did, MapSet.new()) 70 + |> Enum.reduce(acc, fn x_did, inner_acc -> 71 + if MapSet.member?(my_vouch_set, x_did) do 72 + [[source_did, x_did, y_did, target_did] | inner_acc] 73 + else 74 + inner_acc 75 + end 76 + end) 77 + end) 78 + 79 + {:routes, target_did, paths} 80 + end 81 + end 82 + 83 + # Returns DIDs that `source_did` has vouched for (outgoing edges) 84 + defp vouched_by(source_did) do 85 + from(v in Atvouch.Vouch, 86 + where: v.creator_did == ^source_did, 87 + select: v.target_did 88 + ) 89 + |> Atvouch.Repo.replica().all() 90 + end 91 + 92 + # Returns DIDs that vouch for `target_did` (incoming edges / reverse graph) 93 + defp vouchers_of(target_did) do 94 + from(v in Atvouch.Vouch, 95 + where: v.target_did == ^target_did, 96 + select: v.creator_did 97 + ) 98 + |> Atvouch.Repo.replica().all() 99 + end 100 + end
+11
appview/lib/atvouch/lexicons/get_routes.ex
··· 1 + defmodule Atvouch.Lexicons.Graph.GetRoutes do 2 + use Atex.Lexicon 3 + 4 + deflexicon( 5 + Jason.decode!( 6 + File.read!( 7 + Path.join([File.cwd!(), "..", "lexicons", "dev", "atvouch", "graph", "getRoutes.json"]) 8 + ) 9 + ) 10 + ) 11 + end
+48 -2
appview/lib/atvouch/xrpc_router.ex
··· 78 78 end 79 79 end 80 80 81 + get "/dev.atvouch.graph.getRoutes" do 82 + conn = fetch_query_params(conn) 83 + params = conn.query_params 84 + 85 + with {:ok, did} <- require_auth(conn), 86 + {:ok, target} <- parse_target(params) do 87 + {direct_vouch, routes} = 88 + case Atvouch.Graph.find_routes(did, target) do 89 + {:direct, _} -> 90 + {true, []} 91 + 92 + {:routes, _, paths} -> 93 + {false, Enum.map(paths, fn path -> %{path: path} end)} 94 + end 95 + 96 + output = %{ 97 + target: target, 98 + directVouch: direct_vouch, 99 + routes: routes 100 + } 101 + 102 + conn 103 + |> put_resp_content_type("application/json") 104 + |> send_resp(200, Jason.encode!(output)) 105 + else 106 + {:error, message} -> 107 + conn 108 + |> put_resp_content_type("application/json") 109 + |> send_resp(400, Jason.encode!(%{error: "InvalidRequest", message: message})) 110 + 111 + %Plug.Conn{halted: true} = halted_conn -> 112 + halted_conn 113 + end 114 + end 115 + 81 116 options "/dev.atvouch.graph.getEntireGraph" do 82 117 conn 83 118 |> put_resp_header("access-control-allow-origin", "*") ··· 118 153 match _ do 119 154 conn 120 155 |> put_resp_content_type("application/json") 121 - |> send_resp(404, Jason.encode!(%{error: "MethodNotImplemented", message: "XRPC method not found"})) 156 + |> send_resp( 157 + 404, 158 + Jason.encode!(%{error: "MethodNotImplemented", message: "XRPC method not found"}) 159 + ) 122 160 end 123 161 124 162 defp require_auth(conn) do ··· 126 164 nil -> 127 165 conn 128 166 |> put_resp_content_type("application/json") 129 - |> send_resp(401, Jason.encode!(%{error: "AuthRequired", message: "Authentication required"})) 167 + |> send_resp( 168 + 401, 169 + Jason.encode!(%{error: "AuthRequired", message: "Authentication required"}) 170 + ) 130 171 |> halt() 131 172 132 173 did -> 133 174 {:ok, did} 134 175 end 135 176 end 177 + 178 + defp parse_target(%{"target" => target}) when is_binary(target) and target != "", 179 + do: {:ok, target} 180 + 181 + defp parse_target(_), do: {:error, "target parameter is required"} 136 182 137 183 defp parse_limit(%{"limit" => limit_str}) do 138 184 case Integer.parse(limit_str) do
+205
appview/test/atvouch/xrpc_get_routes_test.exs
··· 1 + defmodule Atvouch.XrpcGetRoutesTest do 2 + use ExUnit.Case 3 + import Plug.Test 4 + import Plug.Conn 5 + 6 + @opts Atvouch.Router.init([]) 7 + 8 + setup do 9 + Ecto.Adapters.SQL.Sandbox.checkout(Atvouch.Repo) 10 + Ecto.Adapters.SQL.Sandbox.mode(Atvouch.Repo, {:shared, self()}) 11 + 12 + {_server_pid, auth_port} = Atvouch.Test.FakeAuthServer.start() 13 + auth_url = "http://127.0.0.1:#{auth_port}" 14 + prev_auth_url = Application.get_env(:atvouch, :auth_url) 15 + Application.put_env(:atvouch, :auth_url, auth_url) 16 + 17 + on_exit(fn -> 18 + Application.put_env(:atvouch, :auth_url, prev_auth_url) 19 + end) 20 + 21 + # Create identities 22 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:alice", handle: "alice.test"}) 23 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:bob", handle: "bob.test"}) 24 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:carol", handle: "carol.test"}) 25 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:dave", handle: "dave.test"}) 26 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:eve", handle: "eve.test"}) 27 + 28 + :ok 29 + end 30 + 31 + defp create_vouch(creator_did, target_did, created_at \\ "2026-03-01T00:00:00Z") do 32 + {:ok, _} = 33 + Atvouch.Vouch.create(%{ 34 + at_uri: "at://#{creator_did}/dev.atvouch.graph.vouch/#{target_did}", 35 + creator_did: creator_did, 36 + target_did: target_did, 37 + original_created_at: created_at, 38 + remote_created_at: created_at, 39 + at_cid: "bafytest#{:erlang.phash2({creator_did, target_did})}", 40 + live: true 41 + }) 42 + end 43 + 44 + defp get_routes(target_did, auth_did) do 45 + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes?target=#{target_did}") 46 + |> put_req_header("authorization", "Bearer valid-token:#{auth_did}") 47 + |> Atvouch.Router.call(@opts) 48 + end 49 + 50 + defp route_paths(body) do 51 + Enum.map(body["routes"], fn r -> r["path"] end) 52 + end 53 + 54 + describe "GET /xrpc/dev.atvouch.graph.getRoutes" do 55 + test "returns 401 without authentication" do 56 + conn = 57 + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes?target=did:plc:bob") 58 + |> Atvouch.Router.call(@opts) 59 + 60 + assert conn.status == 401 61 + end 62 + 63 + test "returns 401 with invalid token" do 64 + conn = 65 + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes?target=did:plc:bob") 66 + |> put_req_header("authorization", "Bearer bad-token") 67 + |> Atvouch.Router.call(@opts) 68 + 69 + assert conn.status == 401 70 + end 71 + 72 + test "returns 400 when target parameter is missing" do 73 + conn = 74 + conn(:get, "/xrpc/dev.atvouch.graph.getRoutes") 75 + |> put_req_header("authorization", "Bearer valid-token:did:plc:alice") 76 + |> Atvouch.Router.call(@opts) 77 + 78 + assert conn.status == 400 79 + body = Jason.decode!(conn.resp_body) 80 + assert body["error"] == "InvalidRequest" 81 + end 82 + 83 + test "detects direct vouch" do 84 + create_vouch("did:plc:alice", "did:plc:bob") 85 + 86 + conn = get_routes("did:plc:bob", "did:plc:alice") 87 + 88 + assert conn.status == 200 89 + body = Jason.decode!(conn.resp_body) 90 + assert body["target"] == "did:plc:bob" 91 + assert body["directVouch"] == true 92 + assert body["routes"] == [] 93 + end 94 + 95 + test "returns empty routes when no connection exists" do 96 + conn = get_routes("did:plc:bob", "did:plc:alice") 97 + 98 + assert conn.status == 200 99 + body = Jason.decode!(conn.resp_body) 100 + assert body["target"] == "did:plc:bob" 101 + assert body["directVouch"] == false 102 + assert body["routes"] == [] 103 + end 104 + 105 + test "finds two-hop path: alice -> bob -> carol" do 106 + create_vouch("did:plc:alice", "did:plc:bob") 107 + create_vouch("did:plc:bob", "did:plc:carol") 108 + 109 + conn = get_routes("did:plc:carol", "did:plc:alice") 110 + 111 + assert conn.status == 200 112 + body = Jason.decode!(conn.resp_body) 113 + assert body["target"] == "did:plc:carol" 114 + assert body["directVouch"] == false 115 + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol"]] 116 + end 117 + 118 + test "finds three-hop path: alice -> bob -> carol -> dave" do 119 + create_vouch("did:plc:alice", "did:plc:bob") 120 + create_vouch("did:plc:bob", "did:plc:carol") 121 + create_vouch("did:plc:carol", "did:plc:dave") 122 + 123 + conn = get_routes("did:plc:dave", "did:plc:alice") 124 + 125 + assert conn.status == 200 126 + body = Jason.decode!(conn.resp_body) 127 + assert body["target"] == "did:plc:dave" 128 + assert body["directVouch"] == false 129 + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol", "did:plc:dave"]] 130 + end 131 + 132 + test "does not find four-hop paths (beyond depth limit)" do 133 + create_vouch("did:plc:alice", "did:plc:bob") 134 + create_vouch("did:plc:bob", "did:plc:carol") 135 + create_vouch("did:plc:carol", "did:plc:dave") 136 + create_vouch("did:plc:dave", "did:plc:eve") 137 + 138 + conn = get_routes("did:plc:eve", "did:plc:alice") 139 + 140 + assert conn.status == 200 141 + body = Jason.decode!(conn.resp_body) 142 + assert body["directVouch"] == false 143 + assert body["routes"] == [] 144 + end 145 + 146 + test "finds multiple two-hop paths" do 147 + create_vouch("did:plc:alice", "did:plc:bob") 148 + create_vouch("did:plc:alice", "did:plc:carol") 149 + create_vouch("did:plc:bob", "did:plc:dave") 150 + create_vouch("did:plc:carol", "did:plc:dave") 151 + 152 + conn = get_routes("did:plc:dave", "did:plc:alice") 153 + 154 + assert conn.status == 200 155 + body = Jason.decode!(conn.resp_body) 156 + paths = route_paths(body) 157 + assert length(paths) == 2 158 + 159 + assert Enum.all?(paths, fn p -> length(p) == 3 end) 160 + assert Enum.all?(paths, fn [first | _] -> first == "did:plc:alice" end) 161 + assert Enum.all?(paths, fn p -> List.last(p) == "did:plc:dave" end) 162 + 163 + middles = Enum.map(paths, fn [_, mid, _] -> mid end) |> MapSet.new() 164 + assert middles == MapSet.new(["did:plc:bob", "did:plc:carol"]) 165 + end 166 + 167 + test "handles cyclic vouches without infinite loops" do 168 + create_vouch("did:plc:alice", "did:plc:bob") 169 + create_vouch("did:plc:bob", "did:plc:carol") 170 + create_vouch("did:plc:carol", "did:plc:alice") 171 + 172 + conn = get_routes("did:plc:carol", "did:plc:alice") 173 + 174 + assert conn.status == 200 175 + body = Jason.decode!(conn.resp_body) 176 + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol"]] 177 + end 178 + 179 + test "handles mutual vouches without spurious paths" do 180 + create_vouch("did:plc:alice", "did:plc:bob") 181 + create_vouch("did:plc:bob", "did:plc:alice") 182 + create_vouch("did:plc:bob", "did:plc:carol") 183 + create_vouch("did:plc:carol", "did:plc:bob") 184 + 185 + conn = get_routes("did:plc:carol", "did:plc:alice") 186 + 187 + assert conn.status == 200 188 + body = Jason.decode!(conn.resp_body) 189 + assert route_paths(body) == [["did:plc:alice", "did:plc:bob", "did:plc:carol"]] 190 + end 191 + 192 + test "direct vouch takes priority even when indirect paths exist" do 193 + create_vouch("did:plc:alice", "did:plc:carol") 194 + create_vouch("did:plc:alice", "did:plc:bob") 195 + create_vouch("did:plc:bob", "did:plc:carol") 196 + 197 + conn = get_routes("did:plc:carol", "did:plc:alice") 198 + 199 + assert conn.status == 200 200 + body = Jason.decode!(conn.resp_body) 201 + assert body["directVouch"] == true 202 + assert body["routes"] == [] 203 + end 204 + end 205 + end
+11
lexicons/dev/atvouch/graph/defs.json
··· 2 2 "lexicon": 1, 3 3 "id": "dev.atvouch.graph.defs", 4 4 "defs": { 5 + "routeView": { 6 + "type": "object", 7 + "required": ["path"], 8 + "properties": { 9 + "path": { 10 + "type": "array", 11 + "description": "List of DIDs forming a vouch path from source to target.", 12 + "items": { "type": "string", "format": "did" } 13 + } 14 + } 15 + }, 5 16 "vouchView": { 6 17 "type": "object", 7 18 "required": ["uri", "creatorDid", "targetDid", "createdAt"],
+46
lexicons/dev/atvouch/graph/getRoutes.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "dev.atvouch.graph.getRoutes", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Find all vouch paths (up to 3 hops) from the authenticated user to a target DID.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["target"], 11 + "properties": { 12 + "target": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "The DID to find vouch routes to." 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["routes", "target", "directVouch"], 24 + "properties": { 25 + "target": { 26 + "type": "string", 27 + "format": "did" 28 + }, 29 + "directVouch": { 30 + "type": "boolean", 31 + "description": "True if the authenticated user directly vouches for the target." 32 + }, 33 + "routes": { 34 + "type": "array", 35 + "description": "List of vouch paths from the authenticated user to the target.", 36 + "items": { 37 + "type": "ref", 38 + "ref": "dev.atvouch.graph.defs#routeView" 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }