Compare commits

..

No commits in common. 'test/local-setup' and 'master' have entirely different histories.

  1. 14
      .env.example
  2. 2
      .formatter.exs
  3. 8
      .gitignore
  4. 4
      AGENTS.md
  5. 137
      README.md
  6. 49
      compose.yaml
  7. 36
      config/config.exs
  8. 5
      config/dev.exs
  9. 10
      config/prod.exs
  10. 68
      config/runtime.exs
  11. 20
      config/test.exs
  12. 9
      docker/server.Dockerfile
  13. 7
      lib/gc_index_relay.ex
  14. 1
      lib/gc_index_relay/application.ex
  15. 3
      lib/gc_index_relay/mailer.ex
  16. 27
      lib/gc_index_relay/nostr.ex
  17. 43
      lib/gc_index_relay/nostr/filter.ex
  18. 62
      lib/gc_index_relay/nostr/pub_event.ex
  19. 2
      lib/gc_index_relay/nostr/tag.ex
  20. 15
      lib/gc_index_relay/nostr/validator.ex
  21. 2
      lib/gc_index_relay_web.ex
  22. 5
      lib/gc_index_relay_web/components/layouts/root.html.heex
  23. 30
      lib/gc_index_relay_web/controllers/api_controller.ex
  24. 15
      lib/gc_index_relay_web/controllers/event_controller.ex
  25. 5
      lib/gc_index_relay_web/controllers/event_html.ex
  26. 6
      lib/gc_index_relay_web/controllers/event_html/new.html.heex
  27. 11
      lib/gc_index_relay_web/controllers/fallback_controller.ex
  28. 51
      lib/gc_index_relay_web/controllers/filter_controller.ex
  29. 2
      lib/gc_index_relay_web/controllers/page_controller.ex
  30. 306
      lib/gc_index_relay_web/controllers/page_html/home.html.heex
  31. 16
      lib/gc_index_relay_web/endpoint.ex
  32. 80
      lib/gc_index_relay_web/plugs/cors.ex
  33. 86
      lib/gc_index_relay_web/plugs/relay_info.ex
  34. 28
      lib/gc_index_relay_web/router.ex
  35. 39
      lib/mix/tasks/test/integration.ex
  36. 19
      mix.exs
  37. 9
      mix.lock
  38. 9
      priv/repo/migrations/20260407062511_allow_null_tag_value.exs
  39. 11
      priv/repo/seeds.exs
  40. BIN
      priv/static/favicon.ico
  41. BIN
      priv/static/images/favicon-32x32.png
  42. BIN
      priv/static/images/gitcitadel_icon.png
  43. BIN
      priv/static/images/mercury_icon.png
  44. BIN
      priv/static/images/mercury_icon_small.png
  45. 27
      priv/static/swagger.json
  46. 377
      setup.sh
  47. 170
      test/features/relay_api.feature
  48. 13
      test/gc_index_relay/nostr/filter_test.exs
  49. 83
      test/gc_index_relay/nostr/pub_event_test.exs
  50. 35
      test/gc_index_relay/nostr/validator_test.exs
  51. 21
      test/gc_index_relay/nostr_test.exs
  52. 2
      test/gc_index_relay_web/controllers/error_html_test.exs
  53. 2
      test/gc_index_relay_web/controllers/error_json_test.exs
  54. 46
      test/gc_index_relay_web/controllers/filter_controller_test.exs
  55. 6
      test/gc_index_relay_web/controllers/page_controller_test.exs
  56. 85
      test/gc_index_relay_web/plugs/cors_plug_test.exs
  57. 201
      test/gc_index_relay_web/relay_integration_test.exs
  58. 176
      test/gc_index_relay_web/relay_unit_test.exs
  59. 25
      test/support/conn_case.ex
  60. 24
      test/support/data_case.ex
  61. 36
      test/support/fixtures/nostr_fixtures.ex

14
.env.example

@ -1,14 +0,0 @@
# Copy this file to .env and fill in values, then run: source .env
# 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_PORT=5455
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev
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}"] inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
] ]

8
.gitignore vendored

@ -32,13 +32,5 @@ gc_index_relay-*.tar
# Secrets -- copy from .env.example and fill in values # Secrets -- copy from .env.example and fill in values
.env .env
# Personal / production deploy layout (Docker Hub, server env, etc.) — keep locally, not in git
compose.prod.yml
compose.prod.override.yml
scripts/deploy_prod.sh
.env.prod
.env.prod.example
.env.production
# Local Postgres data # Local Postgres data
/pgdata/ /pgdata/

4
AGENTS.md

@ -4,14 +4,14 @@ This is a web application written using the Phoenix web framework.
- **Always** run unit tests with `mix test.unit` and autoformat with `mix format` after making edits - **Always** run unit tests with `mix test.unit` and autoformat with `mix format` after making edits
- Use `mix precommit` alias when you are done with all changes and fix any pending issues - Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps - Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
## Commands ## Commands
- `mix test.unit` — Run unit tests (no database required). **Always run after edits.** - `mix test.unit` — Run unit tests (no database required). **Always run after edits.**
- `mix test.integration` — Run integration tests (requires database). - `mix test.integration` — Run integration tests (requires database).
- `mix format` — Apply autoformatting. **Always run after edits.** - `mix format` — Apply autoformatting. **Always run after edits.**
- `mix precommit` — Full pre-commit check: compile with warnings-as-errors, unlock unused deps, format, credo, and run unit tests. - `mix precommit` — Full pre-commit check: compile with warnings-as-errors, unlock unused deps, format, and run integration tests.
- `mix test test/path/to/file_test.exs` — Run a single test file. - `mix test test/path/to/file_test.exs` — Run a single test file.
- `mix test --failed` — Re-run previously failed tests. - `mix test --failed` — Re-run previously failed tests.

137
README.md

