docs: Sketch NIF-77 sync and ACLs
This commit is contained in:
397
docs/SYNC.md
Normal file
397
docs/SYNC.md
Normal file
@@ -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: "<remote-server-auth-pubkey>",
|
||||||
|
mode: :req_stream,
|
||||||
|
filters: [
|
||||||
|
%{
|
||||||
|
"kinds" => [5000],
|
||||||
|
"authors" => ["<trusted-node-pubkey-a>", "<trusted-node-pubkey-b>"],
|
||||||
|
"#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: "<pin-a>"},
|
||||||
|
%{type: :spki_sha256, value: "<pin-b>"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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.
|
||||||
@@ -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.
|
This document defines the Parrhesia contract. It does **not** define Tribes or Ash sync behavior.
|
||||||
- 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
|
## 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.*`.
|
### Out of scope
|
||||||
- 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.
|
- 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
|
```text
|
||||||
Transport layer
|
Transport / embedding / background workers
|
||||||
- Parrhesia.Web.Connection (WS)
|
- Parrhesia.Web.Connection
|
||||||
- Parrhesia.Web.Management (HTTP)
|
- Parrhesia.Web.Management
|
||||||
- Parrhesia.Local.* wrappers (in-process)
|
- Parrhesia.Local.*
|
||||||
|
- Parrhesia.Sync.*
|
||||||
|
|
||||||
Shared API layer
|
Shared API
|
||||||
- Parrhesia.API.Auth
|
- Parrhesia.API.Auth
|
||||||
- Parrhesia.API.Events
|
- Parrhesia.API.Events
|
||||||
- Parrhesia.API.Stream (optional)
|
- Parrhesia.API.Stream
|
||||||
- Parrhesia.API.Admin (optional, for management methods)
|
- Parrhesia.API.Admin
|
||||||
|
- Parrhesia.API.Identity
|
||||||
|
- Parrhesia.API.ACL
|
||||||
|
- Parrhesia.API.Sync
|
||||||
|
|
||||||
Domain/runtime dependencies
|
Runtime internals
|
||||||
- Parrhesia.Policy.EventPolicy
|
- Parrhesia.Policy.EventPolicy
|
||||||
- Parrhesia.Storage.* adapters
|
- Parrhesia.Storage.*
|
||||||
- Parrhesia.Groups.Flow
|
- Parrhesia.Groups.Flow
|
||||||
- Parrhesia.Subscriptions.Index
|
- Parrhesia.Subscriptions.Index
|
||||||
- Parrhesia.Fanout.MultiNode
|
- Parrhesia.Fanout.MultiNode
|
||||||
- Parrhesia.Telemetry
|
- 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:
|
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
|
```elixir
|
||||||
@spec validate_event(map()) :: :ok | {:error, term()}
|
@spec validate_event(map()) :: :ok | {:error, term()}
|
||||||
@@ -77,100 +102,65 @@ Proposed functions:
|
|||||||
{:ok, Parrhesia.API.Auth.Context.t()} | {:error, term()}
|
{:ok, Parrhesia.API.Auth.Context.t()} | {:error, term()}
|
||||||
```
|
```
|
||||||
|
|
||||||
`validate_nip98/4` options:
|
### 5.2 `Parrhesia.API.Events`
|
||||||
|
|
||||||
```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`
|
|
||||||
|
|
||||||
Purpose:
|
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
|
```elixir
|
||||||
@spec publish(map(), keyword()) :: {:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()}
|
@spec publish(map(), keyword()) ::
|
||||||
@spec query([map()], keyword()) :: {:ok, [map()]} | {:error, term()}
|
{:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()}
|
||||||
@spec count([map()], keyword()) :: {:ok, non_neg_integer() | map()} | {: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
|
- `:context` - `%Parrhesia.API.RequestContext{}`
|
||||||
defmodule Parrhesia.API.RequestContext do
|
|
||||||
defstruct authenticated_pubkeys: MapSet.new(),
|
|
||||||
actor: nil,
|
|
||||||
metadata: %{}
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish result:
|
`publish/2` must preserve current `EVENT` semantics:
|
||||||
|
|
||||||
```elixir
|
1. size checks,
|
||||||
defmodule Parrhesia.API.Events.PublishResult do
|
2. `Protocol.validate_event/1`,
|
||||||
@enforce_keys [:event_id, :accepted, :message]
|
3. `EventPolicy.authorize_write/2`,
|
||||||
defstruct [:event_id, :accepted, :message]
|
4. group handling,
|
||||||
end
|
5. persistence or control-event path,
|
||||||
```
|
6. local plus multi-node fanout,
|
||||||
|
7. telemetry.
|
||||||
|
|
||||||
### Publish semantics (must match websocket EVENT)
|
Return shape mirrors `OK`:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
{:ok, %PublishResult{event_id: id, accepted: true, message: "ok: event stored"}}
|
{:ok, %PublishResult{event_id: id, accepted: true, message: "ok: event stored"}}
|
||||||
{:ok, %PublishResult{event_id: id, accepted: false, message: "blocked: ..."}}
|
{: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`:
|
### 5.3 `Parrhesia.API.Stream`
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
Purpose:
|
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
|
```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
|
@spec unsubscribe(reference()) :: :ok
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Required options:
|
||||||
|
|
||||||
|
- `:context` - `%Parrhesia.API.RequestContext{}`
|
||||||
|
|
||||||
Subscriber contract:
|
Subscriber contract:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
@@ -179,220 +169,233 @@ Subscriber contract:
|
|||||||
{:parrhesia, :closed, ref, subscription_id, reason}
|
{: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`
|
### 5.4 `Parrhesia.API.Admin`
|
||||||
- `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.
|
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: "<server-auth-pubkey>",
|
||||||
|
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: "<remote-server-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: "<base64-sha256-spki-pin>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
- `EVENT` -> `Parrhesia.API.Events.publish/2`
|
||||||
- `REQ` -> `Parrhesia.API.Events.query/2`
|
- `REQ` -> `Parrhesia.API.Stream.subscribe/4`
|
||||||
- `COUNT` -> `Parrhesia.API.Events.count/2`
|
- `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:
|
### HTTP management
|
||||||
- websocket framing
|
|
||||||
- subscription lifecycle per connection
|
|
||||||
- AUTH challenge rotation protocol frames
|
|
||||||
|
|
||||||
## 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`
|
### Local wrappers
|
||||||
- command execution via `Parrhesia.API.Admin` (or existing storage admin adapter via API facade)
|
|
||||||
|
`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
|
- `Parrhesia.API.Events.publish/2`
|
||||||
@spec publish_post(Parrhesia.API.Auth.Context.t(), String.t(), keyword()) ::
|
- `Parrhesia.API.Events.query/2`
|
||||||
{:ok, Parrhesia.API.Events.PublishResult.t()} | {:error, term()}
|
- `Parrhesia.API.Stream.subscribe/4`
|
||||||
|
- `Parrhesia.API.Sync.*`
|
||||||
|
|
||||||
@spec list_posts(keyword()) :: {:ok, [map()]} | {:error, term()}
|
But they must not move application conflict rules or payload semantics into Parrhesia.
|
||||||
@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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user