From 8075f3ebb75f6affb82c503523bc924168ccfad1 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Wed, 8 Mar 2023 18:26:14 +0100 Subject: [PATCH 01/21] vastly simplify the api --- packages/nostr/src/client/conn.ts | 34 +- packages/nostr/src/client/emitter.ts | 5 +- packages/nostr/src/client/index.ts | 87 +--- packages/nostr/src/crypto.ts | 171 +++----- packages/nostr/src/event.ts | 457 ++++++++------------ packages/nostr/src/util.ts | 7 +- packages/nostr/test/simple-communication.ts | 46 +- 7 files changed, 316 insertions(+), 491 deletions(-) diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index e35ff618..766037e1 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,6 +1,6 @@ import { ProtocolError } from "../error" import { Filters, SubscriptionId } from "." -import { EventId, RawEvent, SignedEvent } from "../event" +import { EventId, RawEvent } from "../event" import WebSocket from "ws" import { unixTimestamp } from "../util" @@ -116,11 +116,11 @@ export class Conn { `second element of "EVENT" should be an object, but wasn't: ${data}` ) } - const raw = parseEventData(json[2]) + const event = parseEventData(json[2]) return { kind: "event", - subscriptionId: new SubscriptionId(json[1]), - raw, + subscriptionId: json[1], + event, } } if (json[0] === "NOTICE") { @@ -152,7 +152,7 @@ export class Conn { } return { kind: "ok", - eventId: new EventId(json[1]), + eventId: json[1], ok: json[2], message: json[3], } @@ -174,7 +174,7 @@ export type IncomingKind = "event" | "notice" | "ok" export interface IncomingEvent { kind: "event" subscriptionId: SubscriptionId - raw: RawEvent + event: RawEvent } /** @@ -210,7 +210,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription" */ export interface OutgoingEvent { kind: "event" - event: SignedEvent | RawEvent + event: RawEvent } /** @@ -243,9 +243,7 @@ interface RawFilters { function serializeOutgoingMessage(msg: OutgoingMessage): string { if (msg.kind === "event") { - const raw = - msg.event instanceof SignedEvent ? msg.event.serialize() : msg.event - return JSON.stringify(["EVENT", raw]) + return JSON.stringify(["EVENT", msg.event]) } else if (msg.kind === "openSubscription") { return JSON.stringify([ "REQ", @@ -264,13 +262,15 @@ function serializeFilters(filters: Filters[]): RawFilters[] { return [{}] } return filters.map((filter) => ({ - ids: filter.ids?.map((id) => id.toHex()), - authors: filter.authors?.map((author) => author), - kinds: filter.kinds?.map((kind) => kind), - ["#e"]: filter.eventTags?.map((e) => e.toHex()), - ["#p"]: filter.pubkeyTags?.map((p) => p.toHex()), - since: filter.since !== undefined ? unixTimestamp(filter.since) : undefined, - until: filter.until !== undefined ? unixTimestamp(filter.until) : undefined, + ids: filter.ids, + authors: filter.authors, + kinds: filter.kinds, + ["#e"]: filter.eventTags, + ["#p"]: filter.pubkeyTags, + since: + filter.since instanceof Date ? unixTimestamp(filter.since) : filter.since, + until: + filter.until instanceof Date ? unixTimestamp(filter.until) : filter.until, limit: filter.limit, })) } diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 2c9eb78c..d3d646dc 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -1,6 +1,6 @@ import Base from "events" import { Nostr, SubscriptionId } from "." -import { EventId, RawEvent, SignedEvent } from "../event" +import { Event, EventId } from "../event" /** * Overrides providing better types for EventEmitter methods. @@ -166,9 +166,8 @@ type Listener = // TODO Document this export interface EventParams { - signed: SignedEvent + event: Event subscriptionId: SubscriptionId - raw: RawEvent } // TODO Document this diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 34af5e12..0fc116b2 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,10 +1,9 @@ import { ProtocolError } from "../error" -import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event" -import { PrivateKey, PublicKey } from "../crypto" +import { EventId, EventKind, RawEvent, parseEvent } from "../event" +import { PublicKey } from "../crypto" import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" -import { defined } from "../util" /** * A nostr client. @@ -21,17 +20,7 @@ export class Nostr extends EventEmitter { /** * Mapping of subscription IDs to corresponding filters. */ - readonly #subscriptions: Map = new Map() - - /** - * Optional client private key. - */ - readonly #key?: PrivateKey - - constructor(key?: PrivateKey) { - super() - this.#key = key - } + readonly #subscriptions: Map = new Map() /** * Open a connection and start communicating with a relay. This method recreates all existing @@ -69,9 +58,8 @@ export class Nostr extends EventEmitter { this.emit( "event", { - signed: await SignedEvent.verify(msg.raw, this.#key), + event: await parseEvent(msg.event), subscriptionId: msg.subscriptionId, - raw: msg.raw, }, this ) @@ -101,10 +89,9 @@ export class Nostr extends EventEmitter { // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { - const subscriptionId = new SubscriptionId(key) conn.send({ kind: "openSubscription", - id: subscriptionId, + id: key, filters, }) } @@ -159,9 +146,9 @@ export class Nostr extends EventEmitter { */ subscribe( filters: Filters[], - subscriptionId: SubscriptionId = SubscriptionId.random() + subscriptionId: SubscriptionId = randomSubscriptionId() ): SubscriptionId { - this.#subscriptions.set(subscriptionId.toString(), filters) + this.#subscriptions.set(subscriptionId, filters) for (const { conn, read } of this.#conns.values()) { if (!read) { continue @@ -181,7 +168,7 @@ export class Nostr extends EventEmitter { * TODO Reference subscribed() */ async unsubscribe(subscriptionId: SubscriptionId): Promise { - if (!this.#subscriptions.delete(subscriptionId.toString())) { + if (!this.#subscriptions.delete(subscriptionId)) { throw new Error(`subscription ${subscriptionId} does not exist`) } for (const { conn, read } of this.#conns.values()) { @@ -198,39 +185,11 @@ export class Nostr extends EventEmitter { /** * Publish an event. */ - async publish(event: SignedEvent): Promise - async publish(event: RawEvent): Promise - // TODO This will need to change when I add NIP-44 AUTH support - the key should be optional - async publish(event: Event, key: PrivateKey): Promise - async publish( - event: SignedEvent | RawEvent | Event, - key?: PrivateKey - ): Promise { - // Validate the parameters. - if (event instanceof SignedEvent || "sig" in event) { - if (key !== undefined) { - throw new Error( - "when calling publish with a SignedEvent, private key should not be specified" - ) - } - } else { - if (key === undefined) { - throw new Error( - "publish called with an unsigned Event, private key must be specified" - ) - } - if (event.pubkey.toHex() !== key.pubkey.toHex()) { - throw new Error("invalid private key") - } - } - + async publish(event: RawEvent): Promise { for (const { conn, write } of this.#conns.values()) { if (!write) { continue } - if (!(event instanceof SignedEvent) && !("sig" in event)) { - event = await SignedEvent.sign(event, defined(key)) - } conn.send({ kind: "event", event, @@ -258,21 +217,7 @@ interface ConnState { /** * A string uniquely identifying a client subscription. */ -export class SubscriptionId { - #id: string - - constructor(subscriptionId: string) { - this.#id = subscriptionId - } - - static random(): SubscriptionId { - return new SubscriptionId(secp.utils.bytesToHex(secp.utils.randomBytes(32))) - } - - toString() { - return this.#id - } -} +export type SubscriptionId = string /** * Subscription filters. All filters from the fields must pass for a message to get through. @@ -283,14 +228,18 @@ export interface Filters { authors?: string[] kinds?: EventKind[] /** - * Filters for the "#e" tags. + * Filters for the "e" tags. */ eventTags?: EventId[] /** - * Filters for the "#p" tags. + * Filters for the "p" tags. */ pubkeyTags?: PublicKey[] - since?: Date - until?: Date + since?: Date | number + until?: Date | number limit?: number } + +function randomSubscriptionId(): SubscriptionId { + return secp.utils.bytesToHex(secp.utils.randomBytes(32)) +} diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index 8a4fa8c0..493db919 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -1,5 +1,4 @@ import * as secp from "@noble/secp256k1" -import { ProtocolError } from "./error" import base64 from "base64-js" import { bech32 } from "bech32" @@ -8,92 +7,84 @@ import { bech32 } from "bech32" // TODO Or maybe always store Uint8Array and properly use the format parameter passed into toString /** - * A 32-byte secp256k1 public key. + * A lowercase hex string. */ -export class PublicKey { - #hex: Hex +export type Hex = string - /** - * Expects the key encoded as an npub-prefixed bech32 string, lowercase hex string, or byte buffer. - */ - constructor(key: string | Uint8Array) { - this.#hex = parseKey(key, "npub1") - if (this.#hex.toString().length !== 64) { - throw new ProtocolError(`invalid pubkey: ${key}`) - } - } +/** + * A public key encoded as hex. + */ +export type PublicKey = string - toHex(): string { - return this.#hex.toString() - } +/** + * A private key encoded as hex or bech32 with the "nsec" prefix. + */ +export type HexOrBechPublicKey = string - toString(): string { - return this.toHex() - } +/** + * A private key encoded as hex. + */ +export type PrivateKey = string + +/** + * A private key encoded as hex or bech32 with the "nsec" prefix. + */ +export type HexOrBechPrivateKey = string + +/** + * Get a public key corresponding to a private key. + */ +export function getPublicKey(priv: HexOrBechPrivateKey): PublicKey { + priv = parsePrivateKey(priv) + return toHex(secp.schnorr.getPublicKey(priv)) } /** - * A 32-byte secp256k1 private key. + * Convert the data to lowercase hex. */ -export class PrivateKey { - #hex: Hex - - /** - * Expects the key encoded as an nsec-prefixed bech32 string, lowercase hex string, or byte buffer. - */ - constructor(key: string | Uint8Array) { - this.#hex = parseKey(key, "nsec1") - if (this.#hex.toString().length !== 64) { - throw new ProtocolError(`invalid private key: ${this.#hex}`) - } - } - - get pubkey(): PublicKey { - return new PublicKey(secp.schnorr.getPublicKey(this.#hex.toString())) - } - - /** - * The hex representation of the private key. Use with caution! - */ - toHexDangerous(): string { - return this.#hex.toString() - } - - toString(): string { - return "PrivateKey" - } +function toHex(data: Uint8Array): Hex { + return secp.utils.bytesToHex(data).toLowerCase() } /** - * Parse a public or private key into its hex representation. + * Convert the public key to hex. Accepts a hex or bech32 string with the "npub" prefix. */ -function parseKey(key: string | Uint8Array, bechPrefix: string): Hex { - if (typeof key === "string") { - // If the key is bech32-encoded, decode it. - if (key.startsWith(bechPrefix)) { - const { words } = bech32.decode(key) - const bytes = Uint8Array.from(bech32.fromWords(words)) - return new Hex(bytes) - } - } - return new Hex(key) +export function parsePublicKey(key: HexOrBechPublicKey): PublicKey { + return parseKey(key, "npub") } /** - * Get the SHA256 hash of the data. + * Convert the private key to hex. Accepts a hex or bech32 string with the "nsec" prefix. */ -export async function sha256(data: Uint8Array): Promise { - return await secp.utils.sha256(data) +export function parsePrivateKey(key: HexOrBechPrivateKey): PrivateKey { + return parseKey(key, "nsec") +} + +/** + * Convert a public or private key into its hex representation. + */ +function parseKey(key: string, bechPrefix: string): Hex { + // If the key is bech32-encoded, decode it. + if (key.startsWith(bechPrefix)) { + const { words } = bech32.decode(key) + const bytes = Uint8Array.from(bech32.fromWords(words)) + return toHex(bytes) + } + return key +} + +/** + * Get the SHA256 hash of the data, in hex format. + */ +export async function sha256(data: Uint8Array): Promise { + return toHex(await secp.utils.sha256(data)) } /** * Sign the data using elliptic curve cryptography. */ -export async function schnorrSign( - data: Hex, - key: PrivateKey -): Promise { - return secp.schnorr.sign(data.toString(), key.toHexDangerous()) +export async function schnorrSign(data: Hex, priv: PrivateKey): Promise { + return toHex(await secp.schnorr.sign(data, priv)) } /** @@ -107,20 +98,12 @@ export async function schnorrVerify( return secp.schnorr.verify(sig.toString(), data.toString(), key.toString()) } -interface AesEncryptedBase64 { - data: string - iv: string -} - export async function aesEncryptBase64( sender: PrivateKey, recipient: PublicKey, plaintext: string ): Promise { - const sharedPoint = secp.getSharedSecret( - sender.toHexDangerous(), - "02" + recipient.toHex() - ) + const sharedPoint = secp.getSharedSecret(sender, "02" + recipient) const sharedKey = sharedPoint.slice(2, 33) if (typeof window === "object") { const key = await window.crypto.subtle.importKey( @@ -166,16 +149,12 @@ export async function aesEncryptBase64( } } -// TODO export async function aesDecryptBase64( sender: PublicKey, recipient: PrivateKey, { data, iv }: AesEncryptedBase64 ): Promise { - const sharedPoint = secp.getSharedSecret( - recipient.toHexDangerous(), - "02" + sender.toHex() - ) + const sharedPoint = secp.getSharedSecret(recipient, "02" + sender) const sharedKey = sharedPoint.slice(2, 33) if (typeof window === "object") { // TODO Can copy this from the legacy code @@ -192,33 +171,7 @@ export async function aesDecryptBase64( } } -/** - * A string in lowercase hex. This type is not available to the users of the library. - */ -export class Hex { - #value: string - - /** - * Passing a non-lowercase or non-hex string to the constructor - * results in an error being thrown. - */ - constructor(value: string | Uint8Array) { - if (value instanceof Uint8Array) { - value = secp.utils.bytesToHex(value).toLowerCase() - } - if (value.length % 2 != 0) { - throw new ProtocolError(`invalid lowercase hex string: ${value}`) - } - const valid = "0123456789abcdef" - for (const c of value) { - if (!valid.includes(c)) { - throw new ProtocolError(`invalid lowercase hex string: ${value}`) - } - } - this.#value = value - } - - toString(): string { - return this.#value - } +interface AesEncryptedBase64 { + data: string + iv: string } diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index 4e865f68..abf1bbfd 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -3,14 +3,15 @@ import { PublicKey, PrivateKey, sha256, - Hex, schnorrSign, schnorrVerify, aesDecryptBase64, + getPublicKey, + HexOrBechPrivateKey, + parsePrivateKey, } from "./crypto" import { defined, unixTimestamp } from "./util" -// TODO This file is missing proper documentation // TODO Add remaining event types export enum EventKind { @@ -31,331 +32,255 @@ export enum EventKind { ZapReceipt = 9735, // NIP 57 } -export type Event = - | SetMetadataEvent - | TextNoteEvent - | DirectMessageEvent - | UnknownEvent - -interface EventCommon { +/** + * A nostr event in the format that's sent across the wire. + */ +export interface RawEvent { + id: string pubkey: PublicKey - createdAt: Date + created_at: number + kind: EventKind + tags: string[][] + content: string + sig: string } -// TODO Refactor: the event names don't need to all end with *Event - -export interface SetMetadataEvent extends EventCommon { +interface SetMetadata extends RawEvent { kind: EventKind.SetMetadata - content: UserMetadata + + /** + * Get the user metadata specified in this event. + */ + getUserMetadata(): UserMetadata } +export interface TextNote extends RawEvent { + kind: EventKind.TextNote +} + +interface DirectMessage extends RawEvent { + kind: EventKind.DirectMessage + + /** + * Get the message plaintext, or undefined if this client is not the recipient. + */ + getMessage(recipient: PrivateKey): Promise + /** + * Get the recipient pubkey. + */ + getRecipient(): PublicKey + /** + * Get the event ID of the previous message. + */ + getPrevious(): EventId | undefined +} + +export interface Unknown extends RawEvent { + kind: Exclude< + EventKind, + EventKind.SetMetadata | EventKind.TextNote | EventKind.DirectMessage + > +} + +export type Event = SetMetadata | TextNote | DirectMessage | Unknown + export interface UserMetadata { name: string about: string picture: string } -export interface TextNoteEvent extends EventCommon { - kind: EventKind.TextNote - content: string +/** + * Event ID encoded as hex. + */ +export type EventId = string + +/** + * An unsigned event. + */ +export type Unsigned = Omit< + T, + "id" | "pubkey" | "sig" | "created_at" +> & { + id?: EventId + pubkey?: PublicKey + sig?: string + created_at?: number } -export interface DirectMessageEvent extends EventCommon { - kind: EventKind.DirectMessage - /** - * The plaintext message, or undefined if this client is not the recipient. - */ - message?: string - recipient: PublicKey - previous?: EventId -} - -export interface UnknownEvent extends EventCommon { - kind: Exclude -} - -// TODO Doc comment -export class EventId { - #hex: Hex - - static async create(event: Event | RawEvent): Promise { - // It's not defined whether JSON.stringify produces a string with whitespace stripped. - // Building the JSON string manually as follows ensures that there's no whitespace. - // In hindsight using JSON as a data format for hashing and signing is not the best - // design decision. - if ("id" in event) { - // Raw event. - const serializedTags = `[${event.tags - .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) - .join(",")}]` - const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]` - const hash = await sha256(Uint8Array.from(charCodes(serialized))) - return new EventId(hash) - } else { - // Not a raw event. - const tags = serializeTags(event) - const content = serializeContent(event) - const serializedTags = `[${tags - .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) - .join(",")}]` - const serialized = `[0,"${event.pubkey}",${unixTimestamp( - event.createdAt - )},${event.kind},${serializedTags},"${content}"]` - const hash = await sha256(Uint8Array.from(charCodes(serialized))) - return new EventId(hash) - } - } - - constructor(hex: string | Uint8Array) { - this.#hex = new Hex(hex) - } - - toHex(): string { - return this.#hex.toString() - } - - toString(): string { - return this.toHex() - } +type UnsignedWithPubkey = Omit< + T, + "id" | "sig" | "created_at" +> & { + id?: EventId + sig?: string + created_at?: number } /** - * A signed event. Provides access to the event data, ID, and signature. + * Add the "id," "sig," and "pubkey" fields to the event. Set "created_at" to the current timestamp + * if missing. Return the event. */ -export class SignedEvent { - #event: Readonly - #eventId: EventId - #signature: Hex - - /** - * Sign an event using the specified private key. The private key must match the - * public key from the event. - */ - static async sign(event: Event, key: PrivateKey): Promise { - const id = await EventId.create(event) - const sig = await schnorrSign(new Hex(id.toHex()), key) - return new SignedEvent(event, id, new Hex(sig)) - } - - /** - * Verify the signature of a raw event. Throw a `ProtocolError` if the signature - * is invalid. - */ - static async verify(raw: RawEvent, key?: PrivateKey): Promise { - const id = await EventId.create(raw) - if (id.toHex() !== raw.id) { - throw new ProtocolError(`invalid event id: ${raw.id}, expected ${id}`) - } - const sig = new Hex(raw.sig) - if ( - !(await schnorrVerify( - sig, - new Hex(id.toHex()), - new PublicKey(raw.pubkey) - )) - ) { - throw new ProtocolError(`invalid signature: ${sig}`) - } - return new SignedEvent(await parseEvent(raw, key), id, sig) - } - - private constructor(event: Event, eventId: EventId, signature: Hex) { - this.#event = deepCopy(event) - this.#eventId = eventId - this.#signature = signature - } - - /** - * Event ID. - */ - get eventId(): EventId { - return this.#eventId - } - - /** - * Event data. - */ - get event(): Event { - return deepCopy(this.#event) - } - - /** - * Event signature in hex format. - */ - get signature(): string { - return this.#signature.toString() - } - - /** - * Serialize the event into its raw format. - */ - serialize(): RawEvent { - const { event, eventId: id, signature } = this - const tags = serializeTags(event) - const content = serializeContent(event) - return { - id: id.toHex(), - pubkey: event.pubkey.toHex(), - created_at: unixTimestamp(event.createdAt), - kind: event.kind, - tags, - content, - sig: signature, - } +export async function signEvent( + event: Unsigned, + priv?: HexOrBechPrivateKey +): Promise { + event.created_at ??= unixTimestamp() + if (priv !== undefined) { + priv = parsePrivateKey(priv) + event.pubkey = getPublicKey(priv) + const id = await calculateEventId(event as UnsignedWithPubkey) + event.id = id + event.sig = await schnorrSign(id, priv) + return event as T + } else { + // TODO Try to use NIP-07, otherwise throw + throw new Error("todo") } } -export interface RawEvent { - id: string - pubkey: string - created_at: number - kind: number - tags: string[][] - content: string - sig: string +export function createTextNote(content: string): Unsigned { + return { + kind: EventKind.TextNote, + tags: [], + content, + } +} + +export function createSetMetadata( + content: UserMetadata +): Unsigned { + return { + kind: EventKind.SetMetadata, + tags: [], + content: JSON.stringify(content), + getUserMetadata, + } } /** * Parse an event from its raw format. */ -async function parseEvent( - raw: RawEvent, - key: PrivateKey | undefined -): Promise { - const pubkey = new PublicKey(raw.pubkey) - const createdAt = new Date(raw.created_at * 1000) - const event = { - pubkey, - createdAt, +export async function parseEvent(event: RawEvent): Promise { + // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. + if (event.id !== (await calculateEventId(event))) { + throw new ProtocolError( + `invalid id ${event.id} for event ${JSON.stringify( + event + )}, expected ${await calculateEventId(event)}` + ) + } + if (!schnorrVerify(event.sig, event.id, event.pubkey)) { + throw new ProtocolError( + `invalid signature for event ${JSON.stringify(event)}` + ) } - if (raw.kind === EventKind.SetMetadata) { - const userMetadata = parseJson(raw.content) - if ( - typeof userMetadata["name"] !== "string" || - typeof userMetadata["about"] !== "string" || - typeof userMetadata["picture"] !== "string" - ) { - throw new ProtocolError( - `invalid user metadata ${userMetadata} in ${JSON.stringify(raw)}` - ) - } - return { - ...event, - kind: EventKind.SetMetadata, - content: userMetadata, - } - } - - if (raw.kind === EventKind.TextNote) { + if (event.kind === EventKind.TextNote) { return { ...event, kind: EventKind.TextNote, - content: raw.content, } } - if (raw.kind === EventKind.DirectMessage) { - // Parse the tag identifying the recipient. - const recipientTag = raw.tags.find((tag) => tag[0] === "p") - if (typeof recipientTag?.[1] !== "string") { - throw new ProtocolError( - `expected "p" tag to be of type string, but got ${ - recipientTag?.[1] - } in ${JSON.stringify(raw)}` - ) + if (event.kind === EventKind.SetMetadata) { + return { + ...event, + kind: EventKind.SetMetadata, + getUserMetadata, } - const recipient = new PublicKey(recipientTag[1]) + } - // Parse the tag identifying the optional previous message. - const previousTag = raw.tags.find((tag) => tag[0] === "e") - if (typeof recipientTag[1] !== "string") { - throw new ProtocolError( - `expected "e" tag to be of type string, but got ${ - previousTag?.[1] - } in ${JSON.stringify(raw)}` - ) - } - const previous = new EventId(defined(previousTag?.[1])) - - // Decrypt the message content. - const [data, iv] = raw.content.split("?iv=") - if (data === undefined || iv === undefined) { - throw new ProtocolError(`invalid direct message content ${raw.content}`) - } - let message: string | undefined - if (key?.pubkey?.toHex() === recipient.toHex()) { - message = await aesDecryptBase64(event.pubkey, key, { data, iv }) - } + if (event.kind === EventKind.DirectMessage) { return { ...event, kind: EventKind.DirectMessage, - message, - recipient, - previous, + getMessage, + getRecipient, + getPrevious, } } return { ...event, - kind: raw.kind, + kind: event.kind, } } -function serializeTags(_event: Event): string[][] { - // TODO As I add different event kinds, this will change - return [] +async function calculateEventId( + event: UnsignedWithPubkey +): Promise { + // It's not defined whether JSON.stringify produces a string with whitespace stripped. + // Building the JSON string manually as follows ensures that there's no whitespace. + // In hindsight using JSON as a data format for hashing and signing is not the best + // design decision. + const serializedTags = `[${event.tags + .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) + .join(",")}]` + const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]` + return await sha256(Uint8Array.from(charCodes(serialized))) } -function serializeContent(event: Event): string { - if (event.kind === EventKind.SetMetadata) { - return JSON.stringify(event.content) - } else if (event.kind === EventKind.TextNote) { - return event.content - } else { - return "" +function getUserMetadata(this: Unsigned): UserMetadata { + const userMetadata = parseJson(this.content) + if ( + typeof userMetadata.name !== "string" || + typeof userMetadata.about !== "string" || + typeof userMetadata.picture !== "string" + ) { + throw new ProtocolError( + `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` + ) } + return userMetadata } -/** - * Create a deep copy of the event. - */ -function deepCopy(event: Event): Event { - const common = { - createdAt: structuredClone(event.createdAt), - pubkey: event.pubkey, +async function getMessage( + this: UnsignedWithPubkey, + priv?: PrivateKey +): Promise { + const [data, iv] = this.content.split("?iv=") + if (data === undefined || iv === undefined) { + throw new ProtocolError(`invalid direct message content ${this.content}`) } - if (event.kind === EventKind.SetMetadata) { - return { - kind: EventKind.SetMetadata, - content: { - about: event.content.about, - name: event.content.name, - picture: event.content.picture, - }, - ...common, - } - } else if (event.kind === EventKind.TextNote) { - return { - kind: EventKind.TextNote, - content: event.content, - ...common, - } - } else if (event.kind === EventKind.DirectMessage) { + if (priv === undefined) { + // TODO Try to use NIP-07 throw new Error("todo") - } else { - return { - kind: event.kind, - ...common, - } + } else if (getPublicKey(priv) === this.getRecipient()) { + return await aesDecryptBase64(this.pubkey, priv, { data, iv }) } + return undefined +} + +function getRecipient(this: Unsigned): PublicKey { + const recipientTag = this.tags.find((tag) => tag[0] === "p") + if (typeof recipientTag?.[1] !== "string") { + throw new ProtocolError( + `expected "p" tag to be of type string, but got ${ + recipientTag?.[1] + } in ${JSON.stringify(this)}` + ) + } + return recipientTag[1] +} + +function getPrevious(this: Unsigned): EventId | undefined { + const previousTag = this.tags.find((tag) => tag[0] === "e") + if (typeof previousTag?.[1] !== "string") { + throw new ProtocolError( + `expected "e" tag to be of type string, but got ${ + previousTag?.[1] + } in ${JSON.stringify(this)}` + ) + } + return defined(previousTag?.[1]) } function parseJson(data: string) { try { return JSON.parse(data) } catch (e) { - throw new ProtocolError(`invalid json: ${data}`) + throw new ProtocolError(`invalid json: ${e}: ${data}`) } } diff --git a/packages/nostr/src/util.ts b/packages/nostr/src/util.ts index 0a3a3d76..7cd0fdba 100644 --- a/packages/nostr/src/util.ts +++ b/packages/nostr/src/util.ts @@ -1,10 +1,11 @@ import { ProtocolError } from "./error" /** - * Calculate the unix timestamp (seconds since epoch) of the `Date`. + * Calculate the unix timestamp (seconds since epoch) of the `Date`. If no date is specified, + * return the current unix timestamp. */ -export function unixTimestamp(date: Date): number { - return Math.floor(date.getTime() / 1000) +export function unixTimestamp(date?: Date): number { + return Math.floor((date ?? new Date()).getTime() / 1000) } /** diff --git a/packages/nostr/test/simple-communication.ts b/packages/nostr/test/simple-communication.ts index c7e52cb3..fe41d0ab 100644 --- a/packages/nostr/test/simple-communication.ts +++ b/packages/nostr/test/simple-communication.ts @@ -1,20 +1,20 @@ import { Nostr } from "../src/client" -import { EventKind, SignedEvent } from "../src/event" -import { PrivateKey } from "../src/crypto" +import { createTextNote, EventKind, signEvent } from "../src/event" +import { getPublicKey } from "../src/crypto" import assert from "assert" import { EventParams } from "../src/client/emitter" +import { unixTimestamp } from "../src/util" // TODO Switch out the relay implementation and see if the issue persists // TODO Do on("error", done) for all of these describe("simple communication", function () { - const secret = new PrivateKey( + const secret = "nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh" - ) - const pubkey = secret.pubkey - const timestamp = new Date() + const pubkey = getPublicKey(secret) const note = "hello world" const url = new URL("ws://localhost:12648") + const timestamp = unixTimestamp() const publisher = new Nostr() const subscriber = new Nostr() @@ -30,14 +30,12 @@ describe("simple communication", function () { }) it("publish and receive", function (done) { - function listener({ signed: { event } }: EventParams, nostr: Nostr) { + function listener({ event }: EventParams, nostr: Nostr) { assert.equal(nostr, subscriber) assert.equal(event.kind, EventKind.TextNote) - assert.equal(event.pubkey.toHex(), pubkey.toHex()) - assert.equal(event.createdAt.toString(), timestamp.toString()) - if (event.kind === EventKind.TextNote) { - assert.equal(event.content, note) - } + assert.equal(event.pubkey, pubkey) + assert.equal(event.created_at, timestamp) + assert.equal(event.content, note) // There is a bug with the nostr relay used for testing where if the publish and // subscribe happen at the same time, the same event might end up being broadcast twice. @@ -48,32 +46,32 @@ describe("simple communication", function () { done() } + // TODO do this once EOSE is implemented + //subscriber.on("error", done) + //publisher.on("error", done) + subscriber.on("event", listener) subscriber.subscribe([]) - publisher.publish( + signEvent( { - kind: EventKind.TextNote, - createdAt: timestamp, - content: note, - pubkey, + ...createTextNote(note), + tags: [], }, secret - ) + ).then((event) => publisher.publish(event)) }) it("publish and ok", function (done) { - SignedEvent.sign( + signEvent( { - kind: EventKind.TextNote, - createdAt: timestamp, - content: note, - pubkey, + ...createTextNote(note), + tags: [], }, secret ).then((event) => { publisher.on("ok", (params, nostr) => { assert.equal(nostr, publisher) - assert.equal(params.eventId.toHex(), event.eventId.toHex()) + assert.equal(params.eventId, event.id) assert.equal(params.relay.toString(), url.toString()) assert.equal(params.ok, true) done() -- 2.40.1 From f3f02a2f569383a1231f1894cdd0d3c1ebea0b9c Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 15:37:57 +0100 Subject: [PATCH 02/21] add missing await --- packages/nostr/src/event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index abf1bbfd..d4a33427 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -170,7 +170,7 @@ export async function parseEvent(event: RawEvent): Promise { )}, expected ${await calculateEventId(event)}` ) } - if (!schnorrVerify(event.sig, event.id, event.pubkey)) { + if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { throw new ProtocolError( `invalid signature for event ${JSON.stringify(event)}` ) -- 2.40.1 From 9b03c4d2ed8b3084c48bbd6f5450778f8eb55ad5 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Wed, 8 Mar 2023 22:07:05 +0100 Subject: [PATCH 03/21] add eose to emitter --- packages/nostr/src/client/emitter.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index d3d646dc..f672b15c 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -11,10 +11,11 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override addListener(eventName: "event", listener: EventListener): this override addListener(eventName: "notice", listener: NoticeListener): this override addListener(eventName: "ok", listener: OkListener): this + override addListener(eventName: "eose", listener: EoseListener): this override addListener(eventName: "error", listener: ErrorListener): this - override addListener(eventName: "newListener", listener: ErrorListener): this override addListener(eventName: EventName, listener: Listener): this { return super.addListener(eventName, listener) } @@ -24,6 +25,11 @@ export class EventEmitter extends Base { override emit(eventName: "event", params: EventParams, nostr: Nostr): boolean override emit(eventName: "notice", notice: string, nostr: Nostr): boolean override emit(eventName: "ok", params: OkParams, nostr: Nostr): boolean + override emit( + eventName: "eose", + subscriptionId: SubscriptionId, + nostr: Nostr + ): boolean override emit(eventName: "error", err: unknown, nostr: Nostr): boolean override emit(eventName: EventName, ...args: unknown[]): boolean { return super.emit(eventName, ...args) @@ -38,6 +44,7 @@ export class EventEmitter extends Base { override listeners(eventName: "event"): EventListener[] override listeners(eventName: "notice"): NoticeListener[] override listeners(eventName: "ok"): OkListener[] + override listeners(eventName: "eose"): EoseListener[] override listeners(eventName: "error"): ErrorListener[] override listeners(eventName: EventName): Listener[] { return super.listeners(eventName) as Listener[] @@ -48,6 +55,7 @@ export class EventEmitter extends Base { override off(eventName: "event", listener: EventListener): this override off(eventName: "notice", listener: NoticeListener): this override off(eventName: "ok", listener: OkListener): this + override off(eventName: "eose", listener: EoseListener): this override off(eventName: "error", listener: ErrorListener): this override off(eventName: EventName, listener: Listener): this { return super.off(eventName, listener) @@ -58,6 +66,7 @@ export class EventEmitter extends Base { override on(eventName: "event", listener: EventListener): this override on(eventName: "notice", listener: NoticeListener): this override on(eventName: "ok", listener: OkListener): this + override on(eventName: "eose", listener: EoseListener): this override on(eventName: "error", listener: ErrorListener): this override on(eventName: EventName, listener: Listener): this { return super.on(eventName, listener) @@ -68,6 +77,7 @@ export class EventEmitter extends Base { override once(eventName: "event", listener: EventListener): this override once(eventName: "notice", listener: NoticeListener): this override once(eventName: "ok", listener: OkListener): this + override once(eventName: "eose", listener: EoseListener): this override once(eventName: "error", listener: ErrorListener): this override once(eventName: EventName, listener: Listener): this { return super.once(eventName, listener) @@ -84,6 +94,7 @@ export class EventEmitter extends Base { override prependListener(eventName: "event", listener: EventListener): this override prependListener(eventName: "notice", listener: NoticeListener): this override prependListener(eventName: "ok", listener: OkListener): this + override prependListener(eventName: "eose", listener: EoseListener): this override prependListener(eventName: "error", listener: ErrorListener): this override prependListener(eventName: EventName, listener: Listener): this { return super.prependListener(eventName, listener) @@ -106,6 +117,7 @@ export class EventEmitter extends Base { listener: NoticeListener ): this override prependOnceListener(eventName: "ok", listener: OkListener): this + override prependOnceListener(eventName: "eose", listener: EoseListener): this override prependOnceListener( eventName: "error", listener: ErrorListener @@ -126,6 +138,7 @@ export class EventEmitter extends Base { override removeListener(eventName: "event", listener: EventListener): this override removeListener(eventName: "notice", listener: NoticeListener): this override removeListener(eventName: "ok", listener: OkListener): this + override removeListener(eventName: "eose", listener: EoseListener): this override removeListener(eventName: "error", listener: ErrorListener): this override removeListener(eventName: EventName, listener: Listener): this { return super.removeListener(eventName, listener) @@ -139,21 +152,21 @@ export class EventEmitter extends Base { // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } -// TODO Also add on: ("subscribed", subscriptionId) which checks "OK"/"NOTICE" and makes a callback? -// TODO Also add on: ("ok", boolean, eventId) which checks "OK"/"NOTICE" and makes a callback? type EventName = | "newListener" | "removeListener" | "event" | "notice" | "ok" + | "eose" | "error" type NewListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void type EventListener = (params: EventParams, nostr: Nostr) => void -type OkListener = (params: OkParams, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void +type OkListener = (params: OkParams, nostr: Nostr) => void +type EoseListener = (subscriptionId: SubscriptionId, nostr: Nostr) => void type ErrorListener = (error: unknown, nostr: Nostr) => void type Listener = @@ -162,6 +175,7 @@ type Listener = | EventListener | NoticeListener | OkListener + | EoseListener | ErrorListener // TODO Document this -- 2.40.1 From 81f2bb19626571b49afb54761c148bdd6888bf72 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Wed, 8 Mar 2023 22:33:50 +0100 Subject: [PATCH 04/21] add eose to conn --- packages/nostr/src/client/conn.ts | 165 ++++++++++++++++++------------ 1 file changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 766037e1..7b918f3f 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -53,7 +53,7 @@ export class Conn { return } try { - const msg = await Conn.#parseIncomingMessage(value) + const msg = await parseIncomingMessage(value) onMessage(msg) } catch (err) { onError(err) @@ -96,77 +96,18 @@ export class Conn { this.#onError?.(err) } } - - static async #parseIncomingMessage(data: string): Promise { - const json = parseJson(data) - if (!(json instanceof Array)) { - throw new ProtocolError(`incoming message is not an array: ${data}`) - } - if (json.length === 0) { - throw new ProtocolError(`incoming message is an empty array: ${data}`) - } - if (json[0] === "EVENT") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "EVENT" should be a string, but wasn't: ${data}` - ) - } - if (typeof json[2] !== "object") { - throw new ProtocolError( - `second element of "EVENT" should be an object, but wasn't: ${data}` - ) - } - const event = parseEventData(json[2]) - return { - kind: "event", - subscriptionId: json[1], - event, - } - } - if (json[0] === "NOTICE") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "NOTICE" should be a string, but wasn't: ${data}` - ) - } - return { - kind: "notice", - notice: json[1], - } - } - if (json[0] === "OK") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "OK" should be a string, but wasn't: ${data}` - ) - } - if (typeof json[2] !== "boolean") { - throw new ProtocolError( - `third element of "OK" should be a boolean, but wasn't: ${data}` - ) - } - if (typeof json[3] !== "string") { - throw new ProtocolError( - `fourth element of "OK" should be a string, but wasn't: ${data}` - ) - } - return { - kind: "ok", - eventId: json[1], - ok: json[2], - message: json[3], - } - } - throw new ProtocolError(`unknown incoming message: ${data}`) - } } /** * A message sent from a relay to the client. */ -export type IncomingMessage = IncomingEvent | IncomingNotice | IncomingOk +export type IncomingMessage = + | IncomingEvent + | IncomingNotice + | IncomingOk + | IncomingEose -export type IncomingKind = "event" | "notice" | "ok" +export type IncomingKind = "event" | "notice" | "ok" | "eose" /** * Incoming "EVENT" message. @@ -195,6 +136,14 @@ export interface IncomingOk { message: string } +/** + * Incoming "EOSE" message. + */ +export interface IncomingEose { + kind: "eose" + subscriptionId: SubscriptionId +} + /** * A message sent from the client to a relay. */ @@ -275,6 +224,90 @@ function serializeFilters(filters: Filters[]): RawFilters[] { })) } +async function parseIncomingMessage(data: string): Promise { + // Parse the incoming data as a nonempty JSON array. + const json = parseJson(data) + if (!(json instanceof Array)) { + throw new ProtocolError(`incoming message is not an array: ${data}`) + } + if (json.length === 0) { + throw new ProtocolError(`incoming message is an empty array: ${data}`) + } + + // Handle incoming events. + if (json[0] === "EVENT") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "EVENT" should be a string, but wasn't: ${data}` + ) + } + if (typeof json[2] !== "object") { + throw new ProtocolError( + `second element of "EVENT" should be an object, but wasn't: ${data}` + ) + } + const event = parseEventData(json[2]) + return { + kind: "event", + subscriptionId: json[1], + event, + } + } + + // Handle incoming notices. + if (json[0] === "NOTICE") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "NOTICE" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "notice", + notice: json[1], + } + } + + // Handle incoming "OK" messages. + if (json[0] === "OK") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "OK" should be a string, but wasn't: ${data}` + ) + } + if (typeof json[2] !== "boolean") { + throw new ProtocolError( + `third element of "OK" should be a boolean, but wasn't: ${data}` + ) + } + if (typeof json[3] !== "string") { + throw new ProtocolError( + `fourth element of "OK" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "ok", + eventId: json[1], + ok: json[2], + message: json[3], + } + } + + // Handle incoming "EOSE" messages. + if (json[0] === "EOSE") { + if (typeof json[1] !== "string") { + throw new ProtocolError( + `second element of "EOSE" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "eose", + subscriptionId: json[1], + } + } + + throw new ProtocolError(`unknown incoming message: ${data}`) +} + function parseEventData(json: { [key: string]: unknown }): RawEvent { if ( typeof json["id"] !== "string" || -- 2.40.1 From 2fed0f3b8373fcad51597d72ae1d7c0a08e86ecc Mon Sep 17 00:00:00 2001 From: ennmichael Date: Wed, 8 Mar 2023 22:58:41 +0100 Subject: [PATCH 05/21] add eose to the client --- packages/nostr/src/client/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 0fc116b2..cc2cf0a9 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -76,6 +76,8 @@ export class Nostr extends EventEmitter { }, this ) + } else if (msg.kind === "eose") { + this.emit("eose", msg.subscriptionId, this) } else { throw new ProtocolError(`invalid message ${msg}`) } -- 2.40.1 From 67fe6863ed68fff3314d510a2c53d27b7a3352fc Mon Sep 17 00:00:00 2001 From: ennmichael Date: Wed, 8 Mar 2023 22:59:01 +0100 Subject: [PATCH 06/21] eose test --- packages/nostr/test/simple-communication.ts | 47 ++++++++++----------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/nostr/test/simple-communication.ts b/packages/nostr/test/simple-communication.ts index fe41d0ab..510d4923 100644 --- a/packages/nostr/test/simple-communication.ts +++ b/packages/nostr/test/simple-communication.ts @@ -2,12 +2,8 @@ import { Nostr } from "../src/client" import { createTextNote, EventKind, signEvent } from "../src/event" import { getPublicKey } from "../src/crypto" import assert from "assert" -import { EventParams } from "../src/client/emitter" import { unixTimestamp } from "../src/util" -// TODO Switch out the relay implementation and see if the issue persists -// TODO Do on("error", done) for all of these - describe("simple communication", function () { const secret = "nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh" @@ -30,37 +26,39 @@ describe("simple communication", function () { }) it("publish and receive", function (done) { - function listener({ event }: EventParams, nostr: Nostr) { + subscriber.on("error", done) + publisher.on("error", done) + + // Expect the test event. + subscriber.on("event", ({ event }, nostr) => { assert.equal(nostr, subscriber) assert.equal(event.kind, EventKind.TextNote) assert.equal(event.pubkey, pubkey) assert.equal(event.created_at, timestamp) assert.equal(event.content, note) - // There is a bug with the nostr relay used for testing where if the publish and - // subscribe happen at the same time, the same event might end up being broadcast twice. - // To prevent reacting to the same event and calling done() twice, remove the callback - // for future events. - subscriber.off("event", listener) - done() - } + }) - // TODO do this once EOSE is implemented - //subscriber.on("error", done) - //publisher.on("error", done) + const subscriptionId = subscriber.subscribe([]) - subscriber.on("event", listener) - subscriber.subscribe([]) - signEvent( - { - ...createTextNote(note), - tags: [], - }, - secret - ).then((event) => publisher.publish(event)) + // After the subscription event sync is done, publish the test event. + subscriber.on("eose", (id, nostr) => { + assert.equal(nostr, subscriber) + assert.equal(id, subscriptionId) + + signEvent( + { + ...createTextNote(note), + tags: [], + }, + secret + ).then((event) => publisher.publish(event)) + }) }) + // TODO Have a way to run the relay on-demand and then re-add this test + /* it("publish and ok", function (done) { signEvent( { @@ -80,4 +78,5 @@ describe("simple communication", function () { publisher.publish(event) }) }) + */ }) -- 2.40.1 From 7cba13b8a6860027862023da9ecee9b16eb1f43d Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sun, 12 Mar 2023 20:32:47 +0100 Subject: [PATCH 07/21] improve test suite, add dm tests --- packages/nostr/docker-compose.yaml | 4 +- packages/nostr/package.json | 11 +- packages/nostr/relay/.dockerignore | 1 + packages/nostr/relay/.gitignore | 1 + packages/nostr/relay/Dockerfile | 12 ++ packages/nostr/relay/index.ts | 16 +++ packages/nostr/relay/package.json | 14 +++ packages/nostr/src/client/conn.ts | 5 +- packages/nostr/src/client/emitter.ts | 14 +++ packages/nostr/src/client/index.ts | 2 + packages/nostr/src/crypto.ts | 6 +- packages/nostr/src/event.ts | 45 ++++++- packages/nostr/test/dm.ts | 125 ++++++++++++++++++++ packages/nostr/test/setup.ts | 70 +++++++++++ packages/nostr/test/simple-communication.ts | 82 ------------- packages/nostr/test/text-note.ts | 75 ++++++++++++ 16 files changed, 388 insertions(+), 95 deletions(-) create mode 100644 packages/nostr/relay/.dockerignore create mode 100644 packages/nostr/relay/.gitignore create mode 100644 packages/nostr/relay/Dockerfile create mode 100644 packages/nostr/relay/index.ts create mode 100644 packages/nostr/relay/package.json create mode 100644 packages/nostr/test/dm.ts create mode 100644 packages/nostr/test/setup.ts delete mode 100644 packages/nostr/test/simple-communication.ts create mode 100644 packages/nostr/test/text-note.ts diff --git a/packages/nostr/docker-compose.yaml b/packages/nostr/docker-compose.yaml index 32f5cb51..49470d0f 100644 --- a/packages/nostr/docker-compose.yaml +++ b/packages/nostr/docker-compose.yaml @@ -1,6 +1,8 @@ version: "3.1" services: relay: - image: scsibug/nostr-rs-relay + build: ./relay + restart: on-failure ports: - 12648:8080 + - 12649:8000 diff --git a/packages/nostr/package.json b/packages/nostr/package.json index 7927d439..93c05c5d 100644 --- a/packages/nostr/package.json +++ b/packages/nostr/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc", "watch": "tsc -w", - "test": "ts-mocha --type-check -j 1 test/*.ts", + "test": "ts-mocha --type-check -j 1 --timeout 5s test/*.ts", "lint": "eslint ." }, "devDependencies": { @@ -31,5 +31,12 @@ "events": "^3.3.0", "isomorphic-ws": "^5.0.0", "ws": "^8.12.1" - } + }, + "directories": { + "test": "test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" } diff --git a/packages/nostr/relay/.dockerignore b/packages/nostr/relay/.dockerignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/nostr/relay/.dockerignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/nostr/relay/.gitignore b/packages/nostr/relay/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/nostr/relay/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/nostr/relay/Dockerfile b/packages/nostr/relay/Dockerfile new file mode 100644 index 00000000..f986317c --- /dev/null +++ b/packages/nostr/relay/Dockerfile @@ -0,0 +1,12 @@ +FROM scsibug/nostr-rs-relay + +USER root +RUN apt-get update && apt-get install -y curl nodejs npm +RUN npm i -g yarn + +EXPOSE 8000 + +COPY . . +USER $APP_USER +RUN yarn +CMD yarn app /bin/bash -c "rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db" diff --git a/packages/nostr/relay/index.ts b/packages/nostr/relay/index.ts new file mode 100644 index 00000000..856d78ec --- /dev/null +++ b/packages/nostr/relay/index.ts @@ -0,0 +1,16 @@ +import http from "node:http" +import { spawn } from "node:child_process" + +const child = spawn(process.argv[2], process.argv.slice(3), { + stdio: "inherit", +}) + +const server = http.createServer((_, res) => { + if (!child.kill(9)) { + console.error("killing the subprocess failed") + } + res.end() + process.exit(1) +}) + +server.listen(8000) diff --git a/packages/nostr/relay/package.json b/packages/nostr/relay/package.json new file mode 100644 index 00000000..d6eba799 --- /dev/null +++ b/packages/nostr/relay/package.json @@ -0,0 +1,14 @@ +{ + "name": "relay", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "app": "ts-node index.ts" + }, + "dependencies": { + "@types/node": "^18.15.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + } +} diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 7b918f3f..cbbff962 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,7 +1,7 @@ import { ProtocolError } from "../error" import { Filters, SubscriptionId } from "." import { EventId, RawEvent } from "../event" -import WebSocket from "ws" +import WebSocket from "isomorphic-ws" import { unixTimestamp } from "../util" /** @@ -34,10 +34,12 @@ export class Conn { constructor({ url, onMessage, + onOpen, onError, }: { url: URL onMessage: (msg: IncomingMessage) => void + onOpen: () => void onError: (err: unknown) => void }) { this.#onError = onError @@ -66,6 +68,7 @@ export class Conn { this.send(msg) } this.#pending = [] + onOpen() }) this.#socket.addEventListener("error", (err) => { diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index f672b15c..9a407dd8 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -11,6 +11,7 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override addListener(eventName: "open", listener: OpenListener): this override addListener(eventName: "event", listener: EventListener): this override addListener(eventName: "notice", listener: NoticeListener): this override addListener(eventName: "ok", listener: OkListener): this @@ -22,6 +23,7 @@ export class EventEmitter extends Base { override emit(eventName: "newListener", listener: NewListener): boolean override emit(eventName: "removeListener", listener: RemoveListener): boolean + override emit(eventName: "open", relay: URL, nostr: Nostr): boolean override emit(eventName: "event", params: EventParams, nostr: Nostr): boolean override emit(eventName: "notice", notice: string, nostr: Nostr): boolean override emit(eventName: "ok", params: OkParams, nostr: Nostr): boolean @@ -41,6 +43,7 @@ export class EventEmitter extends Base { override listeners(eventName: "newListener"): EventListener[] override listeners(eventName: "removeListener"): EventListener[] + override listeners(eventName: "open"): OpenListener[] override listeners(eventName: "event"): EventListener[] override listeners(eventName: "notice"): NoticeListener[] override listeners(eventName: "ok"): OkListener[] @@ -52,6 +55,7 @@ export class EventEmitter extends Base { override off(eventName: "newListener", listener: NewListener): this override off(eventName: "removeListener", listener: RemoveListener): this + override off(eventName: "open", listener: OpenListener): this override off(eventName: "event", listener: EventListener): this override off(eventName: "notice", listener: NoticeListener): this override off(eventName: "ok", listener: OkListener): this @@ -63,6 +67,7 @@ export class EventEmitter extends Base { override on(eventName: "newListener", listener: NewListener): this override on(eventName: "removeListener", listener: RemoveListener): this + override on(eventName: "open", listener: OpenListener): this override on(eventName: "event", listener: EventListener): this override on(eventName: "notice", listener: NoticeListener): this override on(eventName: "ok", listener: OkListener): this @@ -74,6 +79,7 @@ export class EventEmitter extends Base { override once(eventName: "newListener", listener: NewListener): this override once(eventName: "removeListener", listener: RemoveListener): this + override once(eventName: "open", listener: OpenListener): this override once(eventName: "event", listener: EventListener): this override once(eventName: "notice", listener: NoticeListener): this override once(eventName: "ok", listener: OkListener): this @@ -91,6 +97,7 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override prependListener(eventName: "open", listener: OpenListener): this override prependListener(eventName: "event", listener: EventListener): this override prependListener(eventName: "notice", listener: NoticeListener): this override prependListener(eventName: "ok", listener: OkListener): this @@ -108,6 +115,7 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override prependOnceListener(eventName: "open", listener: OpenListener): this override prependOnceListener( eventName: "event", listener: EventListener @@ -135,6 +143,7 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override removeListener(eventName: "open", listener: OpenListener): this override removeListener(eventName: "event", listener: EventListener): this override removeListener(eventName: "notice", listener: NoticeListener): this override removeListener(eventName: "ok", listener: OkListener): this @@ -152,9 +161,12 @@ export class EventEmitter extends Base { // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } +// TODO Add an open event +// TODO Refactor the params type EventName = | "newListener" | "removeListener" + | "open" | "event" | "notice" | "ok" @@ -163,6 +175,7 @@ type EventName = type NewListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void +type OpenListener = (relay: URL, nostr: Nostr) => void type EventListener = (params: EventParams, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void type OkListener = (params: OkParams, nostr: Nostr) => void @@ -172,6 +185,7 @@ type ErrorListener = (error: unknown, nostr: Nostr) => void type Listener = | NewListener | RemoveListener + | OpenListener | EventListener | NoticeListener | OkListener diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index cc2cf0a9..aab9b216 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -85,6 +85,8 @@ export class Nostr extends EventEmitter { this.emit("error", err, this) } }, + // Forward "open" events. + onOpen: () => this.emit("open", connUrl, this), // Forward errors on this connection. onError: (err) => this.emit("error", err, this), }) diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index 493db919..53dcbbb6 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -104,7 +104,7 @@ export async function aesEncryptBase64( plaintext: string ): Promise { const sharedPoint = secp.getSharedSecret(sender, "02" + recipient) - const sharedKey = sharedPoint.slice(2, 33) + const sharedKey = sharedPoint.slice(1, 33) if (typeof window === "object") { const key = await window.crypto.subtle.importKey( "raw", @@ -141,7 +141,7 @@ export async function aesEncryptBase64( ) let encrypted = cipher.update(plaintext, "utf8", "base64") // TODO Could save an allocation here by avoiding the += - encrypted += cipher.final() + encrypted += cipher.final("base64") return { data: encrypted, iv: Buffer.from(iv.buffer).toString("base64"), @@ -155,7 +155,7 @@ export async function aesDecryptBase64( { data, iv }: AesEncryptedBase64 ): Promise { const sharedPoint = secp.getSharedSecret(recipient, "02" + sender) - const sharedKey = sharedPoint.slice(2, 33) + const sharedKey = sharedPoint.slice(1, 33) if (typeof window === "object") { // TODO Can copy this from the legacy code throw new Error("todo") diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index d4a33427..fa98419c 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -5,10 +5,12 @@ import { sha256, schnorrSign, schnorrVerify, + parsePublicKey, aesDecryptBase64, getPublicKey, HexOrBechPrivateKey, parsePrivateKey, + aesEncryptBase64, } from "./crypto" import { defined, unixTimestamp } from "./util" @@ -64,7 +66,7 @@ interface DirectMessage extends RawEvent { /** * Get the message plaintext, or undefined if this client is not the recipient. */ - getMessage(recipient: PrivateKey): Promise + getMessage(priv?: HexOrBechPrivateKey): Promise /** * Get the recipient pubkey. */ @@ -129,7 +131,7 @@ export async function signEvent( if (priv !== undefined) { priv = parsePrivateKey(priv) event.pubkey = getPublicKey(priv) - const id = await calculateEventId(event as UnsignedWithPubkey) + const id = await serializeEventId(event as UnsignedWithPubkey) event.id = id event.sig = await schnorrSign(id, priv) return event as T @@ -158,16 +160,44 @@ export function createSetMetadata( } } +// TODO This is incomplete +// TODO Since you already have the private key, maybe this should return the message already signed? +// Think about this more +// Perhaps the best option is for all these factory methods to have an overload which also accept a private +// key as last parameter and return the event already signed, whereas for this method that would be +// mandatory +export async function createDirectMessage({ + message, + recipient, + priv, +}: { + message: string + recipient: PublicKey + priv: PrivateKey +}): Promise> { + recipient = parsePublicKey(recipient) + priv = parsePrivateKey(priv) + const { data, iv } = await aesEncryptBase64(priv, recipient, message) + return { + kind: EventKind.DirectMessage, + tags: [["p", recipient]], + content: `${data}?iv=${iv}`, + getMessage, + getRecipient, + getPrevious, + } +} + /** * Parse an event from its raw format. */ export async function parseEvent(event: RawEvent): Promise { // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. - if (event.id !== (await calculateEventId(event))) { + if (event.id !== (await serializeEventId(event))) { throw new ProtocolError( `invalid id ${event.id} for event ${JSON.stringify( event - )}, expected ${await calculateEventId(event)}` + )}, expected ${await serializeEventId(event)}` ) } if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { @@ -207,7 +237,7 @@ export async function parseEvent(event: RawEvent): Promise { } } -async function calculateEventId( +async function serializeEventId( event: UnsignedWithPubkey ): Promise { // It's not defined whether JSON.stringify produces a string with whitespace stripped. @@ -237,8 +267,11 @@ function getUserMetadata(this: Unsigned): UserMetadata { async function getMessage( this: UnsignedWithPubkey, - priv?: PrivateKey + priv?: HexOrBechPrivateKey ): Promise { + if (priv !== undefined) { + priv = parsePrivateKey(priv) + } const [data, iv] = this.content.split("?iv=") if (data === undefined || iv === undefined) { throw new ProtocolError(`invalid direct message content ${this.content}`) diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts new file mode 100644 index 00000000..cac77033 --- /dev/null +++ b/packages/nostr/test/dm.ts @@ -0,0 +1,125 @@ +import { createDirectMessage, EventKind, signEvent } from "../src/event" +import { parsePublicKey } from "../src/crypto" +import assert from "assert" +import { setup } from "./setup" + +describe("dm", async function () { + const message = "for your eyes only" + + // Test that the intended recipient can receive and decrypt the direct message. + it("to intended recipient", (done) => { + setup(done).then( + ({ + publisher, + publisherPubkey, + publisherSecret, + subscriber, + subscriberPubkey, + subscriberSecret, + timestamp, + }) => { + // Expect the direct message. + subscriber.on( + "event", + async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + try { + assert.equal(nostr, subscriber) + assert.equal(event.kind, EventKind.DirectMessage) + assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) + assert.equal(actualSubscriptionId, subscriptionId) + assert.ok(event.created_at >= timestamp) + + if (event.kind === EventKind.DirectMessage) { + assert.equal( + event.getRecipient(), + parsePublicKey(subscriberPubkey) + ) + assert.equal(await event.getMessage(subscriberSecret), message) + } + + done() + } catch (e) { + done(e) + } + } + ) + + const subscriptionId = subscriber.subscribe([]) + + subscriber.on("eose", async () => { + // TODO No signEvent, do something more convenient + const event = await signEvent( + await createDirectMessage({ + message, + recipient: subscriberPubkey, + priv: publisherSecret, + }), + publisherSecret + ) + publisher.publish(event) + }) + } + ) + }) + + // Test that an unintended recipient still receives the direct message event, but cannot decrypt it. + it.only("to unintended recipient", (done) => { + setup(done).then( + ({ + publisher, + publisherPubkey, + publisherSecret, + subscriber, + subscriberSecret, + timestamp, + }) => { + const recipientPubkey = + "npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc" + + // Expect the direct message. + subscriber.on( + "event", + async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + try { + assert.equal(nostr, subscriber) + assert.equal(event.kind, EventKind.DirectMessage) + assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) + assert.equal(actualSubscriptionId, subscriptionId) + assert.ok(event.created_at >= timestamp) + + if (event.kind === EventKind.DirectMessage) { + assert.equal( + event.getRecipient(), + parsePublicKey(recipientPubkey) + ) + assert.strictEqual( + await event.getMessage(subscriberSecret), + undefined + ) + } + + done() + } catch (e) { + done(e) + } + } + ) + + const subscriptionId = subscriber.subscribe([]) + + subscriber.on("eose", async () => { + // TODO No signEvent, do something more convenient + const event = await signEvent( + await createDirectMessage({ + message, + recipient: recipientPubkey, + priv: publisherSecret, + }), + publisherSecret + ) + publisher.publish(event) + }) + } + ) + }) +}) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts new file mode 100644 index 00000000..1505fed3 --- /dev/null +++ b/packages/nostr/test/setup.ts @@ -0,0 +1,70 @@ +import { Nostr } from "../src/client" +import { unixTimestamp } from "../src/util" + +export interface Setup { + publisher: Nostr + publisherSecret: string + publisherPubkey: string + subscriber: Nostr + subscriberSecret: string + subscriberPubkey: string + timestamp: number + url: URL +} + +export async function setup(done: jest.DoneCallback): Promise { + await restartRelay() + const publisher = new Nostr() + const subscriber = new Nostr() + const url = new URL("ws://localhost:12648") + + publisher.on("error", done) + subscriber.on("error", done) + + publisher.open(url) + subscriber.open(url) + + return { + publisher, + publisherSecret: + "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", + publisherPubkey: + "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", + subscriber, + subscriberSecret: + "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", + subscriberPubkey: + "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", + timestamp: unixTimestamp(), + url, + } +} + +async function restartRelay() { + // Make a request to the endpoint which will crash the process and cause it to restart. + try { + await fetch("http://localhost:12649") + } catch (e) { + // Since the process exits, an error is expected. + } + + // Wait until the relay process is ready. + for (;;) { + const ok = await new Promise((resolve) => { + const nostr = new Nostr() + nostr.on("error", () => { + nostr.close() + resolve(false) + }) + nostr.on("open", () => { + nostr.close() + resolve(true) + }) + nostr.open("ws://localhost:12648") + }) + if (ok) { + break + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} diff --git a/packages/nostr/test/simple-communication.ts b/packages/nostr/test/simple-communication.ts deleted file mode 100644 index 510d4923..00000000 --- a/packages/nostr/test/simple-communication.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Nostr } from "../src/client" -import { createTextNote, EventKind, signEvent } from "../src/event" -import { getPublicKey } from "../src/crypto" -import assert from "assert" -import { unixTimestamp } from "../src/util" - -describe("simple communication", function () { - const secret = - "nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh" - const pubkey = getPublicKey(secret) - const note = "hello world" - const url = new URL("ws://localhost:12648") - const timestamp = unixTimestamp() - - const publisher = new Nostr() - const subscriber = new Nostr() - - beforeEach(() => { - publisher.open(url) - subscriber.open(url) - }) - - afterEach(() => { - publisher.close() - subscriber.close() - }) - - it("publish and receive", function (done) { - subscriber.on("error", done) - publisher.on("error", done) - - // Expect the test event. - subscriber.on("event", ({ event }, nostr) => { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.TextNote) - assert.equal(event.pubkey, pubkey) - assert.equal(event.created_at, timestamp) - assert.equal(event.content, note) - - done() - }) - - const subscriptionId = subscriber.subscribe([]) - - // After the subscription event sync is done, publish the test event. - subscriber.on("eose", (id, nostr) => { - assert.equal(nostr, subscriber) - assert.equal(id, subscriptionId) - - signEvent( - { - ...createTextNote(note), - tags: [], - }, - secret - ).then((event) => publisher.publish(event)) - }) - }) - - // TODO Have a way to run the relay on-demand and then re-add this test - /* - it("publish and ok", function (done) { - signEvent( - { - ...createTextNote(note), - tags: [], - }, - secret - ).then((event) => { - publisher.on("ok", (params, nostr) => { - assert.equal(nostr, publisher) - assert.equal(params.eventId, event.id) - assert.equal(params.relay.toString(), url.toString()) - assert.equal(params.ok, true) - done() - }) - publisher.on("error", done) - publisher.publish(event) - }) - }) - */ -}) diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts new file mode 100644 index 00000000..06e7e40c --- /dev/null +++ b/packages/nostr/test/text-note.ts @@ -0,0 +1,75 @@ +import { createTextNote, EventKind, signEvent } from "../src/event" +import { parsePublicKey } from "../src/crypto" +import assert from "assert" +import { setup } from "./setup" + +describe("text note", async function () { + const note = "hello world" + + it("publish and receive", (done) => { + // Test that a text note can be published by one client and received by the other. + setup(done).then( + ({ + publisher, + publisherSecret, + publisherPubkey, + subscriber, + timestamp, + }) => { + // Expect the test event. + subscriber.on( + "event", + ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.TextNote) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(event.created_at, timestamp) + assert.strictEqual(event.content, note) + assert.strictEqual(actualSubscriptionId, subscriptionId) + + subscriber.close() + publisher.close() + + done() + } + ) + + const subscriptionId = subscriber.subscribe([]) + + // After the subscription event sync is done, publish the test event. + subscriber.on("eose", (id, nostr) => { + assert.strictEqual(nostr, subscriber) + assert.strictEqual(id, subscriptionId) + + // TODO No signEvent, have a convenient way to do this + signEvent( + { ...createTextNote(note), created_at: timestamp }, + publisherSecret + ).then((event) => publisher.publish(event)) + }) + } + ) + }) + + it("publish and ok", function (done) { + // Test that a client interprets an "OK" message after publishing a text note. + setup(done).then(({ publisher, subscriber, publisherSecret, url }) => { + // TODO No signEvent, have a convenient way to do this + signEvent(createTextNote(note), publisherSecret).then((event) => { + publisher.on("ok", (params, nostr) => { + assert.equal(nostr, publisher) + assert.equal(params.eventId, event.id) + assert.equal(params.relay.toString(), url.toString()) + assert.equal(params.ok, true) + + publisher.close() + subscriber.close() + + done() + }) + + publisher.publish(event) + }) + }) + }) +}) -- 2.40.1 From 43ef562bf3bb192e1743bdaa6c4c709e3750086e Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sun, 12 Mar 2023 21:00:22 +0100 Subject: [PATCH 08/21] demonstrate that nostr-rs-relay auth options don't work --- packages/nostr/relay/Dockerfile | 2 +- packages/nostr/relay/config.toml | 3 +++ packages/nostr/src/client/conn.ts | 18 +++++++++++++++++- packages/nostr/src/client/index.ts | 4 +++- packages/nostr/test/dm.ts | 8 +++++++- 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 packages/nostr/relay/config.toml diff --git a/packages/nostr/relay/Dockerfile b/packages/nostr/relay/Dockerfile index f986317c..5552ffde 100644 --- a/packages/nostr/relay/Dockerfile +++ b/packages/nostr/relay/Dockerfile @@ -9,4 +9,4 @@ EXPOSE 8000 COPY . . USER $APP_USER RUN yarn -CMD yarn app /bin/bash -c "rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db" +CMD yarn app /bin/bash -c "rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db --config ./config.toml" diff --git a/packages/nostr/relay/config.toml b/packages/nostr/relay/config.toml new file mode 100644 index 00000000..cc3c91a1 --- /dev/null +++ b/packages/nostr/relay/config.toml @@ -0,0 +1,3 @@ +[authorization] +nip42_auth = true +nip42_dms = true diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index cbbff962..a2d8eac1 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -109,8 +109,9 @@ export type IncomingMessage = | IncomingNotice | IncomingOk | IncomingEose + | IncomingAuth -export type IncomingKind = "event" | "notice" | "ok" | "eose" +export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth" /** * Incoming "EVENT" message. @@ -147,6 +148,13 @@ export interface IncomingEose { subscriptionId: SubscriptionId } +/** + * Incoming "AUTH" message. + */ +export interface IncomingAuth { + kind: "auth" +} + /** * A message sent from the client to a relay. */ @@ -308,6 +316,14 @@ async function parseIncomingMessage(data: string): Promise { } } + // TODO This is incomplete + // Handle incoming "AUTH" messages. + if (json[0] === "AUTH") { + return { + kind: "auth", + } + } + throw new ProtocolError(`unknown incoming message: ${data}`) } diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index aab9b216..364c1375 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -78,8 +78,10 @@ export class Nostr extends EventEmitter { ) } else if (msg.kind === "eose") { this.emit("eose", msg.subscriptionId, this) + } else if (msg.kind === "auth") { + // TODO This is incomplete } else { - throw new ProtocolError(`invalid message ${msg}`) + throw new ProtocolError(`invalid message ${JSON.stringify(msg)}`) } } catch (err) { this.emit("error", err, this) diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index cac77033..1d024a89 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -37,6 +37,9 @@ describe("dm", async function () { assert.equal(await event.getMessage(subscriberSecret), message) } + publisher.close() + subscriber.close() + done() } catch (e) { done(e) @@ -63,7 +66,7 @@ describe("dm", async function () { }) // Test that an unintended recipient still receives the direct message event, but cannot decrypt it. - it.only("to unintended recipient", (done) => { + it("to unintended recipient", (done) => { setup(done).then( ({ publisher, @@ -98,6 +101,9 @@ describe("dm", async function () { ) } + publisher.close() + subscriber.close() + done() } catch (e) { done(e) -- 2.40.1 From d5cce39400753e51284d5adb57b7bb145ab704e2 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Mon, 13 Mar 2023 00:03:34 +0100 Subject: [PATCH 09/21] readme files --- packages/nostr/README.md | 66 ++++++++++++++++++++++++++++++++++ packages/nostr/relay/README.md | 7 ++++ 2 files changed, 73 insertions(+) create mode 100644 packages/nostr/README.md create mode 100644 packages/nostr/relay/README.md diff --git a/packages/nostr/README.md b/packages/nostr/README.md new file mode 100644 index 00000000..fa6296fd --- /dev/null +++ b/packages/nostr/README.md @@ -0,0 +1,66 @@ +# `@snort/nostr` + +A strongly-typed nostr client for Node and the browser. + +## NIP support + +### Applicable + +The goal of the project is to have all of the following implemented +and tested against a real-world relay implementation. + +_Progress: 4/34 (12%)._ + +- [X] NIP-01: Basic protocol flow description +- [ ] NIP-02: Contact List and Petnames +- [ ] NIP-03: OpenTimestamps Attestations for Events +- [X] NIP-04: Encrypted Direct Message +- [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers +- [ ] NIP-06: Basic key derivation from mnemonic seed phrase +- [ ] NIP-07: window.nostr capability for web browsers +- [ ] NIP-08: Handling Mentions +- [ ] NIP-09: Event Deletion +- [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events + - TODO Check if this applies +- [ ] NIP-11: Relay Information Document +- [ ] NIP-12: Generic Tag Queries +- [ ] NIP-13: Proof of Work +- [ ] NIP-14: Subject tag in text events +- [X] NIP-15: End of Stored Events Notice +- [ ] NIP-19: bech32-encoded entities + - [X] `npub` + - [X] `nsec` + - [ ] `nprofile` +- [X] NIP-20: Command Results +- [ ] NIP-21: `nostr:` URL scheme +- [ ] NIP-23: Long-form Content +- [ ] NIP-25: Reactions +- [ ] NIP-26: Delegated Event Signing +- [ ] NIP-28: Public Chat +- [ ] NIP-36: Sensitive Content +- [ ] NIP-39: External Identities in Profiles +- [ ] NIP-40: Expiration Timestamp +- [ ] NIP-42: Authentication of clients to relays +- [ ] NIP-46: Nostr Connect + - Not sure how much of this applies, but I sure would love to see WalletConnect disappear +- [ ] NIP-50: Keywords filter +- [ ] NIP-51: Lists +- [ ] NIP-56: Reporting +- [ ] NIP-57: Lightning Zaps +- [ ] NIP-58: Badges +- [ ] NIP-65: Relay List Metadata +- [ ] NIP-78: Application-specific data + +### Not Applicable + +These NIPs only apply to relays and have no implications for a generic nostr client. + +- NIP-16: Event Treatment +- NIP-22: Event `created_at` Limits +- NIP-33: Parameterized Replaceable Events + +### Others + +_If you notice an accepted NIP missing from both lists above, please [open an +issue](https://github.com/v0l/snort/issues/new?assignees=&labels=&template=feature_request.md&title=) +to let us know_. diff --git a/packages/nostr/relay/README.md b/packages/nostr/relay/README.md new file mode 100644 index 00000000..716fe674 --- /dev/null +++ b/packages/nostr/relay/README.md @@ -0,0 +1,7 @@ +Allows the relay to be shut down with an HTTP request, after which +docker-compose will restart it. This allows each test to have a clean +slate. The drawback is that the tests can't run in parallel, so the +test suite is very slow. A better option would be to have this relay +server manage the relay completely: star/stop isolated relay instances +with HTTP requests and allow multiple instances to run at the same +time so that the tests can be parallelized. -- 2.40.1 From aa8bc3b598dacbb40006678b07a9daf92894b335 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Mon, 13 Mar 2023 00:19:24 +0100 Subject: [PATCH 10/21] cleanup --- packages/nostr/relay/README.md | 7 ------- packages/nostr/relay/config.toml | 1 + packages/nostr/relay/index.ts | 10 ++++++++++ packages/nostr/src/client/emitter.ts | 3 +-- packages/nostr/src/event.ts | 1 + packages/nostr/test/text-note.ts | 4 ++-- 6 files changed, 15 insertions(+), 11 deletions(-) delete mode 100644 packages/nostr/relay/README.md diff --git a/packages/nostr/relay/README.md b/packages/nostr/relay/README.md deleted file mode 100644 index 716fe674..00000000 --- a/packages/nostr/relay/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Allows the relay to be shut down with an HTTP request, after which -docker-compose will restart it. This allows each test to have a clean -slate. The drawback is that the tests can't run in parallel, so the -test suite is very slow. A better option would be to have this relay -server manage the relay completely: star/stop isolated relay instances -with HTTP requests and allow multiple instances to run at the same -time so that the tests can be parallelized. diff --git a/packages/nostr/relay/config.toml b/packages/nostr/relay/config.toml index cc3c91a1..f7ea106c 100644 --- a/packages/nostr/relay/config.toml +++ b/packages/nostr/relay/config.toml @@ -1,3 +1,4 @@ [authorization] nip42_auth = true +# This seems to have no effect. nip42_dms = true diff --git a/packages/nostr/relay/index.ts b/packages/nostr/relay/index.ts index 856d78ec..1105f157 100644 --- a/packages/nostr/relay/index.ts +++ b/packages/nostr/relay/index.ts @@ -1,3 +1,13 @@ +/** + * Allows the relay to be shut down with an HTTP request, after which + * docker-compose will restart it. This allows each test to have a clean + * slate. The drawback is that the tests can't run in parallel, so the + * test suite is very slow. A better option would be to have this relay + * server manage the relay completely: star/stop isolated relay instances + * with HTTP requests and allow multiple instances to run at the same + * time so that the tests can be parallelized. + */ + import http from "node:http" import { spawn } from "node:child_process" diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 9a407dd8..b08d100e 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -161,8 +161,7 @@ export class EventEmitter extends Base { // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } -// TODO Add an open event -// TODO Refactor the params +// TODO Refactor the params to be a single interface type EventName = | "newListener" | "removeListener" diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index fa98419c..7337a5f4 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -166,6 +166,7 @@ export function createSetMetadata( // Perhaps the best option is for all these factory methods to have an overload which also accept a private // key as last parameter and return the event already signed, whereas for this method that would be // mandatory +// E.g. opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07 export async function createDirectMessage({ message, recipient, diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 06e7e40c..1f581862 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -6,8 +6,8 @@ import { setup } from "./setup" describe("text note", async function () { const note = "hello world" + // Test that a text note can be published by one client and received by the other. it("publish and receive", (done) => { - // Test that a text note can be published by one client and received by the other. setup(done).then( ({ publisher, @@ -51,8 +51,8 @@ describe("text note", async function () { ) }) + // Test that a client interprets an "OK" message after publishing a text note. it("publish and ok", function (done) { - // Test that a client interprets an "OK" message after publishing a text note. setup(done).then(({ publisher, subscriber, publisherSecret, url }) => { // TODO No signEvent, have a convenient way to do this signEvent(createTextNote(note), publisherSecret).then((event) => { -- 2.40.1 From 81b4fd0ce89ca90eedf4b8e190fb7c685eac2e2d Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 11:35:14 +0100 Subject: [PATCH 11/21] fetch relay info --- packages/nostr/README.md | 6 +- packages/nostr/relay/config.toml | 7 + packages/nostr/src/client/conn.ts | 12 +- packages/nostr/src/client/emitter.ts | 26 +- packages/nostr/src/client/index.ts | 340 +++++++++++++++++++++++++-- packages/nostr/test/dm.ts | 44 ++-- packages/nostr/test/relay-info.ts | 26 ++ packages/nostr/test/setup.ts | 73 ++++-- packages/nostr/test/text-note.ts | 14 +- 9 files changed, 458 insertions(+), 90 deletions(-) create mode 100644 packages/nostr/test/relay-info.ts diff --git a/packages/nostr/README.md b/packages/nostr/README.md index fa6296fd..55d81791 100644 --- a/packages/nostr/README.md +++ b/packages/nostr/README.md @@ -9,7 +9,7 @@ A strongly-typed nostr client for Node and the browser. The goal of the project is to have all of the following implemented and tested against a real-world relay implementation. -_Progress: 4/34 (12%)._ +_Progress: 5/34 (15%)._ - [X] NIP-01: Basic protocol flow description - [ ] NIP-02: Contact List and Petnames @@ -22,7 +22,7 @@ _Progress: 4/34 (12%)._ - [ ] NIP-09: Event Deletion - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - TODO Check if this applies -- [ ] NIP-11: Relay Information Document +- [X] NIP-11: Relay Information Document - [ ] NIP-12: Generic Tag Queries - [ ] NIP-13: Proof of Work - [ ] NIP-14: Subject tag in text events @@ -30,7 +30,7 @@ _Progress: 4/34 (12%)._ - [ ] NIP-19: bech32-encoded entities - [X] `npub` - [X] `nsec` - - [ ] `nprofile` + - [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr` - [X] NIP-20: Command Results - [ ] NIP-21: `nostr:` URL scheme - [ ] NIP-23: Long-form Content diff --git a/packages/nostr/relay/config.toml b/packages/nostr/relay/config.toml index f7ea106c..10bacad1 100644 --- a/packages/nostr/relay/config.toml +++ b/packages/nostr/relay/config.toml @@ -1,3 +1,10 @@ +[info] +relay_url = "wss://nostr.example.com/" +name = "nostr-rs-relay" +description = "nostr-rs-relay description" +contact = "mailto:contact@example.com" +favicon = "favicon.ico" + [authorization] nip42_auth = true # This seems to have no effect. diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index a2d8eac1..21c8bf20 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -14,13 +14,12 @@ import { unixTimestamp } from "../util" */ export class Conn { readonly #socket: WebSocket + // TODO This should probably be moved to Nostr because deciding whether or not to send a message + // requires looking at relay info which the Conn should know nothing about. /** * Messages which were requested to be sent before the websocket was ready. * Once the websocket becomes ready, these messages will be sent and cleared. */ - // TODO Another reason why pending messages might be required is when the user tries to send a message - // before NIP-44 auth. The legacy code reuses the same array for these two but I think they should be - // different, and the NIP-44 stuff should be handled by Nostr. #pending: OutgoingMessage[] = [] /** * Callback for errors. @@ -35,11 +34,13 @@ export class Conn { url, onMessage, onOpen, + onClose, onError, }: { url: URL onMessage: (msg: IncomingMessage) => void onOpen: () => void + onClose: () => void onError: (err: unknown) => void }) { this.#onError = onError @@ -71,9 +72,8 @@ export class Conn { onOpen() }) - this.#socket.addEventListener("error", (err) => { - onError(err) - }) + this.#socket.addEventListener("close", onClose) + this.#socket.addEventListener("error", onError) } send(msg: OutgoingMessage): void { diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index b08d100e..a57b5bdc 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -6,12 +6,17 @@ import { Event, EventId } from "../event" * Overrides providing better types for EventEmitter methods. */ export class EventEmitter extends Base { + constructor() { + super({ captureRejections: true }) + } + override addListener(eventName: "newListener", listener: NewListener): this override addListener( eventName: "removeListener", listener: RemoveListener ): this override addListener(eventName: "open", listener: OpenListener): this + override addListener(eventName: "close", listener: CloseListener): this override addListener(eventName: "event", listener: EventListener): this override addListener(eventName: "notice", listener: NoticeListener): this override addListener(eventName: "ok", listener: OkListener): this @@ -24,6 +29,7 @@ export class EventEmitter extends Base { override emit(eventName: "newListener", listener: NewListener): boolean override emit(eventName: "removeListener", listener: RemoveListener): boolean override emit(eventName: "open", relay: URL, nostr: Nostr): boolean + override emit(eventName: "close", relay: URL, nostr: Nostr): boolean override emit(eventName: "event", params: EventParams, nostr: Nostr): boolean override emit(eventName: "notice", notice: string, nostr: Nostr): boolean override emit(eventName: "ok", params: OkParams, nostr: Nostr): boolean @@ -44,6 +50,7 @@ export class EventEmitter extends Base { override listeners(eventName: "newListener"): EventListener[] override listeners(eventName: "removeListener"): EventListener[] override listeners(eventName: "open"): OpenListener[] + override listeners(eventName: "close"): CloseListener[] override listeners(eventName: "event"): EventListener[] override listeners(eventName: "notice"): NoticeListener[] override listeners(eventName: "ok"): OkListener[] @@ -56,6 +63,7 @@ export class EventEmitter extends Base { override off(eventName: "newListener", listener: NewListener): this override off(eventName: "removeListener", listener: RemoveListener): this override off(eventName: "open", listener: OpenListener): this + override off(eventName: "close", listener: CloseListener): this override off(eventName: "event", listener: EventListener): this override off(eventName: "notice", listener: NoticeListener): this override off(eventName: "ok", listener: OkListener): this @@ -68,6 +76,7 @@ export class EventEmitter extends Base { override on(eventName: "newListener", listener: NewListener): this override on(eventName: "removeListener", listener: RemoveListener): this override on(eventName: "open", listener: OpenListener): this + override on(eventName: "close", listener: CloseListener): this override on(eventName: "event", listener: EventListener): this override on(eventName: "notice", listener: NoticeListener): this override on(eventName: "ok", listener: OkListener): this @@ -80,6 +89,7 @@ export class EventEmitter extends Base { override once(eventName: "newListener", listener: NewListener): this override once(eventName: "removeListener", listener: RemoveListener): this override once(eventName: "open", listener: OpenListener): this + override once(eventName: "close", listener: CloseListener): this override once(eventName: "event", listener: EventListener): this override once(eventName: "notice", listener: NoticeListener): this override once(eventName: "ok", listener: OkListener): this @@ -98,6 +108,7 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override prependListener(eventName: "open", listener: OpenListener): this + override prependListener(eventName: "close", listener: CloseListener): this override prependListener(eventName: "event", listener: EventListener): this override prependListener(eventName: "notice", listener: NoticeListener): this override prependListener(eventName: "ok", listener: OkListener): this @@ -116,6 +127,10 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override prependOnceListener(eventName: "open", listener: OpenListener): this + override prependOnceListener( + eventName: "close", + listener: CloseListener + ): this override prependOnceListener( eventName: "event", listener: EventListener @@ -144,6 +159,7 @@ export class EventEmitter extends Base { listener: RemoveListener ): this override removeListener(eventName: "open", listener: OpenListener): this + override removeListener(eventName: "close", listener: CloseListener): this override removeListener(eventName: "event", listener: EventListener): this override removeListener(eventName: "notice", listener: NoticeListener): this override removeListener(eventName: "ok", listener: OkListener): this @@ -156,16 +172,16 @@ export class EventEmitter extends Base { override rawListeners(eventName: EventName): Listener[] { return super.rawListeners(eventName) as Listener[] } - - // TODO - // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } -// TODO Refactor the params to be a single interface +// TODO Refactor the params to always be a single interface +// TODO Params should always include relay as well +// TODO Params should not include Nostr, `this` should be Nostr type EventName = | "newListener" | "removeListener" | "open" + | "close" | "event" | "notice" | "ok" @@ -175,6 +191,7 @@ type EventName = type NewListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void type OpenListener = (relay: URL, nostr: Nostr) => void +type CloseListener = (relay: URL, nostr: Nostr) => void type EventListener = (params: EventParams, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void type OkListener = (params: OkParams, nostr: Nostr) => void @@ -185,6 +202,7 @@ type Listener = | NewListener | RemoveListener | OpenListener + | CloseListener | EventListener | NoticeListener | OkListener diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 364c1375..447b10a3 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -5,12 +5,29 @@ import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" +// TODO The EventEmitter will call "error" by default if errors are thrown, +// but if there is no error listener it actually rethrows the error. Revisit +// the try/catch stuff to be consistent with this. + /** * A nostr client. * * TODO Document the events here + * TODO When document this type, remember to explicitly say that promise rejections will also be routed to "error"! */ export class Nostr extends EventEmitter { + static get CONNECTING(): ReadyState.CONNECTING { + return ReadyState.CONNECTING + } + + static get OPEN(): ReadyState.OPEN { + return ReadyState.OPEN + } + + static get CLOSED(): ReadyState.CLOSED { + return ReadyState.CLOSED + } + // TODO NIP-44 AUTH, leave this for later /** * Open connections to relays. @@ -28,9 +45,14 @@ export class Nostr extends EventEmitter { * this method will only update it with the new options, and an exception will be thrown * if no options are specified. */ - open(url: URL | string, opts?: { read?: boolean; write?: boolean }): void { + async open( + url: URL | string, + opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } + ): Promise { + const connUrl = new URL(url) + // If the connection already exists, update the options. - const existingConn = this.#conns.get(url.toString()) + const existingConn = this.#conns.get(connUrl.toString()) if (existingConn !== undefined) { if (opts === undefined) { throw new Error( @@ -46,7 +68,11 @@ export class Nostr extends EventEmitter { return } - const connUrl = new URL(url) + // Fetch the relay info in parallel to opening the WebSocket connection. + const fetchInfo = + opts?.fetchInfo === false + ? Promise.resolve({}) + : this.#fetchRelayInfo(connUrl) // If there is no existing connection, open a new one. const conn = new Conn({ @@ -87,12 +113,74 @@ export class Nostr extends EventEmitter { this.emit("error", err, this) } }, - // Forward "open" events. - onOpen: () => this.emit("open", connUrl, this), + // Handle "open" events. + onOpen: async () => { + // Update the connection readyState. + const conn = this.#conns.get(connUrl.toString()) + if (conn === undefined) { + this.emit( + "error", + new Error( + `bug: expected connection to ${connUrl.toString()} to be in the map` + ), + this + ) + } else { + if (conn.readyState !== ReadyState.CONNECTING) { + this.emit( + "error", + new Error( + `bug: expected connection to ${connUrl.toString()} to have readyState CONNECTING, got ${ + conn.readyState + }` + ), + this + ) + } + this.#conns.set(connUrl.toString(), { + ...conn, + readyState: ReadyState.OPEN, + info: await fetchInfo, + }) + } + // Forward the event to the user. + this.emit("open", connUrl, this) + }, + // Handle "close" events. + onClose: () => { + // Update the connection readyState. + const conn = this.#conns.get(connUrl.toString()) + if (conn === undefined) { + this.emit( + "error", + new Error( + `bug: expected connection to ${connUrl.toString()} to be in the map` + ), + this + ) + } else { + this.#conns.set(connUrl.toString(), { + ...conn, + readyState: ReadyState.CLOSED, + info: + conn.readyState === ReadyState.CONNECTING ? undefined : conn.info, + }) + } + // Forward the event to the user. + this.emit("close", connUrl, this) + }, // Forward errors on this connection. onError: (err) => this.emit("error", err, this), }) + this.#conns.set(connUrl.toString(), { + conn, + auth: false, + read: opts?.read ?? true, + write: opts?.write ?? true, + readyState: ReadyState.CONNECTING, + }) + // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { conn.send({ @@ -101,13 +189,6 @@ export class Nostr extends EventEmitter { filters, }) } - - this.#conns.set(url.toString(), { - conn, - auth: false, - read: opts?.read ?? true, - write: opts?.write ?? true, - }) } /** @@ -116,23 +197,19 @@ export class Nostr extends EventEmitter { * @param url If specified, only close the connection to this relay. If the connection does * not exist, an exception will be thrown. If this parameter is not specified, all connections * will be closed. - * - * TODO There needs to be a way to check connection state. isOpen(), isReady(), isClosing() maybe? - * Because of how WebSocket states work this isn't as simple as it seems. */ close(url?: URL | string): void { if (url === undefined) { for (const { conn } of this.#conns.values()) { conn.close() } - this.#conns.clear() return } - const c = this.#conns.get(url.toString()) + const connUrl = new URL(url) + const c = this.#conns.get(connUrl.toString()) if (c === undefined) { throw new Error(`connection to ${url} doesn't exist`) } - this.#conns.delete(url.toString()) c.conn.close() } @@ -202,9 +279,219 @@ export class Nostr extends EventEmitter { }) } } + + // TODO Test the readyState stuff + /** + * Get the relays which this client has tried to open connections to. + */ + get relays(): Relay[] { + return [...this.#conns.entries()].map(([url, c]) => { + if (c.readyState === ReadyState.CONNECTING) { + return { + url: new URL(url), + readyState: ReadyState.CONNECTING, + } + } else if (c.readyState === ReadyState.OPEN) { + return { + url: new URL(url), + readyState: ReadyState.OPEN, + info: c.info, + } + } else if (c.readyState === ReadyState.CLOSED) { + return { + url: new URL(url), + readyState: ReadyState.CLOSED, + info: c.info, + } + } else { + throw new Error("bug: unknown readyState") + } + }) + } + + /** + * Fetch the NIP-11 relay info with some reasonable timeout. + */ + async #fetchRelayInfo(url: URL): Promise { + url = new URL(url.toString().replace(/^ws/, "http")) + try { + const abort = new AbortController() + const timeout = setTimeout(() => abort.abort(), 15_000) + const res = await fetch(url, { + signal: abort.signal, + headers: { + Accept: "application/nostr+json", + }, + }) + clearTimeout(timeout) + const info = await res.json() + // Validate the known fields in the JSON. + if (info.name !== undefined && typeof info.name !== "string") { + info.name = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "name" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if ( + info.description !== undefined && + typeof info.description !== "string" + ) { + info.description = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "description" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.pubkey !== undefined && typeof info.pubkey !== "string") { + info.pubkey = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.contact !== undefined && typeof info.contact !== "string") { + info.contact = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "contact" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.supported_nips !== undefined) { + if (info.supported_nips instanceof Array) { + if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { + info.supported_nips = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` + ), + this + ) + } + } else { + info.supported_nips = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( + info + )}` + ), + this + ) + } + } + if (info.software !== undefined && typeof info.software !== "string") { + info.software = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "software" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + if (info.version !== undefined && typeof info.version !== "string") { + info.version = undefined + this.emit( + "error", + new ProtocolError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ), + this + ) + } + return info + } catch (e) { + this.emit("error", e, this) + return {} + } + } } -interface ConnState { +/** + * The state of a relay connection. + */ +export enum ReadyState { + /** + * The connection has not been established yet. + */ + CONNECTING = 0, + /** + * The connection has been established. + */ + OPEN = 1, + /** + * The connection has been closed, forcefully or gracefully, by either party. + */ + CLOSED = 2, +} + +interface RelayCommon { + url: URL +} + +export type Relay = RelayCommon & + ( + | { + readyState: ReadyState.CONNECTING + } + | { + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + readyState: ReadyState.CLOSED + /** + * If the relay is closed before the opening process is fully finished, + * the relay info may be undefined. + */ + info?: RelayInfo + } + ) + +/** + * The information that a relay broadcasts about itself. + */ +export interface RelayInfo { + name?: string + description?: string + pubkey?: PublicKey + contact?: string + supported_nips?: number[] + software?: string + version?: string + [key: string]: unknown +} + +interface ConnStateCommon { conn: Conn /** * Has this connection been authenticated via NIP-44 AUTH? @@ -220,6 +507,21 @@ interface ConnState { write: boolean } +type ConnState = ConnStateCommon & + ( + | { + readyState: ReadyState.CONNECTING + } + | { + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + readyState: ReadyState.CLOSED + info?: RelayInfo + } + ) + /** * A string uniquely identifying a client subscription. */ diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index 1d024a89..104345c6 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -8,7 +8,8 @@ describe("dm", async function () { // Test that the intended recipient can receive and decrypt the direct message. it("to intended recipient", (done) => { - setup(done).then( + setup( + done, ({ publisher, publisherPubkey, @@ -17,33 +18,27 @@ describe("dm", async function () { subscriberPubkey, subscriberSecret, timestamp, + done, }) => { // Expect the direct message. subscriber.on( "event", async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { - try { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.DirectMessage) - assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) - assert.equal(actualSubscriptionId, subscriptionId) - assert.ok(event.created_at >= timestamp) + assert.equal(nostr, subscriber) + assert.equal(event.kind, EventKind.DirectMessage) + assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) + assert.equal(actualSubscriptionId, subscriptionId) + assert.ok(event.created_at >= timestamp) - if (event.kind === EventKind.DirectMessage) { - assert.equal( - event.getRecipient(), - parsePublicKey(subscriberPubkey) - ) - assert.equal(await event.getMessage(subscriberSecret), message) - } - - publisher.close() - subscriber.close() - - done() - } catch (e) { - done(e) + if (event.kind === EventKind.DirectMessage) { + assert.equal( + event.getRecipient(), + parsePublicKey(subscriberPubkey) + ) + assert.equal(await event.getMessage(subscriberSecret), message) } + + done() } ) @@ -67,7 +62,8 @@ describe("dm", async function () { // Test that an unintended recipient still receives the direct message event, but cannot decrypt it. it("to unintended recipient", (done) => { - setup(done).then( + setup( + done, ({ publisher, publisherPubkey, @@ -75,6 +71,7 @@ describe("dm", async function () { subscriber, subscriberSecret, timestamp, + done, }) => { const recipientPubkey = "npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc" @@ -101,9 +98,6 @@ describe("dm", async function () { ) } - publisher.close() - subscriber.close() - done() } catch (e) { done(e) diff --git a/packages/nostr/test/relay-info.ts b/packages/nostr/test/relay-info.ts new file mode 100644 index 00000000..382ffe75 --- /dev/null +++ b/packages/nostr/test/relay-info.ts @@ -0,0 +1,26 @@ +import assert from "assert" +import { Nostr } from "../src/client" +import { setup } from "./setup" + +describe("relay info", async function () { + it("fetching relay info", (done) => { + setup(done, ({ publisher, done }) => { + assert.strictEqual(publisher.relays.length, 1) + const relay = publisher.relays[0] + assert.strictEqual(relay.readyState, Nostr.OPEN) + if (relay.readyState === Nostr.OPEN) { + assert.strictEqual(relay.info.name, "nostr-rs-relay") + assert.strictEqual(relay.info.description, "nostr-rs-relay description") + assert.strictEqual(relay.info.pubkey, undefined) + assert.strictEqual(relay.info.contact, "mailto:contact@example.com") + assert.ok((relay.info.supported_nips?.length ?? 0) > 0) + assert.strictEqual( + relay.info.software, + "https://git.sr.ht/~gheartsfield/nostr-rs-relay" + ) + assert.strictEqual(relay.info.version, "0.8.8") + } + done() + }) + }) +}) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 1505fed3..cd2cb639 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -10,33 +10,60 @@ export interface Setup { subscriberPubkey: string timestamp: number url: URL + /** + * Signal that the test is done. Call this instead of the callback provided by + * mocha. This will also take care of test cleanup. + */ + done: (e?: unknown) => void } -export async function setup(done: jest.DoneCallback): Promise { - await restartRelay() - const publisher = new Nostr() - const subscriber = new Nostr() - const url = new URL("ws://localhost:12648") +export async function setup( + done: jest.DoneCallback, + test: (setup: Setup) => void | Promise +) { + try { + await restartRelay() + const publisher = new Nostr() + const subscriber = new Nostr() + const url = new URL("ws://localhost:12648") - publisher.on("error", done) - subscriber.on("error", done) + publisher.on("error", done) + subscriber.on("error", done) - publisher.open(url) - subscriber.open(url) + const openPromise = Promise.all([ + new Promise((resolve) => publisher.on("open", resolve)), + new Promise((resolve) => subscriber.on("open", resolve)), + ]) - return { - publisher, - publisherSecret: - "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", - publisherPubkey: - "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", - subscriber, - subscriberSecret: - "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", - subscriberPubkey: - "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", - timestamp: unixTimestamp(), - url, + await publisher.open(url) + await subscriber.open(url) + + await openPromise + + const result = test({ + publisher, + publisherSecret: + "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", + publisherPubkey: + "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", + subscriber, + subscriberSecret: + "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", + subscriberPubkey: + "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", + timestamp: unixTimestamp(), + url, + done: (e?: unknown) => { + publisher.close() + subscriber.close() + done(e) + }, + }) + if (result instanceof Promise) { + await result + } + } catch (e) { + done(e) } } @@ -60,7 +87,7 @@ async function restartRelay() { nostr.close() resolve(true) }) - nostr.open("ws://localhost:12648") + nostr.open("ws://localhost:12648", { fetchInfo: false }) }) if (ok) { break diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 1f581862..4d28dee5 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -8,13 +8,15 @@ describe("text note", async function () { // Test that a text note can be published by one client and received by the other. it("publish and receive", (done) => { - setup(done).then( + setup( + done, ({ publisher, publisherSecret, publisherPubkey, subscriber, timestamp, + done, }) => { // Expect the test event. subscriber.on( @@ -26,10 +28,6 @@ describe("text note", async function () { assert.strictEqual(event.created_at, timestamp) assert.strictEqual(event.content, note) assert.strictEqual(actualSubscriptionId, subscriptionId) - - subscriber.close() - publisher.close() - done() } ) @@ -53,7 +51,7 @@ describe("text note", async function () { // Test that a client interprets an "OK" message after publishing a text note. it("publish and ok", function (done) { - setup(done).then(({ publisher, subscriber, publisherSecret, url }) => { + setup(done, ({ publisher, publisherSecret, url, done }) => { // TODO No signEvent, have a convenient way to do this signEvent(createTextNote(note), publisherSecret).then((event) => { publisher.on("ok", (params, nostr) => { @@ -61,10 +59,6 @@ describe("text note", async function () { assert.equal(params.eventId, event.id) assert.equal(params.relay.toString(), url.toString()) assert.equal(params.ok, true) - - publisher.close() - subscriber.close() - done() }) -- 2.40.1 From 94d10a07be627da58194b234d6094cc7ad8637fb Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 21:29:52 +0100 Subject: [PATCH 12/21] test readyState --- packages/nostr/test/ready-state.ts | 24 ++++++++++++++++++++++++ packages/nostr/test/setup.ts | 9 +++++---- 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 packages/nostr/test/ready-state.ts diff --git a/packages/nostr/test/ready-state.ts b/packages/nostr/test/ready-state.ts new file mode 100644 index 00000000..22820ccd --- /dev/null +++ b/packages/nostr/test/ready-state.ts @@ -0,0 +1,24 @@ +import assert from "assert" +import { Nostr } from "../src/client" +import { relayUrl } from "./setup" + +describe("ready state", async function () { + it("ready state transitions", (done) => { + const nostr = new Nostr() + + nostr.on("error", done) + + nostr.on("open", () => { + assert.strictEqual(nostr.relays[0].readyState, Nostr.OPEN) + nostr.close() + }) + + nostr.on("close", () => { + assert.strictEqual(nostr.relays[0].readyState, Nostr.CLOSED) + done() + }) + + nostr.open(relayUrl) + assert.strictEqual(nostr.relays[0].readyState, Nostr.CONNECTING) + }) +}) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index cd2cb639..38b45360 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -1,6 +1,8 @@ import { Nostr } from "../src/client" import { unixTimestamp } from "../src/util" +export const relayUrl = new URL("ws://localhost:12648") + export interface Setup { publisher: Nostr publisherSecret: string @@ -25,7 +27,6 @@ export async function setup( await restartRelay() const publisher = new Nostr() const subscriber = new Nostr() - const url = new URL("ws://localhost:12648") publisher.on("error", done) subscriber.on("error", done) @@ -35,8 +36,8 @@ export async function setup( new Promise((resolve) => subscriber.on("open", resolve)), ]) - await publisher.open(url) - await subscriber.open(url) + await publisher.open(relayUrl) + await subscriber.open(relayUrl) await openPromise @@ -52,7 +53,7 @@ export async function setup( subscriberPubkey: "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", timestamp: unixTimestamp(), - url, + url: relayUrl, done: (e?: unknown) => { publisher.close() subscriber.close() -- 2.40.1 From 9c6d80db5e273333222fb7159883c00d3216ad59 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 21:43:55 +0100 Subject: [PATCH 13/21] export fetchRelayInfo --- packages/nostr/src/client/index.ts | 209 ++++++++++++----------------- 1 file changed, 85 insertions(+), 124 deletions(-) diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 447b10a3..f8c50515 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -28,7 +28,6 @@ export class Nostr extends EventEmitter { return ReadyState.CLOSED } - // TODO NIP-44 AUTH, leave this for later /** * Open connections to relays. */ @@ -72,7 +71,7 @@ export class Nostr extends EventEmitter { const fetchInfo = opts?.fetchInfo === false ? Promise.resolve({}) - : this.#fetchRelayInfo(connUrl) + : fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this)) // If there is no existing connection, open a new one. const conn = new Conn({ @@ -308,132 +307,94 @@ export class Nostr extends EventEmitter { } }) } +} - /** - * Fetch the NIP-11 relay info with some reasonable timeout. - */ - async #fetchRelayInfo(url: URL): Promise { - url = new URL(url.toString().replace(/^ws/, "http")) - try { - const abort = new AbortController() - const timeout = setTimeout(() => abort.abort(), 15_000) - const res = await fetch(url, { - signal: abort.signal, - headers: { - Accept: "application/nostr+json", - }, - }) - clearTimeout(timeout) - const info = await res.json() - // Validate the known fields in the JSON. - if (info.name !== undefined && typeof info.name !== "string") { - info.name = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "name" to be a string: ${JSON.stringify( - info - )}` - ), - this +// TODO Keep in mind this should be part of the public API of the lib +/** + * Fetch the NIP-11 relay info with some reasonable timeout. Throw an error if + * the info is invalid. + */ +export async function fetchRelayInfo(url: URL | string): Promise { + url = new URL(url.toString().trim().replace(/^ws/, "http")) + const abort = new AbortController() + const timeout = setTimeout(() => abort.abort(), 15_000) + const res = await fetch(url, { + signal: abort.signal, + headers: { + Accept: "application/nostr+json", + }, + }) + clearTimeout(timeout) + const info = await res.json() + // Validate the known fields in the JSON. + if (info.name !== undefined && typeof info.name !== "string") { + info.name = undefined + throw new ProtocolError( + `invalid relay info, expected "name" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.description !== undefined && typeof info.description !== "string") { + info.description = undefined + throw new ProtocolError( + `invalid relay info, expected "description" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.pubkey !== undefined && typeof info.pubkey !== "string") { + info.pubkey = undefined + throw new ProtocolError( + `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.contact !== undefined && typeof info.contact !== "string") { + info.contact = undefined + throw new ProtocolError( + `invalid relay info, expected "contact" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.supported_nips !== undefined) { + if (info.supported_nips instanceof Array) { + if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { + info.supported_nips = undefined + throw new ProtocolError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` ) } - if ( - info.description !== undefined && - typeof info.description !== "string" - ) { - info.description = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "description" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.pubkey !== undefined && typeof info.pubkey !== "string") { - info.pubkey = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.contact !== undefined && typeof info.contact !== "string") { - info.contact = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "contact" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.supported_nips !== undefined) { - if (info.supported_nips instanceof Array) { - if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { - info.supported_nips = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( - info - )}` - ), - this - ) - } - } else { - info.supported_nips = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( - info - )}` - ), - this - ) - } - } - if (info.software !== undefined && typeof info.software !== "string") { - info.software = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "software" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - if (info.version !== undefined && typeof info.version !== "string") { - info.version = undefined - this.emit( - "error", - new ProtocolError( - `invalid relay info, expected "version" to be a string: ${JSON.stringify( - info - )}` - ), - this - ) - } - return info - } catch (e) { - this.emit("error", e, this) - return {} + } else { + info.supported_nips = undefined + throw new ProtocolError( + `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( + info + )}` + ) } } + if (info.software !== undefined && typeof info.software !== "string") { + info.software = undefined + throw new ProtocolError( + `invalid relay info, expected "software" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.version !== undefined && typeof info.version !== "string") { + info.version = undefined + throw new ProtocolError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ) + } + return info } /** @@ -478,7 +439,7 @@ export type Relay = RelayCommon & ) /** - * The information that a relay broadcasts about itself. + * The information that a relay broadcasts about itself as defined in NIP-11. */ export interface RelayInfo { name?: string -- 2.40.1 From f7cf0a7b779359ea5bd433184a7d281d5edcfffc Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 22:12:22 +0100 Subject: [PATCH 14/21] cleanup --- packages/nostr/src/client/conn.ts | 46 ++++++++++++------- packages/nostr/src/client/emitter.ts | 1 + packages/nostr/src/client/index.ts | 69 +++++++++++++++------------- packages/nostr/test/setup.ts | 4 +- 4 files changed, 69 insertions(+), 51 deletions(-) diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 21c8bf20..47642e6d 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -14,7 +14,7 @@ import { unixTimestamp } from "../util" */ export class Conn { readonly #socket: WebSocket - // TODO This should probably be moved to Nostr because deciding whether or not to send a message + // TODO This should probably be moved to Nostr (ConnState) because deciding whether or not to send a message // requires looking at relay info which the Conn should know nothing about. /** * Messages which were requested to be sent before the websocket was ready. @@ -39,8 +39,8 @@ export class Conn { }: { url: URL onMessage: (msg: IncomingMessage) => void - onOpen: () => void - onClose: () => void + onOpen: () => void | Promise + onClose: () => void | Promise onError: (err: unknown) => void }) { this.#onError = onError @@ -48,14 +48,12 @@ export class Conn { // Handle incoming messages. this.#socket.addEventListener("message", async (msgData) => { - const value = msgData.data.valueOf() - // Validate and parse the message. - if (typeof value !== "string") { - const err = new ProtocolError(`invalid message data: ${value}`) - onError(err) - return - } try { + const value = msgData.data.valueOf() + // Validate and parse the message. + if (typeof value !== "string") { + throw new ProtocolError(`invalid message data: ${value}`) + } const msg = await parseIncomingMessage(value) onMessage(msg) } catch (err) { @@ -64,15 +62,31 @@ export class Conn { }) // When the connection is ready, send any outstanding messages. - this.#socket.addEventListener("open", () => { - for (const msg of this.#pending) { - this.send(msg) + this.#socket.addEventListener("open", async () => { + try { + for (const msg of this.#pending) { + this.send(msg) + } + this.#pending = [] + const result = onOpen() + if (result instanceof Promise) { + await result + } + } catch (e) { + onError(e) } - this.#pending = [] - onOpen() }) - this.#socket.addEventListener("close", onClose) + this.#socket.addEventListener("close", async () => { + try { + const result = onClose() + if (result instanceof Promise) { + await result + } + } catch (e) { + onError(e) + } + }) this.#socket.addEventListener("error", onError) } diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index a57b5bdc..260b38ae 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -177,6 +177,7 @@ export class EventEmitter extends Base { // TODO Refactor the params to always be a single interface // TODO Params should always include relay as well // TODO Params should not include Nostr, `this` should be Nostr +// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages type EventName = | "newListener" | "removeListener" diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index f8c50515..8036955b 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -44,10 +44,10 @@ export class Nostr extends EventEmitter { * this method will only update it with the new options, and an exception will be thrown * if no options are specified. */ - async open( + open( url: URL | string, opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } - ): Promise { + ): void { const connUrl = new URL(url) // If the connection already exists, update the options. @@ -76,6 +76,7 @@ export class Nostr extends EventEmitter { // If there is no existing connection, open a new one. const conn = new Conn({ url: connUrl, + // Handle messages on this connection. onMessage: async (msg) => { try { @@ -112,6 +113,7 @@ export class Nostr extends EventEmitter { this.emit("error", err, this) } }, + // Handle "open" events. onOpen: async () => { // Update the connection readyState. @@ -145,6 +147,7 @@ export class Nostr extends EventEmitter { // Forward the event to the user. this.emit("open", connUrl, this) }, + // Handle "close" events. onClose: () => { // Update the connection readyState. @@ -168,18 +171,14 @@ export class Nostr extends EventEmitter { // Forward the event to the user. this.emit("close", connUrl, this) }, + + // TODO If there is no error handler, this will silently swallow the error. Maybe have an + // #onError method which re-throws if emit() returns false? This should at least make + // some noise. // Forward errors on this connection. onError: (err) => this.emit("error", err, this), }) - this.#conns.set(connUrl.toString(), { - conn, - auth: false, - read: opts?.read ?? true, - write: opts?.write ?? true, - readyState: ReadyState.CONNECTING, - }) - // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { conn.send({ @@ -188,6 +187,14 @@ export class Nostr extends EventEmitter { filters, }) } + + this.#conns.set(connUrl.toString(), { + conn, + auth: false, + read: opts?.read ?? true, + write: opts?.write ?? true, + readyState: ReadyState.CONNECTING, + }) } /** @@ -279,7 +286,6 @@ export class Nostr extends EventEmitter { } } - // TODO Test the readyState stuff /** * Get the relays which this client has tried to open connections to. */ @@ -415,28 +421,25 @@ export enum ReadyState { CLOSED = 2, } -interface RelayCommon { - url: URL -} - -export type Relay = RelayCommon & - ( - | { - readyState: ReadyState.CONNECTING - } - | { - readyState: ReadyState.OPEN - info: RelayInfo - } - | { - readyState: ReadyState.CLOSED - /** - * If the relay is closed before the opening process is fully finished, - * the relay info may be undefined. - */ - info?: RelayInfo - } - ) +export type Relay = + | { + url: URL + readyState: ReadyState.CONNECTING + } + | { + url: URL + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + url: URL + readyState: ReadyState.CLOSED + /** + * If the relay is closed before the opening process is fully finished, + * the relay info may be undefined. + */ + info?: RelayInfo + } /** * The information that a relay broadcasts about itself as defined in NIP-11. diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 38b45360..8ccccba3 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -36,8 +36,8 @@ export async function setup( new Promise((resolve) => subscriber.on("open", resolve)), ]) - await publisher.open(relayUrl) - await subscriber.open(relayUrl) + publisher.open(relayUrl) + subscriber.open(relayUrl) await openPromise -- 2.40.1 From 3d09ca18c58bd667bc6c81b7a0aafa5ad4d0c643 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 17 Mar 2023 22:16:03 +0100 Subject: [PATCH 15/21] better async/await linting --- packages/nostr/.eslintrc.cjs | 5 ++++- packages/nostr/src/client/conn.ts | 6 +++--- packages/nostr/src/client/index.ts | 4 ++-- packages/nostr/src/crypto.ts | 2 +- packages/nostr/test/dm.ts | 2 +- packages/nostr/test/ready-state.ts | 2 +- packages/nostr/test/relay-info.ts | 2 +- packages/nostr/test/text-note.ts | 2 +- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/nostr/.eslintrc.cjs b/packages/nostr/.eslintrc.cjs index 1e571d86..f4b4dd57 100644 --- a/packages/nostr/.eslintrc.cjs +++ b/packages/nostr/.eslintrc.cjs @@ -3,10 +3,13 @@ module.exports = { parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, - ignorePatterns: ["dist/"], + ignorePatterns: ["dist/", "src/legacy"], env: { browser: true, node: true, mocha: true, }, + rules: { + "require-await": "warn", + }, } diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 47642e6d..53c23717 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -47,14 +47,14 @@ export class Conn { this.#socket = new WebSocket(url) // Handle incoming messages. - this.#socket.addEventListener("message", async (msgData) => { + this.#socket.addEventListener("message", (msgData) => { try { const value = msgData.data.valueOf() // Validate and parse the message. if (typeof value !== "string") { throw new ProtocolError(`invalid message data: ${value}`) } - const msg = await parseIncomingMessage(value) + const msg = parseIncomingMessage(value) onMessage(msg) } catch (err) { onError(err) @@ -249,7 +249,7 @@ function serializeFilters(filters: Filters[]): RawFilters[] { })) } -async function parseIncomingMessage(data: string): Promise { +function parseIncomingMessage(data: string): IncomingMessage { // Parse the incoming data as a nonempty JSON array. const json = parseJson(data) if (!(json instanceof Array)) { diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 8036955b..42953537 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -256,7 +256,7 @@ export class Nostr extends EventEmitter { * * TODO Reference subscribed() */ - async unsubscribe(subscriptionId: SubscriptionId): Promise { + unsubscribe(subscriptionId: SubscriptionId): void { if (!this.#subscriptions.delete(subscriptionId)) { throw new Error(`subscription ${subscriptionId} does not exist`) } @@ -274,7 +274,7 @@ export class Nostr extends EventEmitter { /** * Publish an event. */ - async publish(event: RawEvent): Promise { + publish(event: RawEvent): void { for (const { conn, write } of this.#conns.values()) { if (!write) { continue diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index 53dcbbb6..aab08508 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -90,7 +90,7 @@ export async function schnorrSign(data: Hex, priv: PrivateKey): Promise { /** * Verify that the elliptic curve signature is correct. */ -export async function schnorrVerify( +export function schnorrVerify( sig: Hex, data: Hex, key: PublicKey diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index 104345c6..e7db5feb 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -3,7 +3,7 @@ import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" -describe("dm", async function () { +describe("dm", () => { const message = "for your eyes only" // Test that the intended recipient can receive and decrypt the direct message. diff --git a/packages/nostr/test/ready-state.ts b/packages/nostr/test/ready-state.ts index 22820ccd..3e341ea2 100644 --- a/packages/nostr/test/ready-state.ts +++ b/packages/nostr/test/ready-state.ts @@ -2,7 +2,7 @@ import assert from "assert" import { Nostr } from "../src/client" import { relayUrl } from "./setup" -describe("ready state", async function () { +describe("ready state", () => { it("ready state transitions", (done) => { const nostr = new Nostr() diff --git a/packages/nostr/test/relay-info.ts b/packages/nostr/test/relay-info.ts index 382ffe75..3551f7b5 100644 --- a/packages/nostr/test/relay-info.ts +++ b/packages/nostr/test/relay-info.ts @@ -2,7 +2,7 @@ import assert from "assert" import { Nostr } from "../src/client" import { setup } from "./setup" -describe("relay info", async function () { +describe("relay info", () => { it("fetching relay info", (done) => { setup(done, ({ publisher, done }) => { assert.strictEqual(publisher.relays.length, 1) diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 4d28dee5..9b7b23eb 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -3,7 +3,7 @@ import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" -describe("text note", async function () { +describe("text note", () => { const note = "hello world" // Test that a text note can be published by one client and received by the other. -- 2.40.1 From e353c8da4fe775ce0b0fac6ed1ba2fd2bf29c39f Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sat, 18 Mar 2023 10:46:15 +0100 Subject: [PATCH 16/21] use strictEqual in tests --- packages/nostr/test/dm.ts | 25 ++++++++++++++----------- packages/nostr/test/text-note.ts | 8 ++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index e7db5feb..f826a22d 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -24,18 +24,21 @@ describe("dm", () => { subscriber.on( "event", async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.DirectMessage) - assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) - assert.equal(actualSubscriptionId, subscriptionId) + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.DirectMessage) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(actualSubscriptionId, subscriptionId) assert.ok(event.created_at >= timestamp) if (event.kind === EventKind.DirectMessage) { - assert.equal( + assert.strictEqual( event.getRecipient(), parsePublicKey(subscriberPubkey) ) - assert.equal(await event.getMessage(subscriberSecret), message) + assert.strictEqual( + await event.getMessage(subscriberSecret), + message + ) } done() @@ -81,14 +84,14 @@ describe("dm", () => { "event", async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { try { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.DirectMessage) - assert.equal(event.pubkey, parsePublicKey(publisherPubkey)) - assert.equal(actualSubscriptionId, subscriptionId) + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.DirectMessage) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(actualSubscriptionId, subscriptionId) assert.ok(event.created_at >= timestamp) if (event.kind === EventKind.DirectMessage) { - assert.equal( + assert.strictEqual( event.getRecipient(), parsePublicKey(recipientPubkey) ) diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 9b7b23eb..908d798b 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -55,10 +55,10 @@ describe("text note", () => { // TODO No signEvent, have a convenient way to do this signEvent(createTextNote(note), publisherSecret).then((event) => { publisher.on("ok", (params, nostr) => { - assert.equal(nostr, publisher) - assert.equal(params.eventId, event.id) - assert.equal(params.relay.toString(), url.toString()) - assert.equal(params.ok, true) + assert.strictEqual(nostr, publisher) + assert.strictEqual(params.eventId, event.id) + assert.strictEqual(params.relay.toString(), url.toString()) + assert.strictEqual(params.ok, true) done() }) -- 2.40.1 From 5556b27f0bda4d0391f6fcf1de6daac4dc58b1ee Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sat, 18 Mar 2023 10:47:54 +0100 Subject: [PATCH 17/21] additional eslint rules --- packages/nostr/.eslintrc.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nostr/.eslintrc.cjs b/packages/nostr/.eslintrc.cjs index f4b4dd57..d478b681 100644 --- a/packages/nostr/.eslintrc.cjs +++ b/packages/nostr/.eslintrc.cjs @@ -10,6 +10,7 @@ module.exports = { mocha: true, }, rules: { - "require-await": "warn", + "require-await": "error", + eqeqeq: "error", }, } -- 2.40.1 From c081b219291a655a40c33a52759d92a67c4c4ed8 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sat, 18 Mar 2023 18:11:25 +0100 Subject: [PATCH 18/21] allow arbitrary extensions --- packages/nostr/src/client/conn.ts | 41 +---- packages/nostr/src/client/index.ts | 274 +++++------------------------ packages/nostr/src/client/relay.ts | 142 +++++++++++++++ packages/nostr/src/event.ts | 33 ++-- packages/nostr/src/filters.ts | 81 +++++++++ packages/nostr/src/util.ts | 5 +- packages/nostr/test/text-note.ts | 13 +- 7 files changed, 312 insertions(+), 277 deletions(-) create mode 100644 packages/nostr/src/client/relay.ts create mode 100644 packages/nostr/src/filters.ts diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 53c23717..800b0dac 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,8 +1,8 @@ import { ProtocolError } from "../error" -import { Filters, SubscriptionId } from "." +import { SubscriptionId } from "." import { EventId, RawEvent } from "../event" import WebSocket from "isomorphic-ws" -import { unixTimestamp } from "../util" +import { Filters } from "../filters" /** * The connection to a relay. This is the lowest layer of the nostr protocol. @@ -204,26 +204,13 @@ export interface OutgoingCloseSubscription { id: SubscriptionId } -interface RawFilters { - ids?: string[] - authors?: string[] - kinds?: number[] - ["#e"]?: string[] - ["#p"]?: string[] - since?: number - until?: number - limit?: number -} - function serializeOutgoingMessage(msg: OutgoingMessage): string { if (msg.kind === "event") { return JSON.stringify(["EVENT", msg.event]) } else if (msg.kind === "openSubscription") { - return JSON.stringify([ - "REQ", - msg.id.toString(), - ...serializeFilters(msg.filters), - ]) + // If there are no filters, the client is expected to specify a single empty filter. + const filters = msg.filters.length === 0 ? [{}] : msg.filters + return JSON.stringify(["REQ", msg.id.toString(), ...filters]) } else if (msg.kind === "closeSubscription") { return JSON.stringify(["CLOSE", msg.id.toString()]) } else { @@ -231,24 +218,6 @@ function serializeOutgoingMessage(msg: OutgoingMessage): string { } } -function serializeFilters(filters: Filters[]): RawFilters[] { - if (filters.length === 0) { - return [{}] - } - return filters.map((filter) => ({ - ids: filter.ids, - authors: filter.authors, - kinds: filter.kinds, - ["#e"]: filter.eventTags, - ["#p"]: filter.pubkeyTags, - since: - filter.since instanceof Date ? unixTimestamp(filter.since) : filter.since, - until: - filter.until instanceof Date ? unixTimestamp(filter.until) : filter.until, - limit: filter.limit, - })) -} - function parseIncomingMessage(data: string): IncomingMessage { // Parse the incoming data as a nonempty JSON array. const json = parseJson(data) diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 42953537..8d514dfd 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,9 +1,10 @@ import { ProtocolError } from "../error" -import { EventId, EventKind, RawEvent, parseEvent } from "../event" -import { PublicKey } from "../crypto" +import { RawEvent, parseEvent } from "../event" import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" +import { fetchRelayInfo, ReadyState, Relay } from "./relay" +import { Filters } from "../filters" // TODO The EventEmitter will call "error" by default if errors are thrown, // but if there is no error listener it actually rethrows the error. Revisit @@ -31,7 +32,7 @@ export class Nostr extends EventEmitter { /** * Open connections to relays. */ - readonly #conns: Map = new Map() + readonly #conns: ConnState[] = [] /** * Mapping of subscription IDs to corresponding filters. @@ -48,10 +49,12 @@ export class Nostr extends EventEmitter { url: URL | string, opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } ): void { - const connUrl = new URL(url) + const relayUrl = new URL(url) // If the connection already exists, update the options. - const existingConn = this.#conns.get(connUrl.toString()) + const existingConn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (existingConn !== undefined) { if (opts === undefined) { throw new Error( @@ -71,11 +74,11 @@ export class Nostr extends EventEmitter { const fetchInfo = opts?.fetchInfo === false ? Promise.resolve({}) - : fetchRelayInfo(connUrl).catch((e) => this.emit("error", e, this)) + : fetchRelayInfo(relayUrl).catch((e) => this.emit("error", e, this)) // If there is no existing connection, open a new one. const conn = new Conn({ - url: connUrl, + url: relayUrl, // Handle messages on this connection. onMessage: async (msg) => { @@ -96,7 +99,7 @@ export class Nostr extends EventEmitter { "ok", { eventId: msg.eventId, - relay: connUrl, + relay: relayUrl, ok: msg.ok, message: msg.message, }, @@ -117,59 +120,58 @@ export class Nostr extends EventEmitter { // Handle "open" events. onOpen: async () => { // Update the connection readyState. - const conn = this.#conns.get(connUrl.toString()) + const conn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (conn === undefined) { this.emit( "error", new Error( - `bug: expected connection to ${connUrl.toString()} to be in the map` + `bug: expected connection to ${relayUrl.toString()} to be in the map` ), this ) } else { - if (conn.readyState !== ReadyState.CONNECTING) { + if (conn.relay.readyState !== ReadyState.CONNECTING) { this.emit( "error", new Error( - `bug: expected connection to ${connUrl.toString()} to have readyState CONNECTING, got ${ - conn.readyState + `bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${ + conn.relay.readyState }` ), this ) } - this.#conns.set(connUrl.toString(), { - ...conn, + conn.relay = { + ...conn.relay, readyState: ReadyState.OPEN, info: await fetchInfo, - }) + } } // Forward the event to the user. - this.emit("open", connUrl, this) + this.emit("open", relayUrl, this) }, // Handle "close" events. onClose: () => { // Update the connection readyState. - const conn = this.#conns.get(connUrl.toString()) + const conn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (conn === undefined) { this.emit( "error", new Error( - `bug: expected connection to ${connUrl.toString()} to be in the map` + `bug: expected connection to ${relayUrl.toString()} to be in the map` ), this ) } else { - this.#conns.set(connUrl.toString(), { - ...conn, - readyState: ReadyState.CLOSED, - info: - conn.readyState === ReadyState.CONNECTING ? undefined : conn.info, - }) + conn.relay.readyState = ReadyState.CLOSED } // Forward the event to the user. - this.emit("close", connUrl, this) + this.emit("close", relayUrl, this) }, // TODO If there is no error handler, this will silently swallow the error. Maybe have an @@ -188,12 +190,15 @@ export class Nostr extends EventEmitter { }) } - this.#conns.set(connUrl.toString(), { + this.#conns.push({ + relay: { + url: relayUrl, + readyState: ReadyState.CONNECTING, + }, conn, auth: false, read: opts?.read ?? true, write: opts?.write ?? true, - readyState: ReadyState.CONNECTING, }) } @@ -211,8 +216,10 @@ export class Nostr extends EventEmitter { } return } - const connUrl = new URL(url) - const c = this.#conns.get(connUrl.toString()) + const relayUrl = new URL(url) + const c = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (c === undefined) { throw new Error(`connection to ${url} doesn't exist`) } @@ -290,172 +297,23 @@ export class Nostr extends EventEmitter { * Get the relays which this client has tried to open connections to. */ get relays(): Relay[] { - return [...this.#conns.entries()].map(([url, c]) => { - if (c.readyState === ReadyState.CONNECTING) { - return { - url: new URL(url), - readyState: ReadyState.CONNECTING, - } - } else if (c.readyState === ReadyState.OPEN) { - return { - url: new URL(url), - readyState: ReadyState.OPEN, - info: c.info, - } - } else if (c.readyState === ReadyState.CLOSED) { - return { - url: new URL(url), - readyState: ReadyState.CLOSED, - info: c.info, - } + return this.#conns.map(({ relay }) => { + if (relay.readyState === ReadyState.CONNECTING) { + return { ...relay } } else { - throw new Error("bug: unknown readyState") + const info = + relay.info === undefined + ? undefined + : // Deep copy of the info. + JSON.parse(JSON.stringify(relay.info)) + return { ...relay, info } } }) } } -// TODO Keep in mind this should be part of the public API of the lib -/** - * Fetch the NIP-11 relay info with some reasonable timeout. Throw an error if - * the info is invalid. - */ -export async function fetchRelayInfo(url: URL | string): Promise { - url = new URL(url.toString().trim().replace(/^ws/, "http")) - const abort = new AbortController() - const timeout = setTimeout(() => abort.abort(), 15_000) - const res = await fetch(url, { - signal: abort.signal, - headers: { - Accept: "application/nostr+json", - }, - }) - clearTimeout(timeout) - const info = await res.json() - // Validate the known fields in the JSON. - if (info.name !== undefined && typeof info.name !== "string") { - info.name = undefined - throw new ProtocolError( - `invalid relay info, expected "name" to be a string: ${JSON.stringify( - info - )}` - ) - } - if (info.description !== undefined && typeof info.description !== "string") { - info.description = undefined - throw new ProtocolError( - `invalid relay info, expected "description" to be a string: ${JSON.stringify( - info - )}` - ) - } - if (info.pubkey !== undefined && typeof info.pubkey !== "string") { - info.pubkey = undefined - throw new ProtocolError( - `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( - info - )}` - ) - } - if (info.contact !== undefined && typeof info.contact !== "string") { - info.contact = undefined - throw new ProtocolError( - `invalid relay info, expected "contact" to be a string: ${JSON.stringify( - info - )}` - ) - } - if (info.supported_nips !== undefined) { - if (info.supported_nips instanceof Array) { - if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { - info.supported_nips = undefined - throw new ProtocolError( - `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( - info - )}` - ) - } - } else { - info.supported_nips = undefined - throw new ProtocolError( - `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( - info - )}` - ) - } - } - if (info.software !== undefined && typeof info.software !== "string") { - info.software = undefined - throw new ProtocolError( - `invalid relay info, expected "software" to be a string: ${JSON.stringify( - info - )}` - ) - } - if (info.version !== undefined && typeof info.version !== "string") { - info.version = undefined - throw new ProtocolError( - `invalid relay info, expected "version" to be a string: ${JSON.stringify( - info - )}` - ) - } - return info -} - -/** - * The state of a relay connection. - */ -export enum ReadyState { - /** - * The connection has not been established yet. - */ - CONNECTING = 0, - /** - * The connection has been established. - */ - OPEN = 1, - /** - * The connection has been closed, forcefully or gracefully, by either party. - */ - CLOSED = 2, -} - -export type Relay = - | { - url: URL - readyState: ReadyState.CONNECTING - } - | { - url: URL - readyState: ReadyState.OPEN - info: RelayInfo - } - | { - url: URL - readyState: ReadyState.CLOSED - /** - * If the relay is closed before the opening process is fully finished, - * the relay info may be undefined. - */ - info?: RelayInfo - } - -/** - * The information that a relay broadcasts about itself as defined in NIP-11. - */ -export interface RelayInfo { - name?: string - description?: string - pubkey?: PublicKey - contact?: string - supported_nips?: number[] - software?: string - version?: string - [key: string]: unknown -} - -interface ConnStateCommon { +interface ConnState { + relay: Relay conn: Conn /** * Has this connection been authenticated via NIP-44 AUTH? @@ -471,47 +329,11 @@ interface ConnStateCommon { write: boolean } -type ConnState = ConnStateCommon & - ( - | { - readyState: ReadyState.CONNECTING - } - | { - readyState: ReadyState.OPEN - info: RelayInfo - } - | { - readyState: ReadyState.CLOSED - info?: RelayInfo - } - ) - /** * A string uniquely identifying a client subscription. */ export type SubscriptionId = string -/** - * Subscription filters. All filters from the fields must pass for a message to get through. - */ -export interface Filters { - // TODO Document the filters, document that for the arrays only one is enough for the message to pass - ids?: EventId[] - authors?: string[] - kinds?: EventKind[] - /** - * Filters for the "e" tags. - */ - eventTags?: EventId[] - /** - * Filters for the "p" tags. - */ - pubkeyTags?: PublicKey[] - since?: Date | number - until?: Date | number - limit?: number -} - function randomSubscriptionId(): SubscriptionId { return secp.utils.bytesToHex(secp.utils.randomBytes(32)) } diff --git a/packages/nostr/src/client/relay.ts b/packages/nostr/src/client/relay.ts new file mode 100644 index 00000000..28c24b25 --- /dev/null +++ b/packages/nostr/src/client/relay.ts @@ -0,0 +1,142 @@ +import { PublicKey } from "../crypto" +import { ProtocolError } from "../error" + +export type Relay = + | { + url: URL + readyState: ReadyState.CONNECTING + } + | { + url: URL + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + url: URL + readyState: ReadyState.CLOSED + /** + * If the relay is closed before the opening process is fully finished, + * the relay info may be undefined. + */ + info?: RelayInfo + } + +/** + * The information that a relay broadcasts about itself as defined in NIP-11. + */ +export interface RelayInfo { + name?: string + description?: string + pubkey?: PublicKey + contact?: string + supported_nips?: number[] + software?: string + version?: string + [key: string]: unknown +} + +/** + * The state of a relay connection. + */ +export enum ReadyState { + /** + * The connection has not been established yet. + */ + CONNECTING = 0, + /** + * The connection has been established. + */ + OPEN = 1, + /** + * The connection has been closed, forcefully or gracefully, by either party. + */ + CLOSED = 2, +} + +// TODO Keep in mind this should be part of the public API of the lib +/** + * Fetch the NIP-11 relay info with some reasonable timeout. Throw an error if + * the info is invalid. + */ +export async function fetchRelayInfo(url: URL | string): Promise { + url = new URL(url.toString().trim().replace(/^ws/, "http")) + const abort = new AbortController() + const timeout = setTimeout(() => abort.abort(), 15_000) + const res = await fetch(url, { + signal: abort.signal, + headers: { + Accept: "application/nostr+json", + }, + }) + clearTimeout(timeout) + const info = await res.json() + // Validate the known fields in the JSON. + if (info.name !== undefined && typeof info.name !== "string") { + info.name = undefined + throw new ProtocolError( + `invalid relay info, expected "name" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.description !== undefined && typeof info.description !== "string") { + info.description = undefined + throw new ProtocolError( + `invalid relay info, expected "description" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.pubkey !== undefined && typeof info.pubkey !== "string") { + info.pubkey = undefined + throw new ProtocolError( + `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.contact !== undefined && typeof info.contact !== "string") { + info.contact = undefined + throw new ProtocolError( + `invalid relay info, expected "contact" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.supported_nips !== undefined) { + if (info.supported_nips instanceof Array) { + if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { + info.supported_nips = undefined + throw new ProtocolError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` + ) + } + } else { + info.supported_nips = undefined + throw new ProtocolError( + `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( + info + )}` + ) + } + } + if (info.software !== undefined && typeof info.software !== "string") { + info.software = undefined + throw new ProtocolError( + `invalid relay info, expected "software" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.version !== undefined && typeof info.version !== "string") { + info.version = undefined + throw new ProtocolError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ) + } + return info +} diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index 7337a5f4..9cd24dc6 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -45,6 +45,8 @@ export interface RawEvent { tags: string[][] content: string sig: string + + [key: string]: unknown } interface SetMetadata extends RawEvent { @@ -100,20 +102,24 @@ export type EventId = string /** * An unsigned event. */ -export type Unsigned = Omit< - T, - "id" | "pubkey" | "sig" | "created_at" -> & { - id?: EventId +export type Unsigned = { + [Property in keyof UnsignedWithPubkey as Exclude< + Property, + "pubkey" + >]: T[Property] +} & { pubkey?: PublicKey - sig?: string - created_at?: number } -type UnsignedWithPubkey = Omit< - T, - "id" | "sig" | "created_at" -> & { +/** + * Same as @see {@link Unsigned}, but with the pubkey field. + */ +type UnsignedWithPubkey = { + [Property in keyof T as Exclude< + Property, + "id" | "sig" | "created_at" + >]: T[Property] +} & { id?: EventId sig?: string created_at?: number @@ -131,7 +137,10 @@ export async function signEvent( if (priv !== undefined) { priv = parsePrivateKey(priv) event.pubkey = getPublicKey(priv) - const id = await serializeEventId(event as UnsignedWithPubkey) + const id = await serializeEventId( + // This conversion is safe because the pubkey field is set above. + event as unknown as UnsignedWithPubkey + ) event.id = id event.sig = await schnorrSign(id, priv) return event as T diff --git a/packages/nostr/src/filters.ts b/packages/nostr/src/filters.ts new file mode 100644 index 00000000..62b7cd95 --- /dev/null +++ b/packages/nostr/src/filters.ts @@ -0,0 +1,81 @@ +import { PublicKey } from "./crypto" +import { EventId, EventKind } from "./event" +import { Timestamp } from "./util" + +/** + * Subscription filters. All filters from the fields must pass for a message to get through. + */ +export interface Filters extends TagFilters { + // TODO Document the filters, document that for the arrays only one is enough for the message to pass + ids?: EventId[] + authors?: string[] + kinds?: EventKind[] + since?: Timestamp + until?: Timestamp + limit?: number + + /** + * Allows for arbitrary, nonstandard extensions. + */ + [key: string]: unknown +} + +/** + * Generic tag queries as defined by NIP-12. + */ +interface TagFilters { + ["#e"]: EventId[] + ["#p"]: PublicKey[] + + ["#a"]: string[] + ["#b"]: string[] + ["#c"]: string[] + ["#d"]: string[] + ["#f"]: string[] + ["#g"]: string[] + ["#h"]: string[] + ["#i"]: string[] + ["#j"]: string[] + ["#k"]: string[] + ["#l"]: string[] + ["#m"]: string[] + ["#n"]: string[] + ["#o"]: string[] + ["#q"]: string[] + ["#r"]: string[] + ["#s"]: string[] + ["#t"]: string[] + ["#u"]: string[] + ["#v"]: string[] + ["#w"]: string[] + ["#x"]: string[] + ["#y"]: string[] + ["#z"]: string[] + + ["#A"]: string[] + ["#B"]: string[] + ["#C"]: string[] + ["#D"]: string[] + ["#E"]: string[] + ["#F"]: string[] + ["#G"]: string[] + ["#H"]: string[] + ["#I"]: string[] + ["#J"]: string[] + ["#K"]: string[] + ["#L"]: string[] + ["#M"]: string[] + ["#N"]: string[] + ["#O"]: string[] + ["#P"]: string[] + ["#Q"]: string[] + ["#R"]: string[] + ["#S"]: string[] + ["#T"]: string[] + ["#U"]: string[] + ["#V"]: string[] + ["#W"]: string[] + ["#X"]: string[] + ["#Y"]: string[] + ["#Z"]: string[] +} diff --git a/packages/nostr/src/util.ts b/packages/nostr/src/util.ts index 7cd0fdba..7891688d 100644 --- a/packages/nostr/src/util.ts +++ b/packages/nostr/src/util.ts @@ -1,10 +1,13 @@ import { ProtocolError } from "./error" +// TODO Start using this +export type Timestamp = number + /** * Calculate the unix timestamp (seconds since epoch) of the `Date`. If no date is specified, * return the current unix timestamp. */ -export function unixTimestamp(date?: Date): number { +export function unixTimestamp(date?: Date): Timestamp { return Math.floor((date ?? new Date()).getTime() / 1000) } diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index 908d798b..fdc8b060 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -1,4 +1,10 @@ -import { createTextNote, EventKind, signEvent } from "../src/event" +import { + createTextNote, + EventKind, + signEvent, + TextNote, + Unsigned, +} from "../src/event" import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" @@ -41,7 +47,10 @@ describe("text note", () => { // TODO No signEvent, have a convenient way to do this signEvent( - { ...createTextNote(note), created_at: timestamp }, + { + ...createTextNote(note), + created_at: timestamp, + } as Unsigned, publisherSecret ).then((event) => publisher.publish(event)) }) -- 2.40.1 From e88fd7f9f2f1d167020cebdee64b96220f0c4e4b Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sat, 18 Mar 2023 20:15:54 +0100 Subject: [PATCH 19/21] saner error handling --- packages/nostr/src/client/conn.ts | 60 ++++++------ packages/nostr/src/client/emitter.ts | 3 +- packages/nostr/src/client/index.ts | 109 +++++++++++----------- packages/nostr/src/client/relay.ts | 18 ++-- packages/nostr/src/{util.ts => common.ts} | 17 +++- packages/nostr/src/crypto.ts | 3 +- packages/nostr/src/error.ts | 10 -- packages/nostr/src/event.ts | 25 +++-- packages/nostr/src/filters.ts | 2 +- packages/nostr/test/setup.ts | 4 +- 10 files changed, 119 insertions(+), 132 deletions(-) rename packages/nostr/src/{util.ts => common.ts} (68%) delete mode 100644 packages/nostr/src/error.ts diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 800b0dac..736e3337 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,4 +1,4 @@ -import { ProtocolError } from "../error" +import { NostrError } from "../common" import { SubscriptionId } from "." import { EventId, RawEvent } from "../event" import WebSocket from "isomorphic-ws" @@ -38,24 +38,24 @@ export class Conn { onError, }: { url: URL - onMessage: (msg: IncomingMessage) => void - onOpen: () => void | Promise - onClose: () => void | Promise + onMessage: (msg: IncomingMessage) => Promise + onOpen: () => Promise + onClose: () => void onError: (err: unknown) => void }) { this.#onError = onError this.#socket = new WebSocket(url) // Handle incoming messages. - this.#socket.addEventListener("message", (msgData) => { + this.#socket.addEventListener("message", async (msgData) => { try { const value = msgData.data.valueOf() // Validate and parse the message. if (typeof value !== "string") { - throw new ProtocolError(`invalid message data: ${value}`) + throw new NostrError(`invalid message data: ${value}`) } const msg = parseIncomingMessage(value) - onMessage(msg) + await onMessage(msg) } catch (err) { onError(err) } @@ -68,21 +68,15 @@ export class Conn { this.send(msg) } this.#pending = [] - const result = onOpen() - if (result instanceof Promise) { - await result - } + await onOpen() } catch (e) { onError(e) } }) - this.#socket.addEventListener("close", async () => { + this.#socket.addEventListener("close", () => { try { - const result = onClose() - if (result instanceof Promise) { - await result - } + onClose() } catch (e) { onError(e) } @@ -91,11 +85,11 @@ export class Conn { } send(msg: OutgoingMessage): void { - if (this.#socket.readyState < WebSocket.OPEN) { - this.#pending.push(msg) - return - } try { + if (this.#socket.readyState < WebSocket.OPEN) { + this.#pending.push(msg) + return + } this.#socket.send(serializeOutgoingMessage(msg), (err) => { if (err !== undefined && err !== null) { this.#onError?.(err) @@ -214,7 +208,7 @@ function serializeOutgoingMessage(msg: OutgoingMessage): string { } else if (msg.kind === "closeSubscription") { return JSON.stringify(["CLOSE", msg.id.toString()]) } else { - throw new Error(`invalid message: ${JSON.stringify(msg)}`) + throw new NostrError(`invalid message: ${JSON.stringify(msg)}`) } } @@ -222,21 +216,21 @@ function parseIncomingMessage(data: string): IncomingMessage { // Parse the incoming data as a nonempty JSON array. const json = parseJson(data) if (!(json instanceof Array)) { - throw new ProtocolError(`incoming message is not an array: ${data}`) + throw new NostrError(`incoming message is not an array: ${data}`) } if (json.length === 0) { - throw new ProtocolError(`incoming message is an empty array: ${data}`) + throw new NostrError(`incoming message is an empty array: ${data}`) } // Handle incoming events. if (json[0] === "EVENT") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "EVENT" should be a string, but wasn't: ${data}` ) } if (typeof json[2] !== "object") { - throw new ProtocolError( + throw new NostrError( `second element of "EVENT" should be an object, but wasn't: ${data}` ) } @@ -251,7 +245,7 @@ function parseIncomingMessage(data: string): IncomingMessage { // Handle incoming notices. if (json[0] === "NOTICE") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "NOTICE" should be a string, but wasn't: ${data}` ) } @@ -264,17 +258,17 @@ function parseIncomingMessage(data: string): IncomingMessage { // Handle incoming "OK" messages. if (json[0] === "OK") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "OK" should be a string, but wasn't: ${data}` ) } if (typeof json[2] !== "boolean") { - throw new ProtocolError( + throw new NostrError( `third element of "OK" should be a boolean, but wasn't: ${data}` ) } if (typeof json[3] !== "string") { - throw new ProtocolError( + throw new NostrError( `fourth element of "OK" should be a string, but wasn't: ${data}` ) } @@ -289,7 +283,7 @@ function parseIncomingMessage(data: string): IncomingMessage { // Handle incoming "EOSE" messages. if (json[0] === "EOSE") { if (typeof json[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `second element of "EOSE" should be a string, but wasn't: ${data}` ) } @@ -307,7 +301,7 @@ function parseIncomingMessage(data: string): IncomingMessage { } } - throw new ProtocolError(`unknown incoming message: ${data}`) + throw new NostrError(`unknown incoming message: ${data}`) } function parseEventData(json: { [key: string]: unknown }): RawEvent { @@ -323,7 +317,7 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent { typeof json["content"] !== "string" || typeof json["sig"] !== "string" ) { - throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`) + throw new NostrError(`invalid event: ${JSON.stringify(json)}`) } return json as unknown as RawEvent } @@ -332,6 +326,6 @@ function parseJson(data: string) { try { return JSON.parse(data) } catch (e) { - throw new ProtocolError(`invalid event json: ${data}`) + throw new NostrError(`invalid event json: ${data}`) } } diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 260b38ae..59663f4d 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -177,7 +177,8 @@ export class EventEmitter extends Base { // TODO Refactor the params to always be a single interface // TODO Params should always include relay as well // TODO Params should not include Nostr, `this` should be Nostr -// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages +// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages, +// "publish" for published events, "send" for sent messages type EventName = | "newListener" | "removeListener" diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 8d514dfd..df11d1bc 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,4 +1,4 @@ -import { ProtocolError } from "../error" +import { NostrError } from "../common" import { RawEvent, parseEvent } from "../event" import { Conn } from "./conn" import * as secp from "@noble/secp256k1" @@ -6,10 +6,6 @@ import { EventEmitter } from "./emitter" import { fetchRelayInfo, ReadyState, Relay } from "./relay" import { Filters } from "../filters" -// TODO The EventEmitter will call "error" by default if errors are thrown, -// but if there is no error listener it actually rethrows the error. Revisit -// the try/catch stuff to be consistent with this. - /** * A nostr client. * @@ -57,7 +53,7 @@ export class Nostr extends EventEmitter { ) if (existingConn !== undefined) { if (opts === undefined) { - throw new Error( + throw new NostrError( `called connect with existing connection ${url}, but options were not specified` ) } @@ -74,7 +70,10 @@ export class Nostr extends EventEmitter { const fetchInfo = opts?.fetchInfo === false ? Promise.resolve({}) - : fetchRelayInfo(relayUrl).catch((e) => this.emit("error", e, this)) + : fetchRelayInfo(relayUrl).catch((e) => { + this.#error(e) + return {} + }) // If there is no existing connection, open a new one. const conn = new Conn({ @@ -82,38 +81,34 @@ export class Nostr extends EventEmitter { // Handle messages on this connection. onMessage: async (msg) => { - try { - if (msg.kind === "event") { - this.emit( - "event", - { - event: await parseEvent(msg.event), - subscriptionId: msg.subscriptionId, - }, - this - ) - } else if (msg.kind === "notice") { - this.emit("notice", msg.notice, this) - } else if (msg.kind === "ok") { - this.emit( - "ok", - { - eventId: msg.eventId, - relay: relayUrl, - ok: msg.ok, - message: msg.message, - }, - this - ) - } else if (msg.kind === "eose") { - this.emit("eose", msg.subscriptionId, this) - } else if (msg.kind === "auth") { - // TODO This is incomplete - } else { - throw new ProtocolError(`invalid message ${JSON.stringify(msg)}`) - } - } catch (err) { - this.emit("error", err, this) + if (msg.kind === "event") { + this.emit( + "event", + { + event: await parseEvent(msg.event), + subscriptionId: msg.subscriptionId, + }, + this + ) + } else if (msg.kind === "notice") { + this.emit("notice", msg.notice, this) + } else if (msg.kind === "ok") { + this.emit( + "ok", + { + eventId: msg.eventId, + relay: relayUrl, + ok: msg.ok, + message: msg.message, + }, + this + ) + } else if (msg.kind === "eose") { + this.emit("eose", msg.subscriptionId, this) + } else if (msg.kind === "auth") { + // TODO This is incomplete + } else { + this.#error(new NostrError(`invalid message ${JSON.stringify(msg)}`)) } }, @@ -124,23 +119,19 @@ export class Nostr extends EventEmitter { (c) => c.relay.url.toString() === relayUrl.toString() ) if (conn === undefined) { - this.emit( - "error", - new Error( + this.#error( + new NostrError( `bug: expected connection to ${relayUrl.toString()} to be in the map` - ), - this + ) ) } else { if (conn.relay.readyState !== ReadyState.CONNECTING) { - this.emit( - "error", - new Error( + this.#error( + new NostrError( `bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${ conn.relay.readyState }` - ), - this + ) ) } conn.relay = { @@ -160,12 +151,10 @@ export class Nostr extends EventEmitter { (c) => c.relay.url.toString() === relayUrl.toString() ) if (conn === undefined) { - this.emit( - "error", - new Error( + this.#error( + new NostrError( `bug: expected connection to ${relayUrl.toString()} to be in the map` - ), - this + ) ) } else { conn.relay.readyState = ReadyState.CLOSED @@ -178,7 +167,7 @@ export class Nostr extends EventEmitter { // #onError method which re-throws if emit() returns false? This should at least make // some noise. // Forward errors on this connection. - onError: (err) => this.emit("error", err, this), + onError: (err) => this.#error(err), }) // Resend existing subscriptions to this connection. @@ -221,7 +210,7 @@ export class Nostr extends EventEmitter { (c) => c.relay.url.toString() === relayUrl.toString() ) if (c === undefined) { - throw new Error(`connection to ${url} doesn't exist`) + throw new NostrError(`connection to ${url} doesn't exist`) } c.conn.close() } @@ -265,7 +254,7 @@ export class Nostr extends EventEmitter { */ unsubscribe(subscriptionId: SubscriptionId): void { if (!this.#subscriptions.delete(subscriptionId)) { - throw new Error(`subscription ${subscriptionId} does not exist`) + throw new NostrError(`subscription ${subscriptionId} does not exist`) } for (const { conn, read } of this.#conns.values()) { if (!read) { @@ -310,6 +299,12 @@ export class Nostr extends EventEmitter { } }) } + + #error(e: unknown) { + if (!this.emit("error", e, this)) { + throw e + } + } } interface ConnState { diff --git a/packages/nostr/src/client/relay.ts b/packages/nostr/src/client/relay.ts index 28c24b25..ac8f9137 100644 --- a/packages/nostr/src/client/relay.ts +++ b/packages/nostr/src/client/relay.ts @@ -1,5 +1,5 @@ import { PublicKey } from "../crypto" -import { ProtocolError } from "../error" +import { NostrError } from "../common" export type Relay = | { @@ -73,7 +73,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { // Validate the known fields in the JSON. if (info.name !== undefined && typeof info.name !== "string") { info.name = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "name" to be a string: ${JSON.stringify( info )}` @@ -81,7 +81,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { } if (info.description !== undefined && typeof info.description !== "string") { info.description = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "description" to be a string: ${JSON.stringify( info )}` @@ -89,7 +89,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { } if (info.pubkey !== undefined && typeof info.pubkey !== "string") { info.pubkey = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( info )}` @@ -97,7 +97,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { } if (info.contact !== undefined && typeof info.contact !== "string") { info.contact = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "contact" to be a string: ${JSON.stringify( info )}` @@ -107,7 +107,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { if (info.supported_nips instanceof Array) { if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { info.supported_nips = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( info )}` @@ -115,7 +115,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { } } else { info.supported_nips = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( info )}` @@ -124,7 +124,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { } if (info.software !== undefined && typeof info.software !== "string") { info.software = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "software" to be a string: ${JSON.stringify( info )}` @@ -132,7 +132,7 @@ export async function fetchRelayInfo(url: URL | string): Promise { } if (info.version !== undefined && typeof info.version !== "string") { info.version = undefined - throw new ProtocolError( + throw new NostrError( `invalid relay info, expected "version" to be a string: ${JSON.stringify( info )}` diff --git a/packages/nostr/src/util.ts b/packages/nostr/src/common.ts similarity index 68% rename from packages/nostr/src/util.ts rename to packages/nostr/src/common.ts index 7891688d..f3154e2b 100644 --- a/packages/nostr/src/util.ts +++ b/packages/nostr/src/common.ts @@ -1,6 +1,6 @@ -import { ProtocolError } from "./error" - -// TODO Start using this +/** + * A UNIX timestamp. + */ export type Timestamp = number /** @@ -16,7 +16,16 @@ export function unixTimestamp(date?: Date): Timestamp { */ export function defined(v: T | undefined | null): T { if (v === undefined || v === null) { - throw new ProtocolError("bug: unexpected undefined") + throw new NostrError("bug: unexpected undefined") } return v } + +/** + * The error thrown by this library. + */ +export class NostrError extends Error { + constructor(message?: string) { + super(message) + } +} diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index aab08508..712b91fa 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -1,6 +1,7 @@ import * as secp from "@noble/secp256k1" import base64 from "base64-js" import { bech32 } from "bech32" +import { NostrError } from "./common" // TODO Use toHex as well as toString? Might be more explicit // Or maybe replace toString with toHex @@ -158,7 +159,7 @@ export async function aesDecryptBase64( const sharedKey = sharedPoint.slice(1, 33) if (typeof window === "object") { // TODO Can copy this from the legacy code - throw new Error("todo") + throw new NostrError("todo") } else { const crypto = await import("crypto") const decipher = crypto.createDecipheriv( diff --git a/packages/nostr/src/error.ts b/packages/nostr/src/error.ts deleted file mode 100644 index 075c677e..00000000 --- a/packages/nostr/src/error.ts +++ /dev/null @@ -1,10 +0,0 @@ -// TODO Rename to NostrError and move to util.ts, always throw NostrError and never throw Error -/** - * An error in the protocol. This error is thrown when a relay sends invalid or - * unexpected data, or otherwise behaves in an unexpected way. - */ -export class ProtocolError extends Error { - constructor(message?: string) { - super(message) - } -} diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts index 9cd24dc6..0bdbcc99 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event.ts @@ -1,4 +1,3 @@ -import { ProtocolError } from "./error" import { PublicKey, PrivateKey, @@ -12,7 +11,7 @@ import { parsePrivateKey, aesEncryptBase64, } from "./crypto" -import { defined, unixTimestamp } from "./util" +import { defined, Timestamp, unixTimestamp, NostrError } from "./common" // TODO Add remaining event types @@ -40,7 +39,7 @@ export enum EventKind { export interface RawEvent { id: string pubkey: PublicKey - created_at: number + created_at: Timestamp kind: EventKind tags: string[][] content: string @@ -146,7 +145,7 @@ export async function signEvent( return event as T } else { // TODO Try to use NIP-07, otherwise throw - throw new Error("todo") + throw new NostrError("todo") } } @@ -204,16 +203,14 @@ export async function createDirectMessage({ export async function parseEvent(event: RawEvent): Promise { // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. if (event.id !== (await serializeEventId(event))) { - throw new ProtocolError( + throw new NostrError( `invalid id ${event.id} for event ${JSON.stringify( event )}, expected ${await serializeEventId(event)}` ) } if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { - throw new ProtocolError( - `invalid signature for event ${JSON.stringify(event)}` - ) + throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`) } if (event.kind === EventKind.TextNote) { @@ -268,7 +265,7 @@ function getUserMetadata(this: Unsigned): UserMetadata { typeof userMetadata.about !== "string" || typeof userMetadata.picture !== "string" ) { - throw new ProtocolError( + throw new NostrError( `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` ) } @@ -284,11 +281,11 @@ async function getMessage( } const [data, iv] = this.content.split("?iv=") if (data === undefined || iv === undefined) { - throw new ProtocolError(`invalid direct message content ${this.content}`) + throw new NostrError(`invalid direct message content ${this.content}`) } if (priv === undefined) { // TODO Try to use NIP-07 - throw new Error("todo") + throw new NostrError("todo") } else if (getPublicKey(priv) === this.getRecipient()) { return await aesDecryptBase64(this.pubkey, priv, { data, iv }) } @@ -298,7 +295,7 @@ async function getMessage( function getRecipient(this: Unsigned): PublicKey { const recipientTag = this.tags.find((tag) => tag[0] === "p") if (typeof recipientTag?.[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `expected "p" tag to be of type string, but got ${ recipientTag?.[1] } in ${JSON.stringify(this)}` @@ -310,7 +307,7 @@ function getRecipient(this: Unsigned): PublicKey { function getPrevious(this: Unsigned): EventId | undefined { const previousTag = this.tags.find((tag) => tag[0] === "e") if (typeof previousTag?.[1] !== "string") { - throw new ProtocolError( + throw new NostrError( `expected "e" tag to be of type string, but got ${ previousTag?.[1] } in ${JSON.stringify(this)}` @@ -323,7 +320,7 @@ function parseJson(data: string) { try { return JSON.parse(data) } catch (e) { - throw new ProtocolError(`invalid json: ${e}: ${data}`) + throw new NostrError(`invalid json: ${e}: ${data}`) } } diff --git a/packages/nostr/src/filters.ts b/packages/nostr/src/filters.ts index 62b7cd95..6211fc99 100644 --- a/packages/nostr/src/filters.ts +++ b/packages/nostr/src/filters.ts @@ -1,6 +1,6 @@ import { PublicKey } from "./crypto" import { EventId, EventKind } from "./event" -import { Timestamp } from "./util" +import { Timestamp } from "./common" /** * Subscription filters. All filters from the fields must pass for a message to get through. diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 8ccccba3..f8d50e25 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -1,5 +1,5 @@ import { Nostr } from "../src/client" -import { unixTimestamp } from "../src/util" +import { Timestamp, unixTimestamp } from "../src/common" export const relayUrl = new URL("ws://localhost:12648") @@ -10,7 +10,7 @@ export interface Setup { subscriber: Nostr subscriberSecret: string subscriberPubkey: string - timestamp: number + timestamp: Timestamp url: URL /** * Signal that the test is done. Call this instead of the callback provided by -- 2.40.1 From dc5514bb74c48aea525e9645a1001bf7e334d5e8 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sat, 18 Mar 2023 20:43:59 +0100 Subject: [PATCH 20/21] update README --- packages/nostr/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nostr/README.md b/packages/nostr/README.md index 55d81791..b65045f2 100644 --- a/packages/nostr/README.md +++ b/packages/nostr/README.md @@ -9,7 +9,7 @@ A strongly-typed nostr client for Node and the browser. The goal of the project is to have all of the following implemented and tested against a real-world relay implementation. -_Progress: 5/34 (15%)._ +_Progress: 6/34 (18%)._ - [X] NIP-01: Basic protocol flow description - [ ] NIP-02: Contact List and Petnames @@ -23,7 +23,7 @@ _Progress: 5/34 (15%)._ - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - TODO Check if this applies - [X] NIP-11: Relay Information Document -- [ ] NIP-12: Generic Tag Queries +- [X] NIP-12: Generic Tag Queries - [ ] NIP-13: Proof of Work - [ ] NIP-14: Subject tag in text events - [X] NIP-15: End of Stored Events Notice -- 2.40.1 From 59c4b60a6a7133853f466a4fe5311b5147975213 Mon Sep 17 00:00:00 2001 From: ennmichael Date: Sun, 19 Mar 2023 00:25:12 +0100 Subject: [PATCH 21/21] implement nip-02 --- packages/nostr/README.md | 4 +- packages/nostr/src/client/conn.ts | 12 +- packages/nostr/src/common.ts | 11 + packages/nostr/src/event/contact-list.ts | 83 ++++++++ packages/nostr/src/event/direct-message.ts | 124 +++++++++++ .../nostr/src/{event.ts => event/index.ts} | 201 ++++-------------- packages/nostr/src/event/set-metadata.ts | 50 +++++ packages/nostr/src/event/text.ts | 18 ++ packages/nostr/test/contact-list.ts | 56 +++++ packages/nostr/test/dm.ts | 10 +- packages/nostr/test/text-note.ts | 27 ++- 11 files changed, 404 insertions(+), 192 deletions(-) create mode 100644 packages/nostr/src/event/contact-list.ts create mode 100644 packages/nostr/src/event/direct-message.ts rename packages/nostr/src/{event.ts => event/index.ts} (50%) create mode 100644 packages/nostr/src/event/set-metadata.ts create mode 100644 packages/nostr/src/event/text.ts create mode 100644 packages/nostr/test/contact-list.ts diff --git a/packages/nostr/README.md b/packages/nostr/README.md index b65045f2..b088f31f 100644 --- a/packages/nostr/README.md +++ b/packages/nostr/README.md @@ -9,10 +9,10 @@ A strongly-typed nostr client for Node and the browser. The goal of the project is to have all of the following implemented and tested against a real-world relay implementation. -_Progress: 6/34 (18%)._ +_Progress: 7/34 (20%)._ - [X] NIP-01: Basic protocol flow description -- [ ] NIP-02: Contact List and Petnames +- [X] NIP-02: Contact List and Petnames - [ ] NIP-03: OpenTimestamps Attestations for Events - [X] NIP-04: Encrypted Direct Message - [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 736e3337..4584fbe9 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,4 +1,4 @@ -import { NostrError } from "../common" +import { NostrError, parseJson } from "../common" import { SubscriptionId } from "." import { EventId, RawEvent } from "../event" import WebSocket from "isomorphic-ws" @@ -8,7 +8,7 @@ import { Filters } from "../filters" * The connection to a relay. This is the lowest layer of the nostr protocol. * The only responsibility of this type is to send and receive * well-formatted nostr messages on the underlying websocket. All other details of the protocol - * are handled by `Nostr`. + * are handled by `Nostr`. This type does not know anything about event semantics. * * @see Nostr */ @@ -321,11 +321,3 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent { } return json as unknown as RawEvent } - -function parseJson(data: string) { - try { - return JSON.parse(data) - } catch (e) { - throw new NostrError(`invalid event json: ${data}`) - } -} diff --git a/packages/nostr/src/common.ts b/packages/nostr/src/common.ts index f3154e2b..b1c542f6 100644 --- a/packages/nostr/src/common.ts +++ b/packages/nostr/src/common.ts @@ -21,6 +21,17 @@ export function defined(v: T | undefined | null): T { return v } +/** + * Parse the JSON and throw a @see {@link NostrError} in case of error. + */ +export function parseJson(data: string) { + try { + return JSON.parse(data) + } catch (e) { + throw new NostrError(`invalid json: ${e}: ${data}`) + } +} + /** * The error thrown by this library. */ diff --git a/packages/nostr/src/event/contact-list.ts b/packages/nostr/src/event/contact-list.ts new file mode 100644 index 00000000..e3740c74 --- /dev/null +++ b/packages/nostr/src/event/contact-list.ts @@ -0,0 +1,83 @@ +import { EventKind, RawEvent, Unsigned } from "." +import { NostrError } from "../common" +import { PublicKey } from "../crypto" + +/** + * Contact list event. + * + * Related NIPs: NIP-02. + */ +export interface ContactList extends RawEvent { + kind: EventKind.ContactList + + /** + * Get the contacts in from the contact list. + */ + getContacts(): Contact[] +} + +/** + * A contact from the contact list. + */ +export interface Contact { + pubkey: PublicKey + relay?: URL + petname?: string +} + +/** + * Create a contact list event. + */ +export function createContactList(contacts: Contact[]): Unsigned { + return { + kind: EventKind.ContactList, + tags: contacts.map((contact) => [ + "p", + contact.pubkey, + contact.relay?.toString() ?? "", + contact.petname ?? "", + ]), + content: "", + getContacts, + } +} + +export function getContacts(this: ContactList): Contact[] { + return this.tags + .filter((tags) => tags[0] === "p") + .map((tags) => { + // The first element is the pubkey. + const pubkey = tags[1] + if (pubkey === undefined) { + throw new NostrError( + `missing contact pubkey for contact list event: ${JSON.stringify( + this + )}` + ) + } + + // The second element is the optional relay URL. + let relay: URL | undefined + try { + if (tags[2] !== undefined && tags[2] !== "") { + relay = new URL(tags[2]) + } + } catch (e) { + throw new NostrError( + `invalid relay URL for contact list event: ${JSON.stringify(this)}` + ) + } + + // The third element is the optional petname. + let petname: string | undefined + if (tags[3] !== undefined && tags[3] !== "") { + petname = tags[3] + } + + return { + pubkey, + relay, + petname, + } + }) +} diff --git a/packages/nostr/src/event/direct-message.ts b/packages/nostr/src/event/direct-message.ts new file mode 100644 index 00000000..ade4b0c8 --- /dev/null +++ b/packages/nostr/src/event/direct-message.ts @@ -0,0 +1,124 @@ +import { + EventId, + EventKind, + RawEvent, + signEvent, + Unsigned, + UnsignedWithPubkey, +} from "." +import { defined, NostrError } from "../common" +import { + aesDecryptBase64, + aesEncryptBase64, + getPublicKey, + HexOrBechPrivateKey, + parsePrivateKey, + parsePublicKey, + PrivateKey, + PublicKey, +} from "../crypto" + +/** + * An encrypted direct message event. + * + * Related NIPs: NIP-04. + */ +export interface DirectMessage extends RawEvent { + kind: EventKind.DirectMessage + + /** + * Get the message plaintext, or undefined if you are not the recipient. + */ + getMessage(priv?: HexOrBechPrivateKey): Promise + /** + * Get the recipient pubkey. + */ + getRecipient(): PublicKey + /** + * Get the event ID of the previous message. + */ + getPrevious(): EventId | undefined +} + +// TODO Since you already require the private key, maybe this should return the message already signed? +// With NIP-07 the parameter will be optional, then what? +/** + * Create an encrypted direct message event. + */ +export async function createDirectMessage( + { + message, + recipient, + }: { + message: string + recipient: PublicKey + }, + priv?: PrivateKey +): Promise { + recipient = parsePublicKey(recipient) + if (priv === undefined) { + // TODO Use NIP-07 + throw new NostrError("todo") + } else { + priv = parsePrivateKey(priv) + const { data, iv } = await aesEncryptBase64(priv, recipient, message) + return await signEvent( + { + kind: EventKind.DirectMessage, + tags: [["p", recipient]], + content: `${data}?iv=${iv}`, + getMessage, + getRecipient, + getPrevious, + }, + priv + ) + } +} + +export async function getMessage( + this: UnsignedWithPubkey, + priv?: HexOrBechPrivateKey +): Promise { + if (priv !== undefined) { + priv = parsePrivateKey(priv) + } + const [data, iv] = this.content.split("?iv=") + if (data === undefined || iv === undefined) { + throw new NostrError(`invalid direct message content ${this.content}`) + } + if (priv === undefined) { + // TODO Try to use NIP-07 + throw new NostrError("todo") + } else if (getPublicKey(priv) === this.getRecipient()) { + return await aesDecryptBase64(this.pubkey, priv, { data, iv }) + } + return undefined +} + +export function getRecipient(this: Unsigned): PublicKey { + const recipientTag = this.tags.find((tag) => tag[0] === "p") + if (typeof recipientTag?.[1] !== "string") { + throw new NostrError( + `expected "p" tag to be of type string, but got ${ + recipientTag?.[1] + } in ${JSON.stringify(this)}` + ) + } + return recipientTag[1] +} + +export function getPrevious(this: Unsigned): EventId | undefined { + const previousTag = this.tags.find((tag) => tag[0] === "e") + if (previousTag === undefined) { + return undefined + } + if (typeof previousTag[1] !== "string") { + throw new NostrError( + `expected "e" tag to be of type string, but got ${ + previousTag?.[1] + } in ${JSON.stringify(this)}` + ) + } + return previousTag[1] +} diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event/index.ts similarity index 50% rename from packages/nostr/src/event.ts rename to packages/nostr/src/event/index.ts index 0bdbcc99..91bc184f 100644 --- a/packages/nostr/src/event.ts +++ b/packages/nostr/src/event/index.ts @@ -1,20 +1,32 @@ import { PublicKey, - PrivateKey, sha256, schnorrSign, schnorrVerify, - parsePublicKey, - aesDecryptBase64, getPublicKey, HexOrBechPrivateKey, parsePrivateKey, - aesEncryptBase64, -} from "./crypto" -import { defined, Timestamp, unixTimestamp, NostrError } from "./common" +} from "../crypto" +import { Timestamp, unixTimestamp, NostrError } from "../common" +import { TextNote } from "./text" +import { getUserMetadata, SetMetadata } from "./set-metadata" +import { + DirectMessage, + getMessage, + getPrevious, + getRecipient, +} from "./direct-message" +import { ContactList, getContacts } from "./contact-list" // TODO Add remaining event types +// TODO +// Think about this more +// Perhaps the best option is for all these factory methods to have an overload which also accept a private +// key as last parameter and return the event already signed +// Or maybe opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07, setting +// it to a string will use that string as the private key + export enum EventKind { SetMetadata = 0, // NIP-01 TextNote = 1, // NIP-01 @@ -48,50 +60,22 @@ export interface RawEvent { [key: string]: unknown } -interface SetMetadata extends RawEvent { - kind: EventKind.SetMetadata - - /** - * Get the user metadata specified in this event. - */ - getUserMetadata(): UserMetadata -} - -export interface TextNote extends RawEvent { - kind: EventKind.TextNote -} - -interface DirectMessage extends RawEvent { - kind: EventKind.DirectMessage - - /** - * Get the message plaintext, or undefined if this client is not the recipient. - */ - getMessage(priv?: HexOrBechPrivateKey): Promise - /** - * Get the recipient pubkey. - */ - getRecipient(): PublicKey - /** - * Get the event ID of the previous message. - */ - getPrevious(): EventId | undefined -} - export interface Unknown extends RawEvent { kind: Exclude< EventKind, - EventKind.SetMetadata | EventKind.TextNote | EventKind.DirectMessage + | EventKind.SetMetadata + | EventKind.TextNote + | EventKind.DirectMessage + | EventKind.ContactList > } -export type Event = SetMetadata | TextNote | DirectMessage | Unknown - -export interface UserMetadata { - name: string - about: string - picture: string -} +export type Event = + | SetMetadata + | TextNote + | ContactList + | DirectMessage + | Unknown /** * Event ID encoded as hex. @@ -110,10 +94,11 @@ export type Unsigned = { pubkey?: PublicKey } +// TODO This doesn't need to be exposed by the lib /** * Same as @see {@link Unsigned}, but with the pubkey field. */ -type UnsignedWithPubkey = { +export type UnsignedWithPubkey = { [Property in keyof T as Exclude< Property, "id" | "sig" | "created_at" @@ -149,59 +134,10 @@ export async function signEvent( } } -export function createTextNote(content: string): Unsigned { - return { - kind: EventKind.TextNote, - tags: [], - content, - } -} - -export function createSetMetadata( - content: UserMetadata -): Unsigned { - return { - kind: EventKind.SetMetadata, - tags: [], - content: JSON.stringify(content), - getUserMetadata, - } -} - -// TODO This is incomplete -// TODO Since you already have the private key, maybe this should return the message already signed? -// Think about this more -// Perhaps the best option is for all these factory methods to have an overload which also accept a private -// key as last parameter and return the event already signed, whereas for this method that would be -// mandatory -// E.g. opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07 -export async function createDirectMessage({ - message, - recipient, - priv, -}: { - message: string - recipient: PublicKey - priv: PrivateKey -}): Promise> { - recipient = parsePublicKey(recipient) - priv = parsePrivateKey(priv) - const { data, iv } = await aesEncryptBase64(priv, recipient, message) - return { - kind: EventKind.DirectMessage, - tags: [["p", recipient]], - content: `${data}?iv=${iv}`, - getMessage, - getRecipient, - getPrevious, - } -} - /** * Parse an event from its raw format. */ export async function parseEvent(event: RawEvent): Promise { - // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. if (event.id !== (await serializeEventId(event))) { throw new NostrError( `invalid id ${event.id} for event ${JSON.stringify( @@ -213,6 +149,9 @@ export async function parseEvent(event: RawEvent): Promise { throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`) } + // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. + // TODO Also validate that tags have at least one element + if (event.kind === EventKind.TextNote) { return { ...event, @@ -238,6 +177,14 @@ export async function parseEvent(event: RawEvent): Promise { } } + if (event.kind === EventKind.ContactList) { + return { + ...event, + kind: EventKind.ContactList, + getContacts, + } + } + return { ...event, kind: event.kind, @@ -258,72 +205,6 @@ async function serializeEventId( return await sha256(Uint8Array.from(charCodes(serialized))) } -function getUserMetadata(this: Unsigned): UserMetadata { - const userMetadata = parseJson(this.content) - if ( - typeof userMetadata.name !== "string" || - typeof userMetadata.about !== "string" || - typeof userMetadata.picture !== "string" - ) { - throw new NostrError( - `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` - ) - } - return userMetadata -} - -async function getMessage( - this: UnsignedWithPubkey, - priv?: HexOrBechPrivateKey -): Promise { - if (priv !== undefined) { - priv = parsePrivateKey(priv) - } - const [data, iv] = this.content.split("?iv=") - if (data === undefined || iv === undefined) { - throw new NostrError(`invalid direct message content ${this.content}`) - } - if (priv === undefined) { - // TODO Try to use NIP-07 - throw new NostrError("todo") - } else if (getPublicKey(priv) === this.getRecipient()) { - return await aesDecryptBase64(this.pubkey, priv, { data, iv }) - } - return undefined -} - -function getRecipient(this: Unsigned): PublicKey { - const recipientTag = this.tags.find((tag) => tag[0] === "p") - if (typeof recipientTag?.[1] !== "string") { - throw new NostrError( - `expected "p" tag to be of type string, but got ${ - recipientTag?.[1] - } in ${JSON.stringify(this)}` - ) - } - return recipientTag[1] -} - -function getPrevious(this: Unsigned): EventId | undefined { - const previousTag = this.tags.find((tag) => tag[0] === "e") - if (typeof previousTag?.[1] !== "string") { - throw new NostrError( - `expected "e" tag to be of type string, but got ${ - previousTag?.[1] - } in ${JSON.stringify(this)}` - ) - } - return defined(previousTag?.[1]) -} - -function parseJson(data: string) { - try { - return JSON.parse(data) - } catch (e) { - throw new NostrError(`invalid json: ${e}: ${data}`) - } -} - function* charCodes(data: string): Iterable { for (let i = 0; i < data.length; i++) { yield data.charCodeAt(i) diff --git a/packages/nostr/src/event/set-metadata.ts b/packages/nostr/src/event/set-metadata.ts new file mode 100644 index 00000000..0c20c05f --- /dev/null +++ b/packages/nostr/src/event/set-metadata.ts @@ -0,0 +1,50 @@ +import { EventKind, RawEvent, Unsigned } from "." +import { NostrError, parseJson } from "../common" + +/** + * Set metadata event. Used for disseminating use profile information. + * + * Related NIPs: NIP-01. + */ +export interface SetMetadata extends RawEvent { + kind: EventKind.SetMetadata + + /** + * Get the user metadata specified in this event. + */ + getUserMetadata(): UserMetadata +} + +export interface UserMetadata { + name: string + about: string + picture: string +} + +/** + * Create a set metadata event. + */ +export function createSetMetadata( + content: UserMetadata +): Unsigned { + return { + kind: EventKind.SetMetadata, + tags: [], + content: JSON.stringify(content), + getUserMetadata, + } +} + +export function getUserMetadata(this: Unsigned): UserMetadata { + const userMetadata = parseJson(this.content) + if ( + typeof userMetadata.name !== "string" || + typeof userMetadata.about !== "string" || + typeof userMetadata.picture !== "string" + ) { + throw new NostrError( + `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` + ) + } + return userMetadata +} diff --git a/packages/nostr/src/event/text.ts b/packages/nostr/src/event/text.ts new file mode 100644 index 00000000..0fb845a2 --- /dev/null +++ b/packages/nostr/src/event/text.ts @@ -0,0 +1,18 @@ +import { EventKind, RawEvent, Unsigned } from "." + +/** + * A text note event. Used for transmitting user posts. + * + * Related NIPs: NIP-01. + */ +export interface TextNote extends RawEvent { + kind: EventKind.TextNote +} + +export function createTextNote(content: string): Unsigned { + return { + kind: EventKind.TextNote, + tags: [], + content, + } +} diff --git a/packages/nostr/test/contact-list.ts b/packages/nostr/test/contact-list.ts new file mode 100644 index 00000000..a6dbedc4 --- /dev/null +++ b/packages/nostr/test/contact-list.ts @@ -0,0 +1,56 @@ +import assert from "assert" +import { EventKind, signEvent } from "../src/event" +import { createContactList } from "../src/event/contact-list" +import { setup } from "./setup" + +describe("contact-list", () => { + it("publish and receive the contact list", (done) => { + setup(done, ({ publisher, subscriber, subscriberSecret, done }) => { + const contacts = [ + { + pubkey: + "db9df52f7fcaf30b2718ad17e4c5521058bb20b95073b5c4ff53221b36447c4f", + relay: undefined, + petname: undefined, + }, + { + pubkey: + "94d5ce4cb06f67cab69a2f6e28e0a795222a74ac6a1dd6223743913cc99eaf37", + relay: new URL("ws://example.com"), + petname: undefined, + }, + { + pubkey: + "e6e9a25dbf3e931c991f43c97378e294c25f59e88adc91eda11ed17249a00c20", + relay: undefined, + petname: "john", + }, + { + pubkey: + "13d629a3a879f2157199491408711ff5e1450002a9f9d8b0ad750f1c6b96661d", + relay: new URL("ws://example2.com"), + petname: "jack", + }, + ] + + subscriber.on("event", ({ event }) => { + assert.strictEqual(event.kind, EventKind.ContactList) + assert.strictEqual(event.content, "") + if (event.kind === EventKind.ContactList) { + assert.deepStrictEqual(event.getContacts(), contacts) + } + done() + }) + + subscriber.subscribe([]) + + // After the subscription event sync is done, publish the test event. + subscriber.on("eose", async () => { + // TODO No signEvent, have a convenient way to do this + publisher.publish( + await signEvent(createContactList(contacts), subscriberSecret) + ) + }) + }) + }) +}) diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts index f826a22d..ec09324d 100644 --- a/packages/nostr/test/dm.ts +++ b/packages/nostr/test/dm.ts @@ -1,7 +1,8 @@ -import { createDirectMessage, EventKind, signEvent } from "../src/event" +import { EventKind, signEvent } from "../src/event" import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" +import { createDirectMessage } from "../src/event/direct-message" describe("dm", () => { const message = "for your eyes only" @@ -112,12 +113,11 @@ describe("dm", () => { subscriber.on("eose", async () => { // TODO No signEvent, do something more convenient - const event = await signEvent( - await createDirectMessage({ + const event = await createDirectMessage( + { message, recipient: recipientPubkey, - priv: publisherSecret, - }), + }, publisherSecret ) publisher.publish(event) diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts index fdc8b060..217ccfc2 100644 --- a/packages/nostr/test/text-note.ts +++ b/packages/nostr/test/text-note.ts @@ -1,13 +1,8 @@ -import { - createTextNote, - EventKind, - signEvent, - TextNote, - Unsigned, -} from "../src/event" +import { EventKind, signEvent, Unsigned } from "../src/event" import { parsePublicKey } from "../src/crypto" import assert from "assert" import { setup } from "./setup" +import { createTextNote, TextNote } from "../src/event/text" describe("text note", () => { const note = "hello world" @@ -41,18 +36,20 @@ describe("text note", () => { const subscriptionId = subscriber.subscribe([]) // After the subscription event sync is done, publish the test event. - subscriber.on("eose", (id, nostr) => { + subscriber.on("eose", async (id, nostr) => { assert.strictEqual(nostr, subscriber) assert.strictEqual(id, subscriptionId) // TODO No signEvent, have a convenient way to do this - signEvent( - { - ...createTextNote(note), - created_at: timestamp, - } as Unsigned, - publisherSecret - ).then((event) => publisher.publish(event)) + publisher.publish( + await signEvent( + { + ...createTextNote(note), + created_at: timestamp, + } as Unsigned, + publisherSecret + ) + ) }) } ) -- 2.40.1