@ -1,141 +1,38 @@
<div align="center"> # GcIndexRelay
<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 ## Getting Started
### Prerequisites
- Docker (for the database)
- Elixir ~> 1.15
### Automated setup
Run the setup script — on Debian/Ubuntu it installs Erlang/Elixir from **Team RabbitMQ’s apt repositories** ([Elixir install guide](https://elixir-lang.org/install.html)); on Fedora/RHEL it uses **asdf**. It 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` (including `REQUIRE_DB=true` for integration tests). Source it before running `mix` tasks that need the DB or asdf’s `mix` in a new terminal:
```bash
source "$HOME/.asdf/asdf.sh" # if `mix` is not found
source .env
```
### Manual setup
Start the Apache AGE Docker container: Start the Apache AGE Docker container:
```bash ```bash
docker build \
-t age \
-f ./db/Dockerfile \
./db
docker run \ docker run \
-d \ -d \
--name gc_age_db \
-p 5455:5432 \ -p 5455:5432 \
-e POSTGRES_USER=postgres \ -e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \ -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=gc_index_relay_dev \ -e POSTGRES_DB=gc_index_relay_dev \
apache/age:release_PG17_1.6.0 age
``` ```
Set database environment variables, then install dependencies and run migrations: To start your Phoenix server:
```bash - Run `mix setup` to install and setup dependencies
export POSTGRES_HOST=localhost - Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
export POSTGRES_PORT=5455
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres
export POSTGRES_DB=gc_index_relay_dev
export REQUIRE_DB=true
mix setup
```
### Docker Compose (dev) Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
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. Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
```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
```bash
source "$HOME/.asdf/asdf.sh" # if needed
source .env
mix phx.server
```
The relay listens on port **4000** by default (see `PORT` in `config/runtime.exs`). Open [http://localhost:4000](http://localhost:4000). After edits to `config/config.exs`, `config/dev.exs`, or `config/runtime.exs`, restart the server manually; other code is hot-reloaded.
### 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). 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
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, unit tests):
```bash
source .env
mix precommit
```
## Project Overview ## Project Overview
### Database ### Database
- The database stores Nostr events. - The database stores Nostr events.
- Nostr events, once signed, are considered immutable. - Nostr events, once signed, are considered to be immutable.
- Uses Apache AGE (PostgreSQL with graph extensions).
#### Migrations #### Migrations
@ -145,7 +42,7 @@ After modifying an Ecto schema, generate a migration with:
mix ecto.gen.migration <migration-name> mix ecto.gen.migration <migration-name>
``` ```
Edit the generated migration file as needed, then apply it: Edit the generated migration file as needed, then perform the migration with:
```bash ```bash
mix ecto.migrate mix ecto.migrate
@ -155,6 +52,8 @@ Refer to the Fly.io guide [Safe Ecto Migrations](https://github.com/fly-apps/saf
## Learn more ## Learn more
- Phoenix: https://www.phoenixframework.org/ - Official website: https://www.phoenixframework.org/
- Nostr protocol: https://github.com/nostr-protocol/nostr - Guides: https://hexdocs.pm/phoenix/overview.html
- NIP-01 (basic protocol): https://github.com/nostr-protocol/nips/blob/master/01.md - Docs: https://hexdocs.pm/phoenix
- Forum: https://elixirforum.com/c/phoenix-forum
- Source: https://github.com/phoenixframework/phoenix

49
compose.yaml

@ -3,16 +3,15 @@ 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
# Host port matches setup.sh, .env.example, and config defaults (5455 → container 5432). user: 1000:1000 # Should match host user
ports: ports:
- "5455:5432" - "5432:5432"
# Named volume avoids host uid mismatches (do not combine image postgres user with user: 1000 + ./pgdata).
volumes: volumes:
- pgdata:/var/lib/postgresql/data - ./pgdata:/var/lib/postgresql/data # Ensure host user owns the ./pgdata directory
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-gc_index_relay_dev} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
command: > command: >
postgres postgres
deploy: deploy:
@ -23,8 +22,18 @@ 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:-postgres} -d ${POSTGRES_DB:-gc_index_relay_dev}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@ -39,13 +48,12 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
# Docker DNS name of this service (not localhost from .env — that is for host-side mix). POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_HOST: postgres POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_DB: ${POSTGRES_DB:-gc_index_relay_dev} POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER}
POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER:-gc_index_relay} POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD}
POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD:-gc_index_relay_runtime}
migrator: migrator:
build: build:
@ -59,11 +67,10 @@ services:
setup: setup:
condition: service_completed_successfully condition: service_completed_successfully
environment: environment:
# Inside the compose network Postgres listens on 5432; 5455 is only the host publish port. DATABASE_URL: "ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}"
DATABASE_URL: "ecto://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-gc_index_relay_dev}" SECRET_KEY_BASE: ${SECRET_KEY_BASE}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-0000000000000000000000000000000000000000000000000000000000000000}
mercury: isidore:
build: build:
context: . context: .
dockerfile: ./docker/server.Dockerfile dockerfile: ./docker/server.Dockerfile
@ -77,8 +84,8 @@ services:
ports: ports:
- "4000:4000" - "4000:4000"
environment: environment:
DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER:-gc_index_relay}:${POSTGRES_RUNTIME_PASSWORD:-gc_index_relay_runtime}@postgres:5432/${POSTGRES_DB:-gc_index_relay_dev}" DATABASE_URL: "ecto://${POSTGRES_RUNTIME_USER}:${POSTGRES_RUNTIME_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}"
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-0000000000000000000000000000000000000000000000000000000000000000} SECRET_KEY_BASE: ${SECRET_KEY_BASE}
volumes: volumes:
pgdata: pgdata:

36
config/config.exs

@ -11,14 +11,6 @@ config :gc_index_relay,
ecto_repos: [GcIndexRelay.Repo], ecto_repos: [GcIndexRelay.Repo],
generators: [timestamp_type: :utc_datetime] generators: [timestamp_type: :utc_datetime]
# In production, set `CORS_ENABLED=false` (runtime) or disable here when a reverse proxy
# adds CORS. Use `allow_origins: ["https://app.example.com"]` to restrict browser access.
config :gc_index_relay, :cors,
enabled: true,
allow_origins: "*",
allow_methods: "GET, POST, DELETE, OPTIONS",
allow_headers: "content-type, authorization"
# Configure the endpoint # Configure the endpoint
config :gc_index_relay, GcIndexRelayWeb.Endpoint, config :gc_index_relay, GcIndexRelayWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
@ -30,6 +22,15 @@ 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" => [
@ -48,25 +49,6 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason 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: "favicon.ico",
banner: "images/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: Mix.Project.config()[:version],
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 # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

5
config/dev.exs

@ -60,7 +60,7 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
] ]
] ]
# Enable dev routes for LiveDashboard # Enable dev routes for dashboard and mailbox
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,3 +80,6 @@ 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

10
config/prod.exs

@ -15,12 +15,14 @@ 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
# In-app CORS: disable when your L7 proxy sets CORS headers, e.g.:
# config :gc_index_relay, :cors, enabled: false
# Or use CORS_ENABLED / CORS_ALLOW_ORIGINS in runtime.exs.
# Runtime production configuration, including reading # Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs. # of environment variables, is done on config/runtime.exs.

68
config/runtime.exs

@ -23,17 +23,12 @@ 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"))]
# Host-side dev/test: DB published on localhost:5455 (see setup.sh / .env.example). config :gc_index_relay, GcIndexRelay.Repo,
# Do not set this in :prod: DATABASE_URL omits port on purpose; merging these options hostname: System.get_env("POSTGRES_HOST") || "localhost",
# would keep port 5455 while the hostname came from the URL (e.g. postgres in Docker). port: String.to_integer(System.get_env("POSTGRES_PORT") || "5432"),
if config_env() != :prod do username: System.get_env("POSTGRES_USER"),
config :gc_index_relay, GcIndexRelay.Repo, password: System.get_env("POSTGRES_PASSWORD"),
hostname: System.get_env("POSTGRES_HOST") || "localhost", database: System.get_env("POSTGRES_DB")
port: String.to_integer(System.get_env("POSTGRES_PORT") || "5455"),
username: System.get_env("POSTGRES_USER") || "postgres",
password: System.get_env("POSTGRES_PASSWORD") || "postgres",
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 =
@ -67,6 +62,8 @@ 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: [
@ -78,37 +75,6 @@ if config_env() == :prod do
], ],
secret_key_base: secret_key_base secret_key_base: secret_key_base
cors_base = Application.get_env(:gc_index_relay, :cors) || []
cors_enabled =
case System.get_env("CORS_ENABLED") do
nil -> Keyword.get(cors_base, :enabled, true)
v -> String.downcase(String.trim(v)) in ~w(true 1 yes)
end
allow_origins =
case System.get_env("CORS_ALLOW_ORIGINS") do
nil ->
Keyword.get(cors_base, :allow_origins, "*")
raw ->
raw
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> "*"
["*"] -> "*"
list -> list
end
end
config :gc_index_relay,
:cors,
cors_base
|> Keyword.put(:enabled, cors_enabled)
|> Keyword.put(:allow_origins, allow_origins)
# ## SSL Support # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key
@ -140,4 +106,22 @@ 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

20
config/test.exs

@ -2,21 +2,17 @@ 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
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :gc_index_relay, GcIndexRelay.Repo, config :gc_index_relay, GcIndexRelay.Repo,
username: System.get_env("POSTGRES_USER", "postgres"), username: System.get_env("POSTGRES_USER"),
password: System.get_env("POSTGRES_PASSWORD", "postgres"), password: System.get_env("POSTGRES_PASSWORD"),
database: database: "#{System.get_env("POSTGRES_DB")}#{System.get_env("MIX_TEST_PARTITION")}",
"#{System.get_env("POSTGRES_DB", "gc_index_relay_dev")}#{System.get_env("MIX_TEST_PARTITION")}", hostname: "localhost",
hostname: System.get_env("POSTGRES_HOST", "localhost"), port: 5432,
port: String.to_integer(System.get_env("POSTGRES_PORT", "5455")),
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2 pool_size: System.schedulers_online() * 2
@ -28,6 +24,12 @@ 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

9
docker/server.Dockerfile

@ -58,15 +58,10 @@ COPY config/runtime.exs config/
COPY rel rel COPY rel rel
RUN mix release RUN mix release
# Lean runtime stage (NIFs ship compiled libs; lib_secp256k1 links libsecp256k1 statically in the builder). # Lean runtime stage
FROM ${RUNNER_IMAGE} FROM ${RUNNER_IMAGE}
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y libstdc++6 openssl libncurses6 locales && \
ca-certificates \
libstdc++6 \
libncurses6 \
locales \
openssl && \
apt-get clean && \ apt-get clean && \
rm -f /var/lib/apt/lists/*_* rm -f /var/lib/apt/lists/*_*

7
lib/gc_index_relay.ex

@ -1,8 +1,9 @@
defmodule GcIndexRelay do defmodule GcIndexRelay do
@moduledoc """ @moduledoc """
Mercury Index-Relay a Nostr relay by GitCitadel. GcIndexRelay keeps the contexts that define your domain
and business logic.
Organises the application's domain contexts: event storage, validation, Contexts are also responsible for managing your data, regardless
filtering, and querying via `GcIndexRelay.Nostr`. if it comes from the database, an external API or others.
""" """
end end

1
lib/gc_index_relay/application.ex

@ -11,6 +11,7 @@ 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

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

27
lib/gc_index_relay/nostr.ex

@ -42,21 +42,13 @@ 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) do |> Filter.apply(filter),
pub_events_from_db(events) pub_events <-
end Enum.map(events, fn event ->
end {:ok, pub_event} = PubEvent.from_db(event)
pub_event
defp pub_events_from_db(events) do end) do
Enum.reduce_while(events, {:ok, []}, fn event, {:ok, acc} -> {:ok, pub_events}
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
@ -65,9 +57,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) 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,10 +1,7 @@
defmodule GcIndexRelay.Nostr.Filter do defmodule GcIndexRelay.Nostr.Filter do
@moduledoc """ @moduledoc """
Nostr NIP-01 filters: struct, parsing, validation, and query building. An implementation of Nostr filters, including a struct representation and parsing and validation
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
@ -27,11 +24,9 @@ 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
# Keys `#p` and `#P` (and likewise for every letter) are distinct filters; preserve case. # Extract tag filters (keys starting with "#")
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: %{}
@ -67,36 +62,24 @@ 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. `#` keys must be exactly `#` + one letter. # Validate that only known filter keys are present
@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"]
case Enum.find(Map.keys(map), &invalid_filter_map_key?(&1, known_keys)) do unknown_keys =
nil -> :ok map
<<"#", _::binary>> = key -> {:error, invalid_hash_filter_key_message(key)} |> Map.keys()
key -> {:error, "Unknown filter key: '#{key}'"} |> Enum.reject(fn key ->
end key in known_keys or String.starts_with?(key, "#")
end end)
defp invalid_filter_map_key?(key, known_keys) do case unknown_keys do
cond do [] -> :ok
key in known_keys -> false [key | _] -> {:error, "Unknown filter key: '#{key}'"}
valid_tag_filter_key?(key) -> false
true -> true
end end
end end
defp valid_tag_filter_key?(<<"#", letter::binary-size(1)>>) do
String.match?(letter, ~r/^[a-zA-Z]$/)
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
@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()}
defp validate_ids(nil), do: {:ok, nil} defp validate_ids(nil), do: {:ok, nil}
defp validate_ids([]), do: {:ok, nil} defp validate_ids([]), do: {:ok, nil}

62
lib/gc_index_relay/nostr/pub_event.ex

@ -25,52 +25,41 @@ 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.
"""
@spec to_db(t()) :: {:ok, Event.t()} | {:error, atom()}
def to_db(%__MODULE__{} = pub_event) do
tags = pub_event.tags || []
with {:ok, event} <- to_event(pub_event) do Returns an Ecto for `GcIndexRelay.Nostr.Event`.
{:ok, %{event | tags: to_tags(tags)}} """
end @spec to_db(t()) :: Ecto.Schema.t()
def to_db(%__MODULE__{tags: tags} = pub_event) do
%Event{to_event(pub_event) | tags: to_tags(tags)}
end end
@spec to_event(t()) :: {:ok, Event.t()} | {:error, atom()} @spec to_event(t()) :: Ecto.Schema.t()
defp to_event(%__MODULE__{} = pub_event) do defp to_event(%__MODULE__{} = pub_event)
with true <- pub_event_fields_valid?(pub_event), when is_binary(pub_event.id) and is_binary(pub_event.pubkey) and
{:ok, id} <- Base.decode16(pub_event.id, case: :lower), is_integer(pub_event.created_at) and is_integer(pub_event.kind) and
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, created_at: DateTime.from_unix!(pub_event.created_at),
created_at: DateTime.from_unix!(pub_event.created_at), kind: pub_event.kind,
kind: pub_event.kind, content: pub_event.content,
content: pub_event.content, sig: signature
sig: signature }
}}
else else
false -> {:error, :invalid_event} error -> {:error, error}
:error -> {:error, :invalid_hex}
end 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
@spec to_tags([[String.t()]]) :: [Ecto.Schema.t()] @spec to_tags([[String.t()]]) :: [Ecto.Schema.t()]
defp to_tags(tags) when is_list(tags) do defp to_tags(tags) when is_list(tags) do
for t <- tags do for t <- tags do
[name | values] = t [name | values] = t
# Single-element tags will cause a crash
{value, rest} = [value | rest] = values
case values do
[] -> {nil, []}
[v | r] -> {v, r}
end
%Tag{ %Tag{
name: name, name: name,
@ -103,11 +92,6 @@ defmodule GcIndexRelay.Nostr.PubEvent do
end end
defp from_tags(tags) when is_list(tags) do defp from_tags(tags) when is_list(tags) do
for t <- tags do for t <- tags, do: [t.name, t.value | t.additional_values]
case t.value do
nil -> [t.name]
value -> [t.name, value | t.additional_values]
end
end
end end
end end

2
lib/gc_index_relay/nostr/tag.ex

@ -13,6 +13,6 @@ defmodule GcIndexRelay.Nostr.Tag do
def changeset(tag, attrs) do def changeset(tag, attrs) do
tag tag
|> cast(attrs, [:name, :value, :additional_values]) |> cast(attrs, [:name, :value, :additional_values])
|> validate_required([:name]) |> validate_required([:name, :value])
end end
end end

15
lib/gc_index_relay/nostr/validator.ex

@ -58,21 +58,6 @@ defmodule GcIndexRelay.Nostr.Validator do
event.id == computed_id event.id == computed_id
end end
@doc """
Rejects protected events per [NIP-70](https://github.com/nostr-protocol/nips/blob/master/70.md).
An event containing the `["-"]` tag is considered protected and may only be
published by its author after completing the NIP-42 AUTH flow. Since this relay
does not implement NIP-42, protected events are rejected outright.
"""
def validate_not_protected(event) when is_struct(event, PubEvent) do
if Enum.member?(event.tags, ["-"]) do
{:error, "auth-required: this event may only be published by its author"}
else
{:ok, event}
end
end
@doc """ @doc """
Validates a Nostr event signature per [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md). Validates a Nostr event signature per [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md).
""" """

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 images favicon.ico robots.txt) def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do def router do
quote do quote do

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

@ -4,12 +4,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Mercury Index-Relay" suffix=" · GitCitadel"> <.live_title default="GcIndexRelay" suffix=" · Phoenix Framework">
{assigns[:page_title]} {assigns[:page_title]}
</.live_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/css/app.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/default.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"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>

30
lib/gc_index_relay_web/controllers/api_controller.ex

@ -1,30 +0,0 @@
defmodule GcIndexRelayWeb.ApiController do
use GcIndexRelayWeb, :controller
def index(conn, _params) do
relay_info = Application.fetch_env!(:gc_index_relay, :relay_info)
json(conn, %{
relay: Keyword.fetch!(relay_info, :name),
version:
Keyword.get(relay_info, :version, Application.spec(:gc_index_relay, :vsn)) |> to_string(),
endpoints: [
%{
method: "GET",
path: "/api/events",
description: "List events (requires filter params)"
},
%{
method: "POST",
path: "/api/events/filter",
description: "Query events with a NIP-01 filter body"
},
%{method: "GET", path: "/api/events/:id", description: "Get a single event by ID"},
%{method: "POST", path: "/api/events", description: "Publish a new event"},
%{method: "DELETE", path: "/api/events/:id", description: "Delete an event by ID"},
%{method: "GET", path: "/api/swagger", description: "Swagger UI"},
%{method: "GET", path: "/health", description: "Health check"}
]
})
end
end

15
lib/gc_index_relay_web/controllers/event_controller.ex

@ -19,20 +19,9 @@ 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
pub_event = atoms_map = Map.new(event_params, fn {k, v} -> {String.to_existing_atom(k), v} end)
@pub_event_keys pub_event = struct(PubEvent, atoms_map)
|> 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

5
lib/gc_index_relay_web/controllers/event_html.ex

@ -0,0 +1,5 @@
defmodule GcIndexRelayWeb.EventHTML do
use GcIndexRelayWeb, :html
embed_templates "event_html/*"
end

6
lib/gc_index_relay_web/controllers/event_html/new.html.heex

@ -0,0 +1,6 @@
<Layouts.app flash={@flash}>
<section>
<h2>New Event</h2>
<%!-- TODO: Display form for event details --%>
</section>
</Layouts.app>

11
lib/gc_index_relay_web/controllers/fallback_controller.ex

@ -28,13 +28,6 @@ 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
@ -49,8 +42,4 @@ 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

51
lib/gc_index_relay_web/controllers/filter_controller.ex

@ -56,7 +56,7 @@ defmodule GcIndexRelayWeb.FilterController do
""") """)
tag("Events") tag("Events")
operation_id("filter_events") operation_id("query_events")
response(200, "OK", Schema.ref(:PubEventList)) response(200, "OK", Schema.ref(:PubEventList))
response(400, "Bad Request") response(400, "Bad Request")
end end
@ -83,26 +83,15 @@ 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
with {:ok, limit} <- parse_limit_value(Map.get(params, "limit")) do %{"limit" => limit} = params
if limit < 1 or limit > 100 do
{:error, "The filter limit must be between 1 and 100."}
else
{:ok, Map.put(params, "limit", limit)}
end
end
end
defp parse_limit_value(v) when is_integer(v), do: {:ok, v} if limit < 1 or limit > 100 do
{:error, "The filter limit must be between 1 and 100."}
defp parse_limit_value(v) when is_binary(v) do else
case Integer.parse(v) do {:ok, params}
{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
@ -162,28 +151,14 @@ defmodule GcIndexRelayWeb.FilterController do
end) end)
end end
# Bare `p=` / `P=` and `#p` / `#P` map to NIP-01 tag filters; letter case is significant (NIP-22). # Parse filter keys that represent tags. Note that only single-letter keys are treated as tags;
# 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
cond do if byte_size(key) == 1 and
single_bare_letter_tag_key?(key) -> ((key >= "a" and key <= "z") or (key >= "A" and key <= "Z")),
"#" <> key do: "#" <> 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
@ -235,7 +210,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 or letter in ?A..?Z do defp parse_param(<<letter>> = _key, value) when letter in ?a..?z do
{:ok, String.split(value, ",")} {:ok, String.split(value, ",")}
end end
end end

