Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Repository Guidelines

## Project Structure & Module Organization

This is an Elixir Mix library for sending encrypted Web Push notifications.
Core source lives in `lib/`: `lib/web_push_elixir.ex` contains the public
notification API and crypto/header logic, `lib/web_push_elixir/application.ex`
starts supervision, and `lib/mix/tasks/generate_vapid_keys.ex` defines the VAPID
key generator task. Runtime and test configuration are in `config/`. Tests live
in `test/`, with shared local HTTP behavior in `test/support/mock_server.ex`.
The browser demo and GitHub Pages assets are in `example/`.

## Build, Test, and Development Commands

- `mix deps.get` installs Hex dependencies.
- `mix compile` compiles the library and checks for compile-time errors.
- `mix test` runs the ExUnit suite; tests start a local Plug/Cowboy server on
port `4040`.
- `mix coveralls.json` runs test coverage in the same format used by CI.
- `mix format` formats all Elixir files according to Mix formatter defaults.
- `mix generate.vapid.keys` generates sample VAPID keys for local configuration.
- `mix precommit` checks formatting, compiles with warnings as errors, and runs
the test suite. Run this before every commit.

## Coding Style & Naming Conventions

Use standard Elixir formatting: two-space indentation, pipeline-friendly
function chains, and `snake_case` for functions, variables, and test names.
Modules use `PascalCase` under the `WebPushElixir` namespace, with Mix tasks
under `Mix.Tasks.*`. Prefer pattern matching and tagged tuples such as
`{:ok, response}` and `{:error, reason}` for control flow. Keep comments sparse;
public behavior should be documented with `@doc` examples when helpful.

## Testing Guidelines

Tests use ExUnit and should be named `*_test.exs`. Add focused tests next to the
behavior being changed, and use `test/support/mock_server.ex` for push-service
HTTP responses instead of calling external services. When changing request
headers, encryption payload behavior, or error handling, assert both return
tuples and relevant response/request fields. Run `mix test` before submitting;
run `mix coveralls.json` for changes that affect coverage-sensitive logic.

## Commit & Pull Request Guidelines

Recent history uses short imperative messages, often with Conventional Commit
prefixes such as `build(deps): bump jason from 1.4.4 to 1.4.5`, `build: 0.8.0`,
and `ci: revert test`. Run `mix precommit` before every commit, then keep
commits scoped and descriptive. Pull requests should include a concise summary,
test results, linked issues when applicable, and note any configuration or
example asset changes. Screenshots are only useful for changes under `example/`.

## Security & Configuration Tips

Never commit real VAPID private keys. Use placeholders in docs and configure
`vapid_public_key`, `vapid_private_key`, and `vapid_subject` through environment
specific config or application runtime configuration.
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ import Config

if Mix.env() == :test do
import_config "test.exs"
end
end
6 changes: 3 additions & 3 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Config

config :web_push_elixir,
vapid_public_key: "someVapidPublicKey",
vapid_private_key: "someVapidPrivateKey",
vapid_subject: "mailto:admin@email.com"
vapid_public_key: "someVapidPublicKey",
vapid_private_key: "someVapidPrivateKey",
vapid_subject: "mailto:admin@email.com"
2 changes: 1 addition & 1 deletion lib/mix/tasks/generate_vapid_keys.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ defmodule Mix.Tasks.Generate.Vapid.Keys do
vapid_private_key: Base.url_encode64(private_key, padding: false),
vapid_subject: "mailto:admin@email.com"
}
|> IO.inspect
|> IO.inspect()
end
end
13 changes: 7 additions & 6 deletions lib/web_push_elixir.ex
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ defmodule WebPushElixir do
{"content-encoding", "aesgcm"},
{"content-length", "#{byte_size(encrypted_payload.ciphertext)}"},
{"content-type", "application/octet-stream"},
{"crypto-key", "dh=#{url_encode(encrypted_payload.local_public_key)};p256ecdsa=#{url_encode(vapid_public_key)}"},
{"crypto-key",
"dh=#{url_encode(encrypted_payload.local_public_key)};p256ecdsa=#{url_encode(vapid_public_key)}"},
{"encryption", "salt=#{url_encode(encrypted_payload.salt)}"},
{"ttl", "#{ttl}"}
]
Expand All @@ -156,11 +157,11 @@ defmodule WebPushElixir do
headers = if topic, do: [{"topic", topic} | headers], else: headers

