Files
parrhesia/docs/slop/LOCAL_API.md

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 to Parrhesia.API.*.
  • Keep Parrhesia.Local.* as optional convenience wrappers over Parrhesia.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:

  1. frame/event size limits
  2. Parrhesia.Protocol.validate_event/1
  3. Parrhesia.Policy.EventPolicy.authorize_write/2
  4. group handling (Parrhesia.Groups.Flow.handle_event/1)
  5. persistence path (put_event, deletion, vanish, ephemeral rules)
  6. fanout (local + multi-node)
  7. 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:

  1. validate filters
  2. run read policy (EventPolicy.authorize_read/2)
  3. call storage with requester_pubkeys from context
  4. return ordered events/count payload

Giftwrap restrictions (kind 1059) must remain identical to websocket behavior.


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 to Parrhesia.API.Auth
  • Parrhesia.Local.Events -> delegates to Parrhesia.API.Events
  • Parrhesia.Local.Stream -> delegates to Parrhesia.API.Stream
  • Parrhesia.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/2
  • REQ -> Parrhesia.API.Events.query/2
  • COUNT -> Parrhesia.API.Events.count/2
  • AUTH keep transport-specific challenge/session flow, but can use API.Auth.validate_event/1 internally

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
  • :signer callback (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/4 builds event template with kind 1059 and p tag.
  • host signer signs template.
  • publish through API.Events.publish/2.
  • inbox/2 queries %{"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_class
  • caller (:websocket | :http | :local)
  • optional account_present?

Transport-level telemetry can remain separate where needed.


9) Refactor sequence

Phase 1: Extract shared API

  1. Create Parrhesia.API.Events with publish/query/count from current Web.Connection paths.
  2. Create Parrhesia.API.Auth wrappers for NIP-98/event validation.
  3. Add API-level tests.

Phase 2: Migrate transports

  1. Update Parrhesia.Web.Connection to delegate publish/query/count to API.Events.
  2. Update Parrhesia.Web.Management to use API.Auth.
  3. Keep behavior unchanged.

Phase 3: Add local wrappers/helpers

  1. Implement Parrhesia.Local.Auth/Events/Stream as thin delegates.
  2. Add Parrhesia.Local.Client post/inbox/send helpers.
  3. Add embedding documentation.

Phase 4: Lock parity

  1. Add parity tests: WS vs Local API for same inputs and policy outcomes.
  2. Add property tests for query/count equivalence where feasible.

10) Testing requirements

  1. Transport parity tests
    • Same signed event via WS and API => same accepted/message semantics.
  2. Policy parity tests
    • Giftwrap visibility and auth-required behavior identical across WS/API/local.
  3. Auth tests
    • NIP-98 success/failure + account resolver success/failure.
  4. Fanout tests
    • publish via API reaches local stream subscribers and WS subscribers.
  5. 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.