From 0d0d802badf49dcf456e80a1961a4782dc6cad0e Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Wed, 31 Dec 2025 18:36:45 +0000 Subject: [PATCH 1/8] Add api key --- bruno/api/stacks-api-keys/create.bru | 19 ++ bruno/api/stacks-api-keys/delete.bru | 15 ++ bruno/api/stacks-api-keys/folder.bru | 4 + bruno/api/stacks-api-keys/show.bru | 19 ++ server/config/runtime.exs | 1 + server/lib/ethui/accounts.ex | 62 ++++++ server/lib/ethui/accounts/user.ex | 4 + server/lib/ethui/stacks.ex | 7 + server/lib/ethui/stacks/api_key.ex | 36 ++++ server/lib/ethui/stacks/stack.ex | 1 + .../controllers/api/api_key_controller.ex | 76 +++++++ server/lib/ethui_web/plugs/api_key_auth.ex | 54 +++++ server/lib/ethui_web/router.ex | 5 + .../20251218102457_create_api_keys.exs | 16 ++ .../api/api_key_controller_test.exs | 195 ++++++++++++++++++ .../ethui_web/plugs/api_key_auth_test.exs | 98 +++++++++ 16 files changed, 612 insertions(+) create mode 100644 bruno/api/stacks-api-keys/create.bru create mode 100644 bruno/api/stacks-api-keys/delete.bru create mode 100644 bruno/api/stacks-api-keys/folder.bru create mode 100644 bruno/api/stacks-api-keys/show.bru create mode 100644 server/lib/ethui/stacks/api_key.ex create mode 100644 server/lib/ethui_web/controllers/api/api_key_controller.ex create mode 100644 server/lib/ethui_web/plugs/api_key_auth.ex create mode 100644 server/priv/repo/migrations/20251218102457_create_api_keys.exs create mode 100644 server/test/ethui_web/controllers/api/api_key_controller_test.exs create mode 100644 server/test/ethui_web/plugs/api_key_auth_test.exs diff --git a/bruno/api/stacks-api-keys/create.bru b/bruno/api/stacks-api-keys/create.bru new file mode 100644 index 0000000..a0781fe --- /dev/null +++ b/bruno/api/stacks-api-keys/create.bru @@ -0,0 +1,19 @@ +meta { + name: create + type: http + seq: 2 +} + +post { + url: {{API}}/stacks?slug=demo + body: json + auth: bearer +} + +params:query { + slug: demo +} + +auth:bearer { + token: {{TOKEN}} +} diff --git a/bruno/api/stacks-api-keys/delete.bru b/bruno/api/stacks-api-keys/delete.bru new file mode 100644 index 0000000..aff5958 --- /dev/null +++ b/bruno/api/stacks-api-keys/delete.bru @@ -0,0 +1,15 @@ +meta { + name: delete + type: http + seq: 3 +} + +delete { + url: {{API}}/stacks/:slug + body: none + auth: inherit +} + +params:path { + slug: demo +} diff --git a/bruno/api/stacks-api-keys/folder.bru b/bruno/api/stacks-api-keys/folder.bru new file mode 100644 index 0000000..0e99221 --- /dev/null +++ b/bruno/api/stacks-api-keys/folder.bru @@ -0,0 +1,4 @@ +meta { + name: /stacks/api-keys + seq: 3 +} diff --git a/bruno/api/stacks-api-keys/show.bru b/bruno/api/stacks-api-keys/show.bru new file mode 100644 index 0000000..4d8b552 --- /dev/null +++ b/bruno/api/stacks-api-keys/show.bru @@ -0,0 +1,19 @@ +meta { + name: show + type: http + seq: 1 +} + +get { + url: {{API}}/stacks/:stack-id/api-keys + body: none + auth: bearer +} + +params:path { + stack-id: 1 +} + +auth:bearer { + token: {{TOKEN}} +} diff --git a/server/config/runtime.exs b/server/config/runtime.exs index 0854056..d2cce47 100644 --- a/server/config/runtime.exs +++ b/server/config/runtime.exs @@ -28,6 +28,7 @@ end is_saas? = !!System.get_env("ETHUI_STACKS_SAAS") config :ethui, EthuiWeb.Plugs.Authenticate, enabled: is_saas? +config :ethui, EthuiWeb.Plugs.ApiKeyAuth, enabled: is_saas? if config_env() == :prod do data_root = diff --git a/server/lib/ethui/accounts.ex b/server/lib/ethui/accounts.ex index d04c99d..b0d3252 100644 --- a/server/lib/ethui/accounts.ex +++ b/server/lib/ethui/accounts.ex @@ -7,6 +7,9 @@ defmodule Ethui.Accounts do alias Ethui.Repo alias Ethui.Accounts.User alias Ethui.Mailer + alias Ethui.Stacks.Stack + + alias Ethui.Accounts.ApiKey ## Database getters @@ -143,4 +146,63 @@ defmodule Ethui.Accounts do error end end + + def list_api_keys(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug) do + {:ok, Repo.all(Ecto.assoc(stack, :api_key))} + else + nil -> {:error, :not_found} + end + end + + def get_stack_api_key(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug) do + {:ok, stack.api_key} + else + nil -> {:error, :not_found} + end + end + + def create_api_key(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug), + {:ok, api_key} <- get_or_insert_api_key(stack) do + {:ok, api_key} + else + nil -> {:error, :not_found} + end + end + + def delete_api_key(%User{id: user_id}, slug) do + with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug), + %ApiKey{} = api_key <- Repo.get_by(ApiKey, stack_id: stack.id), + {:ok, _} <- Repo.delete(api_key) do + {:ok, :deleted} + else + nil -> {:error, :not_found} + error -> error + end + end + + def get_user_stack_by_slug(user_id, slug) do + Repo.get_by(Stack, slug: slug, user_id: user_id) + |> Repo.preload(:api_key) + end + + def get_or_insert_api_key(%Stack{api_key: nil} = stack) do + %ApiKey{} + |> ApiKey.changeset(%{stack_id: stack.id}) + # |> Ecto.Changeset.put_assoc(:stack, stack) + |> Repo.insert() + end + + def get_or_insert_api_key(%Stack{api_key: api_key}) do + {:ok, api_key} + end + + def get_api_key_by_token(token) do + ApiKey + |> where([k], k.token == ^token) + |> preload([:stack]) + |> Repo.one() + end end diff --git a/server/lib/ethui/accounts/user.ex b/server/lib/ethui/accounts/user.ex index 226b023..beeaa01 100644 --- a/server/lib/ethui/accounts/user.ex +++ b/server/lib/ethui/accounts/user.ex @@ -5,6 +5,7 @@ defmodule Ethui.Accounts.User do use Ecto.Schema import Ecto.Changeset + alias Ethui.Stacks.Stack schema "users" do field(:email, :string) @@ -12,6 +13,9 @@ defmodule Ethui.Accounts.User do field(:verification_code_sent_at, :naive_datetime) field(:verified_at, :naive_datetime) + has_many(:stacks, Stack) + has_many(:api_key, through: [:stacks, :api_key]) + timestamps() end diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index f47d056..d5e1496 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -4,7 +4,9 @@ defmodule Ethui.Stacks do """ alias EthuiWeb.Endpoint alias Ethui.Stacks.Server + alias Ethui.Stacks.Stack + alias Ethui.Repo @components ~w(graph graph-rpc graph-status ipfs) @reserved ~w(rpc) @@ -91,6 +93,11 @@ defmodule Ethui.Stacks do val end + @doc "Gets a stack by ID" + def get_stack(id) do + Repo.get(Stack, id) + end + defp build_url(slug) do "#{http_protocol()}#{slug}.#{host()}" end diff --git a/server/lib/ethui/stacks/api_key.ex b/server/lib/ethui/stacks/api_key.ex new file mode 100644 index 0000000..625847c --- /dev/null +++ b/server/lib/ethui/stacks/api_key.ex @@ -0,0 +1,36 @@ +defmodule Ethui.Accounts.ApiKey do + use Ecto.Schema + import Ecto.Changeset + + schema "api_keys" do + field(:token, :string) + field(:expires_at, :utc_datetime) + + belongs_to(:stack, Ethui.Stacks.Stack) + + timestamps() + end + + def changeset(api_key, attrs) do + api_key + |> cast(attrs, [:stack_id, :expires_at]) + |> validate_required([:stack_id]) + |> put_token() + |> unique_constraint(:token) + |> foreign_key_constraint(:stack_id) + end + + defp put_token(changeset) do + if get_field(changeset, :token) do + changeset + else + token = generate_token() + put_change(changeset, :token, token) + end + end + + defp generate_token do + :crypto.strong_rand_bytes(24) + |> Base.url_encode64(padding: false) + end +end diff --git a/server/lib/ethui/stacks/stack.ex b/server/lib/ethui/stacks/stack.ex index 5d35a00..7f483bb 100644 --- a/server/lib/ethui/stacks/stack.ex +++ b/server/lib/ethui/stacks/stack.ex @@ -21,6 +21,7 @@ defmodule Ethui.Stacks.Stack do field(:anvil_opts, :map, default: %{}) field(:graph_opts, :map, default: %{}) belongs_to(:user, Ethui.Accounts.User) + has_one(:api_key, Ethui.Accounts.ApiKey) timestamps(type: :utc_datetime) end diff --git a/server/lib/ethui_web/controllers/api/api_key_controller.ex b/server/lib/ethui_web/controllers/api/api_key_controller.ex new file mode 100644 index 0000000..ffcf0e4 --- /dev/null +++ b/server/lib/ethui_web/controllers/api/api_key_controller.ex @@ -0,0 +1,76 @@ +defmodule EthuiWeb.ApiKeyController do + use EthuiWeb, :controller + alias Ethui.Accounts + + def create(conn, %{"stack_slug" => slug}) do + user = conn.assigns.current_user + + case Accounts.create_api_key(user, slug) do + {:ok, api_key} -> + conn + |> put_status(:created) + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{status: "error", error: "Unable to create API key"}) + end + end + + def show(conn, %{"stack_slug" => stack_slug}) do + user = conn.assigns.current_user + + case Accounts.get_stack_api_key(user, stack_slug) do + {:ok, api_key} -> + conn + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{status: "error", error: "Unable to create API key"}) + end + end + + def delete(conn, %{"stack_slug" => stack_slug}) do + user = conn.assigns.current_user + + case Accounts.delete_api_key(user, stack_slug) do + {:ok, api_key} -> + conn + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{status: "error", error: "Unable to create API key"}) + end + end + + defp serialize_api_key(api_key) do + %{ + id: api_key.id, + stack_slug: api_key.stack_id, + token: api_key.token, + expires_at: api_key.expires_at, + inserted_at: api_key.inserted_at, + updated_at: api_key.updated_at + } + end +end diff --git a/server/lib/ethui_web/plugs/api_key_auth.ex b/server/lib/ethui_web/plugs/api_key_auth.ex new file mode 100644 index 0000000..8e340a2 --- /dev/null +++ b/server/lib/ethui_web/plugs/api_key_auth.ex @@ -0,0 +1,54 @@ +defmodule EthuiWeb.Plugs.ApiKeyAuth do + @moduledoc """ + Authenticates requests using API keys in the URL path. + + URL format: `/:token/*rest` + Example: `https://graph-my-stack.example.com/wnlT5EkiG_pd93A1N2m7/execute` + + """ + + import Plug.Conn + import Phoenix.Controller + + alias Ethui.Accounts.ApiKey + alias Ethui.Accounts + + @min_token_length 20 + + def init(opts), do: opts + + def call(conn, _opts) do + if enabled?() do + do_call(conn) + else + conn + end + end + + defp do_call(conn) do + conn.path_info + + with [token | _] when byte_size(token) >= @min_token_length <- conn.path_info, + %ApiKey{} = api_key <- Accounts.get_api_key_by_token(token), + true <- stack_matches?(conn, api_key) do + conn |> Map.update!(:path_info, &tl/1) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid API key"}) + |> halt() + end + end + + defp stack_matches?(conn, api_key) do + case conn.assigns[:proxy][:slug] do + nil -> false + slug -> api_key.stack.slug == slug + end + end + + def enabled? do + Application.get_env(:ethui, __MODULE__)[:enabled] || false + end +end diff --git a/server/lib/ethui_web/router.ex b/server/lib/ethui_web/router.ex index 24b3556..cbb1bcb 100644 --- a/server/lib/ethui_web/router.ex +++ b/server/lib/ethui_web/router.ex @@ -35,6 +35,7 @@ defmodule EthuiWeb.Router do pipeline :proxy do plug EthuiWeb.Plugs.StackSubdomain + plug EthuiWeb.Plugs.ApiKeyAuth end scope "/", EthuiWeb, host: "api." do @@ -51,6 +52,10 @@ defmodule EthuiWeb.Router do resources "/stacks", Api.StackController, param: "slug" do # get "/logs", StackController, :logs + # + post "/api-keys", ApiKeyController, :create + get "/api-keys", ApiKeyController, :show + delete "/api-keys", ApiKeyController, :delete end end diff --git a/server/priv/repo/migrations/20251218102457_create_api_keys.exs b/server/priv/repo/migrations/20251218102457_create_api_keys.exs new file mode 100644 index 0000000..2a6d619 --- /dev/null +++ b/server/priv/repo/migrations/20251218102457_create_api_keys.exs @@ -0,0 +1,16 @@ +defmodule Ethui.Repo.Migrations.CreateApiKeys do + use Ecto.Migration + +def change do + create table(:api_keys) do + add :stack_id, references(:stacks, on_delete: :delete_all), null: false + add :token, :string, null: false + add :expires_at, :utc_datetime + + timestamps() + end + + create unique_index(:api_keys, [:token]) + create index(:api_keys, [:stack_id]) + end +end diff --git a/server/test/ethui_web/controllers/api/api_key_controller_test.exs b/server/test/ethui_web/controllers/api/api_key_controller_test.exs new file mode 100644 index 0000000..a40a9d8 --- /dev/null +++ b/server/test/ethui_web/controllers/api/api_key_controller_test.exs @@ -0,0 +1,195 @@ +defmodule EthuiWeb.Api.ApiKeyControllerTest do + use EthuiWeb.ConnCase, async: true + + alias Ethui.Repo + alias Ethui.Stacks.Stack + alias Ethui.Accounts.User + + setup do + Ecto.Adapters.SQL.Sandbox.checkout(Repo, sandbox: false) + cleanup() + + # Ensure auth is enabled for all tests + original_config = Application.get_env(:ethui, EthuiWeb.Plugs.Authenticate, []) + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, enabled: true) + + on_exit(fn -> + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, original_config) + end) + + :ok + end + + defp cleanup do + Repo.delete_all(Stack) + Repo.delete_all(User) + end + + defp create_authenticated_conn(email \\ nil) do + email = email || "test-#{System.unique_integer([:positive])}@example.com" + {:ok, user} = Ethui.Accounts.send_verification_code(email) + {:ok, token} = Ethui.Accounts.generate_token(user) + + Phoenix.ConnTest.build_conn(:post, "http://api.lvh.me", nil) + |> Plug.Conn.put_req_header("authorization", "Bearer #{token}") + end + + defp create_stack_conn(slug \\ "slug") do + create_authenticated_conn() + |> post(~p"/stacks", %{slug: slug}) + end + + describe "create/2" do + test "creates api key for stack" do + conn = + create_stack_conn() + |> post(~p"/stacks/slug/api-keys", %{}) + + assert response(conn, 201) + end + + test "return already existing key if its already created" do + conn = + create_stack_conn() + |> post(~p"/stacks/slug/api-keys", %{}) + + res = json_response(conn, 201) + + conn + |> post(~p"/stacks/slug/api-keys", %{}) + + res2 = json_response(conn, 201) + + assert res["data"]["token"] == res2["data"]["token"] + end + + test "return 404 if stack is from other user" do + slug = "demo" + create_stack_conn(slug) + + conn = + create_authenticated_conn("other@email.com") + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + assert response(conn, 404) + end + + test "return 404 if stack doesn't exist" do + conn = + create_authenticated_conn("other@email.com") + |> post(~p"/stacks/WRONG/api-keys", %{}) + + assert response(conn, 404) + end + end + + describe "show/2" do + test "show api key for stack" do + slug = "slug" + + conn = + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + res = json_response(conn, 201) + token = res["data"]["token"] + + conn = + conn + |> get(~p"/stacks/#{slug}/api-keys") + + res = json_response(conn, 200) + assert res["data"]["token"] == token + end + + test "return 404 if api key doesn't exist" do + slug = "slug" + + create_stack_conn(slug) + + conn = + create_authenticated_conn() + |> get(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack is from other user" do + slug = "slug" + + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + conn = + create_authenticated_conn("other@email.com") + |> get(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack doesn't exist" do + conn = + create_authenticated_conn() + |> get(~p"/stacks/WRONG/api-keys") + + assert response(conn, 404) + end + end + + describe "delete/2" do + test "delete api key for stack" do + slug = "slug" + + conn = + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + assert response(conn, 201) + + conn = + create_authenticated_conn() + |> delete(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + + conn = + create_authenticated_conn() + |> get(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if api key doesn't exist" do + slug = "slug" + + create_stack_conn(slug) + + conn = + create_authenticated_conn() + |> delete(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack is from other user" do + slug = "slug" + + create_stack_conn(slug) + |> post(~p"/stacks/#{slug}/api-keys", %{}) + + conn = + create_authenticated_conn("other@email.com") + |> delete(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "return 404 if stack doesn't exist" do + conn = + create_authenticated_conn() + |> delete(~p"/stacks/WRONG/api-keys") + + assert response(conn, 404) + end + end +end diff --git a/server/test/ethui_web/plugs/api_key_auth_test.exs b/server/test/ethui_web/plugs/api_key_auth_test.exs new file mode 100644 index 0000000..168aa01 --- /dev/null +++ b/server/test/ethui_web/plugs/api_key_auth_test.exs @@ -0,0 +1,98 @@ +defmodule EthuiWeb.Plugs.ApiKeyAuthTest do + use EthuiWeb.ConnCase, async: false + + import Plug.Test + + alias EthuiWeb.Plugs.{ApiKeyAuth, Authenticate, StackSubdomain} + alias Ethui.Repo + alias Ethui.Accounts + alias Ethui.Stacks.Stack + + describe "Api key auth plug when enabled" do + setup do + # Ensure auth is enabled for these tests + original_config_auth = Application.get_env(:ethui, Authenticate, []) + original_config_api_key = Application.get_env(:ethui, ApiKeyAuth, []) + Application.put_env(:ethui, Authenticate, enabled: true) + Application.put_env(:ethui, ApiKeyAuth, enabled: true) + + on_exit(fn -> + Application.put_env(:ethui, Authenticate, original_config_auth) + Application.put_env(:ethui, ApiKeyAuth, original_config_api_key) + end) + + # Create a user and get a valid token + email = "auth-plug-test@example.com" + {:ok, _user} = Accounts.send_verification_code(email) + user = Accounts.get_user_by_email(email) + + slug = "example" + {:ok, token} = Accounts.verify_code_and_generate_token(email, user.verification_code) + {:ok, stack} = Stack.create_changeset(%{user_id: user.id, slug: slug}) |> Repo.insert() + {:ok, api_key} = Accounts.create_api_key(user, stack.slug) + + %{user: user, token: token, email: email, slug: slug, api_key: api_key.token} + end + + test "allows request with valid api token", %{ + token: token, + slug: slug, + api_key: api_key + } do + conn = + conn(:get, "/#{api_key}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> put_req_header("authorization", "Bearer #{token}") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + + assert conn.assigns[:proxy].slug == slug + assert conn.path_info == ["execute"] + refute conn.halted + end + end + + describe "Api key auth plug when disabled " do + setup do + # Ensure auth is enabled for these tests + original_config_auth = Application.get_env(:ethui, Authenticate, []) + original_config_api_key = Application.get_env(:ethui, ApiKeyAuth, []) + Application.put_env(:ethui, Authenticate, enabled: false) + Application.put_env(:ethui, ApiKeyAuth, enabled: false) + + on_exit(fn -> + Application.put_env(:ethui, Authenticate, original_config_auth) + Application.put_env(:ethui, ApiKeyAuth, original_config_api_key) + end) + + # Create a user and get a valid token + email = "auth-plug-test@example.com" + {:ok, _user} = Accounts.send_verification_code(email) + user = Accounts.get_user_by_email(email) + + slug = "example" + {:ok, token} = Accounts.verify_code_and_generate_token(email, user.verification_code) + {:ok, stack} = Stack.create_changeset(%{user_id: user.id, slug: slug}) |> Repo.insert() + {:ok, api_key} = Accounts.create_api_key(user, stack.slug) + + %{user: user, token: token, email: email, slug: slug, api_key: api_key.token} + end + + test "allows request with valid api token", %{ + token: token, + slug: slug, + api_key: api_key + } do + conn = + conn(:get, "/#{api_key}/execute") + |> Map.put(:host, "#{slug}.lvh.me") + |> put_req_header("authorization", "Bearer #{token}") + |> StackSubdomain.call(StackSubdomain.init([])) + |> ApiKeyAuth.call(ApiKeyAuth.init([])) + + assert conn.assigns[:proxy].slug == slug + assert conn.path_info == [api_key, "execute"] + refute conn.halted + end + end +end From 31e9d9a951eb7aa5ad259b0da74eb735230a43f0 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 01:42:14 +0000 Subject: [PATCH 2/8] refactor to slim controller --- server/lib/ethui/accounts.ex | 23 ++-- server/lib/ethui/stacks.ex | 59 +++++++++ .../controllers/api/stack_controller.ex | 25 +--- server/lib/ethui_web/router.ex | 2 - .../api/api_key_controller_test.exs | 113 +----------------- .../controllers/api/stack_controller_test.exs | 4 +- .../ethui_web/plugs/api_key_auth_test.exs | 1 - 7 files changed, 75 insertions(+), 152 deletions(-) diff --git a/server/lib/ethui/accounts.ex b/server/lib/ethui/accounts.ex index b0d3252..184c9d7 100644 --- a/server/lib/ethui/accounts.ex +++ b/server/lib/ethui/accounts.ex @@ -8,6 +8,7 @@ defmodule Ethui.Accounts do alias Ethui.Accounts.User alias Ethui.Mailer alias Ethui.Stacks.Stack + alias Ethui.Stacks alias Ethui.Accounts.ApiKey @@ -147,16 +148,8 @@ defmodule Ethui.Accounts do end end - def list_api_keys(%User{id: user_id}, slug) do - with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug) do - {:ok, Repo.all(Ecto.assoc(stack, :api_key))} - else - nil -> {:error, :not_found} - end - end - def get_stack_api_key(%User{id: user_id}, slug) do - with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug) do + with %Stack{} = stack <- Stacks.get_user_stack_by_slug(user_id, slug) do {:ok, stack.api_key} else nil -> {:error, :not_found} @@ -164,7 +157,7 @@ defmodule Ethui.Accounts do end def create_api_key(%User{id: user_id}, slug) do - with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug), + with %Stack{} = stack <- Stacks.get_user_stack_by_slug(user_id, slug), {:ok, api_key} <- get_or_insert_api_key(stack) do {:ok, api_key} else @@ -173,7 +166,7 @@ defmodule Ethui.Accounts do end def delete_api_key(%User{id: user_id}, slug) do - with %Stack{} = stack <- get_user_stack_by_slug(user_id, slug), + with %Stack{} = stack <- Stacks.get_user_stack_by_slug(user_id, slug), %ApiKey{} = api_key <- Repo.get_by(ApiKey, stack_id: stack.id), {:ok, _} <- Repo.delete(api_key) do {:ok, :deleted} @@ -183,15 +176,15 @@ defmodule Ethui.Accounts do end end - def get_user_stack_by_slug(user_id, slug) do - Repo.get_by(Stack, slug: slug, user_id: user_id) - |> Repo.preload(:api_key) + def create_api_key(%Stack{id: id}) do + %ApiKey{} + |> ApiKey.changeset(%{stack_id: id}) + |> Repo.insert() end def get_or_insert_api_key(%Stack{api_key: nil} = stack) do %ApiKey{} |> ApiKey.changeset(%{stack_id: stack.id}) - # |> Ecto.Changeset.put_assoc(:stack, stack) |> Repo.insert() end diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index d5e1496..bc0bfa0 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -5,8 +5,12 @@ defmodule Ethui.Stacks do alias EthuiWeb.Endpoint alias Ethui.Stacks.Server alias Ethui.Stacks.Stack + alias Ethui.Accounts + alias Ethui.Accounts.User alias Ethui.Repo + import Ecto.Query, only: [from: 2] + @components ~w(graph graph-rpc graph-status ipfs) @reserved ~w(rpc) @@ -98,6 +102,61 @@ defmodule Ethui.Stacks do Repo.get(Stack, id) end + def get_stack_by_slug(slug) do + Repo.get_by(Stack, slug: slug) + end + + def list_stacks(user) do + if user do + Repo.all(from(s in Stack, where: s.user_id == ^user.id)) + else + Repo.all(Stack) + end + end + + def create_stack(nil, params) do + Stack.create_changeset(params) + |> Repo.insert() + end + + def create_stack(user, params) do + params = Map.put(params, "user_id", user.id) + + transaction = + Ecto.Multi.new() + |> Ecto.Multi.insert( + :stack, + Stack.create_changeset(params) + ) + |> Ecto.Multi.run(:api_key, fn _repo, %{stack: stack} -> + Accounts.create_api_key(stack) + end) + |> Repo.transaction() + + case transaction do + {:ok, %{stack: stack}} -> {:ok, stack} + {:error, _, changeset, _} -> {:error, changeset} + end + end + + def get_user_stack_by_slug(nil, slug) do + Repo.get_by(Stack, slug: slug) + |> Repo.preload(:api_key) + end + + def get_user_stack_by_slug(%User{id: user_id}, slug) do + get_user_stack_by_slug(user_id, slug) + end + + def get_user_stack_by_slug(user_id, slug) do + Repo.get_by(Stack, slug: slug, user_id: user_id) + |> Repo.preload(:api_key) + end + + def delete_stack(stack) do + Repo.delete(stack) + end + defp build_url(slug) do "#{http_protocol()}#{slug}.#{host()}" end diff --git a/server/lib/ethui_web/controllers/api/stack_controller.ex b/server/lib/ethui_web/controllers/api/stack_controller.ex index 7a4bf35..2aa8342 100644 --- a/server/lib/ethui_web/controllers/api/stack_controller.ex +++ b/server/lib/ethui_web/controllers/api/stack_controller.ex @@ -4,17 +4,11 @@ defmodule EthuiWeb.Api.StackController do alias Ethui.Stacks.{Server, Stack} alias Ethui.Stacks alias Ethui.Repo - import Ecto.Query, only: [from: 2] def index(conn, _params) do user = conn.assigns[:current_user] - stacks = - if user do - Repo.all(from(s in Stack, where: s.user_id == ^user.id)) - else - Repo.all(Stack) - end + stacks = Stacks.list_stacks(user) stack_data = Enum.map(stacks, fn stack -> @@ -30,7 +24,7 @@ defmodule EthuiWeb.Api.StackController do def show(conn, %{"slug" => slug}) do user = conn.assigns[:current_user] - with %Stack{} = stack <- Repo.get_by(Stack, slug: slug), + with %Stack{} = stack <- Stacks.get_stack_by_slug(slug), :ok <- authorize_user_access(user, stack) do json(conn, %{ status: "success", @@ -48,16 +42,7 @@ defmodule EthuiWeb.Api.StackController do def create(conn, params) do user = conn.assigns[:current_user] - # Add user_id to params if user is authenticated - stack_params = - if user do - Map.put(params, "user_id", user.id) - else - params - end - - with changeset <- Stack.create_changeset(stack_params), - {:ok, stack} <- Repo.insert(changeset), + with {:ok, stack} <- Stacks.create_stack(user, params), _ <- Server.start(stack) do conn |> put_status(201) @@ -91,10 +76,10 @@ defmodule EthuiWeb.Api.StackController do def delete(conn, %{"slug" => slug}) do user = conn.assigns[:current_user] - with %Stack{} = stack <- Repo.get_by(Stack, slug: slug), + with %Stack{} = stack <- Stacks.get_stack_by_slug(slug), :ok <- authorize_user_access(user, stack), _ <- Server.stop(stack), - _ <- Repo.delete(stack) do + _ <- Stacks.delete_stack(stack) do conn |> send_resp(204, "") else nil -> diff --git a/server/lib/ethui_web/router.ex b/server/lib/ethui_web/router.ex index cbb1bcb..c86b092 100644 --- a/server/lib/ethui_web/router.ex +++ b/server/lib/ethui_web/router.ex @@ -53,9 +53,7 @@ defmodule EthuiWeb.Router do resources "/stacks", Api.StackController, param: "slug" do # get "/logs", StackController, :logs # - post "/api-keys", ApiKeyController, :create get "/api-keys", ApiKeyController, :show - delete "/api-keys", ApiKeyController, :delete end end diff --git a/server/test/ethui_web/controllers/api/api_key_controller_test.exs b/server/test/ethui_web/controllers/api/api_key_controller_test.exs index a40a9d8..477f5f1 100644 --- a/server/test/ethui_web/controllers/api/api_key_controller_test.exs +++ b/server/test/ethui_web/controllers/api/api_key_controller_test.exs @@ -1,14 +1,11 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do - use EthuiWeb.ConnCase, async: true + use EthuiWeb.ConnCase, async: false alias Ethui.Repo alias Ethui.Stacks.Stack alias Ethui.Accounts.User setup do - Ecto.Adapters.SQL.Sandbox.checkout(Repo, sandbox: false) - cleanup() - # Ensure auth is enabled for all tests original_config = Application.get_env(:ethui, EthuiWeb.Plugs.Authenticate, []) Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, enabled: true) @@ -20,11 +17,6 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do :ok end - defp cleanup do - Repo.delete_all(Stack) - Repo.delete_all(User) - end - defp create_authenticated_conn(email \\ nil) do email = email || "test-#{System.unique_integer([:positive])}@example.com" {:ok, user} = Ethui.Accounts.send_verification_code(email) @@ -39,57 +31,12 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do |> post(~p"/stacks", %{slug: slug}) end - describe "create/2" do - test "creates api key for stack" do - conn = - create_stack_conn() - |> post(~p"/stacks/slug/api-keys", %{}) - - assert response(conn, 201) - end - - test "return already existing key if its already created" do - conn = - create_stack_conn() - |> post(~p"/stacks/slug/api-keys", %{}) - - res = json_response(conn, 201) - - conn - |> post(~p"/stacks/slug/api-keys", %{}) - - res2 = json_response(conn, 201) - - assert res["data"]["token"] == res2["data"]["token"] - end - - test "return 404 if stack is from other user" do - slug = "demo" - create_stack_conn(slug) - - conn = - create_authenticated_conn("other@email.com") - |> post(~p"/stacks/#{slug}/api-keys", %{}) - - assert response(conn, 404) - end - - test "return 404 if stack doesn't exist" do - conn = - create_authenticated_conn("other@email.com") - |> post(~p"/stacks/WRONG/api-keys", %{}) - - assert response(conn, 404) - end - end - describe "show/2" do test "show api key for stack" do slug = "slug" conn = create_stack_conn(slug) - |> post(~p"/stacks/#{slug}/api-keys", %{}) res = json_response(conn, 201) token = res["data"]["token"] @@ -118,7 +65,6 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do slug = "slug" create_stack_conn(slug) - |> post(~p"/stacks/#{slug}/api-keys", %{}) conn = create_authenticated_conn("other@email.com") @@ -135,61 +81,4 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do assert response(conn, 404) end end - - describe "delete/2" do - test "delete api key for stack" do - slug = "slug" - - conn = - create_stack_conn(slug) - |> post(~p"/stacks/#{slug}/api-keys", %{}) - - assert response(conn, 201) - - conn = - create_authenticated_conn() - |> delete(~p"/stacks/#{slug}/api-keys") - - assert response(conn, 404) - - conn = - create_authenticated_conn() - |> get(~p"/stacks/#{slug}/api-keys") - - assert response(conn, 404) - end - - test "return 404 if api key doesn't exist" do - slug = "slug" - - create_stack_conn(slug) - - conn = - create_authenticated_conn() - |> delete(~p"/stacks/#{slug}/api-keys") - - assert response(conn, 404) - end - - test "return 404 if stack is from other user" do - slug = "slug" - - create_stack_conn(slug) - |> post(~p"/stacks/#{slug}/api-keys", %{}) - - conn = - create_authenticated_conn("other@email.com") - |> delete(~p"/stacks/#{slug}/api-keys") - - assert response(conn, 404) - end - - test "return 404 if stack doesn't exist" do - conn = - create_authenticated_conn() - |> delete(~p"/stacks/WRONG/api-keys") - - assert response(conn, 404) - end - end end diff --git a/server/test/ethui_web/controllers/api/stack_controller_test.exs b/server/test/ethui_web/controllers/api/stack_controller_test.exs index eed32b7..ff55f09 100644 --- a/server/test/ethui_web/controllers/api/stack_controller_test.exs +++ b/server/test/ethui_web/controllers/api/stack_controller_test.exs @@ -6,7 +6,7 @@ defmodule EthuiWeb.Api.StackControllerTest do alias Ethui.Accounts.User setup do - Ecto.Adapters.SQL.Sandbox.checkout(Repo, sandbox: false) + Ecto.Adapters.SQL.Sandbox.checkout(Repo) cleanup() # Ensure auth is enabled for all tests @@ -164,7 +164,6 @@ defmodule EthuiWeb.Api.StackControllerTest do response = json_response(conn, 200) stacks = response["data"] - assert conn.status == 200 assert response["status"] == "success" assert length(stacks) == 2 @@ -221,6 +220,7 @@ defmodule EthuiWeb.Api.StackControllerTest do assert is_binary(stack["explorer_url"]) assert is_binary(stack["ipfs_url"]) assert is_binary(stack["rpc_url"]) + assert %{ "fork_url" => "https://eth.llamarpc.com", "fork_block_number" => 24_026_490 diff --git a/server/test/ethui_web/plugs/api_key_auth_test.exs b/server/test/ethui_web/plugs/api_key_auth_test.exs index 168aa01..e4c09a9 100644 --- a/server/test/ethui_web/plugs/api_key_auth_test.exs +++ b/server/test/ethui_web/plugs/api_key_auth_test.exs @@ -21,7 +21,6 @@ defmodule EthuiWeb.Plugs.ApiKeyAuthTest do Application.put_env(:ethui, ApiKeyAuth, original_config_api_key) end) - # Create a user and get a valid token email = "auth-plug-test@example.com" {:ok, _user} = Accounts.send_verification_code(email) user = Accounts.get_user_by_email(email) From 8374899a20948dd0fe1d58d1e8a83a375c03b2f6 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 01:48:32 +0000 Subject: [PATCH 3/8] fix failling test --- .../controllers/api/api_key_controller.ex | 43 +------------------ .../api/api_key_controller_test.exs | 5 +-- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/server/lib/ethui_web/controllers/api/api_key_controller.ex b/server/lib/ethui_web/controllers/api/api_key_controller.ex index ffcf0e4..f603daf 100644 --- a/server/lib/ethui_web/controllers/api/api_key_controller.ex +++ b/server/lib/ethui_web/controllers/api/api_key_controller.ex @@ -2,27 +2,6 @@ defmodule EthuiWeb.ApiKeyController do use EthuiWeb, :controller alias Ethui.Accounts - def create(conn, %{"stack_slug" => slug}) do - user = conn.assigns.current_user - - case Accounts.create_api_key(user, slug) do - {:ok, api_key} -> - conn - |> put_status(:created) - |> json(%{data: serialize_api_key(api_key)}) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{status: "error", error: "Stack not found"}) - - {:error, _reason} -> - conn - |> put_status(:internal_server_error) - |> json(%{status: "error", error: "Unable to create API key"}) - end - end - def show(conn, %{"stack_slug" => stack_slug}) do user = conn.assigns.current_user @@ -36,27 +15,7 @@ defmodule EthuiWeb.ApiKeyController do |> put_status(:not_found) |> json(%{status: "error", error: "Stack not found"}) - {:error, _reason} -> - conn - |> put_status(:internal_server_error) - |> json(%{status: "error", error: "Unable to create API key"}) - end - end - - def delete(conn, %{"stack_slug" => stack_slug}) do - user = conn.assigns.current_user - - case Accounts.delete_api_key(user, stack_slug) do - {:ok, api_key} -> - conn - |> json(%{data: serialize_api_key(api_key)}) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{status: "error", error: "Stack not found"}) - - {:error, _reason} -> + _ -> conn |> put_status(:internal_server_error) |> json(%{status: "error", error: "Unable to create API key"}) diff --git a/server/test/ethui_web/controllers/api/api_key_controller_test.exs b/server/test/ethui_web/controllers/api/api_key_controller_test.exs index 477f5f1..631d828 100644 --- a/server/test/ethui_web/controllers/api/api_key_controller_test.exs +++ b/server/test/ethui_web/controllers/api/api_key_controller_test.exs @@ -38,15 +38,12 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do conn = create_stack_conn(slug) - res = json_response(conn, 201) - token = res["data"]["token"] - conn = conn |> get(~p"/stacks/#{slug}/api-keys") res = json_response(conn, 200) - assert res["data"]["token"] == token + assert res["data"]["token"] != nil end test "return 404 if api key doesn't exist" do From 8f0a06136b2e375b1a4dfdfbd0eef1ccb652cf11 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 01:54:06 +0000 Subject: [PATCH 4/8] fix credo errors --- server/lib/ethui/accounts.ex | 10 ++++++---- server/lib/ethui/stacks/api_key.ex | 3 +++ .../lib/ethui_web/controllers/api/stack_controller.ex | 1 - .../controllers/api/api_key_controller_test.exs | 6 +----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/lib/ethui/accounts.ex b/server/lib/ethui/accounts.ex index 184c9d7..67ea277 100644 --- a/server/lib/ethui/accounts.ex +++ b/server/lib/ethui/accounts.ex @@ -149,10 +149,12 @@ defmodule Ethui.Accounts do end def get_stack_api_key(%User{id: user_id}, slug) do - with %Stack{} = stack <- Stacks.get_user_stack_by_slug(user_id, slug) do - {:ok, stack.api_key} - else - nil -> {:error, :not_found} + case Stacks.get_user_stack_by_slug(user_id, slug) do + %Stack{} = stack -> + {:ok, stack.api_key} + + nil -> + {:error, :not_found} end end diff --git a/server/lib/ethui/stacks/api_key.ex b/server/lib/ethui/stacks/api_key.ex index 625847c..b70fa42 100644 --- a/server/lib/ethui/stacks/api_key.ex +++ b/server/lib/ethui/stacks/api_key.ex @@ -1,4 +1,7 @@ defmodule Ethui.Accounts.ApiKey do + @moduledoc """ + Key used to access a certain stack + """ use Ecto.Schema import Ecto.Changeset diff --git a/server/lib/ethui_web/controllers/api/stack_controller.ex b/server/lib/ethui_web/controllers/api/stack_controller.ex index 2aa8342..701b391 100644 --- a/server/lib/ethui_web/controllers/api/stack_controller.ex +++ b/server/lib/ethui_web/controllers/api/stack_controller.ex @@ -3,7 +3,6 @@ defmodule EthuiWeb.Api.StackController do alias Ethui.Stacks.{Server, Stack} alias Ethui.Stacks - alias Ethui.Repo def index(conn, _params) do user = conn.assigns[:current_user] diff --git a/server/test/ethui_web/controllers/api/api_key_controller_test.exs b/server/test/ethui_web/controllers/api/api_key_controller_test.exs index 631d828..07f798c 100644 --- a/server/test/ethui_web/controllers/api/api_key_controller_test.exs +++ b/server/test/ethui_web/controllers/api/api_key_controller_test.exs @@ -1,10 +1,6 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do use EthuiWeb.ConnCase, async: false - alias Ethui.Repo - alias Ethui.Stacks.Stack - alias Ethui.Accounts.User - setup do # Ensure auth is enabled for all tests original_config = Application.get_env(:ethui, EthuiWeb.Plugs.Authenticate, []) @@ -26,7 +22,7 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do |> Plug.Conn.put_req_header("authorization", "Bearer #{token}") end - defp create_stack_conn(slug \\ "slug") do + defp create_stack_conn(slug) do create_authenticated_conn() |> post(~p"/stacks", %{slug: slug}) end From 49695b60348cbed5c1d506ba8ad2b0a56916ab75 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 13:48:57 +0000 Subject: [PATCH 5/8] add update to rotate api key --- server/lib/ethui/accounts.ex | 38 +++++++----- server/lib/ethui/stacks.ex | 22 ++++--- .../controllers/api/api_key_controller.ex | 16 ++++- server/lib/ethui_web/router.ex | 1 + .../api/api_key_controller_test.exs | 59 +++++++++++++++++++ 5 files changed, 106 insertions(+), 30 deletions(-) diff --git a/server/lib/ethui/accounts.ex b/server/lib/ethui/accounts.ex index 67ea277..8c5d6c6 100644 --- a/server/lib/ethui/accounts.ex +++ b/server/lib/ethui/accounts.ex @@ -160,13 +160,19 @@ defmodule Ethui.Accounts do def create_api_key(%User{id: user_id}, slug) do with %Stack{} = stack <- Stacks.get_user_stack_by_slug(user_id, slug), - {:ok, api_key} <- get_or_insert_api_key(stack) do + {:ok, api_key} <- create_api_key(stack) do {:ok, api_key} else nil -> {:error, :not_found} end end + def create_api_key(%Stack{id: id}) do + %ApiKey{} + |> ApiKey.changeset(%{stack_id: id}) + |> Repo.insert() + end + def delete_api_key(%User{id: user_id}, slug) do with %Stack{} = stack <- Stacks.get_user_stack_by_slug(user_id, slug), %ApiKey{} = api_key <- Repo.get_by(ApiKey, stack_id: stack.id), @@ -178,20 +184,22 @@ defmodule Ethui.Accounts do end end - def create_api_key(%Stack{id: id}) do - %ApiKey{} - |> ApiKey.changeset(%{stack_id: id}) - |> Repo.insert() - end - - def get_or_insert_api_key(%Stack{api_key: nil} = stack) do - %ApiKey{} - |> ApiKey.changeset(%{stack_id: stack.id}) - |> Repo.insert() - end - - def get_or_insert_api_key(%Stack{api_key: api_key}) do - {:ok, api_key} + def rotate_api_key(user, slug) do + Ecto.Multi.new() + |> Ecto.Multi.run(:delete_api_key, fn _repo, _changes -> + delete_api_key(user, slug) + end) + |> Ecto.Multi.run(:api_key, fn _repo, _changes -> + create_api_key(user, slug) + end) + |> Repo.transaction() + |> case do + {:ok, %{api_key: api_key}} -> + {:ok, api_key} + + {:error, _step, reason, _changes} -> + {:error, reason} + end end def get_api_key_by_token(token) do diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index bc0bfa0..45e9a8e 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -122,18 +122,16 @@ defmodule Ethui.Stacks do def create_stack(user, params) do params = Map.put(params, "user_id", user.id) - transaction = - Ecto.Multi.new() - |> Ecto.Multi.insert( - :stack, - Stack.create_changeset(params) - ) - |> Ecto.Multi.run(:api_key, fn _repo, %{stack: stack} -> - Accounts.create_api_key(stack) - end) - |> Repo.transaction() - - case transaction do + Ecto.Multi.new() + |> Ecto.Multi.insert( + :stack, + Stack.create_changeset(params) + ) + |> Ecto.Multi.run(:api_key, fn _repo, %{stack: stack} -> + Accounts.create_api_key(stack) + end) + |> Repo.transaction() + |> case do {:ok, %{stack: stack}} -> {:ok, stack} {:error, _, changeset, _} -> {:error, changeset} end diff --git a/server/lib/ethui_web/controllers/api/api_key_controller.ex b/server/lib/ethui_web/controllers/api/api_key_controller.ex index f603daf..3c00055 100644 --- a/server/lib/ethui_web/controllers/api/api_key_controller.ex +++ b/server/lib/ethui_web/controllers/api/api_key_controller.ex @@ -14,11 +14,21 @@ defmodule EthuiWeb.ApiKeyController do conn |> put_status(:not_found) |> json(%{status: "error", error: "Stack not found"}) + end + end + + def update(conn, %{"stack_slug" => stack_slug}) do + user = conn.assigns.current_user - _ -> + case Accounts.rotate_api_key(user, stack_slug) do + {:ok, api_key} -> conn - |> put_status(:internal_server_error) - |> json(%{status: "error", error: "Unable to create API key"}) + |> json(%{data: serialize_api_key(api_key)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{status: "error", error: "Stack not found"}) end end diff --git a/server/lib/ethui_web/router.ex b/server/lib/ethui_web/router.ex index c86b092..21759bd 100644 --- a/server/lib/ethui_web/router.ex +++ b/server/lib/ethui_web/router.ex @@ -54,6 +54,7 @@ defmodule EthuiWeb.Router do # get "/logs", StackController, :logs # get "/api-keys", ApiKeyController, :show + patch "/api-keys", ApiKeyController, :update end end diff --git a/server/test/ethui_web/controllers/api/api_key_controller_test.exs b/server/test/ethui_web/controllers/api/api_key_controller_test.exs index 07f798c..0a00444 100644 --- a/server/test/ethui_web/controllers/api/api_key_controller_test.exs +++ b/server/test/ethui_web/controllers/api/api_key_controller_test.exs @@ -74,4 +74,63 @@ defmodule EthuiWeb.Api.ApiKeyControllerTest do assert response(conn, 404) end end + + describe "update/2" do + test "rotates api key for stack" do + slug = "slug" + + conn = + create_stack_conn(slug) + + # fetch old token + old_token = + conn + |> get(~p"/stacks/#{slug}/api-keys") + |> json_response(200) + |> get_in(["data", "token"]) + + # rotate key + conn = + conn + |> patch(~p"/stacks/#{slug}/api-keys") + + res = json_response(conn, 200) + new_token = res["data"]["token"] + + assert new_token != nil + assert new_token != old_token + end + + test "returns 404 if stack does not exist" do + conn = + create_authenticated_conn() + |> patch(~p"/stacks/WRONG/api-keys") + + assert response(conn, 404) + end + + test "returns 404 if stack belongs to another user" do + slug = "slug" + + create_stack_conn(slug) + + conn = + create_authenticated_conn("other@email.com") + |> patch(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + + test "returns 404 if api key does not exist" do + slug = "slug" + + create_stack_conn(slug) + + conn = + create_authenticated_conn() + |> patch(~p"/stacks/#{slug}/api-keys") + + assert response(conn, 404) + end + end end From fe9a72dd340d2da137ade8698e20640c96353057 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 16:41:41 +0000 Subject: [PATCH 6/8] move token generator to base58 and display api key if exists on info --- bruno/api/stacks-api-keys/create.bru | 19 -------- .../{delete.bru => rotate.bru} | 4 +- server/lib/ethui/stacks.ex | 48 ++++++++++++------- server/lib/ethui/stacks/api_key.ex | 4 +- server/mix.exs | 5 +- server/mix.lock | 1 + 6 files changed, 41 insertions(+), 40 deletions(-) delete mode 100644 bruno/api/stacks-api-keys/create.bru rename bruno/api/stacks-api-keys/{delete.bru => rotate.bru} (83%) diff --git a/bruno/api/stacks-api-keys/create.bru b/bruno/api/stacks-api-keys/create.bru deleted file mode 100644 index a0781fe..0000000 --- a/bruno/api/stacks-api-keys/create.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: create - type: http - seq: 2 -} - -post { - url: {{API}}/stacks?slug=demo - body: json - auth: bearer -} - -params:query { - slug: demo -} - -auth:bearer { - token: {{TOKEN}} -} diff --git a/bruno/api/stacks-api-keys/delete.bru b/bruno/api/stacks-api-keys/rotate.bru similarity index 83% rename from bruno/api/stacks-api-keys/delete.bru rename to bruno/api/stacks-api-keys/rotate.bru index aff5958..9cb14f1 100644 --- a/bruno/api/stacks-api-keys/delete.bru +++ b/bruno/api/stacks-api-keys/rotate.bru @@ -1,10 +1,10 @@ meta { - name: delete + name: rotate type: http seq: 3 } -delete { +patch { url: {{API}}/stacks/:slug body: none auth: inherit diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index 45e9a8e..bcef1f9 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -67,28 +67,28 @@ defmodule Ethui.Stacks do def get_urls(stack) do base_urls = %{ - rpc_url: rpc_url(stack.slug), - ipfs_url: ipfs_url(stack.slug), - explorer_url: explorer_url(stack.slug) + rpc_url: rpc_url(stack.slug, stack.api_key), + ipfs_url: ipfs_url(stack.slug, stack.api_key), + explorer_url: explorer_url(stack.slug, stack.api_key) } if graph_enabled?(stack) do Map.merge(base_urls, %{ - graph_url: graph_url(stack.slug), - graph_rpc_url: graph_rpc_url(stack.slug), - graph_status: graph_status(stack.slug) + graph_url: graph_url(stack.slug, stack.api_key), + graph_rpc_url: graph_rpc_url(stack.slug, stack.api_key), + graph_status: graph_status(stack.slug, stack.api_key) }) else base_urls end end - def rpc_url(slug), do: build_url(slug) - def graph_url(slug), do: build_url("graph", slug) - def graph_rpc_url(slug), do: build_url("graph-rpc", slug) - def graph_status(slug), do: build_url("graph-status", slug) - def ipfs_url(slug), do: build_url("ipfs", slug) - def explorer_url(slug), do: build_url(slug) + def rpc_url(slug, api_key), do: build_url(slug, api_key) + def graph_url(slug, api_key), do: build_url("graph", slug, api_key) + def graph_rpc_url(slug, api_key), do: build_url("graph-rpc", slug, api_key) + def graph_status(slug, api_key), do: build_url("graph-status", slug, api_key) + def ipfs_url(slug, api_key), do: build_url("ipfs", slug, api_key) + def explorer_url(slug, api_key), do: build_url(slug, api_key) def chain_id(id) do prefix = config() |> Keyword.fetch!(:chain_id_prefix) @@ -104,11 +104,13 @@ defmodule Ethui.Stacks do def get_stack_by_slug(slug) do Repo.get_by(Stack, slug: slug) + |> Repo.preload(:api_key) end def list_stacks(user) do if user do Repo.all(from(s in Stack, where: s.user_id == ^user.id)) + |> Repo.preload(:api_key) else Repo.all(Stack) end @@ -130,10 +132,16 @@ defmodule Ethui.Stacks do |> Ecto.Multi.run(:api_key, fn _repo, %{stack: stack} -> Accounts.create_api_key(stack) end) + |> Ecto.Multi.run(:stack_with_api_key, fn repo, %{stack: stack} -> + {:ok, repo.preload(stack, :api_key)} + end) |> Repo.transaction() |> case do - {:ok, %{stack: stack}} -> {:ok, stack} - {:error, _, changeset, _} -> {:error, changeset} + {:ok, %{stack: stack}} -> + {:ok, stack} + + {:error, _, changeset, _} -> + {:error, changeset} end end @@ -155,14 +163,22 @@ defmodule Ethui.Stacks do Repo.delete(stack) end - defp build_url(slug) do + defp build_url(slug, nil) do "#{http_protocol()}#{slug}.#{host()}" end - defp build_url(component, slug) do + defp build_url(slug, api_key) do + "#{http_protocol()}#{slug}.#{host()}/#{api_key.token}" + end + + defp build_url(component, slug, nil) do "#{http_protocol()}#{component}-#{slug}.#{host()}" end + defp build_url(component, slug, api_key) do + "#{http_protocol()}#{component}-#{slug}.#{host()}/#{api_key.token}" + end + defp graph_enabled?(stack) do !!stack.graph_opts["enabled"] end diff --git a/server/lib/ethui/stacks/api_key.ex b/server/lib/ethui/stacks/api_key.ex index b70fa42..07c6d56 100644 --- a/server/lib/ethui/stacks/api_key.ex +++ b/server/lib/ethui/stacks/api_key.ex @@ -32,8 +32,8 @@ defmodule Ethui.Accounts.ApiKey do end end - defp generate_token do + def generate_token do :crypto.strong_rand_bytes(24) - |> Base.url_encode64(padding: false) + |> Base58.encode() end end diff --git a/server/mix.exs b/server/mix.exs index 4758258..e087945 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -82,7 +82,10 @@ defmodule Ethui.MixProject do # Websocket {:websock_adapter, "~> 0.5"}, - {:gun, "~> 2.0"} + {:gun, "~> 2.0"}, + + # base 58 for api token + {:b58, "~> 1.0.2"} ] end diff --git a/server/mix.lock b/server/mix.lock index b22d6ef..132f61c 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -1,4 +1,5 @@ %{ + "b58": {:hex, :b58, "1.0.3", "d300d6ae5a3de956a54b9e8220e924e4fee1a349de983df2340fe61e0e464202", [:mix], [], "hexpm", "af62a98a8661fd89978cf3a3a4b5b2ebe82209de6ac6164f0b112e36af72fc59"}, "backpex": {:hex, :backpex, "0.12.0", "cdf05d581da648ec8f7fd2efdf3adad5fe74acb515139d264b1043fd947c19a5", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: true]}, {:ash_postgres, "~> 2.0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:money, "~> 1.13", [hex: :money, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:number, "~> 1.0", [hex: :number, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "8b8c034d7e47ddc91631fa691c69dfdabf6d3faba03082b3a5883f840d69f9de"}, "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, From 10df0e9908f7a8c34a4697d234d0d836ab217d30 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 16:46:11 +0000 Subject: [PATCH 7/8] fix build_url --- server/lib/ethui/stacks.ex | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index bcef1f9..f22f460 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -7,6 +7,7 @@ defmodule Ethui.Stacks do alias Ethui.Stacks.Stack alias Ethui.Accounts alias Ethui.Accounts.User + alias Ethui.Accounts.ApiKey alias Ethui.Repo import Ecto.Query, only: [from: 2] @@ -163,22 +164,23 @@ defmodule Ethui.Stacks do Repo.delete(stack) end - defp build_url(slug, nil) do - "#{http_protocol()}#{slug}.#{host()}" - end - - defp build_url(slug, api_key) do + defp build_url(slug, %ApiKey{} = api_key) do "#{http_protocol()}#{slug}.#{host()}/#{api_key.token}" end - defp build_url(component, slug, nil) do - "#{http_protocol()}#{component}-#{slug}.#{host()}" + defp build_url(slug, _) do + "#{http_protocol()}#{slug}.#{host()}" end - defp build_url(component, slug, api_key) do + defp build_url(component, slug, %ApiKey{} = api_key) do + api_key |> IO.inspect(label: "api_key2 ") "#{http_protocol()}#{component}-#{slug}.#{host()}/#{api_key.token}" end + defp build_url(component, slug, _) do + "#{http_protocol()}#{component}-#{slug}.#{host()}" + end + defp graph_enabled?(stack) do !!stack.graph_opts["enabled"] end From d8f14c3c83e0362ae593e951b7c5729174d60826 Mon Sep 17 00:00:00 2001 From: ZePedroResende Date: Thu, 8 Jan 2026 16:48:26 +0000 Subject: [PATCH 8/8] remove IO.inspect --- server/lib/ethui/stacks.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index f22f460..fda4e3c 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -173,7 +173,6 @@ defmodule Ethui.Stacks do end defp build_url(component, slug, %ApiKey{} = api_key) do - api_key |> IO.inspect(label: "api_key2 ") "#{http_protocol()}#{component}-#{slug}.#{host()}/#{api_key.token}" end