2
lib/gc_index_relay_web/controllers/page_controller.ex

@ -2,6 +2,6 @@ defmodule GcIndexRelayWeb.PageController do
use GcIndexRelayWeb, :controller use GcIndexRelayWeb, :controller
def home(conn, _params) do def home(conn, _params) do
render(conn, :home, page_title: "Home") render(conn, :home)
end end
end end

306
lib/gc_index_relay_web/controllers/page_html/home.html.heex

@ -1,123 +1,201 @@
<Layouts.flash_group flash={@flash} /> <Layouts.flash_group flash={@flash} />
<div style="max-width:480px; margin:0 auto; padding:2rem 1.25rem;"> <div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<div style="text-align:center; margin-bottom:2rem;"> <svg
<img viewBox="0 0 1480 957"
src={~p"/images/mercury_icon.png"} fill="none"
alt="Mercury Index-Relay" aria-hidden="true"
style="width:112px; height:112px; margin:0 auto 1rem;" class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/> />
<h1 style="font-size:1.5rem; font-weight:700; margin-bottom:0.25rem;"> <path
Mercury Index-Relay d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
</h1> fill="#FA8372"
<p style="font-size:0.8rem; opacity:0.6; margin-bottom:0.75rem;"> />
by GitCitadel &nbsp;·&nbsp; v{Application.spec(:gc_index_relay, :vsn)} <path
</p> d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
<p style="font-size:0.9rem; line-height:1.6;"> fill="#E96856"
A Nostr index relay for the HTTP protocol. Specialises in swift retrieval fill-opacity=".6"
of publications, repos, and graphs of related events. />
</p> <path
</div> d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
<div style="margin-bottom:2rem;"> fill-opacity=".2"
<a />
href={~p"/api/swagger"} <path
class="btn btn-primary" d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
style="display:block; width:100%; margin-bottom:0.5rem;" fill="#A41C42"
> fill-opacity=".2"
Swagger UI />
</a> <path
<a href={~p"/api"} class="btn btn-outline" style="display:block; width:100%;"> d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
API Index fill="#A41C42"
</a> fill-opacity=".2"
</div> />
</svg>
<div style="margin-bottom:2rem; display:flex; flex-direction:column; gap:0.75rem;"> </div>
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> <div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<strong style="font-size:0.875rem;">REST API</strong> <div class="mx-auto max-w-xl lg:mx-0">
<p style="font-size:0.8rem; margin-top:0.25rem;"> <svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
Full event lifecycle over HTTP — publish, query, fetch by ID, delete. <path
</p> d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
</div> fill="#FD4F00"
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> />
<strong style="font-size:0.875rem;">NIP-01 Filters</strong> </svg>
<p style="font-size:0.8rem; margin-top:0.25rem;"> <div class="mt-10 flex justify-between items-center">
Filter by kind, author, tags, and time window. Supports #p, #e, and more. <h1 class="flex items-center text-sm font-semibold leading-6">
</p> Phoenix Framework
</div> <small class="badge badge-warning badge-sm ml-3">
<div class="rounded-box border border-base-300" style="padding:0.875rem;"> v{Application.spec(:phoenix, :vsn)}
<strong style="font-size:0.875rem;">NIP-11 &amp; NIP-70</strong> </small>
<p style="font-size:0.8rem; margin-top:0.25rem;"> </h1>
Relay info at <code>GET /</code> with <code>Accept: application/nostr+json</code>.
Protected events are rejected.
</p>
</div> </div>
</div>
<div style="margin-bottom:2rem;"> <p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
<p style="font-size:0.7rem; font-weight:600; opacity:0.55; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:0.5rem;"> Peace of mind from prototype to production.
Endpoints </p>
<p class="mt-4 leading-7 text-base-content/70">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p> </p>
<div <div class="flex">
class="rounded-box border border-base-300" <div class="w-full sm:w-auto">
style="font-size:0.8rem; overflow:hidden;" <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
> <a
<%= for {method, path, desc} <- [ href="https://hexdocs.pm/phoenix/overview.html"
{"GET", "/api", "List available endpoints"}, class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
{"GET", "/api/events", "Cacheable query (since, until, limit)"}, >
{"POST", "/api/events/filter", "Filter query with JSON body"}, <span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
{"GET", "/api/events/:id", "Fetch a single event by ID"}, </span>
{"POST", "/api/events", "Publish a new event"}, <span class="relative flex items-center gap-4 sm:flex-col">
{"DELETE", "/api/events/:id", "Delete an event by ID"}, <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
{"GET", "/api/swagger", "Interactive Swagger UI"} <path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
] do %> <path
<div style="padding:0.5rem 0.875rem; border-bottom:1px solid var(--fallback-b3,oklch(var(--b3)/1));"> d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
<p style="font-family:monospace; font-size:0.75rem; opacity:0.7;">{method} {path}</p> stroke="currentColor"
<p style="margin-top:0.15rem;">{desc}</p> stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div> </div>
<% end %> <div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
<footer style="text-align:center; font-size:0.75rem; opacity:0.6; padding-top:1rem; border-top:1px solid oklch(var(--b3)/1);">
<a
href="https://gitcitadel.com"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
style="display:inline-flex; align-items:center; gap:0.3rem; vertical-align:middle;"
>
<img
src={~p"/images/gitcitadel_icon.png"}
alt=""
style="width:16px; height:16px;"
/>GitCitadel
</a>
&nbsp;·&nbsp;
<a
href="https://git.imwald.eu/silberengel/gc_index_relay"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
>
Relay Repo
</a>
&nbsp;·&nbsp;
<a
href="https://github.com/ShadowySupercode"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
>
GitCitadel on GitHub
</a>
&nbsp;·&nbsp;
<a
href="https://alexandria.gitcitadel.eu/events?q=GitCitadel%40gitcitadel.com"
target="_blank"
rel="noopener noreferrer"
class="hover:opacity-100"
>
GitCitadel on Nostr
</a>
</footer>
</div> </div>