case Req.run(
method: :post,
url: endpoint,
body: encrypted_payload.ciphertext,
headers: headers
) do
method: :post,
url: endpoint,
body: encrypted_payload.ciphertext,
headers: headers
) do
{request, %{status: status} = response} when status in 200..202 ->
{:ok, Map.put(response, :request, request)}

Expand Down
13 changes: 13 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule WebPushElixir.MixProject do
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
description: "Simple web push for Elixir",
package: [
licenses: ["MIT"],
Expand All @@ -18,6 +19,12 @@ defmodule WebPushElixir.MixProject do
]
end

def cli do
[
preferred_envs: [precommit: :test]
]
end

# Run "mix help compile.app" to learn about applications.
def application do
[
Expand All @@ -42,4 +49,10 @@ defmodule WebPushElixir.MixProject do
{:plug_cowboy, "~> 2.0", only: :test}
]
end

defp aliases do
[
precommit: ["format --check-formatted", "compile --warnings-as-errors", "test"]
]
end
end
36 changes: 21 additions & 15 deletions test/web_push_elixir_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,26 @@ defmodule WebPushElixirTest do
@test_subscription %{
"endpoint" => "http://localhost:4040/some-push-service",
"keys" => %{
"p256dh" => "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
"p256dh" =>
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
"auth" => "tBHItJI5svbpez7KI4CCXg=="
}
}
}

test "it should send notification" do
subscription = Jason.encode!(@test_subscription)

{:ok, response} = WebPushElixir.send_notification(subscription, "some message")

assert %{
"authorization" => ["WebPush " <> <<_jwt::binary>>],
"content-encoding" => ["aesgcm"],
"content-length" => ["30"],
"content-type" => ["application/octet-stream"],
"crypto-key" => ["dh=" <> <<_crypto_keys::binary>>],
"encryption" => ["salt=" <> <<_salt::binary>>],
"ttl" => ["60"]
} = response.request.headers
"authorization" => ["WebPush " <> <<_jwt::binary>>],
"content-encoding" => ["aesgcm"],
"content-length" => ["30"],
"content-type" => ["application/octet-stream"],
"crypto-key" => ["dh=" <> <<_crypto_keys::binary>>],
"encryption" => ["salt=" <> <<_salt::binary>>],
"ttl" => ["60"]
} = response.request.headers

assert response.status in 200..202
end
Expand All @@ -38,30 +39,35 @@ defmodule WebPushElixirTest do
test "it should send the urgency parameter" do
subscription = Jason.encode!(@test_subscription)

{:ok, response} = WebPushElixir.send_notification(subscription, "some message", urgency: :high)
{:ok, response} =
WebPushElixir.send_notification(subscription, "some message", urgency: :high)

assert ["high"] = Map.get(response.request.headers, "urgency")
end

test "it should set the topic if it is provided" do
subscription = Jason.encode!(@test_subscription)

{:ok, response} = WebPushElixir.send_notification(subscription, "some message", topic: "some-test-topic")
{:ok, response} =
WebPushElixir.send_notification(subscription, "some message", topic: "some-test-topic")

assert ["some-test-topic"] = Map.get(response.request.headers, "topic")
end

test "it should return expired" do
expired_subscription = Jason.encode!(%{@test_subscription | "endpoint" => "http://localhost:4040/gone"})
expired_subscription =
Jason.encode!(%{@test_subscription | "endpoint" => "http://localhost:4040/gone"})

assert {:error, :expired} = WebPushElixir.send_notification(expired_subscription, "message")
end

test "it should return http error" do
error_subscription =
Jason.encode!(%{@test_subscription | "endpoint" => "http://localhost:4040/error"})

error_subscription = Jason.encode!(%{@test_subscription | "endpoint" => "http://localhost:4040/error"})
assert {:error, {:http_error, status, _body}} =
WebPushElixir.send_notification(error_subscription, "message")

assert {:error, {:http_error, status, _body}} = WebPushElixir.send_notification(error_subscription, "message")
assert status != 200..202
end
end