Compare commits

...

1 Commits

Author SHA1 Message Date
Aleksei Chichenkov
646262447d Revert "Develop" 2025-11-26 22:10:03 +03:00
157 changed files with 1278 additions and 6819 deletions

View File

@@ -33,7 +33,7 @@ test t:
MIX_ENV=test mix test
coverage cover co:
MIX_ENV=test mix test --cover
mix test --cover
unit-tests ut:
@echo "Running unit tests..."

View File

@@ -1,7 +1,7 @@
import { MarkdownComment } from '@/hooks/Mapper/components/mapInterface/components/Comments/components';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useEffect, useRef, useState } from 'react';
import { CommentType } from '@/hooks/Mapper/types';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
export interface CommentsProps {}
@@ -14,9 +14,7 @@ export const Comments = ({}: CommentsProps) => {
comments: { loadComments, comments, lastUpdateKey },
} = useMapRootState();
const systemId = useMemo(() => {
return +selectedSystems[0];
}, [selectedSystems]);
const [systemId] = selectedSystems;
const ref = useRef({ loadComments, systemId });
ref.current = { loadComments, systemId };

View File

@@ -3,7 +3,7 @@ import clsx from 'clsx';
import { PrimeIcons } from 'primereact/api';
import { MarkdownEditor } from '@/hooks/Mapper/components/mapInterface/components/MarkdownEditor';
import { useHotkey } from '@/hooks/Mapper/hooks';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { OutCommand } from '@/hooks/Mapper/types';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import classes from './CommentsEditor.module.scss';
@@ -19,9 +19,7 @@ export const CommentsEditor = ({}: CommentsEditorProps) => {
outCommand,
} = useMapRootState();
const systemId = useMemo(() => {
return +selectedSystems[0];
}, [selectedSystems]);
const [systemId] = selectedSystems;
const ref = useRef({ outCommand, systemId, textVal });
ref.current = { outCommand, systemId, textVal };

View File

@@ -12,7 +12,7 @@ export const useCommandComments = () => {
}, []);
const removeComment = useCallback((data: CommandCommentRemoved) => {
ref.current.removeComment(data.solarSystemId, data.commentId);
ref.current.removeComment(data.solarSystemId.toString(), data.commentId);
}, []);
return { addComment, removeComment };

View File

@@ -1,5 +1,5 @@
import { CommentSystem, CommentType, OutCommand, OutCommandHandler, UseCommentsData } from '@/hooks/Mapper/types';
import { useCallback, useRef, useState } from 'react';
import { CommentSystem, CommentType, OutCommand, OutCommandHandler, UseCommentsData } from '@/hooks/Mapper/types';
interface UseCommentsProps {
outCommand: OutCommandHandler;
@@ -8,12 +8,12 @@ interface UseCommentsProps {
export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData => {
const [lastUpdateKey, setLastUpdateKey] = useState(0);
const commentBySystemsRef = useRef<Map<number, CommentSystem>>(new Map());
const commentBySystemsRef = useRef<Map<string, CommentSystem>>(new Map());
const ref = useRef({ outCommand });
ref.current = { outCommand };
const loadComments = useCallback(async (systemId: number) => {
const loadComments = useCallback(async (systemId: string) => {
let cSystem = commentBySystemsRef.current.get(systemId);
if (cSystem?.loading || cSystem?.loaded) {
return;
@@ -45,7 +45,7 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
setLastUpdateKey(x => x + 1);
}, []);
const addComment = useCallback((systemId: number, comment: CommentType) => {
const addComment = useCallback((systemId: string, comment: CommentType) => {
const cSystem = commentBySystemsRef.current.get(systemId);
if (cSystem) {
cSystem.comments.push(comment);
@@ -61,9 +61,8 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
setLastUpdateKey(x => x + 1);
}, []);
const removeComment = useCallback((systemId: number, commentId: string) => {
const removeComment = useCallback((systemId: string, commentId: string) => {
const cSystem = commentBySystemsRef.current.get(systemId);
console.log('cSystem', cSystem);
if (!cSystem) {
return;
}

View File

@@ -13,9 +13,9 @@ export type CommentSystem = {
};
export interface UseCommentsData {
loadComments: (systemId: number) => Promise<void>;
addComment: (systemId: number, comment: CommentType) => void;
removeComment: (systemId: number, commentId: string) => void;
comments: Map<number, CommentSystem>;
loadComments: (systemId: string) => Promise<void>;
addComment: (systemId: string, comment: CommentType) => void;
removeComment: (systemId: string, commentId: string) => void;
comments: Map<string, CommentSystem>;
lastUpdateKey: number;
}

View File

@@ -131,7 +131,7 @@ export type CommandLinkSignatureToSystem = {
};
export type CommandLinkSignaturesUpdated = number;
export type CommandCommentAdd = {
solarSystemId: number;
solarSystemId: string;
comment: CommentType;
};
export type CommandCommentRemoved = {

View File

@@ -432,7 +432,7 @@ config :wanderer_app, :license_manager,
config :wanderer_app, :sse,
enabled:
config_dir
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "false")
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "true")
|> String.to_existing_atom(),
max_connections_total:
config_dir |> get_int_from_path_or_env("WANDERER_SSE_MAX_CONNECTIONS", 1000),
@@ -447,6 +447,6 @@ config :wanderer_app, :sse,
config :wanderer_app, :external_events,
webhooks_enabled:
config_dir
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "false")
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "true")
|> String.to_existing_atom(),
webhook_timeout_ms: config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000)

View File

@@ -24,11 +24,7 @@ config :wanderer_app,
pubsub_client: Test.PubSubMock,
cached_info: WandererApp.CachedInfo.Mock,
character_api_disabled: false,
environment: :test,
map_subscriptions_enabled: false,
wanderer_kills_service_enabled: false,
sse: [enabled: false],
external_events: [webhooks_enabled: false]
environment: :test
# We don't run a server during test. If one is required,
# you can enable the server option below.

View File

@@ -60,17 +60,19 @@ defmodule WandererApp.Api.AccessList do
# Added :api_key to the accepted attributes
accept [:name, :description, :owner_id, :api_key]
primary?(true)
argument :owner_id, :uuid, allow_nil?: false
change manage_relationship(:owner_id, :owner, on_lookup: :relate, on_no_match: nil)
end
update :update do
accept [:name, :description, :owner_id, :api_key]
primary?(true)
require_atomic? false
end
update :assign_owner do
accept [:owner_id]
require_atomic? false
end
end

View File

@@ -53,11 +53,7 @@ defmodule WandererApp.Api.AccessListMember do
:role
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
read :read_by_access_list do
argument(:access_list_id, :string, allow_nil?: false)
@@ -71,14 +67,12 @@ defmodule WandererApp.Api.AccessListMember do
update :block do
accept([])
require_atomic? false
change(set_attribute(:blocked, true))
end
update :unblock do
accept([])
require_atomic? false
change(set_attribute(:blocked, false))
end

View File

@@ -1,80 +0,0 @@
defmodule WandererApp.Api.ActorHelpers do
@moduledoc """
Utilities for extracting actor information from Ash contexts.
Provides helper functions for working with ActorWithMap and extracting
user, map, and character information from various context formats.
"""
alias WandererApp.Api.ActorWithMap
@doc """
Extract map from actor or context.
Handles various context formats:
- Direct ActorWithMap struct
- Context map with :actor key
- Context map with :map key
- Ash.Resource.Change.Context struct
"""
def get_map(%{actor: %ActorWithMap{map: %{} = map}}), do: map
def get_map(%{map: %{} = map}), do: map
# Handle Ash.Resource.Change.Context struct
def get_map(%Ash.Resource.Change.Context{actor: %ActorWithMap{map: %{} = map}}), do: map
def get_map(%Ash.Resource.Change.Context{actor: _}), do: nil
def get_map(context) when is_map(context) do
# For plain maps, check private.actor
with private when is_map(private) <- Map.get(context, :private),
%ActorWithMap{map: %{} = map} <- Map.get(private, :actor) do
map
else
_ -> nil
end
end
def get_map(_), do: nil
@doc """
Extract user from actor.
Handles:
- ActorWithMap struct
- Direct user struct with :id field
"""
def get_user(%ActorWithMap{user: user}), do: user
def get_user(%{id: _} = user), do: user
def get_user(_), do: nil
@doc """
Get character IDs for the actor.
Used for ACL filtering to determine which resources the user can access.
Returns {:ok, list} or {:ok, []} if no characters found.
"""
def get_character_ids(%ActorWithMap{user: user}), do: get_character_ids(user)
def get_character_ids(%{characters: characters}) when is_list(characters) do
{:ok, Enum.map(characters, & &1.id)}
end
def get_character_ids(%{characters: %Ecto.Association.NotLoaded{}, id: user_id}) do
# Load characters from database
load_characters_by_id(user_id)
end
def get_character_ids(%{id: user_id}) do
# Fallback: load user with characters
load_characters_by_id(user_id)
end
def get_character_ids(_), do: {:ok, []}
defp load_characters_by_id(user_id) do
case WandererApp.Api.User.by_id(user_id, load: [:characters]) do
{:ok, user} -> {:ok, Enum.map(user.characters, & &1.id)}
_ -> {:ok, []}
end
end
end

View File

@@ -1,15 +0,0 @@
defmodule WandererApp.Api.ActorWithMap do
@moduledoc """
Wraps a user and map together as an actor for token-based authentication.
When API requests use Bearer token auth, the token identifies both the user
(map owner) and the map. This struct allows passing both through Ash's actor system.
"""
@enforce_keys [:user, :map]
defstruct [:user, :map]
def new(user, map) do
%__MODULE__{user: user, map: map}
end
end

View File

@@ -1,39 +0,0 @@
defmodule WandererApp.Api.Changes.InjectMapFromActor do
@moduledoc """
Ash change that injects map_id from the authenticated actor.
For token-based auth, the map is determined by the API token.
This change automatically sets map_id, so clients don't need to provide it.
"""
use Ash.Resource.Change
alias WandererApp.Api.ActorHelpers
@impl true
def change(changeset, _opts, context) do
case ActorHelpers.get_map(context) do
%{id: map_id} ->
Ash.Changeset.force_change_attribute(changeset, :map_id, map_id)
_other ->
# nil or unexpected return shape - check for direct map_id
# Check params (input), arguments, and attributes (in that order)
map_id = Map.get(changeset.params, :map_id) ||
Ash.Changeset.get_argument(changeset, :map_id) ||
Ash.Changeset.get_attribute(changeset, :map_id)
case map_id do
nil ->
Ash.Changeset.add_error(changeset,
field: :map_id,
message: "map_id is required (provide via token or attribute)"
)
_map_id ->
# map_id provided directly (internal calls, tests)
changeset
end
end
end
end

View File

@@ -69,6 +69,11 @@ defmodule WandererApp.Api.Character do
filter(expr(user_id == ^arg(:user_id) and deleted == false))
end
read :available_by_map do
argument(:map_id, :uuid, allow_nil?: false)
filter(expr(user_id == ^arg(:user_id) and deleted == false))
end
read :last_active do
argument(:from, :utc_datetime, allow_nil?: false)
@@ -95,7 +100,6 @@ defmodule WandererApp.Api.Character do
update :mark_as_deleted do
accept([])
require_atomic? false
change(atomic_update(:deleted, true))
change(atomic_update(:user_id, nil))
@@ -103,7 +107,6 @@ defmodule WandererApp.Api.Character do
update :update_online do
accept([:online])
require_atomic? false
end
update :update_location do

View File

@@ -33,11 +33,7 @@ defmodule WandererApp.Api.CorpWalletTransaction do
:ref_type
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
create :new do
accept [

View File

@@ -36,11 +36,7 @@ defmodule WandererApp.Api.License do
:expire_at
]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
defaults [:read, :update, :destroy]
create :create do
primary? true
@@ -62,14 +58,12 @@ defmodule WandererApp.Api.License do
update :invalidate do
accept([])
require_atomic? false
change(set_attribute(:is_valid, false))
end
update :set_valid do
accept([])
require_atomic? false
change(set_attribute(:is_valid, true))
end

View File

@@ -8,8 +8,6 @@ defmodule WandererApp.Api.Map do
alias Ash.Resource.Change.Builtins
require Logger
postgres do
repo(WandererApp.Repo)
table("maps_v1")
@@ -46,7 +44,6 @@ defmodule WandererApp.Api.Map do
code_interface do
define(:available, action: :available)
define(:get_map_by_slug, action: :by_slug, args: [:slug])
define(:by_api_key, action: :by_api_key, args: [:api_key])
define(:new, action: :new)
define(:create, action: :create)
define(:update, action: :update)
@@ -57,7 +54,6 @@ defmodule WandererApp.Api.Map do
define(:mark_as_deleted, action: :mark_as_deleted)
define(:update_api_key, action: :update_api_key)
define(:toggle_webhooks, action: :toggle_webhooks)
define(:toggle_sse, action: :toggle_sse)
define(:by_id,
get_by: [:id],
@@ -94,34 +90,22 @@ defmodule WandererApp.Api.Map do
filter expr(slug == ^arg(:slug))
end
read :by_api_key do
get? true
argument :api_key, :string, allow_nil?: false
prepare WandererApp.Api.Preparations.SecureApiKeyLookup
end
read :available do
prepare WandererApp.Api.Preparations.FilterMapsByRoles
end
create :new do
accept [
:name,
:slug,
:description,
:scope,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
primary?(true)
argument :owner_id, :uuid, allow_nil?: false
argument :create_default_acl, :boolean, allow_nil?: true
argument :acls, {:array, :uuid}, allow_nil?: true
argument :acls_text_input, :string, allow_nil?: true
argument :scope_text_input, :string, allow_nil?: true
argument :acls_empty_selection, :string, allow_nil?: true
change manage_relationship(:owner_id, :owner, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:acls, type: :append_and_remove)
change WandererApp.Api.Changes.SlugifyName
end
@@ -129,16 +113,7 @@ defmodule WandererApp.Api.Map do
update :update do
primary? true
require_atomic? false
accept [
:name,
:slug,
:description,
:scope,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
argument :owner_id_text_input, :string, allow_nil?: true
argument :acls_text_input, :string, allow_nil?: true
@@ -153,9 +128,6 @@ defmodule WandererApp.Api.Map do
)
change WandererApp.Api.Changes.SlugifyName
# Validate subscription when enabling SSE
validate &validate_sse_subscription/2
end
update :update_acls do
@@ -170,46 +142,33 @@ defmodule WandererApp.Api.Map do
update :assign_owner do
accept [:owner_id]
require_atomic? false
end
update :update_hubs do
accept [:hubs]
require_atomic? false
end
update :update_options do
accept [:options]
require_atomic? false
end
update :mark_as_deleted do
accept([])
require_atomic? false
change(set_attribute(:deleted, true))
end
update :update_api_key do
accept [:public_api_key]
require_atomic? false
end
update :toggle_webhooks do
accept [:webhooks_enabled]
require_atomic? false
end
update :toggle_sse do
require_atomic? false
accept [:sse_enabled]
# Validate subscription when enabling SSE
validate &validate_sse_subscription/2
end
create :duplicate do
accept [:name, :description, :scope, :only_tracked_characters]
argument :source_map_id, :uuid, allow_nil?: false
argument :copy_acls, :boolean, default: true
argument :copy_user_settings, :boolean, default: true
@@ -353,19 +312,12 @@ defmodule WandererApp.Api.Map do
public?(true)
end
attribute :sse_enabled, :boolean do
default(false)
allow_nil?(false)
public?(true)
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end
identities do
identity :unique_slug, [:slug]
identity :unique_public_api_key, [:public_api_key]
end
relationships do
@@ -392,49 +344,4 @@ defmodule WandererApp.Api.Map do
public? false
end
end
# SSE Subscription Validation
#
# This validation ensures that SSE can only be enabled when:
# 1. SSE is being disabled (always allowed)
# 2. Map is being created (skip validation, will be checked on first update)
# 3. Community Edition mode (always allowed)
# 4. Enterprise mode with active subscription
defp validate_sse_subscription(changeset, _context) do
sse_enabled = Ash.Changeset.get_attribute(changeset, :sse_enabled)
map_id = changeset.data.id
subscriptions_enabled = WandererApp.Env.map_subscriptions_enabled?()
cond do
# Not enabling SSE - no validation needed
not sse_enabled ->
:ok
# Map creation (no ID yet) - skip validation
is_nil(map_id) ->
:ok
# Community Edition mode - always allow
not subscriptions_enabled ->
:ok
# Enterprise mode - check subscription
true ->
validate_active_subscription(map_id)
end
end
defp validate_active_subscription(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} ->
:ok
{:ok, false} ->
{:error, field: :sse_enabled, message: "Active subscription required to enable SSE"}
{:error, reason} ->
Logger.error("Error checking subscription status: #{inspect(reason)}")
{:error, field: :sse_enabled, message: "Unable to verify subscription status"}
end
end
end

View File

@@ -61,11 +61,7 @@ defmodule WandererApp.Api.MapAccessList do
:access_list_id
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
read :read_by_map do
argument(:map_id, :string, allow_nil?: false)

View File

@@ -27,11 +27,7 @@ defmodule WandererApp.Api.MapChainPassages do
:solar_system_target_id
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
create :new do
accept [
@@ -44,6 +40,12 @@ defmodule WandererApp.Api.MapChainPassages do
]
primary?(true)
argument :map_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
action :by_map_id, {:array, :struct} do

View File

@@ -81,6 +81,12 @@ defmodule WandererApp.Api.MapCharacterSettings do
:character_id,
:tracked
]
argument :map_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
read :by_map_filtered do
@@ -139,7 +145,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :track do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -152,7 +159,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :untrack do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -165,7 +173,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :follow do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -178,7 +187,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :unfollow do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do

View File

@@ -4,8 +4,7 @@ defmodule WandererApp.Api.MapConnection do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
extensions: [AshJsonApi.Resource]
postgres do
repo(WandererApp.Repo)
@@ -74,56 +73,7 @@ defmodule WandererApp.Api.MapConnection do
:custom_info
]
create :create do
primary? true
accept [
:map_id,
:solar_system_source,
:solar_system_target,
:type,
:ship_size_type,
:mass_status,
:time_status,
:wormhole_type,
:count_of_passage,
:locked,
:custom_info
]
# Inject map_id from token
change WandererApp.Api.Changes.InjectMapFromActor
end
read :read do
primary? true
# Security: Filter to only connections from actor's map
prepare WandererApp.Api.Preparations.FilterConnectionsByActorMap
end
update :update do
primary? true
accept [
:solar_system_source,
:solar_system_target,
:type,
:ship_size_type,
:mass_status,
:time_status,
:wormhole_type,
:count_of_passage,
:locked,
:custom_info
]
require_atomic? false
end
destroy :destroy do
primary? true
end
defaults [:create, :read, :update, :destroy]
read :read_by_map do
argument(:map_id, :string, allow_nil?: false)
@@ -160,37 +110,30 @@ defmodule WandererApp.Api.MapConnection do
update :update_mass_status do
accept [:mass_status]
require_atomic? false
end
update :update_time_status do
accept [:time_status]
require_atomic? false
end
update :update_ship_size_type do
accept [:ship_size_type]
require_atomic? false
end
update :update_locked do
accept [:locked]
require_atomic? false
end
update :update_custom_info do
accept [:custom_info]
require_atomic? false
end
update :update_type do
accept [:type]
require_atomic? false
end
update :update_wormhole_type do
accept [:wormhole_type]
require_atomic? false
end
end

View File

@@ -30,11 +30,7 @@ defmodule WandererApp.Api.MapInvite do
:token
]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
defaults [:read, :update, :destroy]
create :new do
accept [
@@ -45,6 +41,10 @@ defmodule WandererApp.Api.MapInvite do
]
primary?(true)
argument :map_id, :uuid, allow_nil?: true
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
end
read :by_map do

View File

@@ -3,8 +3,7 @@ defmodule WandererApp.Api.MapPing do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
primary_read_warning?: false
data_layer: AshPostgres.DataLayer
postgres do
repo(WandererApp.Repo)
@@ -37,18 +36,7 @@ defmodule WandererApp.Api.MapPing do
:message
]
defaults [:destroy]
update :update do
require_atomic? false
end
read :read do
primary? true
# Security: Filter to only pings from actor's map
prepare WandererApp.Api.Preparations.FilterPingsByActorMap
end
defaults [:read, :update, :destroy]
create :new do
accept [
@@ -60,6 +48,14 @@ defmodule WandererApp.Api.MapPing do
]
primary?(true)
argument :map_id, :uuid, allow_nil?: false
argument :system_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
read :by_map do

View File

@@ -65,11 +65,7 @@ defmodule WandererApp.Api.MapSolarSystem do
:sun_type_id
]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
defaults [:read, :destroy, :update]
create :create do
primary? true

View File

@@ -24,11 +24,7 @@ defmodule WandererApp.Api.MapSolarSystemJumps do
:to_solar_system_id
]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
defaults [:read, :destroy, :update]
create :create do
primary? true

View File

@@ -45,11 +45,7 @@ defmodule WandererApp.Api.MapState do
:connections_start_time
]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
defaults [:read, :update, :destroy]
create :create do
primary? true

View File

@@ -62,11 +62,7 @@ defmodule WandererApp.Api.MapSubscription do
:auto_renew?
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
read :all_active do
prepare build(sort: [updated_at: :asc], load: [:map])
@@ -92,39 +88,32 @@ defmodule WandererApp.Api.MapSubscription do
update :update_plan do
accept [:plan]
require_atomic? false
end
update :update_characters_limit do
accept [:characters_limit]
require_atomic? false
end
update :update_hubs_limit do
accept [:hubs_limit]
require_atomic? false
end
update :update_active_till do
accept [:active_till]
require_atomic? false
end
update :update_auto_renew do
accept [:auto_renew?]
require_atomic? false
end
update :cancel do
accept([])
require_atomic? false
change(set_attribute(:status, :cancelled))
end
update :expire do
accept([])
require_atomic? false
change(set_attribute(:status, :expired))
end

View File

@@ -24,12 +24,16 @@ defmodule WandererApp.Api.MapSystem do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
extensions: [AshJsonApi.Resource]
postgres do
repo(WandererApp.Repo)
table("map_system_v1")
custom_indexes do
# Partial index for efficient visible systems query
index [:map_id], where: "visible = true", name: "map_system_v1_map_id_visible_index"
end
end
json_api do
@@ -66,7 +70,10 @@ defmodule WandererApp.Api.MapSystem do
define(:upsert, action: :upsert)
define(:destroy, action: :destroy)
define :by_id, action: :get_by_id, args: [:id], get?: true
define(:by_id,
get_by: [:id],
action: :read
)
define(:by_solar_system_id,
get_by: [:solar_system_id],
@@ -96,7 +103,6 @@ defmodule WandererApp.Api.MapSystem do
define(:update_status, action: :update_status)
define(:update_tag, action: :update_tag)
define(:update_temporary_name, action: :update_temporary_name)
define(:update_custom_name, action: :update_custom_name)
define(:update_labels, action: :update_labels)
define(:update_linked_sig_eve_id, action: :update_linked_sig_eve_id)
define(:update_position, action: :update_position)
@@ -122,56 +128,7 @@ defmodule WandererApp.Api.MapSystem do
:linked_sig_eve_id
]
create :create do
primary? true
accept [
:map_id,
:name,
:solar_system_id,
:position_x,
:position_y,
:status,
:visible,
:locked,
:custom_name,
:description,
:tag,
:temporary_name,
:labels,
:added_at,
:linked_sig_eve_id
]
# Inject map_id from token
change WandererApp.Api.Changes.InjectMapFromActor
end
update :update do
primary? true
require_atomic? false
# Note: name and solar_system_id are not in accept
# - solar_system_id should be immutable (identifier)
# - name has allow_nil? false which makes it required in JSON:API
accept [
:position_x,
:position_y,
:status,
:visible,
:locked,
:custom_name,
:description,
:tag,
:temporary_name,
:labels,
:linked_sig_eve_id
]
end
destroy :destroy do
primary? true
end
defaults [:create, :update, :destroy]
create :upsert do
primary? false
@@ -201,9 +158,6 @@ defmodule WandererApp.Api.MapSystem do
read :read do
primary?(true)
# Security: Filter to only systems from actor's map
prepare WandererApp.Api.Preparations.FilterSystemsByActorMap
pagination offset?: true,
default_limit: 100,
max_page_size: 500,
@@ -211,11 +165,6 @@ defmodule WandererApp.Api.MapSystem do
required?: false
end
read :get_by_id do
argument(:id, :string, allow_nil?: false)
filter(expr(id == ^arg(:id)))
end
read :read_all_by_map do
argument(:map_id, :string, allow_nil?: false)
filter(expr(map_id == ^arg(:map_id)))
@@ -237,59 +186,44 @@ defmodule WandererApp.Api.MapSystem do
update :update_name do
accept [:name]
require_atomic? false
end
update :update_description do
accept [:description]
require_atomic? false
end
update :update_locked do
accept [:locked]
require_atomic? false
end
update :update_status do
accept [:status]
require_atomic? false
end
update :update_tag do
accept [:tag]
require_atomic? false
end
update :update_temporary_name do
accept [:temporary_name]
require_atomic? false
end
update :update_custom_name do
accept [:custom_name]
require_atomic? false
end
update :update_labels do
accept [:labels]
require_atomic? false
end
update :update_position do
accept [:position_x, :position_y]
require_atomic? false
change(set_attribute(:visible, true))
end
update :update_linked_sig_eve_id do
accept [:linked_sig_eve_id]
require_atomic? false
end
update :update_visible do
accept [:visible]
require_atomic? false
end
end

View File

@@ -59,6 +59,12 @@ defmodule WandererApp.Api.MapSystemComment do
:character_id,
:text
]
argument :system_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
read :by_system_id do

View File

@@ -111,6 +111,10 @@ defmodule WandererApp.Api.MapSystemSignature do
:custom_info,
:deleted
]
argument :system_id, :uuid, allow_nil?: false
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
end
update :update do
@@ -135,17 +139,14 @@ defmodule WandererApp.Api.MapSystemSignature do
update :update_linked_system do
accept [:linked_system_id]
require_atomic? false
end
update :update_type do
accept [:type]
require_atomic? false
end
update :update_group do
accept [:group]
require_atomic? false
end
read :by_system_id do

View File

@@ -122,6 +122,13 @@ defmodule WandererApp.Api.MapSystemStructure do
:status,
:end_time
]
argument :system_id, :uuid, allow_nil?: false
change manage_relationship(:system_id, :system,
on_lookup: :relate,
on_no_match: nil
)
end
update :update do

View File

@@ -29,11 +29,7 @@ defmodule WandererApp.Api.MapTransaction do
:amount
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
read :by_map do
argument(:map_id, :string, allow_nil?: false)

View File

@@ -53,30 +53,22 @@ defmodule WandererApp.Api.MapUserSettings do
:settings
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
update :update_settings do
accept [:settings]
require_atomic? false
end
update :update_main_character do
accept [:main_character_eve_id]
require_atomic? false
end
update :update_following_character do
accept [:following_character_eve_id]
require_atomic? false
end
update :update_hubs do
accept [:hubs]
require_atomic? false
end
end

View File

@@ -58,8 +58,6 @@ defmodule WandererApp.Api.MapWebhookSubscription do
:consecutive_failures,
:secret
]
require_atomic? false
end
read :by_map do

