10 KiB
Parrhesia Shared API + Local API Design (Option 1)
1) Goal
Expose a stable in-process API for embedding apps and refactor server transports to consume the same API.
Desired end state:
- WebSocket server, HTTP management, and embedding app all call one shared core API.
- Transport layers (WS/HTTP/local) only do framing, auth header extraction, and response encoding.
- Policy/storage/fanout/business semantics live in one place.
This keeps everything in the same dependency (:parrhesia) and avoids a second package.
2) Key architectural decision
Previous direction: Parrhesia.Local.* as primary public API.
Updated direction (this doc):
- Introduce shared core API modules under
Parrhesia.API.*. - Make server code (
Parrhesia.Web.Connection, management handlers) delegate toParrhesia.API.*. - Keep
Parrhesia.Local.*as optional convenience wrappers overParrhesia.API.*.
This ensures no divergence between local embedding behavior and websocket behavior.
3) Layered design
Transport layer
- Parrhesia.Web.Connection (WS)
- Parrhesia.Web.Management (HTTP)
- Parrhesia.Local.* wrappers (in-process)
Shared API layer
- Parrhesia.API.Auth
- Parrhesia.API.Events
- Parrhesia.API.Stream (optional)
- Parrhesia.API.Admin (optional, for management methods)
Domain/runtime dependencies
- Parrhesia.Policy.EventPolicy
- Parrhesia.Storage.* adapters
- Parrhesia.Groups.Flow
- Parrhesia.Subscriptions.Index
- Parrhesia.Fanout.MultiNode
- Parrhesia.Telemetry
Rule: all ingest/query/count decisions happen in Parrhesia.API.Events.
4) Public module plan
4.1 Parrhesia.API.Auth
Purpose:
- event validation helpers
- NIP-98 verification
- optional embedding account resolution hook
Proposed functions:
@spec validate_event(map()) :: :ok | {:error, term()}
@spec compute_event_id(map()) :: String.t()
@spec validate_nip98(String.t() | nil, String.t(), String.t()) ::
{:ok, Parrhesia.API.Auth.Context.t()} | {:error, term()}
@spec validate_nip98(String.t() | nil, String.t(), String.t(), keyword()) ::
{:ok, Parrhesia.API.Auth.Context.t()} | {:error, term()}
validate_nip98/4 options:
account_resolver: (pubkey_hex :: String.t(), auth_event :: map() ->
{:ok, account :: term()} | {:error, term()})
Context struct:
defmodule Parrhesia.API.Auth.Context do
@enforce_keys [:pubkey, :auth_event]
defstruct [:pubkey, :auth_event, :account, claims: %{}]
end
4.2 Parrhesia.API.Events
Purpose:
- canonical ingress/query/count API used by WS + local + HTTP integrations.
Proposed functions:
@spec publish(map(), keyword()) :: {:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()}
@spec query([map()], keyword()) :: {:ok, [map()]} | {:error, term()}
@spec count([map()], keyword()) :: {:ok, non_neg_integer() | map()} | {:error, term()}
Request context:
defmodule Parrhesia.API.RequestContext do
defstruct authenticated_pubkeys: MapSet.new(),
actor: nil,
metadata: %{}
end
Publish result:
defmodule Parrhesia.API.Events.PublishResult do
@enforce_keys [:event_id, :accepted, :message]
defstruct [:event_id, :accepted, :message]
end
Publish semantics (must match websocket EVENT)
Pipeline in publish/2:
- frame/event size limits
Parrhesia.Protocol.validate_event/1Parrhesia.Policy.EventPolicy.authorize_write/2- group handling (
Parrhesia.Groups.Flow.handle_event/1) - persistence path (
put_event, deletion, vanish, ephemeral rules) - fanout (local + multi-node)
- telemetry emit
Return shape mirrors Nostr OK semantics:
{:ok, %PublishResult{event_id: id, accepted: true, message: "ok: event stored"}}
{:ok, %PublishResult{event_id: id, accepted: false, message: "blocked: ..."}}
Query/count semantics (must match websocket REQ/COUNT)
query/2 and count/2:
- validate filters
- run read policy (
EventPolicy.authorize_read/2) - call storage with
requester_pubkeysfrom context - return ordered events/count payload
Giftwrap restrictions (kind 1059) must remain identical to websocket behavior.
4.3 Parrhesia.API.Stream (optional but recommended)
Purpose:
- local in-process subscriptions using same subscription index/fanout model.
Proposed functions:
@spec subscribe(pid(), String.t(), [map()], keyword()) :: {:ok, reference()} | {:error, term()}
@spec unsubscribe(reference()) :: :ok
Subscriber contract:
{:parrhesia, :event, ref, subscription_id, event}
{:parrhesia, :eose, ref, subscription_id}
{:parrhesia, :closed, ref, subscription_id, reason}
4.4 Parrhesia.Local.* wrappers
Parrhesia.Local.* remain as convenience API for embedding apps, implemented as thin wrappers:
Parrhesia.Local.Auth-> delegates toParrhesia.API.AuthParrhesia.Local.Events-> delegates toParrhesia.API.EventsParrhesia.Local.Stream-> delegates toParrhesia.API.StreamParrhesia.Local.Client-> use-case helpers (posts + private messages)
No business logic in wrappers.
5) Server integration plan (critical)
5.1 WebSocket (Parrhesia.Web.Connection)
After decode:
EVENT->Parrhesia.API.Events.publish/2REQ->Parrhesia.API.Events.query/2COUNT->Parrhesia.API.Events.count/2AUTHkeep transport-specific challenge/session flow, but can useAPI.Auth.validate_event/1internally
WebSocket keeps responsibility for:
- websocket framing
- subscription lifecycle per connection
- AUTH challenge rotation protocol frames
5.2 HTTP management (Parrhesia.Web.Management)
- NIP-98 header validation via
Parrhesia.API.Auth.validate_nip98/3 - command execution via
Parrhesia.API.Admin(or existing storage admin adapter via API facade)
6) High-level client helpers for embedding app use case
These helpers are optional and live in Parrhesia.Local.Client.
6.1 Public posts
@spec publish_post(Parrhesia.API.Auth.Context.t(), String.t(), keyword()) ::
{:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()}
@spec list_posts(keyword()) :: {:ok, [map()]} | {:error, term()}
@spec stream_posts(pid(), keyword()) :: {:ok, reference()} | {:error, term()}
publish_post/3 options:
:tags:created_at:signercallback (required unless fully signed event provided)
Signer contract:
(unsigned_event_map -> {:ok, signed_event_map} | {:error, term()})
Parrhesia does not store or manage private keys.
6.2 Private messages (giftwrap kind 1059)
@spec send_private_message(
Parrhesia.API.Auth.Context.t(),
recipient_pubkey :: String.t(),
encrypted_payload :: String.t(),
keyword()
) :: {:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()}
@spec inbox(Parrhesia.API.Auth.Context.t(), keyword()) :: {:ok, [map()]} | {:error, term()}
@spec stream_inbox(pid(), Parrhesia.API.Auth.Context.t(), keyword()) :: {:ok, reference()} | {:error, term()}
Behavior:
send_private_message/4builds event template with kind1059andptag.- host signer signs template.
- publish through
API.Events.publish/2. inbox/2queries%{"kinds" => [1059], "#p" => [auth.pubkey]}with authenticated context.
7) Error model
Shared API should normalize output regardless of transport.
Guideline:
- protocol/policy rejection ->
{:ok, %{accepted: false, message: "..."}} - runtime/system failure ->
{:error, term()}
Common reason mapping:
| Reason | Message prefix |
|---|---|
:auth_required |
auth-required: |
:restricted_giftwrap |
restricted: |
:invalid_event |
invalid: |
:duplicate_event |
duplicate: |
:event_rate_limited |
rate-limited: |
8) Telemetry
Emit shared events in API layer (not transport-specific):
[:parrhesia, :api, :publish, :stop][:parrhesia, :api, :query, :stop][:parrhesia, :api, :count, :stop][:parrhesia, :api, :auth, :stop]
Metadata:
traffic_classcaller(:websocket | :http | :local)- optional
account_present?
Transport-level telemetry can remain separate where needed.
9) Refactor sequence
Phase 1: Extract shared API
- Create
Parrhesia.API.Eventswith publish/query/count from currentWeb.Connectionpaths. - Create
Parrhesia.API.Authwrappers for NIP-98/event validation. - Add API-level tests.
Phase 2: Migrate transports
- Update
Parrhesia.Web.Connectionto delegate publish/query/count toAPI.Events. - Update
Parrhesia.Web.Managementto useAPI.Auth. - Keep behavior unchanged.
Phase 3: Add local wrappers/helpers
- Implement
Parrhesia.Local.Auth/Events/Streamas thin delegates. - Add
Parrhesia.Local.Clientpost/inbox/send helpers. - Add embedding documentation.
Phase 4: Lock parity
- Add parity tests: WS vs Local API for same inputs and policy outcomes.
- Add property tests for query/count equivalence where feasible.
10) Testing requirements
- Transport parity tests
- Same signed event via WS and API => same accepted/message semantics.
- Policy parity tests
- Giftwrap visibility and auth-required behavior identical across WS/API/local.
- Auth tests
- NIP-98 success/failure + account resolver success/failure.
- Fanout tests
- publish via API reaches local stream subscribers and WS subscribers.
- Failure tests
- storage failures surface deterministic errors in all transports.
11) Backwards compatibility
- No breaking change to websocket protocol.
- No breaking change to management endpoint contract.
- New API modules are additive.
- Existing apps can ignore local API entirely.
12) Embedding example flow
12.1 Login/auth
with {:ok, auth} <- Parrhesia.API.Auth.validate_nip98(header, method, url,
account_resolver: &MyApp.Accounts.resolve_nostr_pubkey/2
) do
# use auth.pubkey/auth.account in host session
end
12.2 Post publish
Parrhesia.Local.Client.publish_post(auth, "hello", signer: &MyApp.NostrSigner.sign/1)
12.3 Private message
Parrhesia.Local.Client.send_private_message(
auth,
recipient_pubkey,
encrypted_payload,
signer: &MyApp.NostrSigner.sign/1
)
12.4 Inbox
Parrhesia.Local.Client.inbox(auth, limit: 100)
13) Summary
Yes, this can and should be extracted into a shared API module. The server should consume it too.
That gives:
- one canonical behavior path,
- cleaner embedding,
- easier testing,
- lower long-term maintenance cost.