diff --git a/docs/SYNC.md b/docs/SYNC.md new file mode 100644 index 0000000..a965ae0 --- /dev/null +++ b/docs/SYNC.md @@ -0,0 +1,397 @@ +# Parrhesia Relay Sync + +## 1. Purpose + +This document defines the Parrhesia proposal for **relay-to-relay event synchronization**. + +It is intentionally transport-focused: + +- manage remote relay peers, +- catch up on matching events, +- keep a live stream open, +- expose health and basic stats. + +It does **not** define application data semantics. + +Parrhesia syncs Nostr events. Callers decide which events matter and how to apply them. + +--- + +## 2. Boundary + +### Parrhesia is responsible for + +- storing and validating events, +- querying and streaming events, +- running outbound sync workers against remote relays, +- tracking peer configuration, worker health, and sync counters, +- exposing peer management through `Parrhesia.API.Sync`. + +### Parrhesia is not responsible for + +- resource mapping, +- trusted node allowlists for an app profile, +- mutation payload validation beyond normal event validation, +- conflict resolution, +- replay winner selection, +- database upsert/delete semantics. + +For Tribes, those remain in `TRIBES-NOSTRSYNC` and `AshNostrSync`. + +--- + +## 3. Security Foundation + +### Default posture + +The baseline posture for sync traffic is: + +- no access to sync events by default, +- no implicit trust from ordinary relay usage, +- no reliance on plaintext confidentiality from public relays. + +For the first implementation, Parrhesia should protect sync data primarily with: + +- authenticated server identities, +- ACL-gated read and write access, +- TLS with certificate pinning for outbound peers. + +### Server identity + +Parrhesia owns a low-level server identity used for relay-to-relay authentication. + +This identity is separate from: + +- TLS endpoint identity, +- application event author pubkeys. + +Recommended model: + +- Parrhesia has one local server-auth pubkey, +- sync peers authenticate as server-auth pubkeys, +- ACL grants are bound to those authenticated server-auth pubkeys, +- application-level writer trust remains outside Parrhesia. + +Identity lifecycle: + +1. use configured/imported key if provided, +2. otherwise use persisted local identity, +3. otherwise generate once during initial startup and persist it. + +Private key export should not be supported. + +### ACLs + +Sync traffic should use a real ACL layer, not moderation allowlists. + +Initial ACL model: + +- principal: authenticated pubkey, +- capabilities: `sync_read`, `sync_write`, +- match: event/filter shape such as `kinds: [5000]` and namespace tags. + +This is enough for now. We do **not** need a separate user ACL model and server ACL model yet. + +A sync peer is simply an authenticated principal with sync capabilities. + +### TLS pinning + +Each outbound sync peer must include pinned TLS material. + +Recommended pin type: + +- SPKI SHA-256 pins + +Multiple pins should be allowed to support certificate rotation. + +--- + +## 4. Sync Model + +Each configured sync server represents one outbound worker managed by Parrhesia. + +Minimum behavior: + +1. connect to the remote relay, +2. run an initial catch-up query for the configured filters, +3. ingest received events into the local relay through the normal API path, +4. switch to a live subscription for the same filters, +5. reconnect with backoff when disconnected. + +The worker treats filters as opaque Nostr filters. It does not interpret app payloads. + +### Initial implementation mode + +Initial implementation should use ordinary NIP-01 behavior: + +- catch-up via `REQ`-style query, +- live updates via `REQ` subscription. + +This is enough for Tribes and keeps the first version simple. + +### NIP-77 + +NIP-77 is **not required** for the first sync implementation. + +Reason: + +- Parrhesia currently only has `NEG-*` session tracking, not real negentropy reconciliation. +- The current Tribes sync profile already assumes catch-up plus live replay, not negentropy. + +NIP-77 should be treated as a later optimization for bandwidth-efficient reconciliation once Parrhesia has a real reusable implementation. + +--- + +## 5. API Surface + +Primary control plane: + +- `Parrhesia.API.Identity.get/1` +- `Parrhesia.API.Identity.ensure/1` +- `Parrhesia.API.Identity.import/2` +- `Parrhesia.API.Identity.rotate/1` +- `Parrhesia.API.ACL.grant/2` +- `Parrhesia.API.ACL.revoke/2` +- `Parrhesia.API.ACL.list/1` +- `Parrhesia.API.Sync.put_server/2` +- `Parrhesia.API.Sync.remove_server/2` +- `Parrhesia.API.Sync.get_server/2` +- `Parrhesia.API.Sync.list_servers/1` +- `Parrhesia.API.Sync.start_server/2` +- `Parrhesia.API.Sync.stop_server/2` +- `Parrhesia.API.Sync.sync_now/2` +- `Parrhesia.API.Sync.server_stats/2` +- `Parrhesia.API.Sync.sync_stats/1` +- `Parrhesia.API.Sync.sync_health/1` + +These APIs are in-process. HTTP management may expose them through `Parrhesia.API.Admin` or direct routing to `Parrhesia.API.Sync`. + +--- + +## 6. Server Specification + +`put_server/2` is an upsert. + +Suggested server shape: + +```elixir +%{ + id: "tribes-primary", + url: "wss://relay-a.example/relay", + enabled?: true, + auth_pubkey: "", + mode: :req_stream, + filters: [ + %{ + "kinds" => [5000], + "authors" => ["", ""], + "#r" => ["tribes.accounts.user", "tribes.chat.tribe"] + } + ], + overlap_window_seconds: 300, + auth: %{ + type: :nip42 + }, + tls: %{ + mode: :required, + hostname: "relay-a.example", + pins: [ + %{type: :spki_sha256, value: ""}, + %{type: :spki_sha256, value: ""} + ] + }, + metadata: %{} +} +``` + +Required fields: + +- `id` +- `url` +- `auth_pubkey` +- `filters` +- `tls` + +Recommended fields: + +- `enabled?` +- `mode` +- `overlap_window_seconds` +- `auth` +- `metadata` + +Rules: + +- `id` must be stable and unique locally. +- `url` is the remote relay websocket URL. +- `auth_pubkey` is the expected remote server-auth pubkey. +- `filters` must be valid NIP-01 filters. +- filters are owned by the caller; Parrhesia only validates filter shape. +- `mode` defaults to `:req_stream`. +- `tls.mode` defaults to `:required`. +- `tls.pins` must be non-empty for synced peers. + +--- + +## 7. Runtime State + +Each server should have both configuration and runtime status. + +Suggested runtime fields: + +```elixir +%{ + server_id: "tribes-primary", + state: :running, + connected?: true, + last_connected_at: ~U[2026-03-16 10:00:00Z], + last_disconnected_at: nil, + last_sync_started_at: ~U[2026-03-16 10:00:00Z], + last_sync_completed_at: ~U[2026-03-16 10:00:02Z], + last_event_received_at: ~U[2026-03-16 10:12:45Z], + last_eose_at: ~U[2026-03-16 10:00:02Z], + reconnect_attempts: 0, + last_error: nil +} +``` + +Parrhesia should keep this state generic. It is about relay sync health, not app state convergence. + +--- + +## 8. Stats and Health + +### Per-server stats + +`server_stats/2` should return basic counters such as: + +- `events_received` +- `events_accepted` +- `events_duplicate` +- `events_rejected` +- `query_runs` +- `subscription_restarts` +- `reconnects` +- `last_remote_eose_at` +- `last_error` + +### Aggregate sync stats + +`sync_stats/1` should summarize: + +- total configured servers, +- enabled servers, +- running servers, +- connected servers, +- aggregate event counters, +- aggregate reconnect count. + +### Health + +`sync_health/1` should be operator-oriented, for example: + +```elixir +%{ + "status" => "degraded", + "servers_total" => 3, + "servers_connected" => 2, + "servers_failing" => [ + %{"id" => "tribes-secondary", "reason" => "connection_refused"} + ] +} +``` + +This is intentionally simple. It should answer “is sync working?” without pretending to prove application convergence. + +--- + +## 9. Event Ingest Path + +Events received from a remote sync worker should enter Parrhesia through the same ingest path as any other accepted event. + +That means: + +1. validate the event, +2. run normal write policy, +3. persist or reject, +4. fan out locally, +5. rely on duplicate-event behavior for idempotency. + +This avoids a second ingest path with divergent behavior. + +Before normal event acceptance, the sync worker should enforce: + +1. pinned TLS validation for the remote endpoint, +2. remote server-auth identity match, +3. local ACL grant permitting the peer to perform sync reads and/or writes. + +The sync worker may attach request-context metadata such as: + +```elixir +%Parrhesia.API.RequestContext{ + caller: :sync, + metadata: %{sync_server_id: "tribes-primary"} +} +``` + +That metadata is for telemetry and audit only. It must not become app sync semantics. + +--- + +## 10. Persistence + +Parrhesia should persist enough sync control-plane state to survive restart: + +- local server identity reference, +- configured ACL rules for sync principals, +- configured servers, +- whether a server is enabled, +- optional catch-up cursor or watermark per server, +- basic last-error and last-success markers. + +Parrhesia does not need to persist application replay heads or winner state. That remains in the embedding application. + +--- + +## 11. Relationship to Current Features + +### BEAM cluster fanout + +`Parrhesia.Fanout.MultiNode` is a separate feature. + +It provides best-effort live fanout between connected BEAM nodes. It is not remote relay sync and is not a substitute for `Parrhesia.API.Sync`. + +### Management stats + +Current admin `stats` is relay-global and minimal. + +Sync adds a new dimension: + +- peer config, +- worker state, +- per-peer counters, +- sync health summary. + +That should be exposed without coupling it to app-specific sync semantics. + +--- + +## 12. Tribes Usage + +For Tribes, `AshNostrSync` should be able to: + +1. rely on Parrhesia’s local server identity, +2. register one or more remote relays with `Parrhesia.API.Sync.put_server/2`, +3. grant sync ACLs for trusted server-auth pubkeys, +4. provide narrow Nostr filters for `kind: 5000`, +5. observe sync health and counters, +6. consume events via the normal local Parrhesia ingest/query/stream surface. + +Tribes should not need Parrhesia to know: + +- what a resource namespace means, +- which node pubkeys are trusted for Tribes, +- how to resolve conflicts, +- how to apply an upsert or delete. + +That is the key boundary. diff --git a/docs/slop/LOCAL_API.md b/docs/slop/LOCAL_API.md index d245d06..e514384 100644 --- a/docs/slop/LOCAL_API.md +++ b/docs/slop/LOCAL_API.md @@ -1,70 +1,95 @@ -# Parrhesia Shared API + Local API Design (Option 1) +# Parrhesia Shared API -## 1) Goal +## 1. Goal -Expose a stable in-process API for embedding apps **and** refactor server transports to consume the same API. +Expose a stable in-process API that: -Desired end state: +- is used by WebSocket, HTTP management, local callers, and sync workers, +- keeps protocol and storage behavior in one place, +- stays neutral about application-level replication semantics. -- 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. +This document defines the Parrhesia contract. It does **not** define Tribes or Ash sync behavior. --- -## 2) Key architectural decision +## 2. Scope -Previous direction: `Parrhesia.Local.*` as primary public API. +### In scope -Updated direction (this doc): +- event ingest/query/count parity with WebSocket behavior, +- local subscription APIs, +- NIP-98 validation helpers, +- management/admin helpers, +- remote relay sync worker control and health reporting. -- 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.*`. +### Out of scope -This ensures no divergence between local embedding behavior and websocket behavior. +- resource registration, +- trusted app writers, +- mutation payload semantics, +- conflict resolution, +- replay winner selection, +- Ash action mapping. + +Those belong in app profiles such as `TRIBES-NOSTRSYNC`, not in Parrhesia. --- -## 3) Layered design +## 3. Layering ```text -Transport layer - - Parrhesia.Web.Connection (WS) - - Parrhesia.Web.Management (HTTP) - - Parrhesia.Local.* wrappers (in-process) +Transport / embedding / background workers + - Parrhesia.Web.Connection + - Parrhesia.Web.Management + - Parrhesia.Local.* + - Parrhesia.Sync.* -Shared API layer +Shared API - Parrhesia.API.Auth - Parrhesia.API.Events - - Parrhesia.API.Stream (optional) - - Parrhesia.API.Admin (optional, for management methods) + - Parrhesia.API.Stream + - Parrhesia.API.Admin + - Parrhesia.API.Identity + - Parrhesia.API.ACL + - Parrhesia.API.Sync -Domain/runtime dependencies +Runtime internals - Parrhesia.Policy.EventPolicy - - Parrhesia.Storage.* adapters + - Parrhesia.Storage.* - Parrhesia.Groups.Flow - Parrhesia.Subscriptions.Index - Parrhesia.Fanout.MultiNode - Parrhesia.Telemetry ``` -Rule: all ingest/query/count decisions happen in `Parrhesia.API.Events`. +Rule: transport framing stays at the edge. Business decisions happen in `Parrhesia.API.*`. --- -## 4) Public module plan +## 4. Core Context -## 4.1 `Parrhesia.API.Auth` +```elixir +defmodule Parrhesia.API.RequestContext do + defstruct authenticated_pubkeys: MapSet.new(), + actor: nil, + caller: :local, + metadata: %{} +end +``` + +`caller` is for telemetry and policy parity, for example `:websocket`, `:http`, `:local`, or `:sync`. + +--- + +## 5. Public Modules + +### 5.1 `Parrhesia.API.Auth` Purpose: -- event validation helpers -- NIP-98 verification -- optional embedding account resolution hook -Proposed functions: +- event validation helpers, +- NIP-98 verification, +- optional embedding account resolution. ```elixir @spec validate_event(map()) :: :ok | {:error, term()} @@ -77,100 +102,65 @@ Proposed functions: {:ok, Parrhesia.API.Auth.Context.t()} | {:error, term()} ``` -`validate_nip98/4` options: - -```elixir -account_resolver: (pubkey_hex :: String.t(), auth_event :: map() -> - {:ok, account :: term()} | {:error, term()}) -``` - -Context struct: - -```elixir -defmodule Parrhesia.API.Auth.Context do - @enforce_keys [:pubkey, :auth_event] - defstruct [:pubkey, :auth_event, :account, claims: %{}] -end -``` - ---- - -## 4.2 `Parrhesia.API.Events` +### 5.2 `Parrhesia.API.Events` Purpose: -- canonical ingress/query/count API used by WS + local + HTTP integrations. -Proposed functions: +- canonical ingest/query/count path used by WS, HTTP, local callers, and sync workers. ```elixir -@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()} +@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: +Required options: -```elixir -defmodule Parrhesia.API.RequestContext do - defstruct authenticated_pubkeys: MapSet.new(), - actor: nil, - metadata: %{} -end -``` +- `:context` - `%Parrhesia.API.RequestContext{}` -Publish result: +`publish/2` must preserve current `EVENT` semantics: -```elixir -defmodule Parrhesia.API.Events.PublishResult do - @enforce_keys [:event_id, :accepted, :message] - defstruct [:event_id, :accepted, :message] -end -``` +1. size checks, +2. `Protocol.validate_event/1`, +3. `EventPolicy.authorize_write/2`, +4. group handling, +5. persistence or control-event path, +6. local plus multi-node fanout, +7. telemetry. -### 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: +Return shape mirrors `OK`: ```elixir {: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` must preserve current `REQ` and `COUNT` behavior, including giftwrap restrictions and server-side filter validation. -`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. - ---- - -## 4.3 `Parrhesia.API.Stream` (optional but recommended) +### 5.3 `Parrhesia.API.Stream` Purpose: -- local in-process subscriptions using same subscription index/fanout model. -Proposed functions: +- in-process subscription surface with the same semantics as a WebSocket `REQ`. + +This is **required** for embedding and sync consumers. ```elixir -@spec subscribe(pid(), String.t(), [map()], keyword()) :: {:ok, reference()} | {:error, term()} +@spec subscribe(pid(), String.t(), [map()], keyword()) :: + {:ok, reference()} | {:error, term()} + @spec unsubscribe(reference()) :: :ok ``` +Required options: + +- `:context` - `%Parrhesia.API.RequestContext{}` + Subscriber contract: ```elixir @@ -179,220 +169,233 @@ Subscriber contract: {:parrhesia, :closed, ref, subscription_id, reason} ``` ---- +`subscribe/4` must: -## 4.4 `Parrhesia.Local.*` wrappers +1. validate filters, +2. apply read policy, +3. emit initial catch-up events in the same order as `REQ`, +4. emit exactly one `:eose`, +5. register for live fanout until `unsubscribe/1`. -`Parrhesia.Local.*` remain as convenience API for embedding apps, implemented as thin wrappers: +This module does **not** know why a caller wants the stream. -- `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) +### 5.4 `Parrhesia.API.Admin` -No business logic in wrappers. +Purpose: + +- stable in-process facade for management operations already exposed over HTTP. + +```elixir +@spec execute(String.t() | atom(), map(), keyword()) :: {:ok, map()} | {:error, term()} +@spec stats(keyword()) :: {:ok, map()} | {:error, term()} +@spec health(keyword()) :: {:ok, map()} | {:error, term()} +@spec list_audit_logs(keyword()) :: {:ok, [map()]} | {:error, term()} +``` + +Baseline methods: + +- `ping` +- `stats` +- `health` +- moderation methods already supported by the storage admin adapter + +`stats/1` is relay-level and cheap. `health/1` is liveness/readiness-oriented and may include worker state. + +`API.Admin` is the operator-facing umbrella for management. It may delegate domain-specific work to `API.Identity`, `API.ACL`, and `API.Sync`. + +### 5.5 `Parrhesia.API.Identity` + +Purpose: + +- manage Parrhesia-owned server identity, +- expose public identity metadata, +- support explicit import and rotation, +- keep private key material internal. + +Parrhesia owns a low-level server identity used for relay-to-relay auth and other transport-local security features. + +```elixir +@spec get(keyword()) :: {:ok, map()} | {:error, term()} +@spec ensure(keyword()) :: {:ok, map()} | {:error, term()} +@spec import(map(), keyword()) :: {:ok, map()} | {:error, term()} +@spec rotate(keyword()) :: {:ok, map()} | {:error, term()} +@spec sign_event(map(), keyword()) :: {:ok, map()} | {:error, term()} +``` + +Rules: + +- private key material must never be returned by API, +- production deployments should be able to import a configured key, +- local/dev deployments may generate on first init if none exists, +- identity creation should be eager and deterministic, not lazy on first sync use. + +Recommended boot order: + +1. configured/imported key, +2. persisted local identity, +3. generate once and persist. + +### 5.6 `Parrhesia.API.ACL` + +Purpose: + +- enforce event/filter ACLs for authenticated principals, +- support default-deny sync visibility, +- allow dynamic grants for trusted sync peers. + +This is a real authorization layer, not a reuse of moderation allowlists. + +```elixir +@spec grant(map(), keyword()) :: :ok | {:error, term()} +@spec revoke(map(), keyword()) :: :ok | {:error, term()} +@spec list(keyword()) :: {:ok, [map()]} | {:error, term()} +@spec check(atom(), map(), keyword()) :: :ok | {:error, term()} +``` + +Suggested rule shape: + +```elixir +%{ + principal_type: :pubkey, + principal: "", + capability: :sync_read, + match: %{ + "kinds" => [5000], + "#r" => ["tribes.accounts.user", "tribes.chat.tribe"] + } +} +``` + +For the first implementation, principals should be authenticated pubkeys only. + +We do **not** need a separate user-vs-server ACL model yet. A sync peer is simply a principal with sync capabilities. + +Initial required capabilities: + +- `:sync_read` +- `:sync_write` + +Recommended baseline: + +- ordinary events follow existing relay behavior, +- sync traffic is default-deny, +- access is lifted only by explicit ACL grants for authenticated server pubkeys. + +### 5.7 `Parrhesia.API.Sync` + +Purpose: + +- manage remote relay sync workers without embedding app-specific replication semantics. + +Parrhesia syncs **events**, not records. + +```elixir +@spec put_server(map(), keyword()) :: + {:ok, Parrhesia.API.Sync.Server.t()} | {:error, term()} + +@spec remove_server(String.t(), keyword()) :: :ok | {:error, term()} +@spec get_server(String.t(), keyword()) :: + {:ok, Parrhesia.API.Sync.Server.t()} | :error | {:error, term()} + +@spec list_servers(keyword()) :: + {:ok, [Parrhesia.API.Sync.Server.t()]} | {:error, term()} + +@spec start_server(String.t(), keyword()) :: :ok | {:error, term()} +@spec stop_server(String.t(), keyword()) :: :ok | {:error, term()} +@spec sync_now(String.t(), keyword()) :: :ok | {:error, term()} + +@spec server_stats(String.t(), keyword()) :: + {:ok, map()} | :error | {:error, term()} + +@spec sync_stats(keyword()) :: {:ok, map()} | {:error, term()} +@spec sync_health(keyword()) :: {:ok, map()} | {:error, term()} +``` + +`put_server/2` is upsert-style. It covers both add and update. + +Minimum server shape: + +```elixir +%{ + id: "tribes-a", + url: "wss://relay-a.example/relay", + enabled?: true, + auth_pubkey: "", + filters: [%{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}], + mode: :req_stream, + auth: %{type: :nip42}, + tls: %{ + mode: :required, + hostname: "relay-a.example", + pins: [ + %{type: :spki_sha256, value: ""} + ] + } +} +``` + +Important constraints: + +- filters are caller-provided and opaque to Parrhesia, +- Parrhesia does not inspect `kind: 5000` payload semantics, +- Parrhesia may persist peer config and runtime counters, +- Parrhesia may reconnect and resume catch-up using generic event cursors, +- Parrhesia must expose worker health and basic counters, +- remote relay TLS pinning is required, +- sync peer auth is bound to a server-auth pubkey, not inferred from event author pubkeys. + +Server identity model: + +- Parrhesia owns its local server-auth identity via `API.Identity`, +- peer config declares the expected remote server-auth pubkey, +- ACL grants are bound to authenticated server-auth pubkeys, +- event author pubkeys remain a separate application concern. + +Initial mode should be `:req_stream`: + +1. run catch-up with `API.Events.query/2`-equivalent client behavior against the remote relay, +2. switch to a live subscription, +3. ingest received events through local `API.Events.publish/2`. + +Future optimization: + +- `:negentropy` may be added when real NIP-77 reconciliation exists. +- It is not required for the first implementation. --- -## 5) Server integration plan (critical) +## 6. Server Integration -## 5.1 WebSocket (`Parrhesia.Web.Connection`) +### WebSocket -After decode: - `EVENT` -> `Parrhesia.API.Events.publish/2` -- `REQ` -> `Parrhesia.API.Events.query/2` +- `REQ` -> `Parrhesia.API.Stream.subscribe/4` - `COUNT` -> `Parrhesia.API.Events.count/2` -- `AUTH` keep transport-specific challenge/session flow, but can use `API.Auth.validate_event/1` internally +- `AUTH` stays connection-specific, but validation helpers may move to `API.Auth` +- `NEG-*` remains transport-specific until Parrhesia has a real reusable NIP-77 engine -WebSocket keeps responsibility for: -- websocket framing -- subscription lifecycle per connection -- AUTH challenge rotation protocol frames +### HTTP management -## 5.2 HTTP management (`Parrhesia.Web.Management`) +- NIP-98 validation via `Parrhesia.API.Auth.validate_nip98/3` +- management methods via `Parrhesia.API.Admin` +- sync peer CRUD and health endpoints may delegate to `Parrhesia.API.Sync` +- identity and ACL management may delegate to `API.Identity` and `API.ACL` -- 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) +### Local wrappers + +`Parrhesia.Local.*` remain thin delegates over `Parrhesia.API.*`. --- -## 6) High-level client helpers for embedding app use case +## 7. Relationship to Sync Profiles -These helpers are optional and live in `Parrhesia.Local.Client`. +This document is intentionally lower-level than `TRIBES-NOSTRSYNC` and `SYNC_DB.md`. -## 6.1 Public posts +Those documents may require: -```elixir -@spec publish_post(Parrhesia.API.Auth.Context.t(), String.t(), keyword()) :: - {:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()} +- `Parrhesia.API.Events.publish/2` +- `Parrhesia.API.Events.query/2` +- `Parrhesia.API.Stream.subscribe/4` +- `Parrhesia.API.Sync.*` -@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: - -```elixir -(unsigned_event_map -> {:ok, signed_event_map} | {:error, term()}) -``` - -Parrhesia does not store or manage private keys. - -## 6.2 Private messages (giftwrap kind 1059) - -```elixir -@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 - -```elixir -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 - -```elixir -Parrhesia.Local.Client.publish_post(auth, "hello", signer: &MyApp.NostrSigner.sign/1) -``` - -### 12.3 Private message - -```elixir -Parrhesia.Local.Client.send_private_message( - auth, - recipient_pubkey, - encrypted_payload, - signer: &MyApp.NostrSigner.sign/1 -) -``` - -### 12.4 Inbox - -```elixir -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. +But they must not move application conflict rules or payload semantics into Parrhesia.