16
lib/gc_index_relay_web/endpoint.ex

@ -1,12 +1,6 @@
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.
@ -57,15 +51,5 @@ defmodule GcIndexRelayWeb.Endpoint do
plug Plug.MethodOverride plug Plug.MethodOverride
plug Plug.Head plug Plug.Head
plug Plug.Session, @session_options plug Plug.Session, @session_options
plug GcIndexRelayWeb.Plugs.CORS
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

80
lib/gc_index_relay_web/plugs/cors.ex

@ -1,80 +0,0 @@
defmodule GcIndexRelayWeb.Plugs.CORS do
@moduledoc """
CORS plug for the Nostr relay REST API.
Configure under `:cors` for `:gc_index_relay` (see `config/config.exs`).
* `enabled` when `false`, the plug is a no-op (use when a reverse proxy handles CORS).
* `allow_origins` `\"*\"` or a list of exact `Origin` values to echo back.
* `allow_methods` / `allow_headers` forwarded as response headers when CORS applies.
Preflight `OPTIONS` requests are halted with 200 when CORS is enabled; when disabled,
they continue to the router.
"""
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
cors = Application.get_env(:gc_index_relay, :cors, [])
if Keyword.get(cors, :enabled, true) do
allow_origins = Keyword.get(cors, :allow_origins, "*")
allow_methods = Keyword.get(cors, :allow_methods, "GET, POST, DELETE, OPTIONS")
allow_headers = Keyword.get(cors, :allow_headers, "content-type, authorization")
origin_value = resolve_allow_origin(conn, allow_origins)
if origin_value == nil && restrictive_allowlist?(allow_origins) do
handle_preflight(conn)
else
conn
|> put_resp_header("access-control-allow-origin", origin_value)
|> put_resp_header("access-control-allow-methods", allow_methods)
|> put_resp_header("access-control-allow-headers", allow_headers)
|> handle_preflight()
end
else
conn
end
end
defp resolve_allow_origin(_conn, "*"), do: "*"
defp resolve_allow_origin(conn, allow_origins) when is_list(allow_origins) do
if "*" in allow_origins do
"*"
else
origin_from_allowlist(conn, allow_origins)
end
end
defp resolve_allow_origin(_conn, _), do: "*"
defp origin_from_allowlist(conn, allow_origins) do
case get_req_header(conn, "origin") do
[origin] -> origin_if_allowed(origin, allow_origins)
_ -> nil
end
end
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?(list) when is_list(list) do
"*" not in list
end
defp restrictive_allowlist?(_), do: false
defp handle_preflight(%Plug.Conn{method: "OPTIONS"} = conn) do
conn
|> send_resp(200, "")
|> halt()
end
defp handle_preflight(conn), do: conn
end

86
lib/gc_index_relay_web/plugs/relay_info.ex

@ -1,86 +0,0 @@
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
base_url = build_base_url(conn)
relay_info =
Application.get_env(:gc_index_relay, :relay_info, [])
|> Map.new()
|> resolve_image_urls(base_url)
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
# Build "scheme://host[:port]" from the incoming request.
# Standard ports (80 for http, 443 for https) are omitted.
defp build_base_url(conn) do
port_suffix =
case {conn.scheme, conn.port} do
{:http, 80} -> ""
{:https, 443} -> ""
{_, port} -> ":#{port}"
end
"#{conn.scheme}://#{conn.host}#{port_suffix}"
end
# Prepend base_url to any relative (non-absolute) value for :icon and :banner.
defp resolve_image_urls(relay_info, base_url) do
relay_info
|> resolve_field(:icon, base_url)
|> resolve_field(:banner, base_url)
end
defp resolve_field(map, key, base_url) do
case Map.get(map, key) do
nil ->
map
"" ->
map
url when is_binary(url) ->
resolve_url_field(map, key, url, base_url)
_ ->
map
end
end
defp resolve_url_field(map, key, url, base_url) do
if String.starts_with?(url, ["http://", "https://"]) do
map
else
Map.put(map, key, "#{base_url}#{path_under_base(url)}")
end
end
defp path_under_base(url) do
if String.starts_with?(url, "/"), do: url, else: "/#{url}"
end
end