View File

@@ -1,64 +0,0 @@
defmodule WandererApp.Api.Preparations.FilterByActorMap do
@moduledoc """
Shared filtering logic for actor map context.
Filters queries to only return resources belonging to the actor's map.
Used by preparations for MapSystem, MapConnection, and MapPing resources.
"""
require Ash.Query
alias WandererApp.Api.ActorHelpers
@doc """
Filter a query by the actor's map context.
If a map is found in the context, filters the query to only return
resources where map_id matches. If no map context exists, returns
a query that will return no results.
## Parameters
* `query` - The Ash query to filter
* `context` - The Ash context containing actor/map information
* `resource_name` - Name of the resource for telemetry (atom)
## Examples
iex> query = Ash.Query.new(WandererApp.Api.MapSystem)
iex> context = %{map: %{id: "map-123"}}
iex> result = FilterByActorMap.filter_by_map(query, context, :map_system)
# Returns query filtered by map_id == "map-123"
"""
def filter_by_map(query, context, resource_name) do
case ActorHelpers.get_map(context) do
%{id: map_id} ->
emit_telemetry(resource_name, map_id)
Ash.Query.filter(query, map_id == ^map_id)
nil ->
emit_telemetry_no_context(resource_name)
Ash.Query.filter(query, false)
_other ->
emit_telemetry_no_context(resource_name)
Ash.Query.filter(query, false)
end
end
defp emit_telemetry(resource_name, map_id) do
:telemetry.execute(
[:wanderer_app, :ash, :preparation, :filter_by_map],
%{count: 1},
%{resource: resource_name, map_id: map_id}
)
end
defp emit_telemetry_no_context(resource_name) do
:telemetry.execute(
[:wanderer_app, :ash, :preparation, :filter_by_map, :no_context],
%{count: 1},
%{resource: resource_name}
)
end
end

View File

@@ -1,17 +0,0 @@
defmodule WandererApp.Api.Preparations.FilterConnectionsByActorMap do
@moduledoc """
Ash preparation that filters connections to only those from the actor's map.
For token-based auth, this ensures the API only returns connections
from the map associated with the token.
"""
use Ash.Resource.Preparation
alias WandererApp.Api.Preparations.FilterByActorMap
@impl true
def prepare(query, _opts, context) do
FilterByActorMap.filter_by_map(query, context, :map_connection)
end
end

View File

@@ -1,17 +0,0 @@
defmodule WandererApp.Api.Preparations.FilterPingsByActorMap do
@moduledoc """
Ash preparation that filters pings to only those from the actor's map.
For token-based auth, this ensures the API only returns pings
from the map associated with the token.
"""
use Ash.Resource.Preparation
alias WandererApp.Api.Preparations.FilterByActorMap
@impl true
def prepare(query, _opts, context) do
FilterByActorMap.filter_by_map(query, context, :map_ping)
end
end

View File

@@ -1,17 +0,0 @@
defmodule WandererApp.Api.Preparations.FilterSystemsByActorMap do
@moduledoc """
Ash preparation that filters systems to only those from the actor's map.
For token-based auth, this ensures the API only returns systems
from the map associated with the token.
"""
use Ash.Resource.Preparation
alias WandererApp.Api.Preparations.FilterByActorMap
@impl true
def prepare(query, _opts, context) do
FilterByActorMap.filter_by_map(query, context, :map_system)
end
end

View File

@@ -1,62 +0,0 @@
defmodule WandererApp.Api.Preparations.SecureApiKeyLookup do
@moduledoc """
Preparation that performs secure API key lookup using constant-time comparison.
This preparation:
1. Queries for the map with the given API key using database index
2. Performs constant-time comparison to verify the key matches
3. Returns the map only if the secure comparison passes
The constant-time comparison prevents timing attacks where an attacker
could deduce information about valid API keys by measuring response times.
"""
use Ash.Resource.Preparation
require Ash.Query
@dummy_key "dummy_key_for_timing_consistency_00000000"
def prepare(query, _params, _context) do
api_key = Ash.Query.get_argument(query, :api_key)
if is_nil(api_key) or api_key == "" do
# Return empty result for invalid input
Ash.Query.filter(query, expr(false))
else
# First, do the database lookup using the index
# Then apply constant-time comparison in after_action
query
|> Ash.Query.filter(expr(public_api_key == ^api_key))
|> Ash.Query.after_action(fn _query, results ->
verify_results_with_secure_compare(results, api_key)
end)
end
end
defp verify_results_with_secure_compare(results, provided_key) do
case results do
[map] ->
# Map found - verify with constant-time comparison
stored_key = map.public_api_key || @dummy_key
if Plug.Crypto.secure_compare(stored_key, provided_key) do
{:ok, [map]}
else
# Keys don't match (shouldn't happen if DB returned it, but safety check)
{:ok, []}
end
[] ->
# No map found - still do a comparison to maintain consistent timing
# This prevents timing attacks from distinguishing "not found" from "found but wrong"
_result = Plug.Crypto.secure_compare(@dummy_key, provided_key)
{:ok, []}
_multiple ->
# Multiple results - shouldn't happen with unique constraint
# Do comparison for timing consistency and return error
_result = Plug.Crypto.secure_compare(@dummy_key, provided_key)
{:ok, []}
end
end
end

View File

@@ -49,11 +49,7 @@ defmodule WandererApp.Api.ShipTypeInfo do
:volume
]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
defaults [:read, :destroy, :update]
create :create do
primary? true

View File

@@ -51,15 +51,10 @@ defmodule WandererApp.Api.User do
:hash
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
update :update_last_map do
accept([:last_map_id])
require_atomic? false
end
update :update_balance do

View File

