Browse Source

thinned out unused and inefficient/broken stuff

handle e/E tags more clearly
utilized valid event from prod for tests
created test complete run
sandboxed tests and handle concurrent tests
test/local-setup
Silberengel 3 weeks ago
parent
commit
f76e047472
  1. 7
      .env.example
  2. 2
      .formatter.exs
  3. 16
      README.md
  4. 47
      compose.yaml
  5. 9
      config/config.exs
  6. 5
      config/dev.exs
  7. 6
      config/prod.exs
  8. 27
      config/runtime.exs
  9. 9
      config/test.exs
  10. 1
      lib/gc_index_relay/application.ex
  11. 3
      lib/gc_index_relay/mailer.ex
  12. 26
      lib/gc_index_relay/nostr.ex
  13. 43
      lib/gc_index_relay/nostr/filter.ex
  14. 34
      lib/gc_index_relay/nostr/pub_event.ex
  15. 2
      lib/gc_index_relay_web.ex
  16. 7
      lib/gc_index_relay_web/controllers/api_controller.ex
  17. 15
      lib/gc_index_relay_web/controllers/event_controller.ex
  18. 11
      lib/gc_index_relay_web/controllers/fallback_controller.ex
  19. 45
      lib/gc_index_relay_web/controllers/filter_controller.ex
  20. 14
      lib/gc_index_relay_web/endpoint.ex
  21. 19
      lib/gc_index_relay_web/plugs/cors.ex
  22. 17
      lib/gc_index_relay_web/plugs/relay_info.ex
  23. 34
      lib/gc_index_relay_web/router.ex
  24. 39
      lib/mix/tasks/test/integration.ex
  25. 17
      mix.exs
  26. 9
      mix.lock
  27. 11
      priv/repo/seeds.exs
  28. 22
      priv/static/swagger.json
  29. 13
      test/gc_index_relay/nostr/filter_test.exs
  30. 65
      test/gc_index_relay/nostr/pub_event_test.exs
  31. 7
      test/gc_index_relay/nostr/validator_test.exs
  32. 17
      test/gc_index_relay/nostr_test.exs
  33. 2
      test/gc_index_relay_web/controllers/error_html_test.exs
  34. 2
      test/gc_index_relay_web/controllers/error_json_test.exs
  35. 46
      test/gc_index_relay_web/controllers/filter_controller_test.exs
  36. 25
      test/support/conn_case.ex
  37. 24
      test/support/data_case.ex
  38. 36
      test/support/fixtures/nostr_fixtures.ex

7
.env.example

@ -1,5 +1,5 @@
# Copy this file to .env and fill in values, then run: source .env # Copy this file to .env and fill in values, then run: source .env
# The defaults below match the Docker container started by setup.sh. # The defaults below match setup.sh and compose.yaml: Postgres on host port 5455 (maps to 5432 in the container).
export POSTGRES_HOST=localhost export POSTGRES_HOST=localhost
export POSTGRES_PORT=5455 export POSTGRES_PORT=5455
@ -7,3 +7,8 @@ export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev export POSTGRES_DB=gc_index_relay_dev
export REQUIRE_DB=true export REQUIRE_DB=true
# Optional — only if you use docker compose for setup/migrator/mercury (override compose defaults):
# export POSTGRES_RUNTIME_USER=gc_index_relay
# export POSTGRES_RUNTIME_PASSWORD=gc_index_relay_runtime
# export SECRET_KEY_BASE="$(mix phx.gen.secret)"

2
.formatter.exs

@ -2,5 +2,5 @@
import_deps: [:ecto, :ecto_sql, :phoenix], import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"], subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter], plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
] ]

16
README.md

@ -59,6 +59,18 @@ export REQUIRE_DB=true
mix setup mix setup
``` ```
### Docker Compose (dev)
Postgres is published on **localhost:5455**, same as the manual `docker run` and `setup.sh` flow. The stack works **without** a `.env` file (compose supplies defaults for DB names, the app DB role, and a dev-only `SECRET_KEY_BASE`). Override with `.env` if you want different passwords.
```bash
docker compose -f compose.yaml up -d
```
Use `POSTGRES_PORT=5455` and `POSTGRES_HOST=localhost` in `.env` when running `mix` on the host against this database (compose services talk to Postgres via the hostname `postgres` on the Docker network, not `localhost`).
To reset the compose database volume: `docker compose -f compose.yaml down -v`.
### Starting the server ### Starting the server
```bash ```bash
@ -85,15 +97,13 @@ Unit tests (no database required):
mix test.unit mix test.unit
``` ```
Integration tests (requires the database to be running). You must have `REQUIRE_DB=true` (included in `.env` from `setup.sh`) so the Repo starts under test: Integration tests (requires the database to be running). The `mix test.integration` task runs a subprocess with `REQUIRE_DB=true` so the Repo starts under test—you do not need to export it yourself. Still `source .env` (or set `POSTGRES_*`) so the test database host and port match your container:
```bash ```bash
source .env source .env
mix test.integration mix test.integration
``` ```
Without `.env`: `REQUIRE_DB=true mix test.integration`
Run the full integration probe against the relay API (covers all endpoints, CORS, NIP-11, NIP-70): Run the full integration probe against the relay API (covers all endpoints, CORS, NIP-11, NIP-70):
```bash ```bash

47
compose.yaml

@ -3,15 +3,16 @@ services:
image: docker.io/apache/age:release_PG17_1.6.0 image: docker.io/apache/age:release_PG17_1.6.0
container_name: postgress_01 container_name: postgress_01
restart: unless-stopped restart: unless-stopped
user: 1000:1000 # Should match host user # Host port matches setup.sh, .env.example, and config defaults (5455 → container 5432).
ports: ports:
- "5432:5432" - "5455:5432"
# Named volume avoids host uid mismatches (do not combine image postgres user with user: 1000 + ./pgdata).
volumes: volumes:
- ./pgdata:/var/lib/postgresql/data # Ensure host user owns the ./pgdata directory - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB:-gc_index_relay_dev}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
command: > command: >
postgres postgres
deploy: deploy:
@ -22,18 +23,8 @@ services:
reservations: reservations:
cpus: "0.50" cpus: "0.50"
memory: 512M memory: 512M
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
- SETUID
- SETGID
read_only: false
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-gc_index_relay_dev}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@ -48,12 +39,13 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
POSTGRES_HOST: ${POSTGRES_HOST} # Docker DNS name of this service (not localhost from .env — that is for host-side mix).
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_HOST: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER} POSTGRES_DB: ${POSTGRES_DB:-gc_index_relay_dev}
POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD} POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER:-gc_index_relay}
POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD:-gc_index_relay_runtime}
migrator: migrator:
build: build:
@ -67,8 +59,9 @@ services:
setup: setup:
condition: service_completed_successfully condition: service_completed_successfully
environment: environment:
DATABASE_URL: "ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}" # Inside the compose network Postgres listens on 5432; 5455 is only the host publish port.
SECRET_KEY_BASE: ${SECRET_KEY_BASE} DATABASE_URL: "ecto://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-gc_index_relay_dev}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-0000000000000000000000000000000000000000000000000000000000000000}
mercury: mercury:
build: build:
@ -84,8 +77,8 @@ services:
ports: ports:
- "4000:4000" - "4000:4000"
environment: environment:
DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER}:${POSTGRES_RUNTIME_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}" DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER:-gc_index_relay}:${POSTGRES_RUNTIME_PASSWORD:-gc_index_relay_runtime}@postgres:5432/${POSTGRES_DB:-gc_index_relay_dev}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE} SECRET_KEY_BASE: ${SECRET_KEY_BASE:-0000000000000000000000000000000000000000000000000000000000000000}
volumes: volumes:
pgdata: pgdata:

9
config/config.exs

@ -30,15 +30,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
pubsub_server: GcIndexRelay.PubSub, pubsub_server: GcIndexRelay.PubSub,
live_view: [signing_salt: "CiEK2Shl"] live_view: [signing_salt: "CiEK2Shl"]
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :gc_index_relay, GcIndexRelay.Mailer, adapter: Swoosh.Adapters.Local
config :gc_index_relay, :phoenix_swagger, config :gc_index_relay, :phoenix_swagger,
swagger_files: %{ swagger_files: %{
"priv/static/swagger.json" => [ "priv/static/swagger.json" => [

5
config/dev.exs

@ -60,7 +60,7 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
] ]
] ]
# Enable dev routes for dashboard and mailbox # Enable dev routes for LiveDashboard
config :gc_index_relay, dev_routes: true config :gc_index_relay, dev_routes: true
# Do not include metadata nor timestamps in development logs # Do not include metadata nor timestamps in development logs
@ -80,6 +80,3 @@ config :phoenix_live_view,
debug_attributes: true, debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks # Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

6
config/prod.exs

@ -15,12 +15,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
hosts: ["localhost", "127.0.0.1"] hosts: ["localhost", "127.0.0.1"]
] ]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info

27
config/runtime.exs

@ -23,14 +23,17 @@ end
config :gc_index_relay, GcIndexRelayWeb.Endpoint, config :gc_index_relay, GcIndexRelayWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))] http: [port: String.to_integer(System.get_env("PORT", "4000"))]
# Defaults match setup.sh / .env.example (Apache AGE on host port 5455). Production # Host-side dev/test: DB published on localhost:5455 (see setup.sh / .env.example).
# replaces this with DATABASE_URL in the block below. # Do not set this in :prod: DATABASE_URL omits port on purpose; merging these options
# would keep port 5455 while the hostname came from the URL (e.g. postgres in Docker).
if config_env() != :prod do
config :gc_index_relay, GcIndexRelay.Repo, config :gc_index_relay, GcIndexRelay.Repo,
hostname: System.get_env("POSTGRES_HOST") || "localhost", hostname: System.get_env("POSTGRES_HOST") || "localhost",
port: String.to_integer(System.get_env("POSTGRES_PORT") || "5455"), port: String.to_integer(System.get_env("POSTGRES_PORT") || "5455"),
username: System.get_env("POSTGRES_USER") || "postgres", username: System.get_env("POSTGRES_USER") || "postgres",
password: System.get_env("POSTGRES_PASSWORD") || "postgres", password: System.get_env("POSTGRES_PASSWORD") || "postgres",
database: System.get_env("POSTGRES_DB") || "gc_index_relay_dev" database: System.get_env("POSTGRES_DB") || "gc_index_relay_dev"
end
if config_env() == :prod do if config_env() == :prod do
database_url = database_url =
@ -64,8 +67,6 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || "example.com"
config :gc_index_relay, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :gc_index_relay, GcIndexRelayWeb.Endpoint, config :gc_index_relay, GcIndexRelayWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ http: [
@ -139,22 +140,4 @@ if config_env() == :prod do
# force_ssl: [hsts: true] # force_ssl: [hsts: true]
# #
# Check `Plug.SSL` for all available options in `force_ssl`. # Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :gc_index_relay, GcIndexRelay.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end end

9
config/test.exs

@ -2,6 +2,9 @@ import Config
config :gc_index_relay, :start_repo, System.get_env("REQUIRE_DB") == "true" config :gc_index_relay, :start_repo, System.get_env("REQUIRE_DB") == "true"
# Allow ConnCase HTTP tests to use the same SQL.Sandbox checkout as the test process (see Endpoint).
config :gc_index_relay, sql_sandbox: true
# Configure your database # Configure your database
# #
# The MIX_TEST_PARTITION environment variable can be used # The MIX_TEST_PARTITION environment variable can be used
@ -25,12 +28,6 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
secret_key_base: "gSUz4Ek3rc6PKcY/imWwjsMbwk8g4+aS5HmD1/MyAmqlbSw+r0V83NjR7H0jnwI6", secret_key_base: "gSUz4Ek3rc6PKcY/imWwjsMbwk8g4+aS5HmD1/MyAmqlbSw+r0V83NjR7H0jnwI6",
server: false server: false
# In test we don't send emails
config :gc_index_relay, GcIndexRelay.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warning config :logger, level: :warning

1
lib/gc_index_relay/application.ex

@ -11,7 +11,6 @@ defmodule GcIndexRelay.Application do
[ [
GcIndexRelayWeb.Telemetry, GcIndexRelayWeb.Telemetry,
maybe_repo(), maybe_repo(),
{DNSCluster, query: Application.get_env(:gc_index_relay, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: GcIndexRelay.PubSub}, {Phoenix.PubSub, name: GcIndexRelay.PubSub},
# Start a worker by calling: GcIndexRelay.Worker.start_link(arg) # Start a worker by calling: GcIndexRelay.Worker.start_link(arg)
# {GcIndexRelay.Worker, arg}, # {GcIndexRelay.Worker, arg},

3
lib/gc_index_relay/mailer.ex

@ -1,3 +0,0 @@
defmodule GcIndexRelay.Mailer do
use Swoosh.Mailer, otp_app: :gc_index_relay
end

26
lib/gc_index_relay/nostr.ex

@ -42,13 +42,21 @@ defmodule GcIndexRelay.Nostr do
with {:ok, filter} <- Filter.from_map(filter_map), with {:ok, filter} <- Filter.from_map(filter_map),
events <- events <-
from(e in Event) from(e in Event)
|> Filter.apply(filter), |> Filter.apply(filter) do
pub_events <- pub_events_from_db(events)
Enum.map(events, fn event -> end
{:ok, pub_event} = PubEvent.from_db(event) end
pub_event
end) do defp pub_events_from_db(events) do
{:ok, pub_events} Enum.reduce_while(events, {:ok, []}, fn event, {:ok, acc} ->
case PubEvent.from_db(event) do
{:ok, pub_event} -> {:cont, {:ok, [pub_event | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
|> case do
{:ok, list} -> {:ok, Enum.reverse(list)}
{:error, _} = err -> err
end end
end end
@ -58,8 +66,8 @@ defmodule GcIndexRelay.Nostr do
def create_event(event) when is_struct(event, PubEvent) do def create_event(event) when is_struct(event, PubEvent) do
with {:ok, event} <- Validator.validate_id(event), with {:ok, event} <- Validator.validate_id(event),
{:ok, event} <- Validator.validate_signature(event), {:ok, event} <- Validator.validate_signature(event),
{:ok, event} <- Validator.validate_not_protected(event) do {:ok, event} <- Validator.validate_not_protected(event),
db_event = PubEvent.to_db(event) {:ok, db_event} <- PubEvent.to_db(event) do
tags_as_maps = Enum.map(db_event.tags, &Map.from_struct/1) tags_as_maps = Enum.map(db_event.tags, &Map.from_struct/1)
attrs = db_event |> Map.from_struct() |> Map.put(:tags, tags_as_maps) attrs = db_event |> Map.from_struct() |> Map.put(:tags, tags_as_maps)

43
lib/gc_index_relay/nostr/filter.ex

@ -1,7 +1,10 @@
defmodule GcIndexRelay.Nostr.Filter do defmodule GcIndexRelay.Nostr.Filter do
@moduledoc """ @moduledoc """
An implementation of Nostr filters, including a struct representation and parsing and validation Nostr NIP-01 filters: struct, parsing, validation, and query building.
functions.
Single-letter tag filter keys (`#p`, `#P`, `#e`, `#E`, …) are **case-sensitive**: uppercase and
lowercase names refer to different tags (e.g. NIP-22 uses `P`/`p`, `E`/`e`, `K`/`k` for distinct
semantics). Do not fold case when matching stored tag names.
""" """
alias GcIndexRelay.Nostr.Event alias GcIndexRelay.Nostr.Event
@ -24,9 +27,11 @@ defmodule GcIndexRelay.Nostr.Filter do
@spec from_map(map()) :: {:ok, t()} | {:error, String.t()} @spec from_map(map()) :: {:ok, t()} | {:error, String.t()}
def from_map(map) when is_map(map) do def from_map(map) when is_map(map) do
# Extract tag filters (keys starting with "#") # Keys `#p` and `#P` (and likewise for every letter) are distinct filters; preserve case.
tags = tags =
for {"#" <> k, v} <- map, for {"#" <> k, v} <- map,
String.length(k) == 1,
String.match?(k, ~r/^[a-zA-Z]$/),
do: {k, v}, do: {k, v},
into: %{} into: %{}
@ -62,22 +67,34 @@ defmodule GcIndexRelay.Nostr.Filter do
defp validate_not_empty(_map), do: :ok defp validate_not_empty(_map), do: :ok
# Validate that only known filter keys are present # Validate that only known filter keys are present. `#` keys must be exactly `#` + one letter.
@spec validate_known_keys(map()) :: :ok | {:error, String.t()} @spec validate_known_keys(map()) :: :ok | {:error, String.t()}
defp validate_known_keys(map) do defp validate_known_keys(map) do
known_keys = ["ids", "authors", "kinds", "since", "until", "limit"] known_keys = ["ids", "authors", "kinds", "since", "until", "limit"]
unknown_keys = case Enum.find(Map.keys(map), &invalid_filter_map_key?(&1, known_keys)) do
map nil -> :ok
|> Map.keys() <<"#", _::binary>> = key -> {:error, invalid_hash_filter_key_message(key)}
|> Enum.reject(fn key -> key -> {:error, "Unknown filter key: '#{key}'"}
key in known_keys or String.starts_with?(key, "#") end
end) end
case unknown_keys do defp invalid_filter_map_key?(key, known_keys) do
[] -> :ok cond do
[key | _] -> {:error, "Unknown filter key: '#{key}'"} key in known_keys -> false
valid_tag_filter_key?(key) -> false
true -> true
end
end
defp valid_tag_filter_key?(<<"#", letter::binary-size(1)>>) do
String.match?(letter, ~r/^[a-zA-Z]$/)
end end
defp valid_tag_filter_key?(_), do: false
defp invalid_hash_filter_key_message(key) do
"Invalid tag key '#{key}': must be a single letter (a-z, A-Z)"
end end
@spec validate_ids([String.t()] | nil) :: {:ok, [String.t()] | nil} | {:error, String.t()} @spec validate_ids([String.t()] | nil) :: {:ok, [String.t()] | nil} | {:error, String.t()}

34
lib/gc_index_relay/nostr/pub_event.ex

@ -25,23 +25,23 @@ defmodule GcIndexRelay.Nostr.PubEvent do
@doc """ @doc """
Converts a `GcIndexRelay.Nostr.PubEvent` to its corresponding `GcIndexRelay.Nostr.Event` and Converts a `GcIndexRelay.Nostr.PubEvent` to its corresponding `GcIndexRelay.Nostr.Event` and
`GcIndexRelay.Nostr.Tag` representations. `GcIndexRelay.Nostr.Tag` representations.
Returns an Ecto for `GcIndexRelay.Nostr.Event`.
""" """
@spec to_db(t()) :: Ecto.Schema.t() @spec to_db(t()) :: {:ok, Event.t()} | {:error, atom()}
def to_db(%__MODULE__{tags: tags} = pub_event) do def to_db(%__MODULE__{} = pub_event) do
%Event{} = event = to_event(pub_event) tags = pub_event.tags || []
%{event | tags: to_tags(tags)}
with {:ok, event} <- to_event(pub_event) do
{:ok, %{event | tags: to_tags(tags)}}
end
end end
@spec to_event(t()) :: Ecto.Schema.t() @spec to_event(t()) :: {:ok, Event.t()} | {:error, atom()}
defp to_event(%__MODULE__{} = pub_event) defp to_event(%__MODULE__{} = pub_event) do
when is_binary(pub_event.id) and is_binary(pub_event.pubkey) and with true <- pub_event_fields_valid?(pub_event),
is_integer(pub_event.created_at) and is_integer(pub_event.kind) and {:ok, id} <- Base.decode16(pub_event.id, case: :lower),
is_binary(pub_event.sig) do
with {:ok, id} <- Base.decode16(pub_event.id, case: :lower),
{:ok, pubkey} <- Base.decode16(pub_event.pubkey, case: :lower), {:ok, pubkey} <- Base.decode16(pub_event.pubkey, case: :lower),
{:ok, signature} <- Base.decode16(pub_event.sig, case: :lower) do {:ok, signature} <- Base.decode16(pub_event.sig, case: :lower) do
{:ok,
%Event{ %Event{
id: id, id: id,
pubkey: pubkey, pubkey: pubkey,
@ -49,10 +49,16 @@ defmodule GcIndexRelay.Nostr.PubEvent do
kind: pub_event.kind, kind: pub_event.kind,
content: pub_event.content, content: pub_event.content,
sig: signature sig: signature
} }}
else else
error -> {:error, error} false -> {:error, :invalid_event}
:error -> {:error, :invalid_hex}
end
end end
defp pub_event_fields_valid?(%__MODULE__{} = p) do
is_binary(p.id) and is_binary(p.pubkey) and is_integer(p.created_at) and is_integer(p.kind) and
is_binary(p.sig) and is_list(p.tags || [])
end end
@spec to_tags([[String.t()]]) :: [Ecto.Schema.t()] @spec to_tags([[String.t()]]) :: [Ecto.Schema.t()]

2
lib/gc_index_relay_web.ex

@ -17,7 +17,7 @@ defmodule GcIndexRelayWeb do
those modules here. those modules here.
""" """
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) def static_paths, do: ~w(assets images favicon.ico robots.txt)
def router do def router do
quote do quote do

7
lib/gc_index_relay_web/controllers/api_controller.ex

@ -2,9 +2,12 @@ defmodule GcIndexRelayWeb.ApiController do
use GcIndexRelayWeb, :controller use GcIndexRelayWeb, :controller
def index(conn, _params) do def index(conn, _params) do
relay_info = Application.fetch_env!(:gc_index_relay, :relay_info)
json(conn, %{ json(conn, %{
relay: "Mercury Index-Relay", relay: Keyword.fetch!(relay_info, :name),
version: Application.spec(:gc_index_relay, :vsn) |> to_string(), version:
Keyword.get(relay_info, :version, Application.spec(:gc_index_relay, :vsn)) |> to_string(),
endpoints: [ endpoints: [
%{ %{
method: "GET", method: "GET",

15
lib/gc_index_relay_web/controllers/event_controller.ex

@ -19,9 +19,20 @@ defmodule GcIndexRelayWeb.EventController do
response(400, "BadRequest") response(400, "BadRequest")
end end
@pub_event_keys ~w(id pubkey created_at kind tags content sig)
def create(conn, %{"event" => event_params}) do def create(conn, %{"event" => event_params}) do
atoms_map = Map.new(event_params, fn {k, v} -> {String.to_existing_atom(k), v} end) pub_event =
pub_event = struct(PubEvent, atoms_map) @pub_event_keys
|> Enum.reduce(%{}, fn key, acc ->
case Map.get(event_params, key) do
nil -> acc
v -> Map.put(acc, String.to_existing_atom(key), v)
end
end)
|> Map.put_new(:tags, [])
|> Map.put_new(:content, "")
|> then(&struct(PubEvent, &1))
with {:ok, _event} <- Nostr.create_event(pub_event) do with {:ok, _event} <- Nostr.create_event(pub_event) do
conn conn

11
lib/gc_index_relay_web/controllers/fallback_controller.ex

@ -28,6 +28,13 @@ defmodule GcIndexRelayWeb.FallbackController do
|> json(%{errors: %{detail: message}}) |> json(%{errors: %{detail: message}})
end end
# Atom errors (e.g. from PubEvent.to_db/1).
def call(conn, {:error, reason}) when is_atom(reason) and reason != :not_found do
conn
|> put_status(:bad_request)
|> json(%{errors: %{detail: atom_error_message(reason)}})
end
# This clause is an example of how to handle resources that cannot be found. # This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do def call(conn, {:error, :not_found}) do
conn conn
@ -42,4 +49,8 @@ defmodule GcIndexRelayWeb.FallbackController do
_ -> false _ -> false
end) end)
end end
defp atom_error_message(:invalid_hex), do: "Invalid hexadecimal encoding in event fields"
defp atom_error_message(:invalid_event), do: "Invalid event structure"
defp atom_error_message(_), do: "Bad request"
end end

45
lib/gc_index_relay_web/controllers/filter_controller.ex

@ -83,15 +83,26 @@ defmodule GcIndexRelayWeb.FilterController do
@spec validate_param_values(map()) :: {:ok, map()} | {:error, String.t()} @spec validate_param_values(map()) :: {:ok, map()} | {:error, String.t()}
def validate_param_values(params) do def validate_param_values(params) do
%{"limit" => limit} = params with {:ok, limit} <- parse_limit_value(Map.get(params, "limit")) do
if limit < 1 or limit > 100 do if limit < 1 or limit > 100 do
{:error, "The filter limit must be between 1 and 100."} {:error, "The filter limit must be between 1 and 100."}
else else
{:ok, params} {:ok, Map.put(params, "limit", limit)}
end
end
end
defp parse_limit_value(v) when is_integer(v), do: {:ok, v}
defp parse_limit_value(v) when is_binary(v) do
case Integer.parse(v) do
{int, ""} -> {:ok, int}
_ -> {:error, "Invalid limit value: must be an integer"}
end end
end end
defp parse_limit_value(_), do: {:error, "Invalid limit value: must be an integer"}
# Parse query parameters into a NIP-01 filter map # Parse query parameters into a NIP-01 filter map
@spec parse_query_params(map()) :: {:ok, map()} | {:error, String.t()} @spec parse_query_params(map()) :: {:ok, map()} | {:error, String.t()}
defp parse_query_params(params) do defp parse_query_params(params) do
@ -151,14 +162,28 @@ defmodule GcIndexRelayWeb.FilterController do
end) end)
end end
# Parse filter keys that represent tags. Note that only single-letter keys are treated as tags; # Bare `p=` / `P=` and `#p` / `#P` map to NIP-01 tag filters; letter case is significant (NIP-22).
# all other keys are passed through unchanged.
@spec parse_tag(String.t()) :: String.t() @spec parse_tag(String.t()) :: String.t()
defp parse_tag(key) do defp parse_tag(key) do
if byte_size(key) == 1 and cond do
((key >= "a" and key <= "z") or (key >= "A" and key <= "Z")), single_bare_letter_tag_key?(key) ->
do: "#" <> key, "#" <> key
else: key
single_letter_hash_tag_key?(key) ->
key
true ->
key
end
end
defp single_bare_letter_tag_key?(key) do
String.length(key) == 1 and String.match?(key, ~r/^[a-zA-Z]$/)
end
defp single_letter_hash_tag_key?(key) do
String.starts_with?(key, "#") and String.length(key) == 2 and
String.match?(String.at(key, 1), ~r/^[a-zA-Z]$/)
end end
# Parse individual parameter based on its key # Parse individual parameter based on its key
@ -210,7 +235,7 @@ defmodule GcIndexRelayWeb.FilterController do
# Handle single-letter tag filters without "#" prefix (e.g., "p" instead of "#p") # Handle single-letter tag filters without "#" prefix (e.g., "p" instead of "#p")
# The "#" is trimmed by URL fragment parsing; bare single-letter keys are treated as tag filters # The "#" is trimmed by URL fragment parsing; bare single-letter keys are treated as tag filters
defp parse_param(<<letter>> = _key, value) when letter in ?a..?z do defp parse_param(<<letter>> = _key, value) when letter in ?a..?z or letter in ?A..?Z do
{:ok, String.split(value, ",")} {:ok, String.split(value, ",")}
end end
end end

14
lib/gc_index_relay_web/endpoint.ex

@ -1,6 +1,12 @@
defmodule GcIndexRelayWeb.Endpoint do defmodule GcIndexRelayWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :gc_index_relay use Phoenix.Endpoint, otp_app: :gc_index_relay
# Use runtime config so the plug is active whenever test.exs sets :sql_sandbox (compile_env
# would omit the plug if Endpoint was last compiled in an env without that key).
@sandbox_plug_opts Phoenix.Ecto.SQL.Sandbox.init([])
plug :maybe_sql_sandbox
# The session will be stored in the cookie and signed, # The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with. # this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it. # Set :encryption_salt if you would also like to encrypt it.
@ -54,4 +60,12 @@ defmodule GcIndexRelayWeb.Endpoint do
plug GcIndexRelayWeb.Plugs.CORS plug GcIndexRelayWeb.Plugs.CORS
plug GcIndexRelayWeb.Plugs.RelayInfo plug GcIndexRelayWeb.Plugs.RelayInfo
plug GcIndexRelayWeb.Router plug GcIndexRelayWeb.Router
def maybe_sql_sandbox(conn, _opts) do
if Application.get_env(:gc_index_relay, :sql_sandbox, false) do
Phoenix.Ecto.SQL.Sandbox.call(conn, @sandbox_plug_opts)
else
conn
end
end
end end

19
lib/gc_index_relay_web/plugs/cors.ex

@ -42,22 +42,25 @@ defmodule GcIndexRelayWeb.Plugs.CORS do
defp resolve_allow_origin(_conn, "*"), do: "*" defp resolve_allow_origin(_conn, "*"), do: "*"
defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do
cond do if "*" in allow_origins do
"*" in allow_origins ->
"*" "*"
else
origin_from_allowlist(conn, allow_origins)
end
end
Enum.any?(allow_origins, &(&1 == "*")) -> defp resolve_allow_origin(_conn, _), do: "*"
"*"
true -> defp origin_from_allowlist(conn, allow_origins) do
case get_req_header(conn, "origin") do case get_req_header(conn, "origin") do
[origin] -> if Enum.member?(allow_origins, origin), do: origin, else: nil [origin] -> origin_if_allowed(origin, allow_origins)
_ -> nil _ -> nil
end end
end end
end
defp resolve_allow_origin(_conn, _), do: "*" defp origin_if_allowed(origin, allow_origins) do
if Enum.member?(allow_origins, origin), do: origin, else: nil
end
defp restrictive_allowlist?("*"), do: false defp restrictive_allowlist?("*"), do: false

17
lib/gc_index_relay_web/plugs/relay_info.ex

@ -65,15 +65,22 @@ defmodule GcIndexRelayWeb.Plugs.RelayInfo do
map map
url when is_binary(url) -> url when is_binary(url) ->
if String.starts_with?(url, ["http://", "https://"]) do resolve_url_field(map, key, url, base_url)
_ ->
map map
else end
path = if String.starts_with?(url, "/"), do: url, else: "/#{url}"
Map.put(map, key, "#{base_url}#{path}")
end end
_ -> defp resolve_url_field(map, key, url, base_url) do
if String.starts_with?(url, ["http://", "https://"]) do
map map
else
Map.put(map, key, "#{base_url}#{path_under_base(url)}")
end end
end end
defp path_under_base(url) do
if String.starts_with?(url, "/"), do: url, else: "/#{url}"
end
end end

34
lib/gc_index_relay_web/router.ex

@ -32,25 +32,36 @@ defmodule GcIndexRelayWeb.Router do
end end
def swagger_info do def swagger_info do
relay_info = Application.fetch_env!(:gc_index_relay, :relay_info)
title = Keyword.fetch!(relay_info, :name)
version = Keyword.fetch!(relay_info, :version) |> to_string()
base_desc = Keyword.fetch!(relay_info, :description)
description =
base_desc <>
supported_nips_swagger_suffix(Keyword.get(relay_info, :supported_nips, [])) <>
"\n\nRelay information document available at `GET /` with `Accept: application/nostr+json`."
%{ %{
# `wss` reserved for a future NIP-01 WebSocket endpoint; REST-only for now.
schemes: ["https", "wss"], schemes: ["https", "wss"],
info: %{ info: %{
version: Application.spec(:gc_index_relay, :vsn) |> to_string(), version: version,
title: "Mercury Index-Relay", title: title,
description: """ description: description
A Nostr index relay by [GitCitadel](https://gitcitadel.eu). \
Stores and serves Nostr events via a REST API with NIP-01 filter-based querying.
**Supported NIPs:** NIP-11 (relay info), NIP-70 (protected events)
Relay information document available at `GET /` with `Accept: application/nostr+json`.
"""
}, },
consumes: ["application/json"], consumes: ["application/json"],
produces: ["application/json"] produces: ["application/json"]
} }
end end
defp supported_nips_swagger_suffix([]), do: ""
defp supported_nips_swagger_suffix(nips) do
"\n\n**Supported NIPs:** " <> Enum.map_join(nips, ", ", &"NIP-#{&1}")
end
scope "/api/swagger" do scope "/api/swagger" do
forward "/", PhoenixSwagger.Plug.SwaggerUI, forward "/", PhoenixSwagger.Plug.SwaggerUI,
otp_app: :gc_index_relay, otp_app: :gc_index_relay,
@ -62,7 +73,7 @@ defmodule GcIndexRelayWeb.Router do
# pipe_through :api # pipe_through :api
# end # end
# Enable LiveDashboard and Swoosh mailbox preview in development # Enable LiveDashboard in development
if Application.compile_env(:gc_index_relay, :dev_routes) do if Application.compile_env(:gc_index_relay, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put # If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it. # it behind authentication and allow only admins to access it.
@ -75,7 +86,6 @@ defmodule GcIndexRelayWeb.Router do
pipe_through :browser pipe_through :browser
live_dashboard "/dashboard", metrics: GcIndexRelayWeb.Telemetry live_dashboard "/dashboard", metrics: GcIndexRelayWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end end
end end
end end

39
lib/mix/tasks/test/integration.ex

@ -0,0 +1,39 @@
defmodule Mix.Tasks.Test.Integration do
use Mix.Task
@shortdoc "Runs integration tests with REQUIRE_DB=true (requires PostgreSQL)"
@moduledoc """
Creates the test database if needed, runs migrations, then runs tests tagged `:integration`.
Spawns a subprocess with `REQUIRE_DB=true` so the application starts `GcIndexRelay.Repo`
under `MIX_ENV=test`. Extra args are forwarded to `mix test` (e.g. `--failed`, `--trace` for line-by-line output).
"""
@impl Mix.Task
def run(args) do
Mix.Task.run("loadconfig")
argv =
[
"REQUIRE_DB=true",
"MIX_ENV=test",
"mix",
"do",
"ecto.create",
"--quiet",
"+",
"ecto.migrate",
"--quiet",
"+",
"test",
"--only",
"integration"
] ++ args
{_output, exit_code} =
System.cmd("env", argv, into: IO.stream(:stdio, :line), cd: File.cwd!())
System.halt(exit_code)
end
end

17
mix.exs

@ -30,7 +30,8 @@ defmodule GcIndexRelay.MixProject do
preferred_envs: [ preferred_envs: [
precommit: :test, precommit: :test,
"test.unit": :test, "test.unit": :test,
"test.integration": :test "test.integration": :test,
"test.complete": :test
] ]
] ]
end end
@ -53,13 +54,10 @@ defmodule GcIndexRelay.MixProject do
{:phoenix_live_view, "~> 1.1.0"}, {:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test}, {:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"}, {:phoenix_live_dashboard, "~> 0.8.3"},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"}, {:gettext, "~> 1.0"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:lib_secp256k1, "~> 0.7.1"}, {:lib_secp256k1, "~> 0.7.1"},
{:phoenix_swagger, "~> 0.8"}, {:phoenix_swagger, "~> 0.8"},
@ -76,21 +74,18 @@ defmodule GcIndexRelay.MixProject do
defp aliases do defp aliases do
[ [
setup: ["deps.get", "ecto.setup"], setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"], "ecto.reset": ["ecto.drop", "ecto.setup"],
"test.unit": ["test --only unit"], "test.unit": ["test --only unit"],
"test.integration": [
"ecto.create --quiet",
"ecto.migrate --quiet",
"test --only integration --trace"
],
precommit: [ precommit: [
"compile --warnings-as-errors", "compile --warnings-as-errors",
"deps.unlock --unused", "deps.unlock --unused",
"format", "format",
"credo", "credo",
"test.unit" "test.unit"
] ],
# Full local CI: precommit (compile, format, credo, unit) then integration tests (needs PostgreSQL).
"test.complete": ["precommit", "test.integration"]
] ]
end end
end end

9
mix.lock

@ -5,24 +5,18 @@
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [: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", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [: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", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"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": {: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.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [: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", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [: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", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"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"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"lib_secp256k1": {:hex, :lib_secp256k1, "0.7.1", "53cad778b8da3a29e453a7a477517d99fb5f13f615c8050eb2db8fd1dce7a1db", [:make, :mix], [{:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "78bdd3661a17448aff5aeec5ca74c8ddbc09b01f0ecfa3ba1aba3e8ae47ab2b3"}, "lib_secp256k1": {:hex, :lib_secp256k1, "0.7.1", "53cad778b8da3a29e453a7a477517d99fb5f13f615c8050eb2db8fd1dce7a1db", [:make, :mix], [{:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "78bdd3661a17448aff5aeec5ca74c8ddbc09b01f0ecfa3ba1aba3e8ae47ab2b3"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@ -35,13 +29,10 @@
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [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", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [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", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
} }

11
priv/repo/seeds.exs

@ -1,11 +0,0 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# GcIndexRelay.Repo.insert!(%GcIndexRelay.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

22
priv/static/swagger.json

@ -1,19 +1,11 @@
{ {
"info": { "info": {
"version": "0.2.0", "version": "0.2.0",
"description": "A Nostr index relay by [GitCitadel](https://gitcitadel.eu). Stores and serves Nostr events via a REST API with NIP-01 filter-based querying.\n\n**Supported NIPs:** NIP-11 (relay info), NIP-70 (protected events)\n\nRelay information document available at `GET /` with `Accept: application/nostr+json`.\n", "description": "A Nostr index relay for the http protocol, from GitCitadel. Featuring a RESTful API and Swagger, it specializes in swift retrieval or publications, repos, and similar graphs of related events\n\n**Supported NIPs:** NIP-11, NIP-70\n\nRelay information document available at `GET /` with `Accept: application/nostr+json`.",
"title": "Mercury Index-Relay" "title": "Mercury Index-Relay"
}, },
"host": "localhost:4000", "host": "localhost:4000",
"definitions": { "definitions": {
"PubEventList": {
"description": "A list of Nostr events",
"items": {
"$ref": "#/definitions/PubEvent"
},
"title": "PubEventList",
"type": "array"
},
"PubEvent": { "PubEvent": {
"description": "A signed Nostr event", "description": "A signed Nostr event",
"example": { "example": {
@ -72,6 +64,14 @@
], ],
"title": "PubEvent", "title": "PubEvent",
"type": "object" "type": "object"
},
"PubEventList": {
"description": "A list of Nostr events",
"items": {
"$ref": "#/definitions/PubEvent"
},
"title": "PubEventList",
"type": "array"
} }
}, },
"schemes": [ "schemes": [
@ -228,10 +228,10 @@
} }
}, },
"swagger": "2.0", "swagger": "2.0",
"consumes": [ "produces": [
"application/json" "application/json"
], ],
"produces": [ "consumes": [
"application/json" "application/json"
] ]
} }

13
test/gc_index_relay/nostr/filter_test.exs

@ -27,11 +27,22 @@ defmodule GcIndexRelay.Nostr.FilterTest do
assert filter.tags == %{"e" => ["value1", "value2"]} assert filter.tags == %{"e" => ["value1", "value2"]}
end end
test "returns {:ok, filter} with valid tags (uppercase)" do test "returns {:ok, filter} with valid tags (uppercase letter is distinct from lowercase)" do
assert {:ok, filter} = Filter.from_map(%{"#E" => ["value1"]}) assert {:ok, filter} = Filter.from_map(%{"#E" => ["value1"]})
assert filter.tags == %{"E" => ["value1"]} assert filter.tags == %{"E" => ["value1"]}
end end
test "returns {:ok, filter} with both #e and #E as separate tag filters" do
assert {:ok, filter} =
Filter.from_map(%{
"#e" => ["lowercase-scope"],
"#E" => ["uppercase-scope"],
"limit" => 10
})
assert filter.tags == %{"e" => ["lowercase-scope"], "E" => ["uppercase-scope"]}
end
test "returns {:ok, filter} with multiple valid tags" do test "returns {:ok, filter} with multiple valid tags" do
assert {:ok, filter} = Filter.from_map(%{"#e" => ["val1"], "#p" => ["val2"]}) assert {:ok, filter} = Filter.from_map(%{"#e" => ["val1"], "#p" => ["val2"]})
assert filter.tags == %{"e" => ["val1"], "p" => ["val2"]} assert filter.tags == %{"e" => ["val1"], "p" => ["val2"]}

65
test/gc_index_relay/nostr/pub_event_test.exs

@ -12,7 +12,7 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
describe "to_db/1" do describe "to_db/1" do
test "converts tags with name and value" do test "converts tags with name and value" do
pub_event = valid_pub_event_fixture(tags: [["e", "abc123"], ["p", "def456"]]) pub_event = valid_pub_event_fixture(tags: [["e", "abc123"], ["p", "def456"]])
event = PubEvent.to_db(pub_event) assert {:ok, event} = PubEvent.to_db(pub_event)
assert [tag1, tag2] = event.tags assert [tag1, tag2] = event.tags
assert %Tag{name: "e", value: "abc123", additional_values: []} = tag1 assert %Tag{name: "e", value: "abc123", additional_values: []} = tag1
@ -22,16 +22,33 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
test "converts tags with additional values" do test "converts tags with additional values" do
tags = [["p", "pubkey_hex", "wss://relay.example.com", "alice"]] tags = [["p", "pubkey_hex", "wss://relay.example.com", "alice"]]
pub_event = valid_pub_event_fixture(tags: tags) pub_event = valid_pub_event_fixture(tags: tags)
event = PubEvent.to_db(pub_event) assert {:ok, event} = PubEvent.to_db(pub_event)
assert [tag] = event.tags assert [tag] = event.tags
assert %Tag{name: "p", value: "pubkey_hex"} = tag assert %Tag{name: "p", value: "pubkey_hex"} = tag
assert tag.additional_values == ["wss://relay.example.com", "alice"] assert tag.additional_values == ["wss://relay.example.com", "alice"]
end end
test "maps reference kind 1111 tags including uppercase names and extras" do
pub_event = reference_kind1111_pub_event()
assert {:ok, event} = PubEvent.to_db(pub_event)
assert length(event.tags) == 7
e_upper = Enum.find(event.tags, &(&1.name == "E"))
assert e_upper.value == "6e35ec65661c5e2c453f9585a785b3f082c1daf9f769b3bb208af5459d178fca"
assert e_upper.additional_values == [
"wss://nostr.land/",
"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"
]
assert %Tag{name: "client", value: "imwald", additional_values: []} in event.tags
end
test "preserves empty content" do test "preserves empty content" do
pub_event = valid_pub_event_fixture(content: "") pub_event = valid_pub_event_fixture(content: "")
event = PubEvent.to_db(pub_event) assert {:ok, event} = PubEvent.to_db(pub_event)
assert event.content == "" assert event.content == ""
end end
@ -96,7 +113,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
test "preserves all fields for a basic event" do test "preserves all fields for a basic event" do
pub_event = valid_pub_event_fixture(content: "round-trip test") pub_event = valid_pub_event_fixture(content: "round-trip test")
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.id == pub_event.id assert result.id == pub_event.id
assert result.pubkey == pub_event.pubkey assert result.pubkey == pub_event.pubkey
@ -110,7 +128,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
test "preserves event with empty tags" do test "preserves event with empty tags" do
pub_event = valid_pub_event_fixture(tags: []) pub_event = valid_pub_event_fixture(tags: [])
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == [] assert result.tags == []
end end
@ -123,7 +142,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags) pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags assert result.tags == tags
end end
@ -132,7 +152,8 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags) pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags assert result.tags == tags
end end
@ -145,14 +166,16 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags) pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags assert result.tags == tags
end end
test "preserves event with empty content" do test "preserves event with empty content" do
pub_event = valid_pub_event_fixture(content: "") pub_event = valid_pub_event_fixture(content: "")
assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.content == "" assert result.content == ""
end end
@ -160,8 +183,10 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event_1 = valid_pub_event_fixture(keypair: :keypair1) pub_event_1 = valid_pub_event_fixture(keypair: :keypair1)
pub_event_2 = valid_pub_event_fixture(keypair: :keypair2) pub_event_2 = valid_pub_event_fixture(keypair: :keypair2)
assert {:ok, result_1} = pub_event_1 |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db1} = PubEvent.to_db(pub_event_1)
assert {:ok, result_2} = pub_event_2 |> PubEvent.to_db() |> PubEvent.from_db() assert {:ok, db2} = PubEvent.to_db(pub_event_2)
assert {:ok, result_1} = PubEvent.from_db(db1)
assert {:ok, result_2} = PubEvent.from_db(db2)
assert result_1.pubkey == pub_event_1.pubkey assert result_1.pubkey == pub_event_1.pubkey
assert result_2.pubkey == pub_event_2.pubkey assert result_2.pubkey == pub_event_2.pubkey
@ -170,31 +195,25 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
end end
describe "to_db/1 with invalid hex input" do describe "to_db/1 with invalid hex input" do
test "raises on invalid hex in id" do test "returns error on invalid hex in id" do
pub_event = valid_pub_event_fixture() pub_event = valid_pub_event_fixture()
invalid = %{pub_event | id: String.duplicate("zz", 32)} invalid = %{pub_event | id: String.duplicate("zz", 32)}
assert_raise MatchError, fn -> assert {:error, :invalid_hex} = PubEvent.to_db(invalid)
PubEvent.to_db(invalid)
end
end end
test "raises on invalid hex in pubkey" do test "returns error on invalid hex in pubkey" do
pub_event = valid_pub_event_fixture() pub_event = valid_pub_event_fixture()
invalid = %{pub_event | pubkey: String.duplicate("zz", 32)} invalid = %{pub_event | pubkey: String.duplicate("zz", 32)}
assert_raise MatchError, fn -> assert {:error, :invalid_hex} = PubEvent.to_db(invalid)
PubEvent.to_db(invalid)
end
end end
test "raises on invalid hex in sig" do test "returns error on invalid hex in sig" do
pub_event = valid_pub_event_fixture() pub_event = valid_pub_event_fixture()
invalid = %{pub_event | sig: String.duplicate("zz", 64)} invalid = %{pub_event | sig: String.duplicate("zz", 64)}
assert_raise MatchError, fn -> assert {:error, :invalid_hex} = PubEvent.to_db(invalid)
PubEvent.to_db(invalid)
end
end end
end end
end end

7
test/gc_index_relay/nostr/validator_test.exs

@ -175,5 +175,12 @@ defmodule GcIndexRelay.Nostr.ValidatorTest do
assert {:ok, ^event} = Validator.validate_id(event) assert {:ok, ^event} = Validator.validate_id(event)
assert {:ok, ^event} = Validator.validate_signature(event) assert {:ok, ^event} = Validator.validate_signature(event)
end end
test "validates real kind 1111 multi-tag reference event" do
event = reference_kind1111_pub_event()
assert {:ok, ^event} = Validator.validate_id(event)
assert {:ok, ^event} = Validator.validate_signature(event)
end
end end
end end

17
test/gc_index_relay/nostr_test.exs

@ -1,5 +1,22 @@
defmodule GcIndexRelay.NostrTest do defmodule GcIndexRelay.NostrTest do
use GcIndexRelay.DataCase use GcIndexRelay.DataCase
import GcIndexRelay.NostrFixtures
@moduletag :integration @moduletag :integration
describe "reference kind 1111 event" do
test "create_event and get_event round-trip all fields and tags" do
event = reference_kind1111_pub_event()
assert {:ok, _} = GcIndexRelay.Nostr.create_event(event)
assert {:ok, loaded} = GcIndexRelay.Nostr.get_event(event.id)
assert loaded.kind == event.kind
assert loaded.content == event.content
assert loaded.pubkey == event.pubkey
assert loaded.created_at == event.created_at
assert loaded.tags == event.tags
end
end
end end

2
test/gc_index_relay_web/controllers/error_html_test.exs

@ -1,6 +1,8 @@
defmodule GcIndexRelayWeb.ErrorHTMLTest do defmodule GcIndexRelayWeb.ErrorHTMLTest do
use GcIndexRelayWeb.ConnCase, async: true use GcIndexRelayWeb.ConnCase, async: true
@moduletag :unit
# Bring render_to_string/4 for testing custom views # Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4] import Phoenix.Template, only: [render_to_string: 4]

2
test/gc_index_relay_web/controllers/error_json_test.exs

@ -1,6 +1,8 @@
defmodule GcIndexRelayWeb.ErrorJSONTest do defmodule GcIndexRelayWeb.ErrorJSONTest do
use GcIndexRelayWeb.ConnCase, async: true use GcIndexRelayWeb.ConnCase, async: true
@moduletag :unit
test "renders 404" do test "renders 404" do
assert GcIndexRelayWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} assert GcIndexRelayWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end end

46
test/gc_index_relay_web/controllers/filter_controller_test.exs

@ -80,6 +80,24 @@ defmodule GcIndexRelayWeb.FilterControllerTest do
assert hd(events)["id"] == tagged_pub_event.id assert hd(events)["id"] == tagged_pub_event.id
end end
test "filters by uppercase P tag separately from lowercase p (NIP-22)", %{conn: conn} do
upper = valid_pub_event_fixture(%{tags: [["P", "abc123def456"]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(upper)
lower = valid_pub_event_fixture(%{tags: [["p", "abc123def456"]], keypair: :keypair2})
{:ok, _} = GcIndexRelay.Nostr.create_event(lower)
event_fixture(%{kind: 2, created_at: 1_640_000_002})
conn_p = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&p=abc123def456")
assert %{"data" => p_events} = json_response(conn_p, 200)
assert Enum.map(p_events, & &1["id"]) == [lower.id]
conn_upper_p = get(conn, ~p"/api/events?since=0&until=9999999999&limit=10&P=abc123def456")
assert %{"data" => p_upper_events} = json_response(conn_upper_p, 200)
assert Enum.map(p_upper_events, & &1["id"]) == [upper.id]
end
test "respects limit parameter", %{conn: conn} do test "respects limit parameter", %{conn: conn} do
event_fixture(%{kind: 1}) event_fixture(%{kind: 1})
event_fixture(%{kind: 2, created_at: 1_640_000_001}) event_fixture(%{kind: 2, created_at: 1_640_000_001})
@ -239,6 +257,34 @@ defmodule GcIndexRelayWeb.FilterControllerTest do
assert hd(events)["id"] == tagged_pub_event.id assert hd(events)["id"] == tagged_pub_event.id
end end
test "POST filter #P matches uppercase P tag only, not lowercase p", %{conn: conn} do
upper = valid_pub_event_fixture(%{tags: [["P", "abc123def456"]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(upper)
lower = valid_pub_event_fixture(%{tags: [["p", "abc123def456"]], keypair: :keypair2})
{:ok, _} = GcIndexRelay.Nostr.create_event(lower)
event_fixture(%{kind: 2, created_at: 1_640_000_002})
conn_upper_p =
post(conn, ~p"/api/events/filter", %{
"#P" => ["abc123def456"],
"limit" => 10
})
assert %{"data" => p_upper} = json_response(conn_upper_p, 200)
assert Enum.map(p_upper, & &1["id"]) == [upper.id]
conn_p =
post(conn, ~p"/api/events/filter", %{
"#p" => ["abc123def456"],
"limit" => 10
})
assert %{"data" => p_lower} = json_response(conn_p, 200)
assert Enum.map(p_lower, & &1["id"]) == [lower.id]
end
test "respects limit parameter", %{conn: conn} do test "respects limit parameter", %{conn: conn} do
event_fixture(%{kind: 1}) event_fixture(%{kind: 1})
event_fixture(%{kind: 2, created_at: 1_640_000_001}) event_fixture(%{kind: 2, created_at: 1_640_000_001})

25
test/support/conn_case.ex

@ -32,7 +32,28 @@ defmodule GcIndexRelayWeb.ConnCase do
end end
setup tags do setup tags do
GcIndexRelay.DataCase.setup_sandbox(tags) sandbox_owner = GcIndexRelay.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
conn =
Phoenix.ConnTest.build_conn()
|> maybe_put_sql_sandbox_user_agent(sandbox_owner)
{:ok, conn: conn}
end
defp maybe_put_sql_sandbox_user_agent(conn, nil), do: conn
defp maybe_put_sql_sandbox_user_agent(conn, owner) when is_pid(owner) do
if Application.get_env(:gc_index_relay, :sql_sandbox, false) do
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(GcIndexRelay.Repo, owner)
Plug.Conn.put_req_header(
conn,
"user-agent",
Phoenix.Ecto.SQL.Sandbox.encode_metadata(metadata)
)
else
conn
end
end end
end end

24
test/support/data_case.ex

@ -28,19 +28,33 @@ defmodule GcIndexRelay.DataCase do
end end
setup tags do setup tags do
GcIndexRelay.DataCase.setup_sandbox(tags) _ = GcIndexRelay.DataCase.setup_sandbox(tags)
:ok :ok
end end
@doc """ @doc """
Sets up the sandbox based on the test tags. Starts a sandbox owner for the Repo when it is started in this environment.
Returns the sandbox owner pid when the Repo is started, otherwise `nil`.
""" """
def setup_sandbox(tags) do @spec setup_sandbox(map()) :: pid() | nil
# Only set up sandbox if Repo was started def setup_sandbox(_tags) do
if Application.get_env(:gc_index_relay, :start_repo, true) do if Application.get_env(:gc_index_relay, :start_repo, true) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: not tags[:async]) # Always use shared: false so concurrent test *modules* (ExUnit default) do not
# clobber each other via `mode(repo, {:shared, _})`. The test process is allowed on
# the owner checkout; HTTP tests rely on `Phoenix.Ecto.SQL.Sandbox` + metadata header.
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: false)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
empty_nostr_event_tables!(GcIndexRelay.Repo)
pid
end
end end
defp empty_nostr_event_tables!(repo) do
alias GcIndexRelay.Nostr.{Event, Tag}
_ = repo.delete_all(Tag)
_ = repo.delete_all(Event)
end end
@doc """ @doc """

36
test/support/fixtures/nostr_fixtures.ex

@ -153,6 +153,42 @@ defmodule GcIndexRelay.NostrFixtures do
}) })
end end
@doc """
Returns a real kind `1111` note with many tags (`E`/`P`/`K`/`e`/`k`/`p`/`client`) and a valid NIP-01 id and Schnorr signature.
Use for tag-parsing, filter, and crypto regression tests. Captured from the network as a known-good vector.
"""
def reference_kind1111_pub_event do
%PubEvent{
id: "ec42b22bcf5e9b7849ee951e0b55c8c9c1467ac694c8087eabf2bf0a7f6c035f",
pubkey: "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319",
created_at: 1_775_829_401,
kind: 1111,
content: "Ganz brav komprimiert, versprochen. Habe mir extra eine App dafür besorgt.",
sig:
"8f5323d967074351f459e562b66ebb7fc16b505b7dec6a979b6b50ce661f6c51e523215e2bc5afd048ed8b5914ba5e2f3ec4562032e971209579a6a9d2516c82",
tags: [
[
"E",
"6e35ec65661c5e2c453f9585a785b3f082c1daf9f769b3bb208af5459d178fca",
"wss://nostr.land/",
"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"
],
["P", "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"],
["K", "21"],
[
"e",
"6e35ec65661c5e2c453f9585a785b3f082c1daf9f769b3bb208af5459d178fca",
"wss://nostr.land/",
"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"
],
["k", "21"],
["p", "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319"],
["client", "imwald"]
]
}
end
# Private helpers # Private helpers
defp compute_event_id(event) do defp compute_event_id(event) do

Loading…
Cancel
Save