28
lib/gc_index_relay_web/router.ex

@ -25,43 +25,24 @@ defmodule GcIndexRelayWeb.Router do
scope "/api", GcIndexRelayWeb do scope "/api", GcIndexRelayWeb do
pipe_through :api pipe_through :api
get "/", ApiController, :index
get "/events", FilterController, :index get "/events", FilterController, :index
post "/events/filter", FilterController, :query post "/events/filter", FilterController, :query
resources "/events", EventController, only: [:show, :create, :delete] resources "/events", EventController, only: [:show, :create, :delete]
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: version, version: "0.1",
title: title, # Change this to read from config/env for deployed name
description: description title: "Isidore Relay"
}, },
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,
@ -73,7 +54,7 @@ defmodule GcIndexRelayWeb.Router do
# pipe_through :api # pipe_through :api
# end # end
# Enable LiveDashboard in development # Enable LiveDashboard and Swoosh mailbox preview 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.
@ -86,6 +67,7 @@ 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

@ -1,39 +0,0 @@
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

19
mix.exs

@ -4,7 +4,7 @@ defmodule GcIndexRelay.MixProject do
def project do def project do
[ [
app: :gc_index_relay, app: :gc_index_relay,
version: "0.2.0", version: "0.1.0",
elixir: "~> 1.15", elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@ -30,8 +30,7 @@ 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
@ -54,10 +53,13 @@ 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"},
@ -74,18 +76,21 @@ 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"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"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"
],
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,18 +5,24 @@
"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"},
@ -29,10 +35,13 @@
"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"},
} }

9
priv/repo/migrations/20260407062511_allow_null_tag_value.exs

@ -1,9 +0,0 @@
defmodule GcIndexRelay.Repo.Migrations.AllowNullTagValue do
use Ecto.Migration
def change do
alter table(:tags) do
modify :value, :string, null: true, from: {:string, null: false}
end
end
end

11
priv/repo/seeds.exs

@ -0,0 +1,11 @@
# 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.

BIN
priv/static/favicon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 152 B

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

BIN
priv/static/images/gitcitadel_icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

BIN
priv/static/images/mercury_icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

BIN
priv/static/images/mercury_icon_small.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

27
priv/static/swagger.json