@@ -4,8 +4,7 @@ defmodule WandererApp.Api.UserActivity do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
extensions: [AshJsonApi.Resource]
require Ash.Expr
@@ -56,8 +55,7 @@ defmodule WandererApp.Api.UserActivity do
:entity_type,
:event_type,
:event_data,
:user_id,
:character_id
:user_id
]
read :read do
@@ -72,8 +70,14 @@ defmodule WandererApp.Api.UserActivity do
end
create :new do
accept [:entity_id, :entity_type, :event_type, :event_data, :user_id, :character_id]
accept [:entity_id, :entity_type, :event_type, :event_data]
primary?(true)
argument :user_id, :uuid, allow_nil?: true
argument :character_id, :uuid, allow_nil?: true
change manage_relationship(:user_id, :user, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
destroy :archive do

View File

@@ -28,6 +28,10 @@ defmodule WandererApp.Api.UserTransaction do
create :new do
accept [:journal_ref_id, :user_id, :date, :amount, :corporation_id]
primary?(true)
argument :user_id, :uuid, allow_nil?: false
change manage_relationship(:user_id, :user, on_lookup: :relate, on_no_match: nil)
end
end

View File

@@ -153,16 +153,13 @@ defmodule WandererApp.Application do
:ok
end
defp maybe_start_corp_wallet_tracker(true) do
# Don't start corp wallet tracker in test environment
if Application.get_env(:wanderer_app, :environment) == :test do
[]
else
[WandererApp.StartCorpWalletTrackerTask]
end
end
defp maybe_start_corp_wallet_tracker(true),
do: [
WandererApp.StartCorpWalletTrackerTask
]
defp maybe_start_corp_wallet_tracker(_), do: []
defp maybe_start_corp_wallet_tracker(_),
do: []
defp maybe_start_kills_services do
# Don't start kills services in test environment

View File

@@ -93,8 +93,6 @@ defmodule WandererApp.CachedInfo do
end
end
def get_system_static_info(nil), do: {:ok, nil}
def get_system_static_info(solar_system_id) do
{:ok, solar_system_id} = APIUtils.parse_int(solar_system_id)

View File

@@ -17,6 +17,7 @@ defmodule WandererApp.Env do
def invites(), do: get_key(:invites, false)
def map_subscriptions_enabled?(), do: get_key(:map_subscriptions_enabled, false)
def websocket_events_enabled?(), do: get_key(:websocket_events_enabled, false)
def public_api_disabled?(), do: get_key(:public_api_disabled, false)
@decorate cacheable(

View File

@@ -155,23 +155,26 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
# 1. Store in ETS for backfill
store_event(event, state.ets_table)
# 2. Convert event to JSON for delivery methods
event_json = Event.to_json(event)
Logger.debug(fn ->
"MapEventRelay converted event to JSON: #{inspect(String.slice(inspect(event_json), 0, 200))}..."
end)
# 3. Send to webhook subscriptions via WebhookDispatcher
WebhookDispatcher.dispatch_event(event.map_id, event)
case WandererApp.ExternalEvents.SseAccessControl.sse_allowed?(event.map_id) do
:ok ->
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
# 4. Broadcast to SSE clients
Logger.debug(fn -> "MapEventRelay broadcasting to SSE clients for map #{event.map_id}" end)
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
:telemetry.execute(
[:wanderer_app, :external_events, :relay, :delivered],
%{count: 1},
%{map_id: event.map_id, event_type: event.type}
)
{:error, _reason} ->
:ok
end
# Emit delivered telemetry
:telemetry.execute(
[:wanderer_app, :external_events, :relay, :delivered],
%{count: 1},
%{map_id: event.map_id, event_type: event.type}
)
%{state | event_count: state.event_count + 1}
end

View File

@@ -1,71 +0,0 @@
defmodule WandererApp.ExternalEvents.SseAccessControl do
@moduledoc """
Handles SSE access control checks including subscription validation.
Note: Community Edition mode is automatically handled by the
WandererApp.Map.is_subscription_active?/1 function, which returns
{:ok, true} when subscriptions are disabled globally.
"""
@doc """
Checks if SSE is allowed for a given map.
Returns:
- :ok if SSE is allowed
- {:error, reason} if SSE is not allowed
Checks in order:
1. Global SSE enabled (config)
2. Map exists
3. Map SSE enabled (per-map setting)
4. Subscription active (CE mode handled internally)
"""
def sse_allowed?(map_id) do
with :ok <- check_sse_globally_enabled(),
{:ok, map} <- fetch_map(map_id),
:ok <- check_map_sse_enabled(map),
:ok <- check_subscription_or_ce(map_id) do
:ok
end
end
defp check_sse_globally_enabled do
if WandererApp.Env.sse_enabled?() do
:ok
else
{:error, :sse_globally_disabled}
end
end
# Fetches the map by ID.
# Returns {:ok, map} or {:error, :map_not_found}
defp fetch_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, _map} = result -> result
_ -> {:error, :map_not_found}
end
end
defp check_map_sse_enabled(map) do
if map.sse_enabled do
:ok
else
{:error, :sse_disabled_for_map}
end
end
# Checks if map has active subscription or if running Community Edition.
#
# Returns :ok if:
# - Community Edition (handled internally by is_subscription_active?/1), OR
# - Map has active subscription
#
# Returns {:error, :subscription_required} if subscription check fails.
defp check_subscription_or_ce(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} -> :ok
{:ok, false} -> {:error, :subscription_required}
{:error, _reason} = error -> error
end
end
end

View File

@@ -403,24 +403,10 @@ defmodule WandererApp.Kills.MessageHandler do
defp extract_field(_data, _field_names), do: nil
# Generic nested field extraction - tries flat keys first, then nested object
@spec extract_nested_field(map(), list(String.t()), String.t(), String.t()) :: String.t() | nil
defp extract_nested_field(data, flat_keys, nested_key, field) when is_map(data) do
case extract_field(data, flat_keys) do
nil ->
case data[nested_key] do
%{^field => value} when is_binary(value) and value != "" -> value
_ -> nil
end
value ->
value
end
end
# Specific field extractors using the generic functions
# Specific field extractors using the generic function
@spec get_character_name(map() | any()) :: String.t() | nil
defp get_character_name(data) when is_map(data) do
# Try multiple possible field names
field_names = ["attacker_name", "victim_name", "character_name", "name"]
extract_field(data, field_names) ||
@@ -433,26 +419,30 @@ defmodule WandererApp.Kills.MessageHandler do
defp get_character_name(_), do: nil
@spec get_corp_ticker(map() | any()) :: String.t() | nil
defp get_corp_ticker(data) when is_map(data),
do: extract_nested_field(data, ["corporation_ticker", "corp_ticker"], "corporation", "ticker")
defp get_corp_ticker(data) when is_map(data) do
extract_field(data, ["corporation_ticker", "corp_ticker"])
end
defp get_corp_ticker(_), do: nil
@spec get_corp_name(map() | any()) :: String.t() | nil
defp get_corp_name(data) when is_map(data),
do: extract_nested_field(data, ["corporation_name", "corp_name"], "corporation", "name")
defp get_corp_name(data) when is_map(data) do
extract_field(data, ["corporation_name", "corp_name"])
end
defp get_corp_name(_), do: nil
@spec get_alliance_ticker(map() | any()) :: String.t() | nil
defp get_alliance_ticker(data) when is_map(data),
do: extract_nested_field(data, ["alliance_ticker"], "alliance", "ticker")
defp get_alliance_ticker(data) when is_map(data) do
extract_field(data, ["alliance_ticker"])
end
defp get_alliance_ticker(_), do: nil
@spec get_alliance_name(map() | any()) :: String.t() | nil
defp get_alliance_name(data) when is_map(data),
do: extract_nested_field(data, ["alliance_name"], "alliance", "name")
defp get_alliance_name(data) when is_map(data) do
extract_field(data, ["alliance_name"])
end
defp get_alliance_name(_), do: nil

View File

@@ -205,7 +205,7 @@ defmodule WandererApp.Map do
characters_ids =
characters
|> Enum.map(fn %{character_id: char_id} -> char_id end)
|> Enum.map(fn %{id: char_id} -> char_id end)
# Filter out characters that already exist
new_character_ids =

View File

@@ -348,9 +348,9 @@ defmodule WandererApp.Map.CacheRTree do
[{x1_min, x1_max}, {y1_min, y1_max}] = box1
[{x2_min, x2_max}, {y2_min, y2_max}] = box2
# Boxes intersect if they overlap on both axes (strict intersection - not just touching)
x_overlap = x1_min < x2_max and x2_min < x1_max
y_overlap = y1_min < y2_max and y2_min < y1_max
# Boxes intersect if they overlap on both axes
x_overlap = x1_min <= x2_max and x2_min <= x1_max
y_overlap = y1_min <= y2_max and y2_min <= y1_max
x_overlap and y_overlap
end

View File

@@ -18,20 +18,10 @@ defmodule WandererApp.Map.MapPool do
@map_pool_limit 10
@garbage_collection_interval :timer.hours(4)
# Use very long timeouts in test environment to prevent background tasks from running during tests
# This avoids database connection ownership errors when tests finish before async tasks complete
@systems_cleanup_timeout if Mix.env() == :test,
do: :timer.hours(24),
else: :timer.minutes(30)
@characters_cleanup_timeout if Mix.env() == :test,
do: :timer.hours(24),
else: :timer.minutes(5)
@connections_cleanup_timeout if Mix.env() == :test,
do: :timer.hours(24),
else: :timer.minutes(5)
@backup_state_timeout if Mix.env() == :test,
do: :timer.hours(24),
else: :timer.minutes(1)
@systems_cleanup_timeout :timer.minutes(30)
@characters_cleanup_timeout :timer.minutes(5)
@connections_cleanup_timeout :timer.minutes(5)
@backup_state_timeout :timer.minutes(1)
def new(), do: __struct__()
def new(args), do: __struct__(args)
@@ -197,7 +187,7 @@ defmodule WandererApp.Map.MapPool do
# Schedule periodic tasks
Process.send_after(self(), :backup_state, @backup_state_timeout)
Process.send_after(self(), :cleanup_systems, @systems_cleanup_timeout)
Process.send_after(self(), :cleanup_systems, 15_000)
Process.send_after(self(), :cleanup_characters, @characters_cleanup_timeout)
Process.send_after(self(), :cleanup_connections, @connections_cleanup_timeout)
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)

View File

@@ -106,9 +106,6 @@ defmodule WandererApp.Map.PositionCalculator do
defp get_start_index(n, "top_to_bottom"), do: div(n, 2) + n - 1
# Default to left_to_right when layout is nil
defp get_start_index(n, nil), do: div(n, 2)
defp adjusted_coordinates(n, start_x, start_y, opts) when n > 1 do
sorted_coords = sorted_edge_coordinates(n, opts)

View File

@@ -56,8 +56,6 @@ defmodule WandererApp.Map.Server do
defdelegate update_system_temporary_name(map_id, update), to: Impl
defdelegate update_system_custom_name(map_id, update), to: Impl
defdelegate update_system_locked(map_id, update), to: Impl
defdelegate update_system_labels(map_id, update), to: Impl

View File

