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/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..5552ffde --- /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 --config ./config.toml" diff --git a/packages/nostr/relay/config.toml b/packages/nostr/relay/config.toml new file mode 100644 index 00000000..f7ea106c --- /dev/null +++ b/packages/nostr/relay/config.toml @@ -0,0 +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 new file mode 100644 index 00000000..1105f157 --- /dev/null +++ b/packages/nostr/relay/index.ts @@ -0,0 +1,26 @@ +/** + * 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" + +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..a2d8eac1 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) => { @@ -106,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. @@ -144,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. */ @@ -305,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/emitter.ts b/packages/nostr/src/client/emitter.ts index f672b15c..b08d100e 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,11 @@ export class EventEmitter extends Base { // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } +// TODO Refactor the params to be a single interface type EventName = | "newListener" | "removeListener" + | "open" | "event" | "notice" | "ok" @@ -163,6 +174,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 +184,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..364c1375 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -78,13 +78,17 @@ 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) } }, + // 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..7337a5f4 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,45 @@ 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 +// 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 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 +238,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 +268,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..1d024a89 --- /dev/null +++ b/packages/nostr/test/dm.ts @@ -0,0 +1,131 @@ +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) + } + + publisher.close() + subscriber.close() + + 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("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 + ) + } + + publisher.close() + subscriber.close() + + 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..1f581862 --- /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" + + // Test that a text note can be published by one client and received by the other. + it("publish and receive", (done) => { + 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)) + }) + } + ) + }) + + // 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 }) => { + // 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) + }) + }) + }) +})