@ -1,11 +1,18 @@
{ {
"info": { "info": {
"version": "0.2.0", "version": "0.1",
"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": "Isidore 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": {
@ -64,14 +71,6 @@
], ],
"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": [
@ -149,7 +148,7 @@
"/api/events/filter": { "/api/events/filter": {
"post": { "post": {
"description": " Returns a list of events matching the filter in descending order of created_at time.\n Response is returned as a batch, not streamed, so a `limit` parameter is required to prevent\n the response from getting too large.\n", "description": " Returns a list of events matching the filter in descending order of created_at time.\n Response is returned as a batch, not streamed, so a `limit` parameter is required to prevent\n the response from getting too large.\n",
"operationId": "filter_events", "operationId": "query_events",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {
@ -228,10 +227,10 @@
} }
}, },
"swagger": "2.0", "swagger": "2.0",
"produces": [ "consumes": [
"application/json" "application/json"
], ],
"consumes": [ "produces": [
"application/json" "application/json"
] ]
} }

377
setup.sh

@ -1,377 +0,0 @@
#!/usr/bin/env bash
# Local development setup for gc_index_relay (run on your host — NOT inside app Docker images).
# Production/runtime images get dependencies from docker/server.Dockerfile (release build + runtime packages).
# Safe to run multiple times — all steps are idempotent.
#
# Requirements:
# - Docker must already be installed (https://docs.docker.com/engine/install/)
# - sudo access to install OS packages (apt-get on Debian/Ubuntu, dnf/yum on Fedora/RHEL)
#
# Debian / Ubuntu: Erlang + Elixir are installed from Team RabbitMQ’s apt repositories, as
# recommended on https://elixir-lang.org/install.html (Launchpad PPA on Ubuntu; Cloudsmith
# erlang debs on Debian amd64). Fedora/RHEL still use asdf-compiled Erlang.
#
# Usage:
# chmod +x setup.sh
# ./setup.sh
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
ERLANG_VERSION="28.4.1"
ELIXIR_VERSION="1.19.5-otp-28"
POSTGRES_HOST="localhost"
POSTGRES_PORT="5455"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="gc_index_relay_dev"
DOCKER_CONTAINER_NAME="gc_age_db"
AGE_IMAGE="apache/age:release_PG17_1.6.0"
ASDF_DIR="$HOME/.asdf"
ASDF_VERSION="v0.15.0"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[setup]${NC} $*"; }
warn() { echo -e "${YELLOW}[ warn]${NC} $*"; }
err() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
require_cmd() {
command -v "$1" &>/dev/null || err "'$1' is not installed or not on PATH. $2"
}
# Install modern Erlang + Elixir via RabbitMQ-maintained apt repos (Elixir install docs).
# Returns 0 on success, 1 to fall back to asdf.
install_elixir_erlang_via_rabbitmq_apt() {
local id version
[ -f /etc/os-release ] || return 1
# shellcheck source=/dev/null
. /etc/os-release
id="${ID:-}"
version="${VERSION_CODENAME:-}"
if [ "$id" = "linuxmint" ]; then
version="${UBUNTU_CODENAME:-$version}"
fi
case "$id" in
ubuntu | pop | linuxmint)
log "Installing Erlang + Elixir via RabbitMQ Erlang PPA (https://elixir-lang.org/install.html)..."
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:rabbitmq/rabbitmq-erlang
sudo apt-get update -qq
sudo apt-get install -y git elixir erlang
;;
debian)
if [ "$(uname -m)" != "x86_64" ]; then
warn "RabbitMQ Cloudsmith Erlang packages are amd64-only; use asdf on this architecture."
return 1
fi
case "$version" in
bullseye | bookworm | trixie)
log "Installing Erlang via Team RabbitMQ apt + elixir (Debian ${version}; https://www.rabbitmq.com/docs/install-debian)..."
sudo apt-get install -y curl gnupg apt-transport-https
curl -1sLf "https://keys.openpgp.org/vks/v1/by-fingerprint/0A9AF2115F4687BD29803A206B73A36E6026DFCA" |
sudo gpg --dearmor | sudo tee /usr/share/keyrings/com.rabbitmq.team.gpg >/dev/null
sudo tee /etc/apt/sources.list.d/rabbitmq-erlang.list >/dev/null <<EOF
deb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb1.rabbitmq.com/rabbitmq-erlang/debian/${version} ${version} main
deb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb2.rabbitmq.com/rabbitmq-erlang/debian/${version} ${version} main
EOF
sudo apt-get update -qq
sudo apt-get install -y \
erlang-base \
erlang-asn1 \
erlang-crypto \
erlang-eldap \
erlang-ftp \
erlang-inets \
erlang-mnesia \
erlang-os-mon \
erlang-parsetools \
erlang-public-key \
erlang-runtime-tools \
erlang-snmp \
erlang-ssl \
erlang-syntax-tools \
erlang-tftp \
erlang-tools \
erlang-xmerl
sudo apt-get install -y git elixir
;;
*)
warn "Debian ${version:-unknown} not supported for RabbitMQ Erlang apt (expected bullseye, bookworm, or trixie)."
return 1
;;
esac
;;
*)
return 1
;;
esac
hash -r
if ! command -v elixir &>/dev/null; then
warn "elixir not found on PATH after apt install."
return 1
fi
if ! elixir --version 2>/dev/null | grep -qE 'Elixir 1\.(1[5-9]|[2-9][0-9])'; then
warn "Elixir from apt is below 1.15; falling back to asdf."
return 1
fi
log "$(elixir --version 2>/dev/null | grep Elixir || true)"
return 0
}
# ---------------------------------------------------------------------------
# 1. Pre-flight checks
# ---------------------------------------------------------------------------
log "Starting gc_index_relay local setup..."
echo
require_cmd docker "Install Docker first: https://docs.docker.com/engine/install/"
docker info &>/dev/null || err "Docker daemon is not running. Start it and try again."
log "Docker: $(docker --version)"
# ---------------------------------------------------------------------------
# 2. System build dependencies (Debian/Ubuntu, Fedora, RHEL-like)
# ---------------------------------------------------------------------------
install_system_deps_apt() {
log "Installing system build dependencies via apt-get..."
sudo apt-get update -qq
sudo apt-get install -y \
build-essential \
autoconf \
libtool \
inotify-tools \
git \
curl \
jq
}
# Fedora / RHEL / Alma / Rocky (dnf or yum). Includes openssl/ncurses headers for asdf Erlang builds.
install_system_deps_rpm() {
local pm="$1"
log "Installing system build dependencies via $pm..."
sudo "$pm" install -y \
gcc \
gcc-c++ \
make \
autoconf \
automake \
libtool \
inotify-tools \
git \
curl \
jq \
openssl-devel \
ncurses-devel
}
if command -v apt-get &>/dev/null; then
install_system_deps_apt
elif command -v dnf &>/dev/null; then
install_system_deps_rpm dnf
elif command -v yum &>/dev/null; then
install_system_deps_rpm yum
else
warn "No supported package manager found (apt-get, dnf, or yum) — skipping system package install."
warn "Install manually (names differ by distro): C toolchain, autoconf, libtool, inotify-tools, git, curl, jq"
warn "For asdf Erlang on Fedora/RHEL, you typically also need: openssl-devel, ncurses-devel"
fi
# ---------------------------------------------------------------------------
# 2b. Erlang + Elixir (apt on Debian/Ubuntu via RabbitMQ repos, else asdf)
# ---------------------------------------------------------------------------
ELIXIR_FROM_APT=0
if command -v apt-get &>/dev/null; then
if install_elixir_erlang_via_rabbitmq_apt; then
ELIXIR_FROM_APT=1
log "Using apt-installed Erlang/Elixir (no asdf Erlang/Elixir steps)."
else
warn "RabbitMQ apt path skipped or failed — installing Erlang/Elixir with asdf."
fi
fi
if [ "$ELIXIR_FROM_APT" -eq 0 ]; then
# ---------------------------------------------------------------------------
# 3. asdf version manager
# ---------------------------------------------------------------------------
if [ ! -d "$ASDF_DIR" ]; then
log "Installing asdf $ASDF_VERSION..."
git clone https://github.com/asdf-vm/asdf.git "$ASDF_DIR" --branch "$ASDF_VERSION"
else
log "asdf already installed at $ASDF_DIR"
fi
# shellcheck source=/dev/null
source "$ASDF_DIR/asdf.sh"
add_asdf_to_rc() {
local rc="$1"
local line='. "$HOME/.asdf/asdf.sh"'
if [ -f "$rc" ] && ! grep -qF 'asdf/asdf.sh' "$rc"; then
echo "" >> "$rc"
echo "# asdf version manager" >> "$rc"
echo "$line" >> "$rc"
log "Added asdf to $rc (will take effect in new shells)"
fi
}
add_asdf_to_rc "$HOME/.bashrc"
add_asdf_to_rc "$HOME/.zshrc" 2>/dev/null || true
# ---------------------------------------------------------------------------
# 4. Erlang
# ---------------------------------------------------------------------------
asdf plugin add erlang 2>/dev/null || true
if asdf list erlang 2>/dev/null | grep -qF "$ERLANG_VERSION"; then
log "Erlang $ERLANG_VERSION already installed"
else
log "Installing Erlang $ERLANG_VERSION (compiles from source — takes a few minutes)..."
asdf install erlang "$ERLANG_VERSION"
fi
asdf global erlang "$ERLANG_VERSION"
# ---------------------------------------------------------------------------
# 5. Elixir
# ---------------------------------------------------------------------------
asdf plugin add elixir 2>/dev/null || true
if asdf list elixir 2>/dev/null | grep -qF "$ELIXIR_VERSION"; then
log "Elixir $ELIXIR_VERSION already installed"
else
log "Installing Elixir $ELIXIR_VERSION..."
asdf install elixir "$ELIXIR_VERSION"
fi
asdf global elixir "$ELIXIR_VERSION"
log "$(elixir --version | grep 'Elixir')"
else
hash -r
fi
# ---------------------------------------------------------------------------
# 6. Apache AGE database (Docker)
# ---------------------------------------------------------------------------
if docker ps -q --filter "name=^${DOCKER_CONTAINER_NAME}$" | grep -q .; then
log "Database container '$DOCKER_CONTAINER_NAME' is already running"
elif docker ps -aq --filter "name=^${DOCKER_CONTAINER_NAME}$" | grep -q .; then
log "Restarting existing database container '$DOCKER_CONTAINER_NAME'..."
docker start "$DOCKER_CONTAINER_NAME"
else
log "Starting Apache AGE database container..."
docker run -d \
--name "$DOCKER_CONTAINER_NAME" \
-p "${POSTGRES_PORT}:5432" \
-e POSTGRES_USER="$POSTGRES_USER" \
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
-e POSTGRES_DB="$POSTGRES_DB" \
"$AGE_IMAGE"
fi
log "Waiting for database to accept connections..."
until docker exec "$DOCKER_CONTAINER_NAME" pg_isready -U "$POSTGRES_USER" &>/dev/null; do
sleep 1
done
log "Database is ready"
# ---------------------------------------------------------------------------
# 7. .env file
# ---------------------------------------------------------------------------
ENV_FILE="$PROJECT_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
log "Writing .env with database credentials..."
cat > "$ENV_FILE" <<EOF
export POSTGRES_HOST=$POSTGRES_HOST
export POSTGRES_PORT=$POSTGRES_PORT
export POSTGRES_USER=$POSTGRES_USER
export POSTGRES_PASSWORD=$POSTGRES_PASSWORD
export POSTGRES_DB=$POSTGRES_DB
export REQUIRE_DB=true
EOF
else
log ".env already exists — skipping (delete it to regenerate)"
fi
# Export DB vars for this session so mix setup can connect. Do not export REQUIRE_DB here —
# that would make a follow-up `mix test.unit` in the same shell start the Repo. It is only in `.env` for integration tests.
export POSTGRES_HOST POSTGRES_PORT POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB
# ---------------------------------------------------------------------------
# 8. Mix setup (deps + database)
# ---------------------------------------------------------------------------
cd "$PROJECT_DIR"
if [ "$ELIXIR_FROM_APT" -eq 1 ]; then
require_cmd mix "apt elixir package should provide mix; check PATH includes /usr/bin"
else
require_cmd mix "asdf should provide mix; run: source \"\$HOME/.asdf/asdf.sh\" or open a new terminal"
fi
log "Installing Hex and Rebar..."
mix local.hex --force
mix local.rebar --force
log "Fetching dependencies..."
mix deps.get
log "Running mix setup (compile + create DB + migrate)..."
mix setup
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo
log "Setup complete!"
echo
if [ "$ELIXIR_FROM_APT" -eq 1 ]; then
echo " To start the server (system Elixir from apt):"
echo " cd \"$PROJECT_DIR\" && source .env && mix phx.server"
echo
echo " Then open: http://localhost:4000"
echo " REST API: http://localhost:4000/api/events"
echo
echo " Integration tests:"
echo " source .env && mix test.integration"
echo
warn "If mix is not found in a new terminal, ensure /usr/bin is on your PATH."
else
echo " To start the server (use asdf’s mix — see warning below if mix is missing):"
echo " source \"\$HOME/.asdf/asdf.sh\" # once per shell, if needed"
echo " cd \"$PROJECT_DIR\" && source .env && mix phx.server"
echo
echo " Then open: http://localhost:4000"
echo " REST API: http://localhost:4000/api/events"
echo
echo " Integration tests: same shell with .env (includes REQUIRE_DB=true):"
echo " source .env && mix test.integration"
echo
warn "Open a new terminal (or run 'source ~/.bashrc') so asdf provides mix/elixir in future sessions."
fi

170
test/features/relay_api.feature

@ -1,170 +0,0 @@
Feature: Nostr Relay REST API
As a Nostr client (e.g. Jumble at https://jumble.imwald.eu/)
I want to interact with the relay over HTTP
So that I can publish and query Nostr events
Background:
Given the relay is running at http://localhost:4000
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
Scenario: Client discovers available endpoints
When the client sends GET /api
Then the response status is 200
And the response lists the available endpoints
# ---------------------------------------------------------------------------
# Publishing events
# ---------------------------------------------------------------------------
Scenario: Client publishes a valid kind 1 note
Given a valid signed kind 1 event from keypair 1
When the client sends POST /api/events with the event body
Then the response status is 201 Created
And the response body contains the published event
Scenario: Client publishes a kind 0 profile event with metadata
Given a valid signed kind 0 event containing a JSON metadata content field
When the client sends POST /api/events with the event body
Then the response status is 201 Created
Scenario: Relay rejects a duplicate event
Given a valid signed event has already been published
When the client sends POST /api/events with the same event again
Then the response status is 409 Conflict
Scenario: Relay rejects an event with an invalid ID
Given a signed event whose ID does not match the hash of its content
When the client sends POST /api/events with the event body
Then the response status is 400 Bad Request
Scenario: Relay rejects a NIP-70 protected event
Given a valid signed event with a ["-"] protection tag
When the client sends POST /api/events with the event body
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)
# ---------------------------------------------------------------------------
Scenario: Client fetches recent notes (kind 1)
Given two kind 1 notes have been published
When the client sends POST /api/events/filter with body {"kinds": [1], "limit": 10}
Then the response status is 200
And the response contains both kind 1 events
And events are ordered newest first
Scenario: Client fetches a user profile (kind 0)
Given a kind 0 profile event has been published for keypair 1
When the client sends POST /api/events/filter with body {"kinds": [0], "authors": ["<pubkey1>"], "limit": 1}
Then the response status is 200
And the response contains exactly 1 event
And the event kind is 0
Scenario: Client fetches events mentioning a specific pubkey (#p tag)
Given an event tagged with ["p", "<some pubkey>"] has been published
When the client sends POST /api/events/filter with body {"#p": ["<some pubkey>"], "limit": 10}
Then the response status is 200
And the response contains the tagged event
Scenario: Client fetches events within a time window
Given two events with different created_at timestamps exist
When the client sends POST /api/events/filter with a "since" before the newer event
Then only the newer event is returned
Scenario: Filter without a limit is rejected
When the client sends POST /api/events/filter with body {"kinds": [1]}
Then the response status is 400 Bad Request
Scenario: Filter with a limit over 100 is rejected
When the client sends POST /api/events/filter with body {"limit": 101}
Then the response status is 400 Bad Request
# ---------------------------------------------------------------------------
# Fetching a single event by ID (GET /api/events/:id)
# ---------------------------------------------------------------------------
Scenario: Client fetches a specific event by ID
Given a known event has been published
When the client sends GET /api/events/<event_id>
Then the response status is 200
And the response body contains the correct event
Scenario: Fetching a non-existent event returns 404
When the client sends GET /api/events/<unknown_id>
Then the response status is 404 Not Found
# ---------------------------------------------------------------------------
# CORS — required for browser-based clients like Jumble
# ---------------------------------------------------------------------------
Scenario: Relay includes CORS headers on API responses
When the client sends any request to the API
Then the response includes "Access-Control-Allow-Origin: *"
And the response includes "Access-Control-Allow-Methods" listing GET, POST, DELETE, OPTIONS
Scenario: Relay handles a browser preflight OPTIONS request
When the client sends OPTIONS /api/events
Then the response status is 200
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
# ---------------------------------------------------------------------------
Scenario: Health check returns OK
When the client sends GET /health
Then the response status is 200

13
test/gc_index_relay/nostr/filter_test.exs

@ -27,22 +27,11 @@ 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 letter is distinct from lowercase)" do test "returns {:ok, filter} with valid tags (uppercase)" 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"]}

