jimbojw / memorelay

In-memory Nostr relay.
Apache License 2.0
0 stars 0 forks source link

Switch to express-style middleware implementation #49

Closed jimbojw closed 1 year ago

jimbojw commented 1 year ago

Currently, there are a few major project components which interact rather directly:

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):

app.use('/user/:id', (req, res, next) => {
  console.log('Request URL:', req.originalUrl)
  next()
}, (req, res, next) => {
  console.log('Request Type:', req.method)
  next()
})

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 the next() 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:

In response to any of these messages, the relay may respond with messages of different types:

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:

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:

/**
 * Middleware to enforce NIP-10.
 * @see https://github.com/nostr-protocol/nips/blob/master/10.md
 */
memorelay.event((event: NostrEvent, client: NostrClient, next: NextFn) => {
  if (event.kind !== Kind.Text) {
    // NIP-10 only cares about Text events.
    next();
    return;
  }

  // Check 'e' tags.
  for (const tag of event.tags) {
    if (tag[0] !== 'e') {
      // In this loop we're only checking 'e' tags.
      continue;
    }

    const [, tagEventId, relayUrl, marker] = tag;

    if (typeof tagEventId !== 'string') {
      client.send('OK', event.id, false, 'invalid: e tag is not a string');
      next('done');
      return;
    }

    if (tagEventId.lenth !== 64) {
      client.send('OK', event.id, false, 'invalid: e tag wrong length');
      next('done');
      return;
    }

    if (relayUrl === undefined) {
      continue;
    }

    if (typeof relayUrl !== 'string') {
      client.send('OK', event.id, false, 'invalid: non-string e tag relay url');
      next('done');
      return;
    }

    if (marker === undefined) {
      continue;
    }

    if (marker !== 'reply' && marker !== 'root' && marker !== 'mention') {
      client.send('OK', event.id, false, 'invalid: unrecogniized marker');
      next('done');
      return;
    }
  }

  // Check 'p' tags.
  for (const tag of event.tags) {
    const [tagName, tagValue] = tag;

    if (tagName !== 'p') {
      // In this loop we're only checking 'p' tags.
      continue;
    }

    if (typeof tagValue !== 'string') {
      client.send('OK', event.id, false, 'invalid: p tag is not a string');
      next('done');
      return;
    }

    if (tagValue.lenth !== 64) {
      client.send('OK', event.id, false, 'invalid: p tag wrong length');
      next('done');
      return;
    }
  }

  // All checks have passed.
  next();
});

In this thought experiment, NostrClient is an object which represents the connected WebSockt. Its send() method serializes a message and pushes the buffer.

The memorelay object is like the express app object. Its methods like event() and req() provide API hooks for middleware to process EVENT and REQ messages respectively.

jimbojw commented 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');
});