@@ -72,7 +72,7 @@ defmodule WandererApp.Map.Operations.Duplication do
Logger.debug("Copying systems for map #{source_map.id}")
# Get all systems from source map using Ash
case MapSystem.read_all_by_map(%{map_id: source_map.id}) do
case MapSystem |> Ash.Query.filter(map_id == ^source_map.id) |> Ash.read() do
{:ok, source_systems} ->
system_mapping = %{}
@@ -126,7 +126,7 @@ defmodule WandererApp.Map.Operations.Duplication do
defp copy_connections(source_map, new_map, system_mapping) do
Logger.debug("Copying connections for map #{source_map.id}")
case MapConnection.read_by_map(%{map_id: source_map.id}) do
case MapConnection |> Ash.Query.filter(map_id == ^source_map.id) |> Ash.read() do
{:ok, source_connections} ->
Enum.reduce_while(source_connections, {:ok, []}, fn source_connection,
{:ok, acc_connections} ->
@@ -222,7 +222,7 @@ defmodule WandererApp.Map.Operations.Duplication do
source_system_ids = Map.keys(system_mapping)
Enum.flat_map(source_system_ids, fn system_id ->
case MapSystemSignature.by_system_id_all(%{system_id: system_id}) do
case MapSystemSignature |> Ash.Query.filter(system_id == ^system_id) |> Ash.read() do
{:ok, signatures} -> signatures
{:error, _} -> []
end
@@ -355,7 +355,7 @@ defmodule WandererApp.Map.Operations.Duplication do
defp maybe_copy_user_settings(source_map, new_map, true) do
Logger.debug("Copying user settings for map #{source_map.id}")
case MapCharacterSettings.read_by_map(%{map_id: source_map.id}) do
case MapCharacterSettings |> Ash.Query.filter(map_id == ^source_map.id) |> Ash.read() do
{:ok, source_settings} ->
Enum.reduce_while(source_settings, {:ok, []}, fn source_setting, {:ok, acc_settings} ->
case copy_single_character_setting(source_setting, new_map.id) do

View File

@@ -8,38 +8,35 @@ defmodule WandererApp.Map.Operations.Signatures do
alias WandererApp.Api.{Character, MapSystem, MapSystemSignature}
alias WandererApp.Map.Server
# Private helper to validate character_eve_id from params and return internal character ID
# If character_eve_id is provided in params, validates it exists and returns the internal UUID
# If not provided, falls back to the owner's character ID (which is already the internal UUID)
@spec validate_character_eve_id(map() | nil, String.t()) ::
{:ok, String.t()} | {:error, :invalid_character} | {:error, :unexpected_error}
{:ok, String.t()} | {:error, :invalid_character}
defp validate_character_eve_id(params, fallback_char_id) when is_map(params) do
case Map.get(params, "character_eve_id") do
nil ->
# No character_eve_id provided, use fallback (owner's internal character UUID)
{:ok, fallback_char_id}
provided_char_eve_id when is_binary(provided_char_eve_id) ->
# Validate the provided character_eve_id exists and get internal UUID
case Character.by_eve_id(provided_char_eve_id) do
{:ok, character} ->
# Return the internal character UUID, not the eve_id
{:ok, character.id}
{:error, %Ash.Error.Query.NotFound{}} ->
_ ->
{:error, :invalid_character}
{:error, %Ash.Error.Invalid{}} ->
# Invalid format (e.g., non-numeric string for an integer field)
{:error, :invalid_character}
{:error, reason} ->
Logger.error(
"[validate_character_eve_id] Unexpected error looking up character: #{inspect(reason)}"
)
{:error, :unexpected_error}
end
_ ->
# Invalid format
{:error, :invalid_character}
end
end
# Handle nil or non-map params by falling back to owner's character
defp validate_character_eve_id(_params, fallback_char_id) do
{:ok, fallback_char_id}
end
@@ -77,8 +74,12 @@ defmodule WandererApp.Map.Operations.Signatures do
%{"solar_system_id" => solar_system_id} = params
)
when is_integer(solar_system_id) do
# Validate character first, then convert solar_system_id to system_id
# validated_char_uuid is the internal character UUID for Server.update_signatures
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, system} <- MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id) do
# Keep character_eve_id in attrs if provided by user (parse_signatures will use it)
# If not provided, parse_signatures will use the character_eve_id from validated_char_uuid lookup
attrs =
params
|> Map.put("system_id", system.id)
@@ -89,6 +90,7 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [],
removed_signatures: [],
solar_system_id: solar_system_id,
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
@@ -125,10 +127,6 @@ defmodule WandererApp.Map.Operations.Signatures do
Logger.error("[create_signature] Invalid character_eve_id provided")
{:error, :invalid_character}
{:error, :unexpected_error} ->
Logger.error("[create_signature] Unexpected error during character validation")
{:error, :unexpected_error}
_ ->
Logger.error(
"[create_signature] System not found for solar_system_id: #{solar_system_id}"
@@ -154,6 +152,8 @@ defmodule WandererApp.Map.Operations.Signatures do
sig_id,
params
) do
# Validate character first, then look up signature and system
# validated_char_uuid is the internal character UUID
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, sig} <- MapSystemSignature.by_id(sig_id),
{:ok, system} <- MapSystem.by_id(sig.system_id) do
@@ -177,6 +177,7 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [attrs],
removed_signatures: [],
solar_system_id: system.solar_system_id,
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
@@ -199,13 +200,9 @@ defmodule WandererApp.Map.Operations.Signatures do
Logger.error("[update_signature] Invalid character_eve_id provided")
{:error, :invalid_character}
{:error, :unexpected_error} ->
Logger.error("[update_signature] Unexpected error during character validation")
{:error, :unexpected_error}
err ->
Logger.error("[update_signature] Signature or system not found: #{inspect(err)}")
{:error, :not_found}
Logger.error("[update_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end

View File

@@ -35,22 +35,21 @@ defmodule WandererApp.Map.Operations.Systems do
# Private helper for batch upsert
defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do
with {:ok, solar_system_id} <- fetch_system_id(params) do
update_existing = fetch_update_existing(params, false)
{:ok, solar_system_id} = fetch_system_id(params)
update_existing = fetch_update_existing(params, false)
map_id
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
|> case do
{:ok, _location} ->
do_create_system(map_id, user_id, char_id, params)
map_id
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
|> case do
{:ok, _location} ->
do_create_system(map_id, user_id, char_id, params)
{:error, :already_exists} ->
if update_existing do
do_update_system(map_id, user_id, char_id, solar_system_id, params)
else
:ok
end
end
{:error, :already_exists} ->
if update_existing do
do_update_system(map_id, user_id, char_id, solar_system_id, params)
else
:ok
end
end
end
@@ -107,8 +106,8 @@ defmodule WandererApp.Map.Operations.Systems do
Logger.warning("[update_system] Expected error: #{inspect(reason)}")
{:error, :expected_error}
error ->
Logger.error("[update_system] Unexpected error: #{inspect(error)}")
_ ->
Logger.error("[update_system] Unexpected error")
{:error, :unexpected_error}
end
end
@@ -186,8 +185,6 @@ defmodule WandererApp.Map.Operations.Systems do
defp parse_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_int(val, _field) when is_float(val), do: {:ok, trunc(val)}
defp parse_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> {:ok, i}
@@ -271,9 +268,12 @@ defmodule WandererApp.Map.Operations.Systems do
})
"custom_name" ->
Server.update_system_custom_name(map_id, %{
{:ok, solar_system_info} =
WandererApp.CachedInfo.get_system_static_info(system_id)
Server.update_system_name(map_id, %{
solar_system_id: system_id,
custom_name: val
name: val || solar_system_info.solar_system_name
})
"temporary_name" ->

View File

@@ -21,7 +21,6 @@ defmodule WandererApp.Map.Server.Impl do
:map_id,
:rtree_name,
map: nil,
acls: [],
map_opts: []
]
@@ -45,12 +44,6 @@ defmodule WandererApp.Map.Server.Impl do
}
|> new()
# In test mode, give the test setup time to grant database access
# This is necessary for async tests where the sandbox needs to allow this process
if Mix.env() == :test do
Process.sleep(150)
end
# Parallelize database queries for faster initialization
start_time = System.monotonic_time(:millisecond)
@@ -58,15 +51,14 @@ defmodule WandererApp.Map.Server.Impl do
Task.async(fn ->
{:map,
WandererApp.MapRepo.get(map_id, [
:owner
:owner,
:characters,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
])}
end),
Task.async(fn ->
{:acls, WandererApp.Api.MapAccessList.read_by_map(%{map_id: map_id})}
end),
Task.async(fn ->
{:characters, WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)}
end),
Task.async(fn ->
{:systems, WandererApp.MapSystemRepo.get_visible_by_map(map_id)}
end),
@@ -100,18 +92,6 @@ defmodule WandererApp.Map.Server.Impl do
_ -> nil
end)
acls_result =
Enum.find_value(results, fn
{:acls, result} -> result
_ -> nil
end)
characters_result =
Enum.find_value(results, fn
{:characters, result} -> result
_ -> nil
end)
systems_result =
Enum.find_value(results, fn
{:systems, result} -> result
@@ -132,16 +112,12 @@ defmodule WandererApp.Map.Server.Impl do
# Process results
with {:ok, map} <- map_result,
{:ok, acls} <- acls_result,
{:ok, characters} <- characters_result,
{:ok, systems} <- systems_result,
{:ok, connections} <- connections_result,
{:ok, subscription_settings} <- subscription_result do
initial_state
|> init_map(
map,
acls,
characters,
subscription_settings,
systems,
connections
@@ -153,7 +129,7 @@ defmodule WandererApp.Map.Server.Impl do
end
end
def start_map(%__MODULE__{map: map, acls: acls, map_id: map_id} = _state) do
def start_map(%__MODULE__{map: map, map_id: map_id} = _state) do
WandererApp.Cache.insert("map_#{map_id}:started", false)
# Check if map was loaded successfully
@@ -163,7 +139,7 @@ defmodule WandererApp.Map.Server.Impl do
{:error, :map_not_loaded}
map ->
with :ok <- AclsImpl.track_acls(acls |> Enum.map(& &1.access_list_id)) do
with :ok <- AclsImpl.track_acls(map.acls |> Enum.map(& &1.id)) do
@pubsub_client.subscribe(
WandererApp.PubSub,
"maps:#{map_id}"
@@ -243,7 +219,6 @@ defmodule WandererApp.Map.Server.Impl do
defdelegate update_system_status(map_id, update), to: SystemsImpl
defdelegate update_system_tag(map_id, update), to: SystemsImpl
defdelegate update_system_temporary_name(map_id, update), to: SystemsImpl
defdelegate update_system_custom_name(map_id, update), to: SystemsImpl
defdelegate update_system_locked(map_id, update), to: SystemsImpl
defdelegate update_system_labels(map_id, update), to: SystemsImpl
defdelegate update_system_linked_sig_eve_id(map_id, update), to: SystemsImpl
@@ -313,57 +288,12 @@ defmodule WandererApp.Map.Server.Impl do
acc |> Map.put_new(connection_id, connection_start_time)
end)
# Create map state with retry logic for test scenarios
create_map_state_with_retry(
%{
map_id: map_id,
systems_last_activity: systems_last_activity,
connections_eol_time: connections_eol_time,
connections_start_time: connections_start_time
},
3
)
end
# Helper to create map state with retry logic for async tests
defp create_map_state_with_retry(attrs, retries_left) when retries_left > 0 do
case WandererApp.Api.MapState.create(attrs) do
{:ok, map_state} = result ->
result
{:error, %Ash.Error.Invalid{errors: errors}} = error ->
# Check if it's a foreign key constraint error
has_fkey_error =
Enum.any?(errors, fn
%Ash.Error.Changes.InvalidAttribute{private_vars: private_vars} ->
Enum.any?(private_vars, fn
{:constraint_type, :foreign_key} -> true
_ -> false
end)
_ ->
false
end)
if has_fkey_error and retries_left > 1 do
# In test environments with async tests, the parent map might not be
# visible yet due to sandbox timing. Brief retry with exponential backoff.
sleep_time = (4 - retries_left) * 15 + 10
Process.sleep(sleep_time)
create_map_state_with_retry(attrs, retries_left - 1)
else
# Return error if not a foreign key issue or out of retries
error
end
error ->
error
end
end
defp create_map_state_with_retry(attrs, 0) do
# Final attempt without retry
WandererApp.Api.MapState.create(attrs)
WandererApp.Api.MapState.create(%{
map_id: map_id,
systems_last_activity: systems_last_activity,
connections_eol_time: connections_eol_time,
connections_start_time: connections_start_time
})
end
def handle_event({:update_characters, map_id} = event) do
@@ -550,9 +480,7 @@ defmodule WandererApp.Map.Server.Impl do
defp init_map(
state,
%{id: map_id} = initial_map,
acls,
characters,
%{id: map_id, characters: characters} = initial_map,
subscription_settings,
systems,
connections
@@ -581,7 +509,7 @@ defmodule WandererApp.Map.Server.Impl do
WandererApp.Cache.insert("map_#{map_id}:invalidate_character_ids", character_ids)
%{state | map: map, acls: acls, map_opts: map_options(options)}
%{state | map: map, map_opts: map_options(options)}
end
def maybe_import_systems(

View File

@@ -106,7 +106,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
) do
system =
WandererApp.Map.find_system_by_location(map_id, %{
solar_system_id: solar_system_id
solar_system_id: solar_system_id |> String.to_integer()
})
{:ok, comment} =
@@ -118,7 +118,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
comment =
comment
|> Ash.load!([:character])
|> Ash.load!([:character, :system])
Impl.broadcast!(map_id, :system_comment_added, %{
solar_system_id: solar_system_id,
@@ -132,11 +132,9 @@ defmodule WandererApp.Map.Server.SystemsImpl do
user_id,
character_id
) do
{:ok, %{system_id: system_id} = comment} =
{:ok, %{system: system} = comment} =
WandererApp.MapSystemCommentRepo.get_by_id(comment_id)
{:ok, system} = WandererApp.Api.MapSystem.by_id(system_id)
:ok = WandererApp.MapSystemCommentRepo.destroy(comment)
Impl.broadcast!(map_id, :system_comment_removed, %{
@@ -215,12 +213,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
),
do: update_system(map_id, :update_temporary_name, [:temporary_name], update)
def update_system_custom_name(
map_id,
update
),
do: update_system(map_id, :update_custom_name, [:custom_name], update)
def update_system_locked(
map_id,
update
@@ -655,135 +647,104 @@ defmodule WandererApp.Map.Server.SystemsImpl do
user_id,
character_id
) do
# Verify the map exists in the database before attempting to create a system
# This prevents foreign key constraint errors when tests roll back transactions
with {:ok, _map} <- WandererApp.MapRepo.get(map_id),
{:ok, %{map_opts: map_opts}} <- WandererApp.Map.get_map_state(map_id) do
extra_info = system_info |> Map.get(:extra_info)
rtree_name = "rtree_#{map_id}"
extra_info = system_info |> Map.get(:extra_info)
rtree_name = "rtree_#{map_id}"
{:ok, %{map_opts: map_opts}} = WandererApp.Map.get_map_state(map_id)
%{"x" => x, "y" => y} =
coordinates
|> case do
%{"x" => x, "y" => y} ->
%{"x" => x, "y" => y}
%{"x" => x, "y" => y} =
coordinates
|> case do
%{"x" => x, "y" => y} ->
%{"x" => x, "y" => y}
_ ->
%{x: x, y: y} =
WandererApp.Map.PositionCalculator.get_new_system_position(nil, rtree_name, map_opts)
_ ->
%{x: x, y: y} =
WandererApp.Map.PositionCalculator.get_new_system_position(nil, rtree_name, map_opts)
%{"x" => x, "y" => y}
end
%{"x" => x, "y" => y}
end
system_result =
case WandererApp.MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
{:ok, existing_system} when not is_nil(existing_system) ->
use_old_coordinates = Map.get(system_info, :use_old_coordinates, false)
{:ok, system} =
case WandererApp.MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
{:ok, existing_system} when not is_nil(existing_system) ->
use_old_coordinates = Map.get(system_info, :use_old_coordinates, false)
if use_old_coordinates do
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: existing_system.position_x,
position_y: existing_system.position_y
})},
rtree_name
)
if use_old_coordinates do
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: existing_system.position_x,
position_y: existing_system.position_y
})},
rtree_name
)
existing_system
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
else
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
existing_system
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
else
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
existing_system
|> WandererApp.MapSystemRepo.update_position!(%{position_x: x, position_y: y})
|> WandererApp.MapSystemRepo.cleanup_labels!(map_opts)
|> WandererApp.MapSystemRepo.cleanup_tags!()
|> WandererApp.MapSystemRepo.cleanup_temporary_name!()
|> WandererApp.MapSystemRepo.cleanup_linked_sig_eve_id!()
|> maybe_update_extra_info(extra_info)
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
end
existing_system
|> WandererApp.MapSystemRepo.update_position!(%{position_x: x, position_y: y})
|> WandererApp.MapSystemRepo.cleanup_labels!(map_opts)
|> WandererApp.MapSystemRepo.cleanup_tags!()
|> WandererApp.MapSystemRepo.cleanup_temporary_name!()
|> WandererApp.MapSystemRepo.cleanup_linked_sig_eve_id!()
|> maybe_update_extra_info(extra_info)
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
end
_ ->
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
{:ok, solar_system_info} ->
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
_ ->
{:ok, solar_system_info} =
WandererApp.CachedInfo.get_system_static_info(solar_system_id)
WandererApp.MapSystemRepo.create(%{
map_id: map_id,
solar_system_id: solar_system_id,
name: solar_system_info.solar_system_name,
position_x: x,
position_y: y
})
{:error, reason} ->
Logger.error("Failed to get system static info for #{solar_system_id}: #{inspect(reason)}")
{:error, :system_info_not_found}
end
end
case system_result do
{:ok, system} ->
:ok = WandererApp.Map.add_system(map_id, system)
WandererApp.Cache.put(
"map_#{map_id}:system_#{system.id}:last_activity",
DateTime.utc_now(),
ttl: @system_inactive_timeout
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
Impl.broadcast!(map_id, :add_system, system)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
Logger.debug(fn ->
"SystemsImpl.do_add_system calling ExternalEvents.broadcast for map #{map_id}, system: #{solar_system_id}"
end)
WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{
solar_system_id: system.solar_system_id,
position_x: system.position_x,
position_y: system.position_y
WandererApp.MapSystemRepo.create(%{
map_id: map_id,
solar_system_id: solar_system_id,
name: solar_system_info.solar_system_name,
position_x: x,
position_y: y
})
track_add_system(map_id, user_id, character_id, system.solar_system_id)
:ok
{:error, reason} = error ->
Logger.error("Failed to add system #{solar_system_id} to map #{map_id}: #{inspect(reason)}")
error
end
else
{:error, :not_found} ->
Logger.debug(fn ->
"Cannot add system #{solar_system_id} to map #{map_id}: map does not exist in database"
end)
{:error, :map_not_found}
:ok = WandererApp.Map.add_system(map_id, system)
error ->
Logger.error("Failed to verify map #{map_id} exists: #{inspect(error)}")
{:error, :map_verification_failed}
end
end
WandererApp.Cache.put(
"map_#{map_id}:system_#{system.id}:last_activity",
DateTime.utc_now(),
ttl: @system_inactive_timeout
)
Impl.broadcast!(map_id, :add_system, system)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
Logger.debug(fn ->
"SystemsImpl.do_add_system calling ExternalEvents.broadcast for map #{map_id}, system: #{solar_system_id}"
end)
WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{
solar_system_id: system.solar_system_id,
name: system.name,
position_x: system.position_x,
position_y: system.position_y
})
defp track_add_system(map_id, user_id, character_id, solar_system_id) do
WandererApp.User.ActivityTracker.track_map_event(:system_added, %{
character_id: character_id,
user_id: user_id,
@@ -969,7 +930,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
Impl.broadcast!(map_id, :update_system, updated_system)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
# This may fail if the relay is not available (e.g., in tests), which is fine
WandererApp.ExternalEvents.broadcast(map_id, :system_metadata_changed, %{
solar_system_id: updated_system.solar_system_id,
name: updated_system.name,
@@ -978,7 +938,5 @@ defmodule WandererApp.Map.Server.SystemsImpl do
description: updated_system.description,
status: updated_system.status
})
:ok
end
end

View File

@@ -132,14 +132,9 @@ defmodule WandererApp.Maps do
WandererApp.Cache.lookup!("map_characters-#{map_id}")
|> case do
nil ->
{:ok, acls} =
WandererApp.Api.MapAccessList.read_by_map(%{map_id: map_id},
load: [access_list: [:owner, :members]]
)
map_acls =
acls
|> Enum.map(fn acl -> acl.access_list end)
map.acls
|> Enum.map(fn acl -> acl |> Ash.load!(:members) end)
map_acl_owner_ids =
map_acls
@@ -203,7 +198,10 @@ defmodule WandererApp.Maps do
is_member_corp = to_string(c.corporation_id) in map_member_corporation_ids
is_member_alliance = to_string(c.alliance_id) in map_member_alliance_ids
is_owner || is_acl_owner || is_member_eve || is_member_corp || is_member_alliance
has_access =
is_owner or is_acl_owner or is_member_eve or is_member_corp or is_member_alliance
has_access
end)
end
@@ -247,11 +245,11 @@ defmodule WandererApp.Maps do
members ->
members
|> Enum.any?(fn member ->
(member.role == :blocked &&
(member.role == :blocked and
member.eve_character_id in user_character_eve_ids) or
(member.role == :blocked &&
(member.role == :blocked and
member.eve_corporation_id in user_character_corporation_ids) or
(member.role == :blocked &&
(member.role == :blocked and
member.eve_alliance_id in user_character_alliance_ids)
end)
end
@@ -334,7 +332,9 @@ defmodule WandererApp.Maps do
end
def check_user_can_delete_map(map_slug, current_user) do
WandererApp.MapRepo.get_by_slug_with_permissions(map_slug, current_user)
map_slug
|> WandererApp.Api.Map.get_map_by_slug()
|> Ash.load([:owner, :acls, :user_permissions], actor: current_user)
|> case do
{:ok,
%{

View File

@@ -53,40 +53,22 @@ defmodule WandererApp.MapCharacterSettingsRepo do
def get_tracked_by_map_all(map_id),
do: WandererApp.Api.MapCharacterSettings.tracked_by_map_all(%{map_id: map_id})
def track(%{map_id: map_id, character_id: character_id}) do
def track(settings) do
{:ok, _} = get(settings.map_id, settings.character_id)
# Only update the tracked field, preserving other fields
case WandererApp.Api.MapCharacterSettings.track(%{
map_id: map_id,
character_id: character_id
}) do
{:ok, _} ->
:ok
error ->
Logger.error(
"Failed to track character: #{character_id} on map: #{map_id}, #{inspect(error)}"
)
{:error, error}
end
WandererApp.Api.MapCharacterSettings.track(%{
map_id: settings.map_id,
character_id: settings.character_id
})
end
def untrack(%{map_id: map_id, character_id: character_id}) do
def untrack(settings) do
{:ok, _} = get(settings.map_id, settings.character_id)
# Only update the tracked field, preserving other fields
case WandererApp.Api.MapCharacterSettings.untrack(%{
map_id: map_id,
character_id: character_id
}) do
{:ok, _} ->
:ok
error ->
Logger.error(
"Failed to untrack character: #{character_id} on map: #{map_id}, #{inspect(error)}"
)
{:error, error}
end
WandererApp.Api.MapCharacterSettings.untrack(%{
map_id: settings.map_id,
character_id: settings.character_id
})
end
def track!(settings) do

View File

@@ -97,17 +97,9 @@ defmodule WandererApp.MapConnectionRepo do
|> WandererApp.Api.MapConnection.update_custom_info(update)
def get_by_id(map_id, id) do
# Use read_by_map action which doesn't have the FilterConnectionsByActorMap preparation
# that was causing "filter being false" errors in tests
import Ash.Query
WandererApp.Api.MapConnection
|> Ash.Query.for_read(:read_by_map, %{map_id: map_id})
|> Ash.Query.filter(id == ^id)
|> Ash.read_one()
|> case do
{:ok, nil} -> {:error, :not_found}
{:ok, conn} -> {:ok, conn}
case WandererApp.Api.MapConnection.by_id(id) do
{:ok, conn} when conn.map_id == map_id -> {:ok, conn}
{:ok, _} -> {:error, :not_found}
{:error, _} -> {:error, :not_found}
end
end

View File

@@ -1,56 +0,0 @@
defmodule WandererApp.Repositories.MapContextHelper do
@moduledoc """
Helper for providing map context to Ash actions from internal callers.
When InjectMapFromActor is used, internal callers (map duplication, seeds, etc.)
need a way to provide map context without going through token auth.
This helper creates a minimal map struct for the context.
"""
@doc """
Build Ash context options from attributes containing map_id.
Returns a keyword list suitable for passing to Ash actions.
If attrs contains :map_id, creates a context with a minimal map struct.
If no map_id present, returns an empty list.
## Examples
iex> MapContextHelper.build_context(%{map_id: "123", name: "System"})
[context: %{map: %{id: "123"}}]
iex> MapContextHelper.build_context(%{name: "System"})
[]
iex> MapContextHelper.build_context(%{map_id: nil, name: "System"})
[]
"""
def build_context(attrs) when is_map(attrs) do
case Map.get(attrs, :map_id) do
nil -> []
map_id -> [context: %{map: %{id: map_id}}]
end
end
@doc """
Wraps an Ash action call with map context.
Deprecated: Use `build_context/1` instead for a simpler API.
## Examples
# Deprecated callback-based approach
MapContextHelper.with_map_context(%{map_id: "123", name: "System"}, fn attrs, context ->
WandererApp.Api.MapSystem.create(attrs, context)
end)
# Preferred approach using build_context/1
context = MapContextHelper.build_context(attrs)
WandererApp.Api.MapSystem.create(attrs, context)
"""
@deprecated "Use build_context/1 instead"
def with_map_context(attrs, fun) when is_map(attrs) and is_function(fun, 2) do
context = build_context(attrs)
fun.(attrs, context)
end
end

View File

@@ -26,20 +26,11 @@ defmodule WandererApp.MapRepo do
end
end
def get_by_slug_with_permissions(map_slug, current_user) do
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load(
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
|> case do
{:ok, map_with_acls} -> Ash.load(map_with_acls, :user_permissions, actor: current_user)
error -> error
end
end
def get_by_slug_with_permissions(map_slug, current_user),
do:
map_slug
|> WandererApp.Api.Map.get_map_by_slug()
|> load_user_permissions(current_user)
@doc """
Safely retrieves a map by slug, handling the case where multiple maps
@@ -69,19 +60,13 @@ defmodule WandererApp.MapRepo do
handle_multiple_results(slug, multiple_results_error, retry_count)
:error ->
# Check if this is a no results error
if is_no_results_error?(error) do
Logger.debug("Map not found with slug: #{slug}")
{:error, :not_found}
else
# Some other Invalid error
Logger.error("Error retrieving map by slug",
slug: slug,
error: inspect(error)
)
# Some other Invalid error
Logger.error("Error retrieving map by slug",
slug: slug,
error: inspect(error)
)
{:error, :unknown_error}
end
{:error, :unknown_error}
end
error in Ash.Error.Query.NotFound ->
@@ -157,18 +142,17 @@ defmodule WandererApp.MapRepo do
end)
end
# Helper function to check if an error indicates no results were found
defp is_no_results_error?(%Ash.Error.Invalid{errors: errors}) do
# If errors list is empty, it's likely a no results error
Enum.empty?(errors)
end
defp is_no_results_error?(_), do: false
def load_relationships(map, []), do: {:ok, map}
def load_relationships(map, relationships), do: map |> Ash.load(relationships)
defp load_user_permissions({:ok, map}, current_user),
do:
map
|> Ash.load([:acls, :user_permissions], actor: current_user)
defp load_user_permissions(error, _current_user), do: error
def update_hubs(map_id, hubs) do
map_id
|> WandererApp.Api.Map.by_id()

View File

@@ -4,10 +4,10 @@ defmodule WandererApp.MapSystemCommentRepo do
require Logger
def get_by_id(comment_id),
do: WandererApp.Api.MapSystemComment.by_id(comment_id)
do: WandererApp.Api.MapSystemComment.by_id!(comment_id) |> Ash.load([:system])
def get_by_system(system_id),
do: WandererApp.Api.MapSystemComment.by_system_id(system_id, load: [:character])
do: WandererApp.Api.MapSystemComment.by_system_id(system_id)
def create(comment), do: comment |> WandererApp.Api.MapSystemComment.create()
def create!(comment), do: comment |> WandererApp.Api.MapSystemComment.create!()

View File

@@ -1,11 +1,8 @@
defmodule WandererApp.MapSystemRepo do
use WandererApp, :repository
alias WandererApp.Repositories.MapContextHelper
def create(system) do
context = MapContextHelper.build_context(system)
WandererApp.Api.MapSystem.create(system, context)
system |> WandererApp.Api.MapSystem.create()
end
def upsert(system) do
@@ -13,15 +10,12 @@ defmodule WandererApp.MapSystemRepo do
end
def get_by_map_and_solar_system_id(map_id, solar_system_id) do
WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: solar_system_id
})
WandererApp.Api.MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id)
|> case do
{:ok, system} ->
{:ok, system}
_error ->
_ ->
{:error, :not_found}
end
end
@@ -129,16 +123,10 @@ defmodule WandererApp.MapSystemRepo do
system
|> WandererApp.Api.MapSystem.update_description(update)
def update_locked(system, update) do
case WandererApp.Api.MapSystem.update_locked(system, update) do
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} ->
WandererApp.Api.MapSystem.by_id!(system.id)
|> WandererApp.Api.MapSystem.update_locked(update)
{:ok, system} ->
{:ok, system}
end
end
def update_locked(system, update),
do:
system
|> WandererApp.Api.MapSystem.update_locked(update)
def update_status(system, update),
do:
@@ -155,11 +143,6 @@ defmodule WandererApp.MapSystemRepo do
|> WandererApp.Api.MapSystem.update_temporary_name(update)
end
def update_custom_name(system, update) do
system
|> WandererApp.Api.MapSystem.update_custom_name(update)
end
def update_labels(system, update),
do:
system

View File

@@ -501,16 +501,13 @@ defmodule WandererApp.SecurityAudit do
# Ensure event_type is properly formatted
event_type = normalize_event_type(audit_entry.event_type)
# Generate unique entity_id to avoid constraint violations
entity_id = generate_entity_id(audit_entry.session_id)
attrs = %{
entity_id: entity_id,
user_id: audit_entry.user_id,
character_id: nil,
entity_id: hash_identifier(audit_entry.session_id),
entity_type: :security_event,
event_type: event_type,
event_data: encode_event_data(audit_entry),
user_id: audit_entry.user_id,
character_id: nil
event_data: encode_event_data(audit_entry)
}
case UserActivity.new(attrs) do
@@ -622,13 +619,8 @@ defmodule WandererApp.SecurityAudit do
defp convert_datetime(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp convert_datetime(value), do: value
defp generate_entity_id(session_id \\ nil) do
if session_id do
# Include high-resolution timestamp and unique component for guaranteed uniqueness
"#{hash_identifier(session_id)}_#{:os.system_time(:microsecond)}_#{System.unique_integer([:positive])}"
else
"audit_#{:os.system_time(:microsecond)}_#{System.unique_integer([:positive])}"
end
defp generate_entity_id do
"audit_#{DateTime.utc_now() |> DateTime.to_unix(:microsecond)}_#{System.unique_integer([:positive])}"
end
defp async_enabled? do

View File

@@ -88,21 +88,20 @@ defmodule WandererApp.SecurityAudit.AsyncProcessor do
def handle_cast({:log_event, audit_entry}, state) do
# Add to buffer
buffer = [audit_entry | state.buffer]
buf_len = length(buffer)
# Update stats
stats = Map.update!(state.stats, :events_processed, &(&1 + 1))
# Check if we need to flush
cond do
buf_len >= state.batch_size ->
length(buffer) >= state.batch_size ->
# Flush immediately if batch size reached
{:noreply, do_flush(%{state | buffer: buffer, stats: stats})}
buf_len >= @max_buffer_size ->
length(buffer) >= @max_buffer_size ->
# Force flush if max buffer size reached
Logger.warning("Security audit buffer overflow, forcing flush",
buffer_size: buf_len,
buffer_size: length(buffer),
max_size: @max_buffer_size
)
@@ -187,66 +186,23 @@ defmodule WandererApp.SecurityAudit.AsyncProcessor do
# Clear buffer
%{state | buffer: [], stats: stats}
{:partial, success_count, failed_events} ->
failed_count = length(failed_events)
Logger.warning(
"Partial flush: stored #{success_count}, failed #{failed_count} audit events",
success_count: success_count,
failed_count: failed_count,
buffer_size: length(state.buffer)
)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :security_audit, :async_flush_partial],
%{success_count: success_count, failed_count: failed_count},
%{}
)
# Update stats - count partial flush as both success and error
stats =
state.stats
|> Map.update!(:batches_flushed, &(&1 + 1))
|> Map.update!(:errors, &(&1 + 1))
|> Map.put(:last_flush, DateTime.utc_now())
# Extract just the events from failed_events tuples
failed_only = Enum.map(failed_events, fn {event, _reason} -> event end)
remaining_buffer = Enum.reject(state.buffer, fn ev -> ev in failed_only end)
# Re-buffer failed events at the front, preserving newest-first ordering
# Reverse failed_only since flush reversed the buffer to oldest-first
new_buffer = Enum.reverse(failed_only) ++ remaining_buffer
buffer = handle_buffer_overflow(new_buffer, @max_buffer_size)
%{state | buffer: buffer, stats: stats}
{:error, failed_events} ->
failed_count = length(failed_events)
Logger.error("Failed to flush all #{failed_count} security audit events",
failed_count: failed_count,
buffer_size: length(state.buffer)
)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :security_audit, :async_flush_failure],
%{count: 1, event_count: failed_count},
%{}
{:error, reason} ->
Logger.error("Failed to flush security audit events",
reason: inspect(reason),
event_count: length(events)
)
# Update error stats
stats = Map.update!(state.stats, :errors, &(&1 + 1))
# Extract just the events from failed_events tuples
failed_only = Enum.map(failed_events, fn {event, _reason} -> event end)
# Since ALL events failed, the new buffer should only contain the failed events
# Reverse to maintain newest-first ordering (flush reversed to oldest-first)
buffer = handle_buffer_overflow(Enum.reverse(failed_only), @max_buffer_size)
# Implement backoff - keep events in buffer but don't grow indefinitely
buffer =
if length(state.buffer) > @max_buffer_size do
Logger.warning("Dropping oldest audit events due to repeated flush failures")
Enum.take(state.buffer, @max_buffer_size)
else
state.buffer
end
%{state | buffer: buffer, stats: stats}
end
@@ -257,100 +213,34 @@ defmodule WandererApp.SecurityAudit.AsyncProcessor do
events
# Ash bulk operations work better with smaller chunks
|> Enum.chunk_every(50)
|> Enum.reduce({0, []}, fn chunk, {total_success, all_failed} ->
|> Enum.reduce_while({:ok, 0}, fn chunk, {:ok, count} ->
case store_event_chunk(chunk) do
{:ok, chunk_count} ->
{total_success + chunk_count, all_failed}
{:cont, {:ok, count + chunk_count}}
{:partial, chunk_count, failed_events} ->
{total_success + chunk_count, all_failed ++ failed_events}
{:error, failed_events} ->
{total_success, all_failed ++ failed_events}
end
end)
|> then(fn {success_count, failed_events_list} ->
# Derive the final return shape based on results
cond do
failed_events_list == [] ->
{:ok, success_count}
success_count == 0 ->
{:error, failed_events_list}
true ->
{:partial, success_count, failed_events_list}
{:error, _} = error ->
{:halt, error}
end
end)
end
defp handle_buffer_overflow(buffer, max_size) when length(buffer) > max_size do
dropped = length(buffer) - max_size
Logger.warning(
"Dropping #{dropped} oldest audit events due to buffer overflow",
buffer_size: length(buffer),
max_size: max_size
)
# Emit telemetry for dropped events
:telemetry.execute(
[:wanderer_app, :security_audit, :events_dropped],
%{count: dropped},
%{}
)
# Keep the newest events (take from the front since buffer is newest-first)
Enum.take(buffer, max_size)
end
defp handle_buffer_overflow(buffer, _max_size), do: buffer
defp store_event_chunk(events) do
# Process each event and partition results
{successes, failures} =
events
|> Enum.map(fn event ->
case SecurityAudit.do_store_audit_entry(event) do
:ok ->
{:ok, event}
{:error, reason} ->
Logger.error("Failed to store individual audit event",
error: inspect(reason),
event_type: Map.get(event, :event_type),
user_id: Map.get(event, :user_id)
)
{:error, {event, reason}}
end
end)
|> Enum.split_with(fn
{:ok, _} -> true
{:error, _} -> false
# Transform events to Ash attributes
records =
Enum.map(events, fn event ->
SecurityAudit.do_store_audit_entry(event)
end)
successful_count = length(successes)
failed_count = length(failures)
# Count successful stores
successful =
Enum.count(records, fn
:ok -> true
_ -> false
end)
# Extract failed events with reasons
failed_events = Enum.map(failures, fn {:error, event_reason} -> event_reason end)
# Log if some events failed (telemetry will be emitted at flush level)
if failed_count > 0 do
Logger.debug("Chunk processing: #{failed_count} of #{length(events)} events failed")
end
# Return richer result shape
cond do
successful_count == 0 ->
{:error, failed_events}
failed_count > 0 ->
{:partial, successful_count, failed_events}
true ->
{:ok, successful_count}
end
{:ok, successful}
rescue
error ->
{:error, error}
end
end

View File

@@ -12,16 +12,11 @@ defmodule WandererAppWeb.ApiSpecV1 do
# Get the base spec from the original
base_spec = WandererAppWeb.ApiSpec.spec()
# Get v1 spec
# Get v1 spec
v1_spec = WandererAppWeb.OpenApiV1Spec.spec()
# Tag legacy paths and v1 paths appropriately
tagged_legacy_paths = tag_paths(base_spec.paths || %{}, "Legacy API")
# v1 paths already have tags from AshJsonApi, keep them as-is
v1_paths = v1_spec.paths || %{}
# Merge the specs
merged_paths = Map.merge(tagged_legacy_paths, v1_paths)
merged_paths = Map.merge(base_spec.paths || %{}, v1_spec.paths || %{})
# Merge components
merged_components = %Components{
@@ -89,53 +84,11 @@ defmodule WandererAppWeb.ApiSpecV1 do
# Get tags from v1 spec if available
spec_tags = Map.get(v1_spec, :tags, [])
base_tags ++ spec_tags
# Add custom v1 tags
v1_label_tags = [
%{name: "v1 JSON:API", description: "JSON:API compliant endpoints with advanced querying"}
]
base_tags ++ v1_label_tags ++ spec_tags
end
# Tag all operations in paths with the given tag
defp tag_paths(paths, tag) when is_map(paths) do
Map.new(paths, fn {path, path_item} ->
{path, tag_path_item(path_item, tag)}
end)
end
# Handle OpenApiSpex.PathItem structs
defp tag_path_item(%OpenApiSpex.PathItem{} = path_item, tag) do
path_item
|> maybe_tag_operation(:get, tag)
|> maybe_tag_operation(:put, tag)
|> maybe_tag_operation(:post, tag)
|> maybe_tag_operation(:delete, tag)
|> maybe_tag_operation(:patch, tag)
|> maybe_tag_operation(:options, tag)
|> maybe_tag_operation(:head, tag)
end
# Handle plain maps (from AshJsonApi)
defp tag_path_item(path_item, tag) when is_map(path_item) do
Map.new(path_item, fn {method, operation} ->
{method, add_tag_to_operation(operation, tag)}
end)
end
defp tag_path_item(path_item, _tag), do: path_item
defp maybe_tag_operation(path_item, method, tag) do
case Map.get(path_item, method) do
nil -> path_item
operation -> Map.put(path_item, method, add_tag_to_operation(operation, tag))
end
end
defp add_tag_to_operation(%OpenApiSpex.Operation{} = operation, tag) do
%{operation | tags: [tag | List.wrap(operation.tags)]}
end
defp add_tag_to_operation(%{} = operation, tag) do
Map.update(operation, :tags, [tag], fn existing_tags ->
[tag | List.wrap(existing_tags)]
end)
end
defp add_tag_to_operation(operation, _tag), do: operation
end

View File

@@ -6,12 +6,5 @@ defmodule WandererAppWeb.ApiV1Router do
json_schema: "/json_schema",
open_api_title: "WandererApp v1 JSON:API",
open_api_version: "1.0.0",
modify_open_api: {WandererAppWeb.OpenApi, :spec, []},
modify_conn: {__MODULE__, :add_context, []}
def add_context(conn, _resource) do
# Actor is set by CheckJsonApiAuth using Ash.PlugHelpers.set_actor/2
# The actor (ActorWithMap) is passed to Ash actions automatically
conn
end
modify_open_api: {WandererAppWeb.OpenApi, :spec, []}
end

View File

@@ -3,37 +3,6 @@ defmodule WandererAppWeb.Api.EventsController do
Controller for Server-Sent Events (SSE) streaming.
Provides real-time event streaming for map updates to external clients.
## Error Handling
All error responses use structured JSON format for consistency with the API:
{
"error": "Human-readable error message",
"code": "MACHINE_READABLE_CODE",
"status": 403
}
## Error Codes
- `SSE_GLOBALLY_DISABLED` - SSE disabled in server configuration
- `SSE_DISABLED_FOR_MAP` - SSE disabled for this specific map
- `SUBSCRIPTION_REQUIRED` - Active subscription required (Enterprise mode)
- `MAP_NOT_FOUND` - Requested map does not exist
- `UNAUTHORIZED` - Invalid or missing API key
- `MAP_CONNECTION_LIMIT` - Too many concurrent connections to this map
- `API_KEY_CONNECTION_LIMIT` - Too many connections for this API key
- `INTERNAL_SERVER_ERROR` - Unexpected server error
## Access Control
SSE connections require:
1. Valid API key (Bearer token)
2. SSE enabled globally (server config)
3. SSE enabled for the specific map
4. Active subscription (Enterprise mode only)
See `WandererApp.ExternalEvents.SseAccessControl` for details.
"""
use WandererAppWeb, :controller
@@ -59,55 +28,25 @@ defmodule WandererAppWeb.Api.EventsController do
- format: Event format - "legacy" (default) or "jsonapi" for JSON:API compliance
"""
def stream(conn, %{"map_identifier" => map_identifier} = params) do
case validate_api_key(conn, map_identifier) do
{:ok, map, api_key} ->
case WandererApp.ExternalEvents.SseAccessControl.sse_allowed?(map.id) do
:ok ->
establish_sse_connection(conn, map.id, api_key, params)
Logger.debug(fn -> "SSE stream requested for map #{map_identifier}" end)
{:error, :sse_globally_disabled} ->
send_sse_error(
conn,
503,
"Server-Sent Events are disabled on this server",
"SSE_GLOBALLY_DISABLED"
)
# Check if SSE is enabled
unless WandererApp.Env.sse_enabled?() do
conn
|> put_status(:service_unavailable)
|> put_resp_content_type("text/plain")
|> send_resp(503, "Server-Sent Events are disabled on this server")
else
# Validate API key and get map
case validate_api_key(conn, map_identifier) do
{:ok, map, api_key} ->
establish_sse_connection(conn, map.id, api_key, params)
{:error, :sse_disabled_for_map} ->
send_sse_error(
conn,
403,
"Server-Sent Events are disabled for this map",
"SSE_DISABLED_FOR_MAP"
)
{:error, :subscription_required} ->
send_sse_error(
conn,
402,
"Active subscription required for Server-Sent Events",
"SUBSCRIPTION_REQUIRED"
)
{:error, _reason} ->
send_sse_error(
conn,
403,
"Server-Sent Events not available",
"SSE_NOT_AVAILABLE"
)
end
{:error, status, message} ->
# Map validation errors to appropriate codes
code =
case status do
401 -> "UNAUTHORIZED"
404 -> "MAP_NOT_FOUND"
_ -> "SSE_ERROR"
end
send_sse_error(conn, status, message, code)
{:error, status, message} ->
conn
|> put_status(status)
|> json(%{error: message})
end
end
end
@@ -166,24 +105,27 @@ defmodule WandererAppWeb.Api.EventsController do
stream_events(conn, map_id, api_key, event_filter, event_format)
{:error, :map_limit_exceeded} ->
send_sse_error(
conn,
429,
"Too many connections to this map",
"MAP_CONNECTION_LIMIT"
)
conn
|> put_status(:too_many_requests)
|> json(%{
error: "Too many connections to this map",
code: "MAP_CONNECTION_LIMIT"
})
{:error, :api_key_limit_exceeded} ->
send_sse_error(
conn,
429,
"Too many connections for this API key",
"API_KEY_CONNECTION_LIMIT"
)
conn
|> put_status(:too_many_requests)
|> json(%{
error: "Too many connections for this API key",
code: "API_KEY_CONNECTION_LIMIT"
})
{:error, reason} ->
Logger.error("Failed to add SSE client: #{inspect(reason)}")
send_sse_error(conn, 500, "Internal server error", "INTERNAL_SERVER_ERROR")
conn
|> put_status(:internal_server_error)
|> send_resp(500, "Internal server error")
end
end
@@ -347,19 +289,19 @@ defmodule WandererAppWeb.Api.EventsController do
else
[] ->
Logger.warning("Missing or invalid 'Bearer' token")
{:error, 401, "Missing or invalid 'Bearer' token"}
{:error, :unauthorized, "Missing or invalid 'Bearer' token"}
{:error, :not_found} ->
Logger.warning("Map not found: #{map_identifier}")
{:error, 404, "Map not found"}
{:error, :not_found, "Map not found"}
false ->
Logger.warning("Unauthorized: invalid token for map #{map_identifier}")
{:error, 401, "Unauthorized (invalid token for map)"}
{:error, :unauthorized, "Unauthorized (invalid token for map)"}
error ->
Logger.error("Unexpected error validating API key: #{inspect(error)}")
{:error, 500, "Unexpected error"}
{:error, :internal_server_error, "Unexpected error"}
end
end
@@ -379,25 +321,6 @@ defmodule WandererAppWeb.Api.EventsController do
end
end
# Sends a structured JSON error response for SSE connection failures.
#
# Returns consistent JSON format matching the rest of the API:
# - error: Human-readable error message
# - code: Machine-readable error code for programmatic handling
# - status: HTTP status code
#
# This maintains API consistency and makes it easier for clients to
# handle errors programmatically.
defp send_sse_error(conn, status, message, code) do
conn
|> put_status(status)
|> json(%{
error: message,
code: code,
status: status
})
end
# SSE helper functions
defp send_headers(conn) do

View File

@@ -1320,9 +1320,9 @@ defmodule WandererAppWeb.MapAPIController do
errors:
Enum.map(error.errors, fn err ->
%{
field: Map.get(err, :field) || Map.get(err, :input),
message: Map.get(err, :message, "Unknown error"),
value: Map.get(err, :value)
field: err.field,
message: err.message,
value: err.value
}
end)
})

View File

@@ -115,7 +115,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
{:ok, period} <- APIUtils.require_param(params, "period"),
query <- WandererApp.Map.Audit.get_map_activity_query(map_id, period, "all"),
{:ok, data} <-
Ash.read(query, read_opts()) do
Ash.read(query) do
data = Enum.map(data, &map_audit_event_to_json/1)
json(conn, %{data: data})
else
@@ -131,18 +131,6 @@ defmodule WandererAppWeb.MapAuditAPIController do
end
end
# In test environment, disable concurrency to avoid Ecto Sandbox ownership issues
# In production, allow concurrent loading for better performance
defp read_opts do
base_opts = [authorize?: false]
if Application.get_env(:wanderer_app, :sql_sandbox) do
Keyword.put(base_opts, :max_concurrency, 0)
else
base_opts
end
end
defp map_audit_event_to_json(
%{event_type: event_type, event_data: event_data, character: character} = event
) do

View File

@@ -11,7 +11,7 @@ defmodule WandererAppWeb.MapConnectionAPIController do
require Logger
alias OpenApiSpex.Schema
alias WandererApp.MapConnectionRepo
alias WandererApp.Map, as: MapData
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.ResponseSchemas
@@ -180,8 +180,9 @@ defmodule WandererAppWeb.MapConnectionAPIController do
def index(%{assigns: %{map_id: map_id}} = conn, params) do
with {:ok, src_filter} <- parse_optional(params, "solar_system_source"),
{:ok, tgt_filter} <- parse_optional(params, "solar_system_target"),
{:ok, conns} <- MapConnectionRepo.get_by_map(map_id) do
{:ok, tgt_filter} <- parse_optional(params, "solar_system_target") do
conns = MapData.list_connections!(map_id)
conns =
conns
|> filter_by_source(src_filter)

View File

@@ -44,11 +44,6 @@ defmodule WandererAppWeb.MapSystemAPIController do
delete(conn, params)
end
def delete_single(conn, params) do
# Delegate to existing delete action for compatibility
delete(conn, params)
end
# -- JSON Schemas --
@map_system_schema %Schema{
type: :object,
@@ -536,67 +531,18 @@ defmodule WandererAppWeb.MapSystemAPIController do
)
def update(conn, %{"id" => id} = params) do
# Support both solar_system_id (integer) and system.id (UUID)
with {:ok, system_identifier} <- parse_system_identifier(id),
with {:ok, solar_system_id} <- APIUtils.parse_int(id),
{:ok, attrs} <- APIUtils.extract_update_params(params) do
case system_identifier do
{:solar_system_id, solar_system_id} ->
case Operations.update_system(conn, solar_system_id, attrs) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
case Operations.update_system(conn, solar_system_id, attrs) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
error ->
error
end
{:system_id, system_uuid} ->
# Handle update by system UUID
map_id = conn.assigns[:map_id]
case WandererApp.Api.MapSystem.by_id(system_uuid) do
{:ok, system} when system.map_id == map_id ->
case Operations.update_system(conn, system.solar_system_id, attrs) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
error ->
error
end
{:ok, _system} ->
{:error, :not_found}
{:error, _} ->
{:error, :not_found}
end
error ->
error
end
end
end
defp parse_system_identifier(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} ->
{:ok, {:system_id, uuid}}
:error ->
case APIUtils.parse_int(id) do
{:ok, solar_system_id} ->
{:ok, {:solar_system_id, solar_system_id}}
{:error, msg} ->
{:error, msg}
end
end
end
defp parse_system_identifier(id) when is_integer(id) do
{:ok, {:solar_system_id, id}}
end
defp parse_system_identifier(_id) do
{:error, "Invalid system identifier"}
end
operation(:delete_batch,
summary: "Batch Delete Systems and Connections",
parameters: [
@@ -670,22 +616,6 @@ defmodule WandererAppWeb.MapSystemAPIController do
responses: ResponseSchemas.standard_responses(@delete_response_schema)
)
# Batch delete - handles both system_ids and connection_ids
def delete(conn, %{"system_ids" => _system_ids} = params) do
system_ids = Map.get(params, "system_ids", [])
connection_ids = Map.get(params, "connection_ids", [])
# For now, return a simple response
# This should be implemented properly to actually delete the systems/connections
deleted_count = length(system_ids) + length(connection_ids)
APIUtils.respond_data(conn, %{
deleted_count: deleted_count,
deleted_systems: length(system_ids),
deleted_connections: length(connection_ids)
})
end
def delete(conn, %{"id" => id}) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, _} <- Operations.delete_system(conn, sid) do
@@ -712,16 +642,6 @@ defmodule WandererAppWeb.MapSystemAPIController do
end
end
# Catch-all clause for delete with missing or invalid parameters
def delete(conn, _params) do
conn
|> put_status(:bad_request)
|> APIUtils.respond_data(%{
deleted_count: 0,
error: "Missing required parameters: system_ids or id"
})
end
# -- Legacy endpoints --
operation(:list_systems,

View File

@@ -13,19 +13,10 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
import Plug.Conn
alias Plug.Crypto
alias WandererApp.Api.User
alias WandererApp.Api.ActorWithMap
alias WandererApp.SecurityAudit
alias WandererApp.Audit.RequestContext
alias Ash.PlugHelpers
# Error messages for different failure reasons
@error_messages %{
map_owner_not_found: "Authentication failed",
invalid_token: "Authentication failed",
missing_auth_header: "Missing or invalid authorization header",
invalid_session: "Invalid session"
}
def init(opts), do: opts
@@ -48,13 +39,9 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
%{auth_type: get_auth_type(conn), result: "success"}
)
# Wrap user and map together as actor for Ash
actor = ActorWithMap.new(user, map)
conn
|> assign(:current_user, user)
|> assign(:current_user_role, get_user_role(user))
|> PlugHelpers.set_actor(actor)
|> maybe_assign_map(map)
{:ok, user} ->
@@ -73,23 +60,16 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
%{auth_type: get_auth_type(conn), result: "success"}
)
# Wrap user with nil map as actor for Ash (session auth has no map context)
actor = ActorWithMap.new(user, nil)
conn
|> assign(:current_user, user)
|> assign(:current_user_role, get_user_role(user))
|> PlugHelpers.set_actor(actor)
{:error, reason} when is_atom(reason) ->
# Error handling with atom reasons
{:error, reason} when is_binary(reason) ->
# Legacy error handling for simple string errors
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
# Get user-facing message from error messages map
message = Map.get(@error_messages, reason, "Authentication failed")
# Log failed authentication with detailed internal reason
# Log failed authentication
request_details = extract_request_details(conn)
SecurityAudit.log_auth_event(
@@ -108,7 +88,37 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
conn
|> put_status(:unauthorized)
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: message}))
|> send_resp(401, Jason.encode!(%{error: reason}))
|> halt()
{:error, external_message, internal_reason} ->
# New error handling with separate internal and external messages
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
# Log failed authentication with detailed internal reason
request_details = extract_request_details(conn)
SecurityAudit.log_auth_event(
:auth_failure,
nil,
Map.merge(request_details, %{
failure_reason: internal_reason,
external_message: external_message
})
)
# Emit failed authentication event
:telemetry.execute(
[:wanderer_app, :json_api, :auth],
%{count: 1, duration: duration},
%{auth_type: get_auth_type(conn), result: "failure"}
)
conn
|> put_status(:unauthorized)
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: external_message}))
|> halt()
end
end
@@ -123,7 +133,7 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
user_id ->
case User.by_id(user_id, load: :characters) do
{:ok, user} -> {:ok, user}
{:error, _} -> {:error, :invalid_session}
{:error, _} -> {:error, "Invalid session"}
end
end
end
@@ -134,25 +144,89 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
validate_api_token(conn, token)
_ ->
{:error, :missing_auth_header}
{:error, "Missing or invalid authorization header"}
end
end
defp validate_api_token(_conn, token) do
# Token determines map - no need to check request params
find_map_by_token(token)
defp validate_api_token(conn, token) do
# Try to get map identifier from multiple sources
map_identifier = get_map_identifier(conn)
case map_identifier do
nil ->
# No map identifier found - this might be a general API endpoint
{:error, "Authentication failed", :no_map_context}
identifier ->
# Resolve the identifier (could be UUID or slug)
case resolve_map_identifier(identifier) do
{:ok, map} ->
# Validate the token matches this specific map's API key
if is_binary(map.public_api_key) &&
Crypto.secure_compare(map.public_api_key, token) do
# Get the map owner
case User.by_id(map.owner.user_id, load: :characters) do
{:ok, user} ->
{:ok, user, map}
{:error, _error} ->
{:error, "Authentication failed", :map_owner_not_found}
end
else
{:error, "Authentication failed", :invalid_token_for_map}
end
{:error, _} ->
{:error, "Authentication failed", :map_not_found}
end
end
end
defp find_map_by_token(token) do
case WandererApp.Api.Map.by_api_key(token, load: :owner) do
{:ok, map} ->
case User.by_id(map.owner.user_id, load: :characters) do
{:ok, user} -> {:ok, user, map}
_ -> {:error, :map_owner_not_found}
end
# Extract map identifier from multiple sources
defp get_map_identifier(conn) do
# 1. Check path params (e.g., /api/v1/maps/:map_identifier/systems)
case conn.params["map_identifier"] do
id when is_binary(id) and id != "" ->
id
_ ->
{:error, :invalid_token}
# 2. Check request body for map_id (JSON:API format)
case conn.body_params do
%{"data" => %{"attributes" => %{"map_id" => map_id}}}
when is_binary(map_id) and map_id != "" ->
map_id
%{"data" => %{"relationships" => %{"map" => %{"data" => %{"id" => map_id}}}}}
when is_binary(map_id) and map_id != "" ->
map_id
# 3. Check flat body params (non-JSON:API format)
%{"map_id" => map_id} when is_binary(map_id) and map_id != "" ->
map_id
_ ->
# 4. Check query params (e.g., ?filter[map_id]=...)
case conn.params do
%{"filter" => %{"map_id" => map_id}} when is_binary(map_id) and map_id != "" ->
map_id
_ ->
nil
end
end
end
end
# Helper to resolve map by ID or slug
defp resolve_map_identifier(identifier) do
# Try as UUID first
case WandererApp.Api.Map.by_id(identifier, load: :owner) do
{:ok, map} ->
{:ok, map}
_ ->
# Try as slug
WandererApp.Api.Map.get_map_by_slug(identifier, load: :owner)
end
end

View File

@@ -1,21 +0,0 @@
defmodule WandererAppWeb.Plugs.CheckWebhooksDisabled do
@moduledoc """
Plug to check if webhooks are enabled.
This plug blocks access to webhook management endpoints when webhooks are disabled.
Enable webhooks by setting WANDERER_WEBHOOKS_ENABLED=true in your environment.
"""
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
if not WandererApp.Env.webhooks_enabled?() do
conn
|> send_resp(403, "Webhooks are disabled. Set WANDERER_WEBHOOKS_ENABLED=true to enable.")
|> halt()
else
conn
end
end
end

View File

@@ -0,0 +1,15 @@
defmodule WandererAppWeb.Plugs.CheckWebsocketDisabled do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
if not WandererApp.Env.websocket_events_enabled?() do
conn
|> send_resp(403, "WebSocket events are disabled")
|> halt()
else
conn
end
end
end

View File

@@ -74,6 +74,8 @@ defmodule WandererAppWeb.Helpers.APIUtils do
end
@spec parse_int(binary() | integer()) :: {:ok, integer()} | {:error, String.t()}
def parse_int(nil), do: {:ok, nil}
def parse_int(str) when is_binary(str) do
Logger.debug(fn -> "Parsing integer from: #{inspect(str)}" end)

View File

@@ -337,7 +337,7 @@
options={Enum.map(@valid_types, fn valid_type -> {valid_type.label, valid_type.id} end)}
/>
<!-- Modal action buttons -->
<!-- API Key Section with grid layout -->
<div class="modal-action">
<.button class="mt-2" type="submit" phx-disable-with="Saving...">
{(@live_action == :add_invite_link && "Add") || "Save"}

View File

@@ -52,13 +52,10 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
socket
)
when not is_nil(main_character_id) do
solar_system_source_id_int = String.to_integer(solar_system_source_id)
solar_system_target_id_int = String.to_integer(solar_system_target_id)
map_id
|> WandererApp.Map.Server.add_connection(%{
solar_system_source_id: solar_system_source_id_int,
solar_system_target_id: solar_system_target_id_int,
solar_system_source_id: solar_system_source_id |> String.to_integer(),
solar_system_target_id: solar_system_target_id |> String.to_integer(),
character_id: main_character_id
})
@@ -66,8 +63,8 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: solar_system_source_id_int,
solar_system_target_id: solar_system_target_id_int
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer()
})
{:noreply, socket}
@@ -203,15 +200,12 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
_ -> nil
end
solar_system_source_id_int = String.to_integer(solar_system_source_id)
solar_system_target_id_int = String.to_integer(solar_system_target_id)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_updated, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: solar_system_source_id_int,
solar_system_target_id: solar_system_target_id_int,
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer(),
key: key_atom,
value: value
})
@@ -219,8 +213,8 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
apply(WandererApp.Map.Server, method_atom, [
map_id,
%{
solar_system_source_id: solar_system_source_id_int,
solar_system_target_id: solar_system_target_id_int
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer()
}
|> Map.put_new(key_atom, value)
])
@@ -274,8 +268,8 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
defp get_connection_info(map_id, from, to) do
map_id
|> WandererApp.Map.Server.get_connection_info(%{
solar_system_source_id: String.to_integer(from),
solar_system_target_id: String.to_integer(to)
solar_system_source_id: "#{from}" |> String.to_integer(),
solar_system_target_id: "#{to}" |> String.to_integer()
})
|> case do
{:ok, info} ->

View File

@@ -22,9 +22,10 @@ defmodule WandererAppWeb.MapCoreEventHandler do
%{assigns: %{current_user: current_user, map_slug: map_slug}} = socket
) do
try do
# Load acls with members first to avoid lateral join conflicts
{:ok, %{id: map_id, user_permissions: user_permissions, owner_id: owner_id}} =
WandererApp.MapRepo.get_by_slug_with_permissions(map_slug, current_user)
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load(:user_permissions, actor: current_user)
user_permissions =
WandererApp.Permissions.get_map_permissions(

View File

@@ -16,7 +16,7 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
socket
|> MapEventHandler.push_map_event("system_comment_added", %{
solarSystemId: solar_system_id,
comment: comment |> map_system_comment(solar_system_id)
comment: comment |> map_system_comment()
})
def handle_server_event(
@@ -54,7 +54,7 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
when not is_nil(main_character_id) do
system =
WandererApp.Map.find_system_by_location(map_id, %{
solar_system_id: solar_system_id
solar_system_id: solar_system_id |> String.to_integer()
})
comments_count =
@@ -106,15 +106,18 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
socket
) do
WandererApp.Map.find_system_by_location(map_id, %{
solar_system_id: solar_system_id
solar_system_id: solar_system_id |> String.to_integer()
})
|> case do
%{id: system_id} = system when not is_nil(system_id) ->
%{id: system_id} when not is_nil(system_id) ->
{:ok, comments} = WandererApp.MapSystemCommentRepo.get_by_system(system_id)
{:reply,
%{comments: comments |> Enum.map(fn c -> map_system_comment(c, solar_system_id) end)},
socket}
comments =
comments
|> Enum.map(fn c -> c |> Ash.load!([:character, :system]) end)
|> Enum.map(&map_system_comment/1)
{:reply, %{comments: comments}, socket}
_ ->
{:reply, %{comments: []}, socket}
@@ -168,24 +171,4 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
updated_at: updated_at
}
end
def map_system_comment(nil, _solar_system_id), do: nil
def map_system_comment(
%{
id: id,
character: character,
text: text,
updated_at: updated_at
} = _comment,
solar_system_id
) do
%{
id: id,
characterEveId: character.eve_id,
solarSystemId: solar_system_id,
text: text,
updated_at: updated_at
}
end
end

View File

@@ -306,13 +306,13 @@ defmodule WandererAppWeb.MapEventHandler do
} = socket,
type,
body
),
do:
socket
|> Phoenix.LiveView.Utils.push_event("map_event", %{
type: type,
body: body
})
) do
socket
|> Phoenix.LiveView.Utils.push_event("map_event", %{
type: type,
body: body
})
end
def push_map_event(socket, _type, _body), do: socket

View File

@@ -20,7 +20,6 @@ defmodule WandererAppWeb.MapsLive do
user_characters =
active_characters
|> Enum.map(&map_character/1)
|> Enum.reject(&is_nil/1)
{:ok,
socket
@@ -108,18 +107,7 @@ defmodule WandererAppWeb.MapsLive do
WandererApp.Maps.check_user_can_delete_map(map_slug, current_user)
|> case do
{:ok, map} ->
# Load the owner association to get character details
map =
case Ash.load(map, :owner) do
{:ok, loaded_map} -> loaded_map |> map_map()
_ -> map |> map_map()
end
# Add owner to characters list, filtering out nil values
characters =
[map.owner |> map_character() | socket.assigns.characters]
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
map = map |> map_map()
socket
|> assign(:active_page, :maps)
@@ -127,7 +115,10 @@ defmodule WandererAppWeb.MapsLive do
|> assign(:page_title, "Maps - Edit")
|> assign(:scopes, ["wormholes", "stargates", "none", "all"])
|> assign(:map_slug, map_slug)
|> assign(:characters, characters)
|> assign(
:characters,
[map.owner |> map_character() | socket.assigns.characters] |> Enum.uniq()
)
|> assign(
:form,
map |> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
@@ -163,7 +154,6 @@ defmodule WandererAppWeb.MapsLive do
|> assign(:map_slug, map_slug)
|> assign(:map_id, map.id)
|> assign(:public_api_key, map.public_api_key)
|> assign(:sse_enabled, map.sse_enabled)
|> assign(:map, map)
|> assign(
export_settings: export_settings |> _get_export_map_data(),
@@ -233,27 +223,6 @@ defmodule WandererAppWeb.MapsLive do
{:noreply, assign(socket, public_api_key: new_api_key)}
end
def handle_event("toggle-sse", _params, socket) do
new_sse_enabled = not socket.assigns.sse_enabled
map = socket.assigns.map
case WandererApp.Api.Map.toggle_sse(map, %{sse_enabled: new_sse_enabled}) do
{:ok, updated_map} ->
{:noreply, assign(socket, sse_enabled: new_sse_enabled, map: updated_map)}
{:error, %Ash.Error.Invalid{errors: errors}} ->
error_message =
errors
|> Enum.map(fn error -> Map.get(error, :message, "Unknown error") end)
|> Enum.join(", ")
{:noreply, put_flash(socket, :error, error_message)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update SSE setting")}
end
end
@impl true
def handle_event(
"live_select_change",

View File

@@ -165,7 +165,6 @@
field={f[:only_tracked_characters]}
label="Allow only tracked characters"
/>
<.input type="checkbox" field={f[:sse_enabled]} label="Enable Server-Sent Events (SSE)" />
<.input
:if={@live_action == :create}
type="checkbox"
@@ -540,24 +539,6 @@
</.button>
</div>
</div>
<div class="border-t border-stone-700 mt-4 pt-4">
<h3 class="text-md font-semibold mb-3">Server-Sent Events (SSE)</h3>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={@sse_enabled}
phx-click="toggle-sse"
/>
<span>Enable SSE for this map</span>
</label>
</div>
<p class="text-sm text-stone-400 mt-2">
When enabled, external clients can subscribe to real-time map events via SSE.
</p>
</div>
</div>
<.live_component

View File

@@ -201,8 +201,8 @@ defmodule WandererAppWeb.Router do
plug WandererAppWeb.Plugs.CheckCharacterApiDisabled
end
pipeline :api_webhooks do
plug WandererAppWeb.Plugs.CheckWebhooksDisabled
pipeline :api_websocket_events do
plug WandererAppWeb.Plugs.CheckWebsocketDisabled
end
pipeline :api_acl do
@@ -302,9 +302,9 @@ defmodule WandererAppWeb.Router do
get "/tracked-characters", MapAPIController, :show_tracked_characters
end
# Webhook management endpoints (requires WANDERER_WEBHOOKS_ENABLED=true)
# WebSocket events and webhook management endpoints (disabled by default)
scope "/api/maps/:map_identifier", WandererAppWeb do
pipe_through [:api, :api_map, :api_webhooks]
pipe_through [:api, :api_map, :api_websocket_events]
get "/events", MapEventsAPIController, :list_events
@@ -363,6 +363,24 @@ defmodule WandererAppWeb.Router do
#
scope "/api", WandererAppWeb do
pipe_through [:api]
# Basic health check for load balancers (lightweight)
get "/health", Api.HealthController, :health
# Detailed health status for monitoring systems
get "/health/status", Api.HealthController, :status
# Readiness check for deployment validation
get "/health/ready", Api.HealthController, :ready
# Liveness check for container orchestration
get "/health/live", Api.HealthController, :live
# Metrics endpoint for monitoring systems
get "/health/metrics", Api.HealthController, :metrics
# Deep health check for comprehensive diagnostics
get "/health/deep", Api.HealthController, :deep
end
# scope "/api/licenses", WandererAppWeb do

View File

@@ -97,12 +97,12 @@ defmodule WandererApp.MixProject do
{:dart_sass, "~> 0.5.1", runtime: Mix.env() == :dev},
{:oauth2, "~> 1.0 or ~> 2.0"},
{:ueberauth, "~> 0.10.0"},
{:req, "~> 0.5"},
{:ash, "~> 3.9"},
{:ash_cloak, "~> 0.1.7"},
{:req, "~> 0.4.0"},
{:ash, "~> 3.4"},
{:ash_cloak, "~> 0.1.2"},
{:ash_json_api, "~> 1.4"},
{:ash_phoenix, "~> 2.1"},
{:ash_postgres, "~> 2.6"},
{:ash_postgres, "~> 2.4"},
{:exsync, "~> 0.4", only: :dev},
{:nimble_csv, "~> 1.2.0"},
{:ecto_ulid_next, "~> 1.0.2"},

View File

@@ -1,16 +1,16 @@
%{
"ash": {:hex, :ash, "3.9.0", "004371ffb181a142cda09544342dad1ffedf360a5636219d71cb5431ccbe3ad6", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e3e80a182c6e8b4d87f1caa4dba774da210f1f75f1420650ba012eb4388dc8c3"},
"ash_cloak": {:hex, :ash_cloak, "0.1.7", "222137b911cf725189868ea266ec1ba8cebe6c14eed8df518dd3dd51cf8441d9", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "af3cb43a3fb16efe939aa3b4337bc1393bb6687cba5b731120590caae45f636b"},
"ash": {:hex, :ash, "3.4.15", "0b8a0ae9bc543267380ffdacfeb1bc8d1bc831c1acb58b923ac0285464d5badd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3647184d23c40a8d4d381c3616b5c5c783d4d2e969918b6fd36aa171fede9cfa"},
"ash_cloak": {:hex, :ash_cloak, "0.1.2", "d70338491ad8b6a18c691c25a2a236e18bb726c551642f56d996d25a9f1e779b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "8b13dc44d8c58a7a876e537b3eab03672ac04f442568b4f9c1d70ccd9522812f"},
"ash_json_api": {:hex, :ash_json_api, "1.4.10", "24e76a95ce0879c3dead994a9f727f7fc2de7678cdf7a265ba8fd0bbe939caa9", [:mix], [{:ash, "~> 3.3", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.34 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "8f38a6936725c9d1281f4f21e43d72474be7ed60f12ca47ff0f625a70dad52e7"},
"ash_pagify": {:hex, :ash_pagify, "1.4.1", "af25d5f68b6df84ed5388dd4688658fd08fa59e99f70361a0497c376b50ac115", [:mix], [{:ash, "~> 3.3", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.1", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:ash_postgres, "~> 2.1", [hex: :ash_postgres, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.37", [hex: :ex_doc, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}], "hexpm", "5b7f771c5a76f92d120536cd87fb25b7321a681482aeaf127b7202bd18552c84"},
"ash_phoenix": {:hex, :ash_phoenix, "2.1.2", "7215cf3a1ebc82ca0e5317a8449e1725fa753354674a0e8cd7fc1c8ffd1181c7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b591bd731a0855f670b5bc3f48c364b1694d508071f44d57bcd508c82817c51e"},
"ash_postgres": {:hex, :ash_postgres, "2.6.25", "eb425722adf0c5d828c24810ed90aeef1fa1d8c0976579220e3739f102582176", [:mix], [{:ash, ">= 3.7.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "dadf95dfb33d2807cfd393b57315e703a9c938a8fffbdeb2a0f59f28f1909eca"},
"ash_sql": {:hex, :ash_sql, "0.3.13", "1018528aa2589fc69240242c48f553b211899a574d860c564533c830644b5e25", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "2b65acafb677983d96aa6a9c43c107f71c63f3dd7bff220b55b99b3bdab53a95"},
"ash_postgres": {:hex, :ash_postgres, "2.4.1", "6fa9bbb40e9d4a73bcdd2403e036874421e8c919dc57338eb6476cc8a82fa112", [:mix], [{:ash, ">= 3.4.9 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.30 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "9419993fe7f200db7230c372f5aa280f8bebb175501c9e8d58703c9054006c7b"},
"ash_sql": {:hex, :ash_sql, "0.2.32", "de99255becfb9daa7991c18c870e9f276bb372acda7eda3e05c3e2ff2ca8922e", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "43773bcd33d21319c11804d76fe11f1a1b7c8faba7aaedeab6f55fde3d2405db"},
"bandit": {:hex, :bandit, "1.6.8", "be6fcbe01a74e6cba42ae35f4085acaeae9b2d8d360c0908d0b9addbc2811e47", [: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", "4fc08c8d4733735d175a007ecb25895e84d09292b0180a2e9f16948182c88b6e"},
"better_number": {:hex, :better_number, "1.0.1", "5832757e2575feda6f6e67b3ff18f1510a42efec4f5673221f89cff8132add7b", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "782efdaf7bb4a7109265878fa30497a335bf7cd5954ce37ee539a3ce7cf09ceb"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"},
"castore": {:hex, :castore, "1.0.16", "8a4f9a7c8b81cda88231a08fe69e3254f16833053b23fa63274b05cbc61d2a1e", [:mix], [], "hexpm", "33689203a0eaaf02fcd0e86eadfbcf1bd636100455350592e7e2628564022aaf"},
"castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"},
"certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"},
"cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
@@ -20,9 +20,8 @@
"cowlib": {:hex, :cowlib, "2.14.0", "623791c56c1cc9df54a71a9c55147a401549917f00a2e48a6ae12b812c586ced", [:make, :rebar3], [], "hexpm", "0af652d1550c8411c3b58eed7a035a7fb088c0b86aff6bc504b0bc3b7f791aa2"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"dart_sass": {:hex, :dart_sass, "0.5.1", "d45f20a8e324313689fb83287d4702352793ce8c9644bc254155d12656ade8b6", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "24f8a1c67e8b5267c51a33cbe6c0b5ebf12c2c83ace88b5ac04947d676b4ec81"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"debounce_and_throttle": {:hex, :debounce_and_throttle, "0.9.0", "fa86c982963e00365cc9808afa496e82ca2b48f8905c6c79f8edd304800d0892", [:mix], [], "hexpm", "573a7cff4032754023d8e6874f3eff5354864c90b39b692f1fc4a44b3eb7517b"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"},
@@ -31,8 +30,8 @@
"doctor": {:hex, :doctor, "0.21.0", "20ef89355c67778e206225fe74913e96141c4d001cb04efdeba1a2a9704f1ab5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "a227831daa79784eb24cdeedfa403c46a4cb7d0eab0e31232ec654314447e4e0"},
"earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"},
"earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
"ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
"ecto_ulid_next": {:hex, :ecto_ulid_next, "1.0.2", "8372f3c589c8fa50ea7b127dabe008528837b11781f65bfc72d96259d49b44c5", [:mix], [{:ecto, "~> 3.2", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "61c9c2c531f87ce7e2e9e57fc60d533fe97b3a62a43c21b632b0824f0773bcbe"},
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
"error_tracker": {:hex, :error_tracker, "0.2.2", "7635f5ed6016df10d8e63348375acb2ca411e2f6f9703ee90cc2d4262af5faec", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "b975978f64d27373d3486d7de477a699e735f8c0b1c74a7370ecb80e7ae97903"},
@@ -46,19 +45,19 @@
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
"exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
"floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
"fresh": {:hex, :fresh, "0.4.4", "9d67a1d97112e70f4dfabd63b40e4b182ef64dfa84a2d9ee175eb4e34591e9f7", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}], "hexpm", "ba21d3fa0aa77bf18ca397e4c851de7432bb3f9c170a1645a16e09e4bba54315"},
"gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"},
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"},
"hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"},
"heroicons": {:hex, :heroicons, "0.5.5", "c2bcb05a90f010df246a5a2a2b54cac15483b5de137b2ef0bead77fcdf06e21a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "2f4bf929440fecd5191ba9f40e5009b0f75dc993d765c0e4d068fcb7026d6da1"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
"igniter": {:hex, :igniter, "0.3.38", "c45e285098eb8be65bcde7206e113b34be40155026e7926d390c00e39fbc38d9", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "19aa9b109cd9fc858999da0a30ad9e8e883ddff7abfa7817e3b69a711c65cd13"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
@@ -73,9 +72,9 @@
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
"merkle_map": {:hex, :merkle_map, "0.2.1", "01a88c87a6b9fb594c67c17ebaf047ee55ffa34e74297aa583ed87148006c4c8", [:mix], [], "hexpm", "fed4d143a5c8166eee4fa2b49564f3c4eace9cb252f0a82c1613bba905b2d04d"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
"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"},
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [: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", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
"mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"},
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
@@ -89,7 +88,7 @@
"oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"},
"octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"},
"open_api_spex": {:hex, :open_api_spex, "3.21.5", "ff0c7fe5ceff9a56b9b0bb5a6dcfb7bc96e8afc563a3bef6ae91927de4d38b8e", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "bd83c8f462222236fa85044098ba3bf57f7b7d7fd5286e6bc0060c7916f7c0d8"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"},
"parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"pathex": {:hex, :pathex, "2.5.3", "0f2674c7cb52ae9220766cae2653b4013578349ae5ec07cb0c31b92684b3f19a", [:mix], [], "hexpm", "767aefc27d0303f583ba2064f0a49546067ab5de3c42b89f014a0ba32ea04830"},
@@ -104,30 +103,30 @@
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.5", "f072166f87c44ffaf2b47b65c5ced8c375797830e517bfcf0a006fe7eb113911", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "94abbc84df8a93a64514fc41528695d7326b6f3095e906b32f264ec4280811f3"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_content_security_policy": {:hex, :plug_content_security_policy, "0.2.1", "0a19c76307ad000b3757739c14b34b83ecccf7d0a3472e64e14797a20b62939b", [:mix], [{:plug, "~> 1.3", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ceea10050671c0387c64526e2cb337ee08e12705c737eaed80439266df5b2e29"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"plug_dynamic": {:hex, :plug_dynamic, "1.0.0", "aecc1a6c19bb4a4d3ceb35ae85999e9ec77cf50eeead754607bc657d47478b32", [:mix], [{:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "403590330db12255755e0ce6397aaf05b000f255cfe5ea8edf70dc9d4413b99c"},
"postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"},
"postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"},
"prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"},
"qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"},
"quantum": {:hex, :quantum, "3.5.3", "ee38838a07761663468145f489ad93e16a79440bebd7c0f90dc1ec9850776d99", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "500fd3fa77dcd723ed9f766d4a175b684919ff7b6b8cfd9d7d0564d58eba8734"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"reactor": {:hex, :reactor, "0.14.0", "8dc5d4946391010bf9fa7b58dd1e75d3c1cf97315e5489b7797cf64b82ae27a4", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9cf5068e4042791c150f0dfbc00f4f435433eb948036b44b95b940e457b35a6a"},
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [: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", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
"reactor": {:hex, :reactor, "0.10.0", "1206113c21ba69b889e072b2c189c05a7aced523b9c3cb8dbe2dab7062cb699a", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4003c33e4c8b10b38897badea395e404d74d59a31beb30469a220f2b1ffe6457"},
"req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {: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, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"},
"retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"},
"site_encrypt": {:hex, :site_encrypt, "0.6.0", "9b3ae2b11723b9fa9b6fbee1d137ceaa0c245015a40c3f753a4ba1e8887986d2", [:mix], [{:bandit, "~> 0.7 or ~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}, {:mint, "~> 1.4", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:parent, "~> 0.11", [hex: :parent, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.8.8", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "16e77d0bec194b9e9d95ece4b7e5b072638e1c317d3dbe58d0c26ef3635a1e33"},
"sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"},
"spark": {:hex, :spark, "2.2.29", "a52733ff72b05a674e48d3ca7a4172fe7bec81e9116069da8b4db19030d581d9", [:mix], [{:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "111a0dadbb27537c7629bc03ac56fcab15056ab0b9ad985084b9adcdb48836c8"},
"spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"},
"splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"},
"swoosh": {:hex, :swoosh, "1.16.8", "37112d4430f7054676f7652745676a6acad0e9279d3b052fe69a58c2532a67f8", [: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]}, {: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.0", [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.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33de50fa414c07ed55fcb28e60e72a44496e14f8753e46cbf80e7b15d1adaae2"},
"tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
@@ -136,7 +135,6 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
"telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"},
"tesla": {:hex, :tesla, "1.11.0", "81b2b10213dddb27105ec6102d9eb0cc93d7097a918a0b1594f2dfd1a4601190", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "b83ab5d4c2d202e1ea2b7e17a49f788d49a699513d7c4f08f2aef2c281be69db"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.3.11", "b68f3e91f74d564ae20b70d981bbf7097dde084343c14ae8a33e5b5fbb3d6f37", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "555c18c62027f45d9c80df389c3d01d86ba11014652c00be26e33b1b64e98d29"},
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
"tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},

View File

@@ -42,24 +42,6 @@ In the dynamic world of EVE Online wormhole mapping, every second counts. When a
- Your map API token (found in map settings)
- Basic programming knowledge for integration
### Server Configuration (Community Edition)
If you're running Wanderer Community Edition (CE), you need to enable the required features via environment variables:
**For SSE (Server-Sent Events):**
```bash
WANDERER_SSE_ENABLED=true
```
**For Webhooks:**
```bash
WANDERER_WEBHOOKS_ENABLED=true
```
Add these to your `.env` file or Docker environment configuration and restart Wanderer. Without these settings, you'll receive a 403 error when trying to access SSE streams or webhook management endpoints.
*Note: The public Wanderer instance at wanderer.ltd has these features enabled by default.*
### Authentication
Both SSE and webhook APIs use your existing map API token for authentication. This token should be kept secure and never exposed in client-side code.

View File

@@ -81,24 +81,6 @@ You can find or generate your map's API key in the map settings within the Wande
**Session Authentication:**
Web clients can also use session-based authentication for interactive use, maintaining compatibility with existing browser-based integrations.
### Server Configuration (Community Edition)
If you're running Wanderer Community Edition (CE), ensure the following environment variables are configured:
**Required for API access:**
```bash
WANDERER_PUBLIC_API_DISABLED=false # Enable public API (default: false)
```
**Optional features:**
```bash
WANDERER_SSE_ENABLED=true # Enable Server-Sent Events (default: false)
WANDERER_WEBHOOKS_ENABLED=true # Enable webhook management (default: false)
WANDERER_CHARACTER_API_DISABLED=false # Enable character API (default: true)
```
Add these to your `.env` file or Docker environment configuration and restart Wanderer.
## JSON:API Features
### Resource Relationships
@@ -161,7 +143,7 @@ GET /api/v1/user_activities?include=character&sort=-inserted_at&page[limit]=15&p
The API v1 provides access to over 25 resources through the Ash Framework. Here are the primary resources:
### Core Resources
- **Maps** (`/api/v1/maps/:slug`) - Map management with create, read, update, and delete operations (accessed by slug with map-specific API key; no listing endpoint)
- **Maps** (`/api/v1/maps`) - Map management with full CRUD operations
- **Access Lists** (`/api/v1/access_lists`) - ACL management and permissions with full CRUD operations
- **Access List Members** (`/api/v1/access_list_members`) - ACL member management with full CRUD operations
- **Map Access Lists** (`/api/v1/map_access_lists`) - Map-ACL associations with full CRUD operations

View File

@@ -1,21 +0,0 @@
defmodule WandererApp.Repo.Migrations.AddMapSseEnabled do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:maps_v1) do
add :sse_enabled, :boolean, null: false, default: false
end
end
def down do
alter table(:maps_v1) do
remove :sse_enabled
end
end
end

View File

@@ -1,141 +0,0 @@
defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do
@moduledoc """
Adds a unique index on the public_api_key column of maps_v1.
This migration:
1. Creates a backup table (maps_v1_api_key_backup) for data safety
2. Backs up and clears duplicate API keys (keeping the oldest by inserted_at)
3. Creates a unique index on public_api_key where the value is not null
4. Allows multiple NULL values (maps without API keys)
5. Ensures all non-NULL API keys are unique
The partial index (WHERE public_api_key IS NOT NULL) is used because:
- Most maps won't have an API key set
- We only care about uniqueness for maps that do have one
- PostgreSQL's unique constraints on nullable columns already allow multiple NULLs,
but a partial index is more explicit and efficient
## Data Recovery
If you need to restore cleared API keys, query the backup table:
SELECT map_id, old_public_api_key, backed_up_at
FROM maps_v1_api_key_backup
WHERE reason = 'duplicate_api_key_cleared_for_unique_index';
To restore a specific map's API key:
UPDATE maps_v1 SET public_api_key = '<old_key>'
WHERE id = '<map_id>';
Note: Restoring will cause uniqueness conflicts if duplicates still exist.
"""
use Ecto.Migration
def up do
# Create backup table before any destructive changes
create_backup_table()
# Check for any duplicate non-null API keys and handle them (with backup)
check_and_fix_duplicates()
# Create the unique index
create_if_not_exists(
index(:maps_v1, [:public_api_key],
unique: true,
name: :maps_v1_unique_public_api_key_index,
where: "public_api_key IS NOT NULL"
)
)
IO.puts("Created unique index on maps_v1.public_api_key")
end
defp create_backup_table do
repo().query!(
"""
CREATE TABLE IF NOT EXISTS maps_v1_api_key_backup (
id UUID PRIMARY KEY,
map_id UUID NOT NULL,
old_public_api_key TEXT NOT NULL,
reason TEXT NOT NULL,
backed_up_at TIMESTAMP NOT NULL DEFAULT NOW()
)
""",
[]
)
IO.puts("Created backup table maps_v1_api_key_backup")
end
def down do
drop_if_exists(index(:maps_v1, [:public_api_key], name: :maps_v1_unique_public_api_key_index))
IO.puts("Dropped unique index on maps_v1.public_api_key")
# Drop backup table
repo().query!("DROP TABLE IF EXISTS maps_v1_api_key_backup", [])
IO.puts("Dropped backup table maps_v1_api_key_backup")
end
defp check_and_fix_duplicates do
# Check for duplicate non-null API keys
duplicates_query = """
SELECT public_api_key, COUNT(*) as cnt
FROM maps_v1
WHERE public_api_key IS NOT NULL
GROUP BY public_api_key
HAVING COUNT(*) > 1
"""
case repo().query(duplicates_query, []) do
{:ok, %{rows: []}} ->
IO.puts("No duplicate API keys found")
{:ok, %{rows: rows}} when length(rows) > 0 ->
IO.puts("Found #{length(rows)} duplicate API key(s) - clearing duplicates...")
# For each duplicate, keep the first (by inserted_at) and clear the rest
Enum.each(rows, fn [api_key, count] ->
IO.puts(" Processing duplicate key (#{count} occurrences)")
# Get all IDs with this key, ordered by inserted_at
ids_query = """
SELECT id::text
FROM maps_v1
WHERE public_api_key = $1
ORDER BY inserted_at ASC, id ASC
"""
case repo().query(ids_query, [api_key]) do
{:ok, %{rows: id_rows}} ->
# Keep first, clear rest
[_keep | clear_ids] = Enum.map(id_rows, fn [id] -> id end)
Enum.each(clear_ids, fn id ->
# Backup the API key before clearing
backup_query = """
INSERT INTO maps_v1_api_key_backup (id, map_id, old_public_api_key, reason)
VALUES (gen_random_uuid(), $1::uuid, $2, 'duplicate_api_key_cleared_for_unique_index')
"""
repo().query!(backup_query, [id, api_key])
# Clear the duplicate
clear_query = "UPDATE maps_v1 SET public_api_key = NULL WHERE id::text = $1"
repo().query!(clear_query, [id])
IO.puts(" Backed up and cleared API key for map #{id}")
end)
{:error, error} ->
raise "Failed to get duplicate IDs for key: #{inspect(error)}"
end
end)
IO.puts("Duplicate API keys cleared")
{:error, error} ->
raise "Failed to check for duplicate keys: #{inspect(error)}"
end
end
end

View File

@@ -1,216 +0,0 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "personal_note",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "public_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "[]",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "hubs",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "\"wormholes\"",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "scope",
"type": "text"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "deleted",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "only_tracked_characters",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "options",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "webhooks_enabled",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "sse_enabled",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "maps_v1_owner_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": null,
"table": "character_v1"
},
"size": null,
"source": "owner_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "065F1CDA64C0B00355F9D7B692E48A5E547C69E4E66F6F327C03FBA217850B1D",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "maps_v1_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "maps_v1"
}

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# export ERL_AFLAGS="-proto_dist inet6_tcp"
export ERL_AFLAGS="-proto_dist inet6_tcp"
export RELEASE_DISTRIBUTION="name"
# Use custom RELEASE_NODE if set, otherwise detect environment

View File

@@ -56,9 +56,6 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
end
test "returns error when neither map_id nor slug provided", %{conn: conn} do
# The route /api/map/acls is within the :api_map pipeline scope
# which requires proper map authentication, but the CheckMapApiKey plug
# specifically requires map_id or slug query parameters
conn = get(conn, "/api/map/acls")
assert %{"error" => _} = json_response(conn, 400)
end
@@ -132,19 +129,15 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
}
}
# The route expects either map_id or slug as query parameter
# Not providing either should result in a 400 error
conn = post(conn, "/api/map/acls", acl_params)
assert %{"error" => _} = json_response(conn, 400)
end
end
describe "GET /api/acls/:id (show)" do
# Note: show/update actions use :api_acl pipeline (CheckAclApiKey plug)
# which validates the ACL's api_key, not the map's api_key
# So we don't need setup_map_authentication here
setup :setup_map_authentication
test "returns access list details with members", %{conn: conn} do
test "returns access list details with members", %{conn: conn, map: map} do
character = Factory.insert(:character, %{eve_id: "2112073677"})
acl =
@@ -195,24 +188,23 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
assert "Corp Member" in member_names
end
test "returns 404 for non-existent ACL", %{conn: _conn} do
# Create a fresh conn without any authentication setup
conn = build_conn()
test "returns 404 for non-existent ACL", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer some-api-key")
|> get(~p"/api/acls/#{Ecto.UUID.generate()}")
# The CheckAclApiKey plug will return 404 when the ACL ID is not found
assert conn.status == 404
# The response might not be JSON if auth fails first
case conn.status do
404 -> assert conn.status == 404
# Other auth-related errors are acceptable
_ -> assert conn.status in [400, 401, 404]
end
end
end
describe "PUT /api/acls/:id (update)" do
# Note: update action uses :api_acl pipeline (CheckAclApiKey plug)
# which validates the ACL's api_key, not the map's api_key
# So we don't need setup_map_authentication here
setup :setup_map_authentication
test "updates access list attributes", %{conn: conn} do
character = Factory.insert(:character, %{eve_id: "2112073677"})
@@ -252,8 +244,7 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
assert updated_acl.description == "Updated description"
end
test "preserves api_key on update", %{conn: _conn} do
conn = build_conn()
test "preserves api_key on update", %{conn: conn} do
character = Factory.insert(:character, %{eve_id: "2112073677"})
original_api_key = "original-api-key"
@@ -283,8 +274,7 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
} = json_response(conn, 200)
end
test "returns 404 for non-existent ACL", %{conn: _conn} do
conn = build_conn()
test "returns 404 for non-existent ACL", %{conn: conn} do
update_params = %{
"acl" => %{
"name" => "Updated Name"
@@ -296,12 +286,15 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
|> put_req_header("authorization", "Bearer some-api-key")
|> put(~p"/api/acls/#{Ecto.UUID.generate()}", update_params)
# The CheckAclApiKey plug will return 404 when the ACL ID is not found
assert conn.status == 404
# The response might not be JSON if auth fails first
case conn.status do
404 -> assert conn.status == 404
# Other auth-related errors are acceptable
_ -> assert conn.status in [400, 401, 404]
end
end
test "validates update parameters", %{conn: _conn} do
conn = build_conn()
test "validates update parameters", %{conn: conn} do
character = Factory.insert(:character, %{eve_id: "2112073677"})
acl =

View File

@@ -8,8 +8,10 @@ defmodule WandererAppWeb.AccessListMemberAPIControllerTest do
setup :verify_on_exit!
setup do
# Mocks are already in global mode from application startup
# No need to call Mox.set_mox_global() again
# Ensure we're in global mode and re-setup mocks
# This ensures all processes can access the mocks
Mox.set_mox_global()
WandererApp.Test.Mocks.setup_additional_expectations()
:ok
end

View File

@@ -1,5 +1,5 @@
defmodule WandererAppWeb.CommonAPIControllerTest do
use WandererAppWeb.ApiCase, async: false
use WandererAppWeb.ApiCase, async: true
describe "GET /api/common/system-static-info" do
test "returns system static info for valid system ID", %{conn: conn} do

View File

@@ -1,5 +1,5 @@
defmodule WandererAppWeb.MapAuditAPIControllerIntegrationTest do
use WandererAppWeb.ApiCase, async: false
use WandererAppWeb.ApiCase
alias WandererAppWeb.Factory

Some files were not shown because too many files have changed in this diff Show More