83
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"]])
assert {:ok, event} = PubEvent.to_db(pub_event) 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,33 +22,16 @@ 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)
assert {:ok, event} = PubEvent.to_db(pub_event) 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: "")
assert {:ok, event} = PubEvent.to_db(pub_event) event = PubEvent.to_db(pub_event)
assert event.content == "" assert event.content == ""
end end
@ -113,8 +96,7 @@ 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, db} = PubEvent.to_db(pub_event) assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
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
@ -128,8 +110,7 @@ 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, db} = PubEvent.to_db(pub_event) assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == [] assert result.tags == []
end end
@ -142,40 +123,14 @@ defmodule GcIndexRelay.Nostr.PubEventTest do
pub_event = valid_pub_event_fixture(tags: tags) pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, db} = PubEvent.to_db(pub_event) assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags
end
test "preserves single-element tags (e.g. ['bot'])" do
tags = [["bot"]]
pub_event = valid_pub_event_fixture(tags: tags)
assert {:ok, db} = PubEvent.to_db(pub_event)
assert {:ok, result} = PubEvent.from_db(db)
assert result.tags == tags
end
test "preserves mixed single-element and multi-element tags" do
tags = [
["bot"],
["p", "def456"],
["content-warning"]
]
pub_event = valid_pub_event_fixture(tags: tags)
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, db} = PubEvent.to_db(pub_event) assert {:ok, result} = pub_event |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, result} = PubEvent.from_db(db)
assert result.content == "" assert result.content == ""
end end
@ -183,10 +138,8 @@ 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, db1} = PubEvent.to_db(pub_event_1) assert {:ok, result_1} = pub_event_1 |> PubEvent.to_db() |> PubEvent.from_db()
assert {:ok, db2} = PubEvent.to_db(pub_event_2) assert {:ok, result_2} = pub_event_2 |> PubEvent.to_db() |> PubEvent.from_db()
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
@ -195,25 +148,31 @@ 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 "returns error on invalid hex in id" do test "raises 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 {:error, :invalid_hex} = PubEvent.to_db(invalid) assert_raise BadStructError, fn ->
PubEvent.to_db(invalid)
end
end end
test "returns error on invalid hex in pubkey" do test "raises 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 {:error, :invalid_hex} = PubEvent.to_db(invalid) assert_raise BadStructError, fn ->
PubEvent.to_db(invalid)
end
end end
test "returns error on invalid hex in sig" do test "raises 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 {:error, :invalid_hex} = PubEvent.to_db(invalid) assert_raise BadStructError, fn ->
PubEvent.to_db(invalid)
end
end end
end end
end end

35
test/gc_index_relay/nostr/validator_test.exs

@ -139,34 +139,6 @@ defmodule GcIndexRelay.Nostr.ValidatorTest do
end end
end end
describe "validate_not_protected/1" do
test "returns {:ok, event} for a normal event with no tags" do
event = valid_pub_event_fixture()
assert {:ok, ^event} = Validator.validate_not_protected(event)
end
test "returns {:ok, event} for an event with other tags but no protection tag" do
event = valid_pub_event_fixture(tags: [["e", "abc123"], ["p", "def456"]])
assert {:ok, ^event} = Validator.validate_not_protected(event)
end
test "returns {:error, message} for an event with the [\"-\"] protection tag" do
event = valid_pub_event_fixture(tags: [["-"]])
assert {:error, message} = Validator.validate_not_protected(event)
assert message =~ "auth-required"
end
test "returns {:error, message} when [\"-\"] is mixed with other tags" do
event = valid_pub_event_fixture(tags: [["e", "abc123"], ["-"], ["p", "def456"]])
assert {:error, message} = Validator.validate_not_protected(event)
assert message =~ "auth-required"
end
end
describe "static reference test" do describe "static reference test" do
test "validates against known-good pre-computed event" do test "validates against known-good pre-computed event" do
event = static_valid_pub_event() event = static_valid_pub_event()
@ -175,12 +147,5 @@ 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

21
test/gc_index_relay/nostr_test.exs

@ -1,22 +1,11 @@
defmodule GcIndexRelay.NostrTest do defmodule GcIndexRelay.NostrTest do
use GcIndexRelay.DataCase use GcIndexRelay.DataCase
import GcIndexRelay.NostrFixtures describe "events" do
# Tests to be added
@moduletag :integration end
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 describe "tags" do
assert loaded.content == event.content # Tests to be added
assert loaded.pubkey == event.pubkey
assert loaded.created_at == event.created_at
assert loaded.tags == event.tags
end
end end
end end

2
test/gc_index_relay_web/controllers/error_html_test.exs

@ -1,8 +1,6 @@
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,8 +1,6 @@
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,24 +80,6 @@ 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})
@ -257,34 +239,6 @@ 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})

6
test/gc_index_relay_web/controllers/page_controller_test.exs

@ -1,10 +1,8 @@
defmodule GcIndexRelayWeb.PageControllerTest do defmodule GcIndexRelayWeb.PageControllerTest do
use GcIndexRelayWeb.ConnCase use GcIndexRelayWeb.ConnCase
@moduletag :unit test "GET /", %{conn: conn} do
test "GET / renders Mercury landing page", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Mercury Index-Relay" assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
end end
end end

85
test/gc_index_relay_web/plugs/cors_plug_test.exs

