Browse Source

implement issue #6

Basic relay metadata, include name and icons
Add script to reload on config changes
Updated ReadMe to reflect our changes
test/local-setup
Silberengel 3 weeks ago
parent
commit
109768f094
  1. 141
      README.md
  2. 21
      config/config.exs
  3. 50
      dev.sh
  4. 3
      lib/gc_index_relay_web/components/layouts/root.html.heex
  5. 1
      lib/gc_index_relay_web/endpoint.ex
  6. 33
      lib/gc_index_relay_web/plugs/relay_info.ex
  7. BIN
      priv/static/favicon.ico
  8. BIN
      priv/static/images/favicon-32x32.png
  9. BIN
      priv/static/images/mercury_icon.png
  10. BIN
      priv/static/images/mercury_icon_small.png
  11. 3
      setup.sh
  12. 49
      test/features/relay_api.feature
  13. 145
      test/gc_index_relay_web/relay_integration_test.exs

141
README.md

@ -1,38 +1,145 @@ @@ -1,38 +1,145 @@
# GcIndexRelay
<div align="center">
<img src="priv/static/images/mercury_icon.png" alt="Mercury Index-Relay logo" width="180" />
</div>
# GitCitadel Mercury Index-Relay
A Nostr index relay built with Phoenix 1.8 / Elixir. Stores and serves Nostr events via a REST API, with filter-based querying per NIP-01.
**Supported NIPs:** [NIP-11](https://github.com/nostr-protocol/nips/blob/master/11.md) (relay info), [NIP-70](https://github.com/nostr-protocol/nips/blob/master/70.md) (protected events)
## Getting Started
### Prerequisites
- Docker (for the database)
- Elixir ~> 1.15
### Automated setup
Run the setup script — it installs Erlang/Elixir via asdf, starts the Apache AGE database container, and runs `mix setup`:
```bash
chmod +x setup.sh
./setup.sh
```
After setup, database credentials are written to `.env`. Source it before running any `mix` commands:
```bash
source .env
```
### Manual setup
Start the Apache AGE Docker container:
```bash
docker build \
-t age \
-f ./db/Dockerfile \
./db
docker run \
-d \
--name gc_age_db \
-p 5455:5432 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=gc_index_relay_dev \
age
apache/age:release_PG17_1.6.0
```
To start your Phoenix server:
Set database environment variables, then install dependencies and run migrations:
- Run `mix setup` to install and setup dependencies
- Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
```bash
export POSTGRES_HOST=localhost
export POSTGRES_PORT=5455
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
mix setup
```
### Starting the server
```bash
source .env
mix phx.server
```
The relay is available at [http://localhost:4000](http://localhost:4000).
During development, use `dev.sh` instead to get automatic server restarts when config files change:
```bash
source .env && ./dev.sh
```
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## API
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api` | List available endpoints |
| `GET` | `/api/events` | Query events via URL params (`since`, `until`, `limit` required) |
| `POST` | `/api/events/filter` | Query events with a NIP-01 filter body (`limit` required) |
| `GET` | `/api/events/:id` | Fetch a single event by ID |
| `POST` | `/api/events` | Publish a new event |
| `DELETE` | `/api/events/:id` | Delete an event by ID |
| `GET` | `/api/swagger` | Swagger UI |
| `GET` | `/health` | Health check |
### NIP-11 relay info
```bash
curl -s -H "Accept: application/nostr+json" http://localhost:4000 | jq
```
Edit the relay metadata in `config/config.exs` under the `:relay_info` key.
## Testing
Unit tests (no database required):
```bash
mix test.unit
```
Integration tests (requires the database to be running):
```bash
source .env
mix test.integration
```
Run the full integration probe against the relay API (covers all endpoints, CORS, NIP-11, NIP-70):
```bash
source .env
mix test.integration test/gc_index_relay_web/relay_integration_test.exs
```
The test scenarios are documented in [test/features/relay_api.feature](test/features/relay_api.feature) and cover:
- Discovery (`GET /api`)
- Publishing and rejecting events (`POST /api/events`)
- Deleting events (`DELETE /api/events/:id`)
- Cacheable queries (`GET /api/events?since=&until=&limit=`)
- Filter-based queries (`POST /api/events/filter`)
- Single-event lookup (`GET /api/events/:id`)
- CORS preflight and headers
- NIP-11 relay info document including the `icon` field
Pre-commit check (compile with warnings-as-errors, format, credo, integration tests):
```bash
source .env
mix precommit
```
## Project Overview
### Database
- The database stores Nostr events.
- Nostr events, once signed, are considered to be immutable.
- Nostr events, once signed, are considered immutable.
- Uses Apache AGE (PostgreSQL with graph extensions).
#### Migrations
@ -42,7 +149,7 @@ After modifying an Ecto schema, generate a migration with: @@ -42,7 +149,7 @@ After modifying an Ecto schema, generate a migration with:
mix ecto.gen.migration <migration-name>
```
Edit the generated migration file as needed, then perform the migration with:
Edit the generated migration file as needed, then apply it:
```bash
mix ecto.migrate
@ -52,8 +159,6 @@ Refer to the Fly.io guide [Safe Ecto Migrations](https://github.com/fly-apps/saf @@ -52,8 +159,6 @@ Refer to the Fly.io guide [Safe Ecto Migrations](https://github.com/fly-apps/saf
## Learn more
- Official website: https://www.phoenixframework.org/
- Guides: https://hexdocs.pm/phoenix/overview.html
- Docs: https://hexdocs.pm/phoenix
- Forum: https://elixirforum.com/c/phoenix-forum
- Source: https://github.com/phoenixframework/phoenix
- Phoenix: https://www.phoenixframework.org/
- Nostr protocol: https://github.com/nostr-protocol/nostr
- NIP-01 (basic protocol): https://github.com/nostr-protocol/nips/blob/master/01.md

21
config/config.exs

@ -49,6 +49,27 @@ config :logger, :default_formatter, @@ -49,6 +49,27 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# NIP-11 relay information document
# Served at GET / with Accept: application/nostr+json
# Edit these values to describe your relay instance.
config :gc_index_relay, :relay_info,
name: "Mercury Index-Relay",
icon:
"https://git.imwald.eu/silberengel/gc_index_relay/src/branch/test/local-setup/priv/static/favicon.ico",
banner:
"https://git.imwald.eu/silberengel/gc_index_relay/src/branch/test/local-setup/priv/static/mercury_icon.png",
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",
software: "https://git.imwald.eu/silberengel/gc_index_relay.git",
version: "0.2",
supported_nips: [11, 70],
limitation: %{
max_limit: 100,
auth_required: false,
payment_required: false,
restricted_writes: false
}
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

50
dev.sh

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Development server with auto-restart on config file changes.
#
# Normal code changes (controllers, templates, etc.) are still hot-reloaded
# by Phoenix automatically. This script only handles the cases Phoenix can't:
# config/config.exs, config/dev.exs, and config/runtime.exs.
#
# Usage:
# chmod +x dev.sh
# source .env && ./dev.sh
set -euo pipefail
CONFIG_FILES=(
config/config.exs
config/dev.exs
config/runtime.exs
)
cleanup() {
if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
echo ""
echo "[dev] Stopping server (pid $SERVER_PID)..."
kill "$SERVER_PID"
wait "$SERVER_PID" 2>/dev/null || true
fi
exit 0
}
trap cleanup INT TERM
echo "[dev] Watching config files for changes: ${CONFIG_FILES[*]}"
echo "[dev] Normal code changes are still hot-reloaded automatically."
echo "[dev] Press Ctrl+C to stop."
echo ""
while true; do
echo "[dev] Starting server..."
mix phx.server &
SERVER_PID=$!
# Block until any config file is modified
inotifywait -q -e modify "${CONFIG_FILES[@]}" 2>/dev/null
echo ""
echo "[dev] Config changed — restarting server..."
kill "$SERVER_PID" 2>/dev/null
wait "$SERVER_PID" 2>/dev/null || true
echo ""
done

3
lib/gc_index_relay_web/components/layouts/root.html.heex

@ -7,6 +7,9 @@ @@ -7,6 +7,9 @@
<.live_title default="GcIndexRelay" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico"} />
<link rel="icon" type="image/png" sizes="32x32" href={~p"/images/favicon-32x32.png"} />
<link rel="apple-touch-icon" href={~p"/images/mercury_icon_small.png"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/default.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>

1
lib/gc_index_relay_web/endpoint.ex

@ -52,5 +52,6 @@ defmodule GcIndexRelayWeb.Endpoint do @@ -52,5 +52,6 @@ defmodule GcIndexRelayWeb.Endpoint do
plug Plug.Head
plug Plug.Session, @session_options
plug GcIndexRelayWeb.Plugs.CORS
plug GcIndexRelayWeb.Plugs.RelayInfo
plug GcIndexRelayWeb.Router
end

33
lib/gc_index_relay_web/plugs/relay_info.ex

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
defmodule GcIndexRelayWeb.Plugs.RelayInfo do
@moduledoc """
Serves the NIP-11 relay information document.
When a GET / request arrives with `Accept: application/nostr+json`, this plug
intercepts it and returns the relay metadata as JSON before the browser pipeline's
`:accepts` check can reject it with a 406.
Configuration is read from `config :gc_index_relay, :relay_info` edit that key
in config/config.exs to describe your relay instance.
"""
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{method: "GET", request_path: "/"} = conn, _opts) do
accept = conn |> get_req_header("accept") |> Enum.join(",")
if String.contains?(accept, "application/nostr+json") do
relay_info = Application.get_env(:gc_index_relay, :relay_info, []) |> Map.new()
conn
|> put_resp_content_type("application/nostr+json")
|> send_resp(200, Jason.encode!(relay_info))
|> halt()
else
conn
end
end
def call(conn, _opts), do: conn
end

BIN
priv/static/favicon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
priv/static/images/favicon-32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
priv/static/images/mercury_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
priv/static/images/mercury_icon_small.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

3
setup.sh

@ -74,7 +74,8 @@ if command -v apt-get &>/dev/null; then @@ -74,7 +74,8 @@ if command -v apt-get &>/dev/null; then
libtool \
inotify-tools \
git \
curl
curl \
jq
else
warn "apt-get not found — skipping system package install."
warn "Make sure these are installed manually: build-essential autoconf libtool inotify-tools git curl"

49
test/features/relay_api.feature

@ -46,6 +46,33 @@ Feature: Nostr Relay REST API @@ -46,6 +46,33 @@ Feature: Nostr Relay REST API
Then the response status is 400 Bad Request
And the response error contains "auth-required"
# ---------------------------------------------------------------------------
# Deleting events (DELETE /api/events/:id)
# ---------------------------------------------------------------------------
Scenario: Client deletes an existing event by ID
Given a known event has been published
When the client sends DELETE /api/events/<event_id>
Then the response status is 204 No Content
Scenario: Deleting a non-existent event returns 404
When the client sends DELETE /api/events/<unknown_id>
Then the response status is 404 Not Found
# ---------------------------------------------------------------------------
# Fetching events (GET /api/events — cacheable, requires since/until/limit)
# ---------------------------------------------------------------------------
Scenario: Client fetches events using time-bounded query params
Given two kind 1 notes have been published
When the client sends GET /api/events?since=0&until=9999999999&limit=10
Then the response status is 200
And the response contains both kind 1 events
Scenario: GET /api/events without required params is rejected
When the client sends GET /api/events without "since", "until", or "limit"
Then the response status is 400 Bad Request
# ---------------------------------------------------------------------------
# Fetching events (POST /api/events/filter — what most Nostr clients use)
# ---------------------------------------------------------------------------
@ -112,6 +139,28 @@ Feature: Nostr Relay REST API @@ -112,6 +139,28 @@ Feature: Nostr Relay REST API
And the response includes CORS headers
And the response body is empty
# ---------------------------------------------------------------------------
# NIP-11 relay information document
# ---------------------------------------------------------------------------
Scenario: Client requests NIP-11 relay info
When the client sends GET / with header "Accept: application/nostr+json"
Then the response status is 200
And the response content-type is "application/nostr+json"
And the response includes a "name" field
And the response includes a "supported_nips" array
And the response includes a "limitation" object
And the response includes an "icon" field with a URL
Scenario: NIP-11 response includes correct CORS headers
When the client sends GET / with header "Accept: application/nostr+json"
Then the response includes "Access-Control-Allow-Origin: *"
Scenario: Regular browser request to GET / still serves the HTML page
When the client sends GET / with header "Accept: text/html"
Then the response status is 200
And the response content-type contains "text/html"
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------

145
test/gc_index_relay_web/relay_integration_test.exs

@ -110,6 +110,66 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do @@ -110,6 +110,66 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
end
end
# ---------------------------------------------------------------------------
# Deleting events (DELETE /api/events/:id)
# ---------------------------------------------------------------------------
describe "DELETE /api/events/:id — deletion" do
test "client deletes an existing event and gets 204", %{conn: conn} do
# Given
event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower)
# When
conn = delete(conn, ~p"/api/events/#{hex_id}")
# Then
assert conn.status == 204
assert conn.resp_body == ""
end
test "deleting a non-existent event returns 404", %{conn: conn} do
# When
conn = delete(conn, ~p"/api/events/#{String.duplicate("b", 64)}")
# Then
assert json_response(conn, 404)
end
end
# ---------------------------------------------------------------------------
# Querying events (GET /api/events — cacheable with query params)
# ---------------------------------------------------------------------------
describe "GET /api/events — cacheable query" do
test "client fetches events with since/until/limit query params", %{conn: conn} do
# Given
_e1 =
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
|> then(&GcIndexRelay.Nostr.create_event/1)
_e2 =
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
|> then(&GcIndexRelay.Nostr.create_event/1)
# When
conn = get(conn, "/api/events?since=0&until=9999999999&limit=10")
# Then
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2
end
test "GET /api/events without required params is rejected with 400", %{conn: conn} do
# When — missing since, until, limit
conn = get(conn, "/api/events?kinds=1")
# Then
assert json_response(conn, 400)
end
end
# ---------------------------------------------------------------------------
# Querying events (POST /api/events/filter)
# ---------------------------------------------------------------------------
@ -282,6 +342,91 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do @@ -282,6 +342,91 @@ defmodule GcIndexRelayWeb.RelayIntegrationTest do
end
end
# ---------------------------------------------------------------------------
# NIP-11 relay information document
# ---------------------------------------------------------------------------
describe "GET / with Accept: application/nostr+json — NIP-11" do
test "returns 200 with application/nostr+json content-type", %{conn: conn} do
# When
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
# Then
assert conn.status == 200
[content_type | _] = get_resp_header(conn, "content-type")
assert content_type =~ "application/nostr+json"
end
test "response body is valid JSON with required NIP-11 fields", %{conn: conn} do
# When
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
# Then
assert {:ok, body} = Jason.decode(conn.resp_body)
assert is_binary(body["name"])
assert is_list(body["supported_nips"])
assert is_map(body["limitation"])
assert is_binary(body["icon"]) and String.starts_with?(body["icon"], "http")
end
test "supported_nips list includes NIP-11 and NIP-70", %{conn: conn} do
# When
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
# Then
assert {:ok, %{"supported_nips" => nips}} = Jason.decode(conn.resp_body)
assert 11 in nips
assert 70 in nips
end
test "limitation object contains expected fields", %{conn: conn} do
# When
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
# Then
assert {:ok, %{"limitation" => limitation}} = Jason.decode(conn.resp_body)
assert Map.has_key?(limitation, "max_limit")
assert Map.has_key?(limitation, "auth_required")
assert Map.has_key?(limitation, "payment_required")
end
test "NIP-11 response includes CORS headers", %{conn: conn} do
# When
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
# Then
assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
end
test "regular browser request to GET / still returns HTML", %{conn: conn} do
# When — no special Accept header, browser default
conn =
conn
|> put_req_header("accept", "text/html,application/xhtml+xml")
|> get("/")
# Then
assert conn.status == 200
[content_type | _] = get_resp_header(conn, "content-type")
assert content_type =~ "text/html"
end
end
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------

Loading…
Cancel
Save