Closed jimbojw closed 1 year ago
Upon further consideration, the middleware callbacks should be able to attach metadata to either the client connection, or the specific message being processed, or both. This would allow later middleware to benefit from earlier middleware computations without having the two have to directly interact.
For example, to implement NIP-42 authentication, the middleware should store the authentication state as metadata for the client. Later, as a REQ
subscription is being processed, different middleware can check for the authentication status while determining whether to send NIP-04 private direct messages.
Potential Metadata
specification:
interface Metadata {
[key: string]: unknown;
}
Storing metadata associated with a client:
/**
* Connected Client.
*/
class Client {
readonly metadata: Metadata = {};
/* ... */
}
Storing metadata associated with a request:
/**
* Incoming message from a connected Client.
*/
class ClientMessage {
readonly metadata: Metadata = {};
constructor (
readonly client: Client,
readonly messageType: ClientMessageType,
readonly messageArgs: unknown[]
) {}
}
Authentication data:
class ClientAuth {
/**
* Last issued, un-responded, serialized kind:22242 event.
*/
challenge?: string;
/**
* Responded challenges, indexed by pubkey.
*/
readonly responses = new Set<string, {
challenge: string;
response: string;
}>();
}
Attaching metadata in middleware:
memorelay.message((message: ClientMessage, next: NextFn) => {
const {client} = message;
if (!client.metadata['auth'])) {
client['auth'] = new ClientAuth();
}
next();
});
Checking metadata later on REQ
:
memorelay.req((message: ClientMessage, next: NextFn) => {
// ... code to next()/return if no kind=4 in any filters ...
const {client} = message;
const auth = assertDefined<ClientAuth>(client.metadata['auth']);
if (auth.responses.size) {
// An authentication challenge has already been responded to. Continue.
next();
return;
}
if (auth.challenge) {
// There's an outstanding, unanswered challenge.
// Possibly re-send AUTH challenge, or send NOTICE.
// DO NOT set up listener.
next('done');
return;
}
// Generate challenge, send AUTH challenge message.
next('done');
});
Currently, there are a few major project components which interact rather directly:
src/bin.ts
- Command line entry point. Parses args and instantiates aMemorelayServer
instance.src/lib/memorelay-server.ts
- DefinesMemorelayServer
class which can open a port for listening, responds to requests for the NIP-11 relay information document, and createsSubscriber
instances for upgraded WebSocket connections. It also maintains a sharedMemorelayCoordinator
instance.src/lib/subscriber.ts
- Defines theSubscriber
class, which processes incoming WebSocket client messages. Specifically, it handlesEVENT
,REQ
andCLOSE
events. For a validREQ
request, aSubscriber
adds a listener to the sharedMemorelayCoordinator
instance which will sendEVENT
messages. In addition toEVENT
messages,Subscriber
sendsNOTICE
,EOSE
, andOK
messages when appropriate.src/lib/memorelay-coordinator.ts
- TheMemorelayCoordinator
is where the actual Nostr events are stored. It notifies listeners by invoking callbacks when new events arrive.While this direct invocation strategy worked for an initial implementation, it is hard to scale and makes reasoning about behavior difficult. Instead, the memorelay project should adopt an architecture similar to express middleware.
Here's a typical example of express middleware (source):
This example shows how an incoming request (
req
) which matches the path/user/:id
first has its URL logged, and then its request method. Each of the callbacks in this example are synchronous, but they could just as well have been asynchronous, invoking thenext()
function at some future time.A Nostr relay has similar, chainable considerations. When a client WebSocket is connected, in communicates with the relay by sending messages. These messages have any of the following forms:
["EVENT",{...}]
- Client is publishing a signed Nostr event.["REQ","<id>",{...}...]
- Client is requesting events, optionally matching the supplied filters.["CLOSE","<id>",{...}...]
- Client is closing its previousREQ
subscription.In response to any of these messages, the relay may respond with messages of different types:
["EVENT","<id>",{...}]
- Relay is providing a Nostr event matching aREQ
subscription.["EOSE","<id>"]
- Relay signaling that all stored events matching theREQ
filters have been sent.["NOTICE","<description>"]
- Relay providing general information to the client.["OK","<event_id>",true|false,"<descsription>"]
- Relay is signaling whether the client's previousEVENT
message was stored (NIP-20).In order to perform this task, the relay has to do a number of things:
On top of this basic procedure, NIPs introduce still more steps:
kind=5
).e
orp
tags.#e
and#p
.10000<=n<20000
).created_at
value is out of range.authors
field.["AUTH",...]
messages.["COUNT",...]
message queries.search
queries inREQ
filters.To facilitate implementing these features, as well as future features, memorelay should pursue an express-like middleware architecture.
Here's a non-binding example for thought:
In this thought experiment,
NostrClient
is an object which represents the connected WebSockt. Itssend()
method serializes a message and pushes the buffer.The
memorelay
object is like the expressapp
object. Its methods likeevent()
andreq()
provide API hooks for middleware to processEVENT
andREQ
messages respectively.