@ -1,85 +0,0 @@
defmodule GcIndexRelayWeb.Plugs.CorsPlugTest do
@moduledoc false
use GcIndexRelayWeb.ConnCase, async: false
@moduletag :unit
setup do
previous = Application.get_env(:gc_index_relay, :cors)
on_exit(fn -> Application.put_env(:gc_index_relay, :cors, previous) end)
{:ok, previous_cors: previous}
end
describe "allow_origins allowlist" do
setup %{previous_cors: previous} do
base = previous || []
Application.put_env(
:gc_index_relay,
:cors,
Keyword.merge(base,
enabled: true,
allow_origins: ["https://client.example.com"]
)
)
:ok
end
test "reflects Origin when it matches the allowlist", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> put_req_header("origin", "https://client.example.com")
|> get(~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == [
"https://client.example.com"
]
end
test "omits Access-Control-Allow-Origin when Origin does not match", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> put_req_header("origin", "https://evil.example.com")
|> get(~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == []
end
test "preflight without matching Origin has no CORS allow headers", %{conn: conn} do
conn =
conn
|> put_req_header("origin", "https://evil.example.com")
|> options("/api/events")
assert conn.status == 200
assert conn.resp_body == ""
assert get_resp_header(conn, "access-control-allow-origin") == []
end
end
describe "CORS disabled" do
setup %{previous_cors: previous} do
base = previous || []
Application.put_env(:gc_index_relay, :cors, Keyword.merge(base, enabled: false))
:ok
end
test "does not set CORS headers on GET", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> get(~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == []
end
test "does not handle OPTIONS preflight (no CORS headers)", %{conn: conn} do
conn = options(conn, "/api/events")
assert get_resp_header(conn, "access-control-allow-origin") == []
end
end
end

201
test/gc_index_relay_web/relay_integration_test.exs

@ -1,201 +0,0 @@
defmodule GcIndexRelayWeb.RelayIntegrationTest do
@moduledoc """
Integration tests for relay REST API scenarios that require a live database.
Covers event publishing, deletion, and filter-based querying.
Scenarios are mapped 1:1 to test/features/relay_api.feature.
Run with: source .env && mix test.integration test/gc_index_relay_web/relay_integration_test.exs
"""
use GcIndexRelayWeb.ConnCase
import GcIndexRelay.NostrFixtures
@moduletag :integration
setup %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> put_req_header("content-type", "application/json")
{:ok, conn: conn}
end
# ---------------------------------------------------------------------------
# Publishing events (POST /api/events)
# ---------------------------------------------------------------------------
describe "POST /api/events — publishing" do
test "client publishes a valid kind 1 note", %{conn: conn} do
event = valid_pub_event_fixture(%{kind: 1, content: "hello nostr"})
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
assert %{"data" => data} = json_response(conn, 201)
assert data["id"] == event.id
assert data["kind"] == 1
assert data["content"] == "hello nostr"
end
test "client publishes a kind 0 profile event with metadata content", %{conn: conn} do
metadata = Jason.encode!(%{name: "testuser", about: "a test profile", picture: ""})
event = valid_pub_event_fixture(%{kind: 0, content: metadata})
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
assert %{"data" => data} = json_response(conn, 201)
assert data["kind"] == 0
assert data["content"] == metadata
end
test "relay rejects a duplicate event with 409", %{conn: conn} do
event = valid_pub_event_fixture()
post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
assert json_response(conn, 409)
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
event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower)
conn = delete(conn, ~p"/api/events/#{hex_id}")
assert conn.status == 204
assert conn.resp_body == ""
end
test "deleting a non-existent event returns 404", %{conn: conn} do
conn = delete(conn, ~p"/api/events/#{String.duplicate("b", 64)}")
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
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
|> then(&GcIndexRelay.Nostr.create_event/1)
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
|> then(&GcIndexRelay.Nostr.create_event/1)
conn = get(conn, "/api/events?since=0&until=9999999999&limit=10")
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2
[first, second] = events
assert first["created_at"] >= second["created_at"]
end
end
# ---------------------------------------------------------------------------
# Querying events (POST /api/events/filter)
# ---------------------------------------------------------------------------
describe "POST /api/events/filter — querying" do
test "client fetches recent kind 1 notes, newest first", %{conn: conn} do
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001})
|> then(&GcIndexRelay.Nostr.create_event/1)
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_002, keypair: :keypair2})
|> then(&GcIndexRelay.Nostr.create_event/1)
conn = post(conn, ~p"/api/events/filter", %{"kinds" => [1], "limit" => 10})
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 2
[first, second] = events
assert first["created_at"] >= second["created_at"]
end
test "client fetches a user profile (kind 0) by author", %{conn: conn} do
%{keypair1: kp} = test_keypairs()
metadata = Jason.encode!(%{name: "alice"})
event = valid_pub_event_fixture(%{kind: 0, content: metadata})
{:ok, _} = GcIndexRelay.Nostr.create_event(event)
conn =
post(conn, ~p"/api/events/filter", %{
"kinds" => [0],
"authors" => [kp.public_key_hex],
"limit" => 1
})
assert %{"data" => [profile]} = json_response(conn, 200)
assert profile["kind"] == 0
assert profile["pubkey"] == kp.public_key_hex
end
test "client fetches events mentioning a pubkey via #p tag", %{conn: conn} do
mentioned_pubkey = String.duplicate("ab", 32)
event = valid_pub_event_fixture(%{tags: [["p", mentioned_pubkey]]})
{:ok, _} = GcIndexRelay.Nostr.create_event(event)
valid_pub_event_fixture(%{created_at: 1_640_000_001, keypair: :keypair2})
|> then(&GcIndexRelay.Nostr.create_event/1)
conn = post(conn, ~p"/api/events/filter", %{"#p" => [mentioned_pubkey], "limit" => 10})
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 1
assert hd(events)["id"] == event.id
end
test "client fetches events within a time window", %{conn: conn} do
valid_pub_event_fixture(%{kind: 1, created_at: 1_500_000_000})
|> then(&GcIndexRelay.Nostr.create_event/1)
valid_pub_event_fixture(%{kind: 1, created_at: 1_640_000_001, keypair: :keypair2})
|> then(&GcIndexRelay.Nostr.create_event/1)
conn =
post(conn, ~p"/api/events/filter", %{
"since" => 1_600_000_000,
"limit" => 10
})
assert %{"data" => events} = json_response(conn, 200)
assert length(events) == 1
assert hd(events)["created_at"] == 1_640_000_001
end
end
# ---------------------------------------------------------------------------
# Fetching a single event (GET /api/events/:id)
# ---------------------------------------------------------------------------
describe "GET /api/events/:id — single event lookup" do
test "client fetches a specific event by ID", %{conn: conn} do
event = valid_pub_event_fixture()
{:ok, db_event} = GcIndexRelay.Nostr.create_event(event)
hex_id = Base.encode16(db_event.id, case: :lower)
conn = get(conn, ~p"/api/events/#{hex_id}")
assert %{"data" => data} = json_response(conn, 200)
assert data["id"] == hex_id
assert data["content"] == event.content
end
test "fetching a non-existent event returns 404", %{conn: conn} do
conn = get(conn, ~p"/api/events/#{String.duplicate("a", 64)}")
assert json_response(conn, 404)
end
end
end

176
test/gc_index_relay_web/relay_unit_test.exs

@ -1,176 +0,0 @@
defmodule GcIndexRelayWeb.RelayUnitTest do
@moduledoc """
Unit tests for relay HTTP endpoints that require no database access.
Tests controller/plug behaviour: routing, validation rejection, CORS headers,
NIP-11 relay info, and health check.
Scenarios are a subset of test/features/relay_api.feature.
Run with: mix test.unit test/gc_index_relay_web/relay_unit_test.exs
"""
use GcIndexRelayWeb.ConnCase, async: true
import GcIndexRelay.NostrFixtures
@moduletag :unit
setup %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/json")
|> put_req_header("content-type", "application/json")
{:ok, conn: conn}
end
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
describe "GET /api — discovery" do
test "client discovers available endpoints", %{conn: conn} do
conn = get(conn, ~p"/api")
assert %{"endpoints" => endpoints} = json_response(conn, 200)
paths = Enum.map(endpoints, & &1["path"])
assert "/api/events" in paths
assert "/api/events/:id" in paths
assert "/api/events/filter" in paths
end
end
# ---------------------------------------------------------------------------
# Request validation — rejected before any DB access
# ---------------------------------------------------------------------------
describe "POST /api/events — validation rejections" do
test "relay rejects a NIP-70 protected event with 400", %{conn: conn} do
event = valid_pub_event_fixture(%{tags: [["-"]]})
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
assert %{"errors" => %{"detail" => detail}} = json_response(conn, 400)
assert detail =~ "auth-required"
end
end
# ---------------------------------------------------------------------------
# CORS — required for browser-based clients like Jumble
# ---------------------------------------------------------------------------
describe "CORS headers — browser client compatibility" do
test "relay includes CORS headers on a GET response", %{conn: conn} do
conn = get(conn, ~p"/api")
assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("")
assert methods =~ "GET"
assert methods =~ "POST"
assert methods =~ "DELETE"
assert methods =~ "OPTIONS"
end
test "relay includes CORS headers on a POST response", %{conn: conn} do
# Reject before DB (NIP-70 protected) so unit tests need no running Repo.
event = valid_pub_event_fixture(%{tags: [["-"]]})
conn = post(conn, ~p"/api/events", %{"event" => Map.from_struct(event)})
assert json_response(conn, 400)
assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
end
test "relay responds 200 to a browser preflight OPTIONS request", %{conn: conn} do
conn = options(conn, "/api/events")
assert conn.status == 200
assert conn.resp_body == ""
assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
methods = get_resp_header(conn, "access-control-allow-methods") |> List.first("")
assert methods =~ "OPTIONS"
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
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
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
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
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
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
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
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
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
conn =
conn
|> put_req_header("accept", "application/nostr+json")
|> get("/")
assert get_resp_header(conn, "access-control-allow-origin") == ["*"]
end
test "regular browser request to GET / still returns HTML", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/html,application/xhtml+xml")
|> get("/")
assert conn.status == 200
[content_type | _] = get_resp_header(conn, "content-type")
assert content_type =~ "text/html"
end
end
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
describe "GET /health — health check" do
test "health check returns 200", %{conn: conn} do
conn = get(conn, "/health")
assert conn.status == 200
end
end
end

25
test/support/conn_case.ex

@ -32,28 +32,7 @@ defmodule GcIndexRelayWeb.ConnCase do
end end
setup tags do setup tags do
sandbox_owner = GcIndexRelay.DataCase.setup_sandbox(tags) 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,35 +28,21 @@ 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 """
Starts a sandbox owner for the Repo when it is started in this environment. Sets up the sandbox based on the test tags.
Returns the sandbox owner pid when the Repo is started, otherwise `nil`.
""" """
@spec setup_sandbox(map()) :: pid() | nil def setup_sandbox(tags) do
def setup_sandbox(_tags) do # Only set up sandbox if Repo was started
if Application.get_env(:gc_index_relay, :start_repo, true) do if Application.get_env(:gc_index_relay, :start_repo, true) do
# Always use shared: false so concurrent test *modules* (ExUnit default) do not pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: not tags[:async])
# 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 end
defp empty_nostr_event_tables!(repo) do
alias GcIndexRelay.Nostr.{Event, Tag}
_ = repo.delete_all(Tag)
_ = repo.delete_all(Event)
end
@doc """ @doc """
A helper that transforms changeset errors into a map of messages. A helper that transforms changeset errors into a map of messages.

36
test/support/fixtures/nostr_fixtures.ex

@ -153,42 +153,6 @@ 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