nostr package: vastly simplify the API (#412)

* vastly simplify the api

* add missing await

* add eose to emitter

* add eose to conn

* add eose to the client

* eose test

* improve test suite, add dm tests

* demonstrate that nostr-rs-relay auth options don't work

* readme files

* cleanup

* fetch relay info

* test readyState

* export fetchRelayInfo

* cleanup

* better async/await linting

* use strictEqual in tests

* additional eslint rules

* allow arbitrary extensions

* saner error handling

* update README

* implement nip-02

---------

Co-authored-by: Kieran <kieran@harkin.me>
This commit is contained in:
sistemd
2023-03-27 11:09:48 +02:00
committed by GitHub
parent ee73b33cd8
commit 05605bdf28
32 changed files with 1758 additions and 875 deletions

View File

@ -1,26 +1,25 @@
import { ProtocolError } from "../error"
import { Filters, SubscriptionId } from "."
import { EventId, RawEvent, SignedEvent } from "../event"
import WebSocket from "ws"
import { unixTimestamp } from "../util"
import { NostrError, parseJson } from "../common"
import { SubscriptionId } from "."
import { EventId, RawEvent } from "../event"
import WebSocket from "isomorphic-ws"
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
*/
export class Conn {
readonly #socket: WebSocket
// 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.
* 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.
@ -34,10 +33,14 @@ export class Conn {
constructor({
url,
onMessage,
onOpen,
onClose,
onError,
}: {
url: URL
onMessage: (msg: IncomingMessage) => void
onMessage: (msg: IncomingMessage) => Promise<void>
onOpen: () => Promise<void>
onClose: () => void
onError: (err: unknown) => void
}) {
this.#onError = onError
@ -45,40 +48,48 @@ 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 msg = await Conn.#parseIncomingMessage(value)
onMessage(msg)
const value = msgData.data.valueOf()
// Validate and parse the message.
if (typeof value !== "string") {
throw new NostrError(`invalid message data: ${value}`)
}
const msg = parseIncomingMessage(value)
await onMessage(msg)
} catch (err) {
onError(err)
}
})
// 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 = []
await onOpen()
} catch (e) {
onError(e)
}
this.#pending = []
})
this.#socket.addEventListener("error", (err) => {
onError(err)
this.#socket.addEventListener("close", () => {
try {
onClose()
} catch (e) {
onError(e)
}
})
this.#socket.addEventListener("error", onError)
}
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)
@ -96,77 +107,19 @@ export class Conn {
this.#onError?.(err)
}
}
static async #parseIncomingMessage(data: string): Promise<IncomingMessage> {
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 raw = parseEventData(json[2])
return {
kind: "event",
subscriptionId: new SubscriptionId(json[1]),
raw,
}
}
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: new 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
| IncomingAuth
export type IncomingKind = "event" | "notice" | "ok"
export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth"
/**
* Incoming "EVENT" message.
@ -174,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok"
export interface IncomingEvent {
kind: "event"
subscriptionId: SubscriptionId
raw: RawEvent
event: RawEvent
}
/**
@ -195,6 +148,21 @@ export interface IncomingOk {
message: string
}
/**
* Incoming "EOSE" message.
*/
export interface IncomingEose {
kind: "eose"
subscriptionId: SubscriptionId
}
/**
* Incoming "AUTH" message.
*/
export interface IncomingAuth {
kind: "auth"
}
/**
* A message sent from the client to a relay.
*/
@ -210,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription"
*/
export interface OutgoingEvent {
kind: "event"
event: SignedEvent | RawEvent
event: RawEvent
}
/**
@ -230,49 +198,110 @@ 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") {
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",
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 {
throw new Error(`invalid message: ${JSON.stringify(msg)}`)
throw new NostrError(`invalid message: ${JSON.stringify(msg)}`)
}
}
function serializeFilters(filters: Filters[]): RawFilters[] {
if (filters.length === 0) {
return [{}]
function parseIncomingMessage(data: string): IncomingMessage {
// Parse the incoming data as a nonempty JSON array.
const json = parseJson(data)
if (!(json instanceof Array)) {
throw new NostrError(`incoming message is not an array: ${data}`)
}
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,
limit: filter.limit,
}))
if (json.length === 0) {
throw new NostrError(`incoming message is an empty array: ${data}`)
}
// Handle incoming events.
if (json[0] === "EVENT") {
if (typeof json[1] !== "string") {
throw new NostrError(
`second element of "EVENT" should be a string, but wasn't: ${data}`
)
}
if (typeof json[2] !== "object") {
throw new NostrError(
`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 NostrError(
`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 NostrError(
`second element of "OK" should be a string, but wasn't: ${data}`
)
}
if (typeof json[2] !== "boolean") {
throw new NostrError(
`third element of "OK" should be a boolean, but wasn't: ${data}`
)
}
if (typeof json[3] !== "string") {
throw new NostrError(
`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 NostrError(
`second element of "EOSE" should be a string, but wasn't: ${data}`
)
}
return {
kind: "eose",
subscriptionId: json[1],
}
}
// TODO This is incomplete
// Handle incoming "AUTH" messages.
if (json[0] === "AUTH") {
return {
kind: "auth",
}
}
throw new NostrError(`unknown incoming message: ${data}`)
}
function parseEventData(json: { [key: string]: unknown }): RawEvent {
@ -288,15 +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
}
function parseJson(data: string) {
try {
return JSON.parse(data)
} catch (e) {
throw new ProtocolError(`invalid event json: ${data}`)
}
}

View File

@ -1,29 +1,43 @@
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.
*/
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
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)
}
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
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)
@ -35,9 +49,12 @@ 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[]
override listeners(eventName: "eose"): EoseListener[]
override listeners(eventName: "error"): ErrorListener[]
override listeners(eventName: EventName): Listener[] {
return super.listeners(eventName) as Listener[]
@ -45,9 +62,12 @@ 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
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)
@ -55,9 +75,12 @@ 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
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)
@ -65,9 +88,12 @@ 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
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)
@ -81,9 +107,12 @@ export class EventEmitter extends Base {
eventName: "removeListener",
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
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)
@ -97,6 +126,11 @@ export class EventEmitter extends Base {
eventName: "removeListener",
listener: RemoveListener
): this
override prependOnceListener(eventName: "open", listener: OpenListener): this
override prependOnceListener(
eventName: "close",
listener: CloseListener
): this
override prependOnceListener(
eventName: "event",
listener: EventListener
@ -106,6 +140,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
@ -123,9 +158,12 @@ export class EventEmitter extends Base {
eventName: "removeListener",
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
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)
@ -134,41 +172,49 @@ 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 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?
// 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,
// "publish" for published events, "send" for sent messages
type EventName =
| "newListener"
| "removeListener"
| "open"
| "close"
| "event"
| "notice"
| "ok"
| "eose"
| "error"
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 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 =
| NewListener
| RemoveListener
| OpenListener
| CloseListener
| EventListener
| NoticeListener
| OkListener
| EoseListener
| ErrorListener
// TODO Document this
export interface EventParams {
signed: SignedEvent
event: Event
subscriptionId: SubscriptionId
raw: RawEvent
}
// TODO Document this

View File

@ -1,37 +1,39 @@
import { ProtocolError } from "../error"
import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event"
import { PrivateKey, PublicKey } from "../crypto"
import { NostrError } from "../common"
import { RawEvent, parseEvent } from "../event"
import { Conn } from "./conn"
import * as secp from "@noble/secp256k1"
import { EventEmitter } from "./emitter"
import { defined } from "../util"
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
import { Filters } from "../filters"
/**
* 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 {
// TODO NIP-44 AUTH, leave this for later
static get CONNECTING(): ReadyState.CONNECTING {
return ReadyState.CONNECTING
}
static get OPEN(): ReadyState.OPEN {
return ReadyState.OPEN
}
static get CLOSED(): ReadyState.CLOSED {
return ReadyState.CLOSED
}
/**
* Open connections to relays.
*/
readonly #conns: Map<string, ConnState> = new Map()
readonly #conns: ConnState[] = []
/**
* Mapping of subscription IDs to corresponding filters.
*/
readonly #subscriptions: Map<string, Filters[]> = new Map()
/**
* Optional client private key.
*/
readonly #key?: PrivateKey
constructor(key?: PrivateKey) {
super()
this.#key = key
}
readonly #subscriptions: Map<SubscriptionId, Filters[]> = new Map()
/**
* Open a connection and start communicating with a relay. This method recreates all existing
@ -39,12 +41,19 @@ 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 {
open(
url: URL | string,
opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean }
): void {
const relayUrl = new URL(url)
// If the connection already exists, update the options.
const existingConn = this.#conns.get(url.toString())
const existingConn = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (existingConn !== undefined) {
if (opts === undefined) {
throw new Error(
throw new NostrError(
`called connect with existing connection ${url}, but options were not specified`
)
}
@ -57,59 +66,124 @@ 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({})
: fetchRelayInfo(relayUrl).catch((e) => {
this.#error(e)
return {}
})
// 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) => {
try {
if (msg.kind === "event") {
this.emit(
"event",
{
signed: await SignedEvent.verify(msg.raw, this.#key),
subscriptionId: msg.subscriptionId,
raw: msg.raw,
},
this
)
} else if (msg.kind === "notice") {
this.emit("notice", msg.notice, this)
} else if (msg.kind === "ok") {
this.emit(
"ok",
{
eventId: msg.eventId,
relay: connUrl,
ok: msg.ok,
message: msg.message,
},
this
)
} else {
throw new ProtocolError(`invalid message ${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)}`))
}
},
// Handle "open" events.
onOpen: async () => {
// Update the connection readyState.
const conn = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (conn === undefined) {
this.#error(
new NostrError(
`bug: expected connection to ${relayUrl.toString()} to be in the map`
)
)
} else {
if (conn.relay.readyState !== ReadyState.CONNECTING) {
this.#error(
new NostrError(
`bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${
conn.relay.readyState
}`
)
)
}
conn.relay = {
...conn.relay,
readyState: ReadyState.OPEN,
info: await fetchInfo,
}
}
// Forward the event to the user.
this.emit("open", relayUrl, this)
},
// Handle "close" events.
onClose: () => {
// Update the connection readyState.
const conn = this.#conns.find(
(c) => c.relay.url.toString() === relayUrl.toString()
)
if (conn === undefined) {
this.#error(
new NostrError(
`bug: expected connection to ${relayUrl.toString()} to be in the map`
)
)
} else {
conn.relay.readyState = ReadyState.CLOSED
}
// Forward the event to the user.
this.emit("close", relayUrl, 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),
onError: (err) => this.#error(err),
})
// 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,
})
}
this.#conns.set(url.toString(), {
this.#conns.push({
relay: {
url: relayUrl,
readyState: ReadyState.CONNECTING,
},
conn,
auth: false,
read: opts?.read ?? true,
@ -123,23 +197,21 @@ 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 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`)
throw new NostrError(`connection to ${url} doesn't exist`)
}
this.#conns.delete(url.toString())
c.conn.close()
}
@ -159,9 +231,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
@ -180,9 +252,9 @@ export class Nostr extends EventEmitter {
*
* TODO Reference subscribed()
*/
async unsubscribe(subscriptionId: SubscriptionId): Promise<void> {
if (!this.#subscriptions.delete(subscriptionId.toString())) {
throw new Error(`subscription ${subscriptionId} does not exist`)
unsubscribe(subscriptionId: SubscriptionId): void {
if (!this.#subscriptions.delete(subscriptionId)) {
throw new NostrError(`subscription ${subscriptionId} does not exist`)
}
for (const { conn, read } of this.#conns.values()) {
if (!read) {
@ -198,48 +270,45 @@ export class Nostr extends EventEmitter {
/**
* Publish an event.
*/
async publish(event: SignedEvent): Promise<void>
async publish(event: RawEvent): Promise<void>
// 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<void>
async publish(
event: SignedEvent | RawEvent | Event,
key?: PrivateKey
): Promise<void> {
// 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")
}
}
publish(event: RawEvent): void {
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,
})
}
}
/**
* Get the relays which this client has tried to open connections to.
*/
get relays(): Relay[] {
return this.#conns.map(({ relay }) => {
if (relay.readyState === ReadyState.CONNECTING) {
return { ...relay }
} else {
const info =
relay.info === undefined
? undefined
: // Deep copy of the info.
JSON.parse(JSON.stringify(relay.info))
return { ...relay, info }
}
})
}
#error(e: unknown) {
if (!this.emit("error", e, this)) {
throw e
}
}
}
interface ConnState {
relay: Relay
conn: Conn
/**
* Has this connection been authenticated via NIP-44 AUTH?
@ -258,39 +327,8 @@ interface ConnState {
/**
* A string uniquely identifying a client subscription.
*/
export class SubscriptionId {
#id: string
export type SubscriptionId = string
constructor(subscriptionId: string) {
this.#id = subscriptionId
}
static random(): SubscriptionId {
return new SubscriptionId(secp.utils.bytesToHex(secp.utils.randomBytes(32)))
}
toString() {
return this.#id
}
}
/**
* 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
until?: Date
limit?: number
function randomSubscriptionId(): SubscriptionId {
return secp.utils.bytesToHex(secp.utils.randomBytes(32))
}

View File

@ -0,0 +1,142 @@
import { PublicKey } from "../crypto"
import { NostrError } from "../common"
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<RelayInfo> {
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 NostrError(
`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 NostrError(
`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 NostrError(
`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 NostrError(
`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 NostrError(
`invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify(
info
)}`
)
}
} else {
info.supported_nips = undefined
throw new NostrError(
`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 NostrError(
`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 NostrError(
`invalid relay info, expected "version" to be a string: ${JSON.stringify(
info
)}`
)
}
return info
}

View File

@ -0,0 +1,42 @@
/**
* A UNIX timestamp.
*/
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): Timestamp {
return Math.floor((date ?? new Date()).getTime() / 1000)
}
/**
* Throw if the parameter is null or undefined. Return the parameter otherwise.
*/
export function defined<T>(v: T | undefined | null): T {
if (v === undefined || v === null) {
throw new NostrError("bug: unexpected undefined")
}
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.
*/
export class NostrError extends Error {
constructor(message?: string) {
super(message)
}
}

View File

@ -1,105 +1,97 @@
import * as secp from "@noble/secp256k1"
import { ProtocolError } from "./error"
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
// 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<Uint8Array> {
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<Hex> {
return toHex(await secp.utils.sha256(data))
}
/**
* Sign the data using elliptic curve cryptography.
*/
export async function schnorrSign(
data: Hex,
key: PrivateKey
): Promise<Uint8Array> {
return secp.schnorr.sign(data.toString(), key.toHexDangerous())
export async function schnorrSign(data: Hex, priv: PrivateKey): Promise<Hex> {
return toHex(await secp.schnorr.sign(data, priv))
}
/**
* Verify that the elliptic curve signature is correct.
*/
export async function schnorrVerify(
export function schnorrVerify(
sig: Hex,
data: Hex,
key: PublicKey
@ -107,21 +99,13 @@ 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<AesEncryptedBase64> {
const sharedPoint = secp.getSharedSecret(
sender.toHexDangerous(),
"02" + recipient.toHex()
)
const sharedKey = sharedPoint.slice(2, 33)
const sharedPoint = secp.getSharedSecret(sender, "02" + recipient)
const sharedKey = sharedPoint.slice(1, 33)
if (typeof window === "object") {
const key = await window.crypto.subtle.importKey(
"raw",
@ -158,7 +142,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"),
@ -166,20 +150,16 @@ export async function aesEncryptBase64(
}
}
// TODO
export async function aesDecryptBase64(
sender: PublicKey,
recipient: PrivateKey,
{ data, iv }: AesEncryptedBase64
): Promise<string> {
const sharedPoint = secp.getSharedSecret(
recipient.toHexDangerous(),
"02" + sender.toHex()
)
const sharedKey = sharedPoint.slice(2, 33)
const sharedPoint = secp.getSharedSecret(recipient, "02" + sender)
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(
@ -192,33 +172,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
}

View File

@ -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)
}
}

View File

@ -1,366 +0,0 @@
import { ProtocolError } from "./error"
import {
PublicKey,
PrivateKey,
sha256,
Hex,
schnorrSign,
schnorrVerify,
aesDecryptBase64,
} from "./crypto"
import { defined, unixTimestamp } from "./util"
// TODO This file is missing proper documentation
// TODO Add remaining event types
export enum EventKind {
SetMetadata = 0, // NIP-01
TextNote = 1, // NIP-01
RecommendServer = 2, // NIP-01
ContactList = 3, // NIP-02
DirectMessage = 4, // NIP-04
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
Relays = 10002, // NIP-65
Auth = 22242, // NIP-42
PubkeyLists = 30000, // NIP-51a
NoteLists = 30001, // NIP-51b
TagLists = 30002, // NIP-51c
ZapRequest = 9734, // NIP 57
ZapReceipt = 9735, // NIP 57
}
export type Event =
| SetMetadataEvent
| TextNoteEvent
| DirectMessageEvent
| UnknownEvent
interface EventCommon {
pubkey: PublicKey
createdAt: Date
}
// TODO Refactor: the event names don't need to all end with *Event
export interface SetMetadataEvent extends EventCommon {
kind: EventKind.SetMetadata
content: UserMetadata
}
export interface UserMetadata {
name: string
about: string
picture: string
}
export interface TextNoteEvent extends EventCommon {
kind: EventKind.TextNote
content: string
}
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<EventKind, EventKind.SetMetadata | EventKind.TextNote>
}
// TODO Doc comment
export class EventId {
#hex: Hex
static async create(event: Event | RawEvent): Promise<EventId> {
// 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()
}
}
/**
* A signed event. Provides access to the event data, ID, and signature.
*/
export class SignedEvent {
#event: Readonly<Event>
#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<SignedEvent> {
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<SignedEvent> {
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 interface RawEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig: string
}
/**
* Parse an event from its raw format.
*/
async function parseEvent(
raw: RawEvent,
key: PrivateKey | undefined
): Promise<Event> {
const pubkey = new PublicKey(raw.pubkey)
const createdAt = new Date(raw.created_at * 1000)
const event = {
pubkey,
createdAt,
}
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) {
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)}`
)
}
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 })
}
return {
...event,
kind: EventKind.DirectMessage,
message,
recipient,
previous,
}
}
return {
...event,
kind: raw.kind,
}
}
function serializeTags(_event: Event): string[][] {
// TODO As I add different event kinds, this will change
return []
}
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 ""
}
}
/**
* Create a deep copy of the event.
*/
function deepCopy(event: Event): Event {
const common = {
createdAt: structuredClone(event.createdAt),
pubkey: event.pubkey,
}
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) {
throw new Error("todo")
} else {
return {
kind: event.kind,
...common,
}
}
}
function parseJson(data: string) {
try {
return JSON.parse(data)
} catch (e) {
throw new ProtocolError(`invalid json: ${data}`)
}
}
function* charCodes(data: string): Iterable<number> {
for (let i = 0; i < data.length; i++) {
yield data.charCodeAt(i)
}
}

View File

@ -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<ContactList> {
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,
}
})
}

View File

@ -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<string | undefined>
/**
* 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<DirectMessage> {
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<DirectMessage>,
priv?: HexOrBechPrivateKey
): Promise<string | undefined> {
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<RawEvent>): 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<RawEvent>): 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]
}

View File

@ -0,0 +1,212 @@
import {
PublicKey,
sha256,
schnorrSign,
schnorrVerify,
getPublicKey,
HexOrBechPrivateKey,
parsePrivateKey,
} 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
RecommendServer = 2, // NIP-01
ContactList = 3, // NIP-02
DirectMessage = 4, // NIP-04
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
Relays = 10002, // NIP-65
Auth = 22242, // NIP-42
PubkeyLists = 30000, // NIP-51a
NoteLists = 30001, // NIP-51b
TagLists = 30002, // NIP-51c
ZapRequest = 9734, // NIP 57
ZapReceipt = 9735, // NIP 57
}
/**
* A nostr event in the format that's sent across the wire.
*/
export interface RawEvent {
id: string
pubkey: PublicKey
created_at: Timestamp
kind: EventKind
tags: string[][]
content: string
sig: string
[key: string]: unknown
}
export interface Unknown extends RawEvent {
kind: Exclude<
EventKind,
| EventKind.SetMetadata
| EventKind.TextNote
| EventKind.DirectMessage
| EventKind.ContactList
>
}
export type Event =
| SetMetadata
| TextNote
| ContactList
| DirectMessage
| Unknown
/**
* Event ID encoded as hex.
*/
export type EventId = string
/**
* An unsigned event.
*/
export type Unsigned<T extends Event | RawEvent> = {
[Property in keyof UnsignedWithPubkey<T> as Exclude<
Property,
"pubkey"
>]: T[Property]
} & {
pubkey?: PublicKey
}
// TODO This doesn't need to be exposed by the lib
/**
* Same as @see {@link Unsigned}, but with the pubkey field.
*/
export type UnsignedWithPubkey<T extends Event | RawEvent> = {
[Property in keyof T as Exclude<
Property,
"id" | "sig" | "created_at"
>]: T[Property]
} & {
id?: EventId
sig?: string
created_at?: number
}
/**
* Add the "id," "sig," and "pubkey" fields to the event. Set "created_at" to the current timestamp
* if missing. Return the event.
*/
export async function signEvent<T extends Event | RawEvent>(
event: Unsigned<T>,
priv?: HexOrBechPrivateKey
): Promise<T> {
event.created_at ??= unixTimestamp()
if (priv !== undefined) {
priv = parsePrivateKey(priv)
event.pubkey = getPublicKey(priv)
const id = await serializeEventId(
// This conversion is safe because the pubkey field is set above.
event as unknown as UnsignedWithPubkey<T>
)
event.id = id
event.sig = await schnorrSign(id, priv)
return event as T
} else {
// TODO Try to use NIP-07, otherwise throw
throw new NostrError("todo")
}
}
/**
* Parse an event from its raw format.
*/
export async function parseEvent(event: RawEvent): Promise<Event> {
if (event.id !== (await serializeEventId(event))) {
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 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,
kind: EventKind.TextNote,
}
}
if (event.kind === EventKind.SetMetadata) {
return {
...event,
kind: EventKind.SetMetadata,
getUserMetadata,
}
}
if (event.kind === EventKind.DirectMessage) {
return {
...event,
kind: EventKind.DirectMessage,
getMessage,
getRecipient,
getPrevious,
}
}
if (event.kind === EventKind.ContactList) {
return {
...event,
kind: EventKind.ContactList,
getContacts,
}
}
return {
...event,
kind: event.kind,
}
}
async function serializeEventId(
event: UnsignedWithPubkey<RawEvent>
): Promise<EventId> {
// 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* charCodes(data: string): Iterable<number> {
for (let i = 0; i < data.length; i++) {
yield data.charCodeAt(i)
}
}

View File

@ -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<SetMetadata> {
return {
kind: EventKind.SetMetadata,
tags: [],
content: JSON.stringify(content),
getUserMetadata,
}
}
export function getUserMetadata(this: Unsigned<RawEvent>): 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
}

View File

@ -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<TextNote> {
return {
kind: EventKind.TextNote,
tags: [],
content,
}
}

View File

@ -0,0 +1,81 @@
import { PublicKey } from "./crypto"
import { EventId, EventKind } from "./event"
import { Timestamp } from "./common"
/**
* 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[]
}

View File

@ -1,18 +0,0 @@
import { ProtocolError } from "./error"
/**
* Calculate the unix timestamp (seconds since epoch) of the `Date`.
*/
export function unixTimestamp(date: Date): number {
return Math.floor(date.getTime() / 1000)
}
/**
* Throw if the parameter is null or undefined. Return the parameter otherwise.
*/
export function defined<T>(v: T | undefined | null): T {
if (v === undefined || v === null) {
throw new ProtocolError("bug: unexpected undefined")
}
return v
}