nostr
package: vastly simplify the API
#412
@ -3,10 +3,14 @@ module.exports = {
|
|||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
plugins: ["@typescript-eslint"],
|
plugins: ["@typescript-eslint"],
|
||||||
root: true,
|
root: true,
|
||||||
ignorePatterns: ["dist/"],
|
ignorePatterns: ["dist/", "src/legacy"],
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
node: true,
|
node: true,
|
||||||
mocha: true,
|
mocha: true,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"require-await": "error",
|
||||||
|
eqeqeq: "error",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
66
packages/nostr/README.md
Normal file
66
packages/nostr/README.md
Normal file
@ -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: 7/34 (20%)._
|
||||||
|
|
||||||
|
- [X] NIP-01: Basic protocol flow description
|
||||||
|
- [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
|
||||||
|
- [ ] 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
|
||||||
|
- [X] NIP-11: Relay Information Document
|
||||||
|
- [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
|
||||||
|
- [ ] NIP-19: bech32-encoded entities
|
||||||
|
- [X] `npub`
|
||||||
|
- [X] `nsec`
|
||||||
|
- [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr`
|
||||||
|
- [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_.
|
@ -1,6 +1,8 @@
|
|||||||
version: "3.1"
|
version: "3.1"
|
||||||
services:
|
services:
|
||||||
relay:
|
relay:
|
||||||
image: scsibug/nostr-rs-relay
|
build: ./relay
|
||||||
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- 12648:8080
|
- 12648:8080
|
||||||
|
- 12649:8000
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"watch": "tsc -w",
|
"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 ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -31,5 +31,12 @@
|
|||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
"ws": "^8.12.1"
|
"ws": "^8.12.1"
|
||||||
}
|
},
|
||||||
|
"directories": {
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": ""
|
||||||
}
|
}
|
||||||
|
1
packages/nostr/relay/.dockerignore
Normal file
1
packages/nostr/relay/.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
1
packages/nostr/relay/.gitignore
vendored
Normal file
1
packages/nostr/relay/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
12
packages/nostr/relay/Dockerfile
Normal file
12
packages/nostr/relay/Dockerfile
Normal file
@ -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"
|
11
packages/nostr/relay/config.toml
Normal file
11
packages/nostr/relay/config.toml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[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.
|
||||||
|
nip42_dms = true
|
26
packages/nostr/relay/index.ts
Normal file
26
packages/nostr/relay/index.ts
Normal file
@ -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)
|
14
packages/nostr/relay/package.json
Normal file
14
packages/nostr/relay/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,25 @@
|
|||||||
import { ProtocolError } from "../error"
|
import { NostrError, parseJson } from "../common"
|
||||||
import { Filters, SubscriptionId } from "."
|
import { SubscriptionId } from "."
|
||||||
import { EventId, RawEvent, SignedEvent } from "../event"
|
import { EventId, RawEvent } from "../event"
|
||||||
import WebSocket from "ws"
|
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.
|
* 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
|
* 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
|
* 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
|
* @see Nostr
|
||||||
*/
|
*/
|
||||||
export class Conn {
|
export class Conn {
|
||||||
readonly #socket: WebSocket
|
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.
|
* Messages which were requested to be sent before the websocket was ready.
|
||||||
* Once the websocket becomes ready, these messages will be sent and cleared.
|
* 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[] = []
|
#pending: OutgoingMessage[] = []
|
||||||
/**
|
/**
|
||||||
* Callback for errors.
|
* Callback for errors.
|
||||||
@ -34,10 +33,14 @@ export class Conn {
|
|||||||
constructor({
|
constructor({
|
||||||
url,
|
url,
|
||||||
onMessage,
|
onMessage,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
url: URL
|
url: URL
|
||||||
onMessage: (msg: IncomingMessage) => void
|
onMessage: (msg: IncomingMessage) => Promise<void>
|
||||||
|
onOpen: () => Promise<void>
|
||||||
|
onClose: () => void
|
||||||
onError: (err: unknown) => void
|
onError: (err: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
this.#onError = onError
|
this.#onError = onError
|
||||||
@ -45,40 +48,48 @@ export class Conn {
|
|||||||
|
|
||||||
// Handle incoming messages.
|
// Handle incoming messages.
|
||||||
this.#socket.addEventListener("message", async (msgData) => {
|
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 {
|
try {
|
||||||
const msg = await Conn.#parseIncomingMessage(value)
|
const value = msgData.data.valueOf()
|
||||||
onMessage(msg)
|
// 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) {
|
} catch (err) {
|
||||||
onError(err)
|
onError(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// When the connection is ready, send any outstanding messages.
|
// When the connection is ready, send any outstanding messages.
|
||||||
this.#socket.addEventListener("open", () => {
|
this.#socket.addEventListener("open", async () => {
|
||||||
for (const msg of this.#pending) {
|
try {
|
||||||
this.send(msg)
|
for (const msg of this.#pending) {
|
||||||
|
this.send(msg)
|
||||||
|
}
|
||||||
|
this.#pending = []
|
||||||
|
await onOpen()
|
||||||
|
} catch (e) {
|
||||||
|
onError(e)
|
||||||
}
|
}
|
||||||
this.#pending = []
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.#socket.addEventListener("error", (err) => {
|
this.#socket.addEventListener("close", () => {
|
||||||
onError(err)
|
try {
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
onError(e)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
this.#socket.addEventListener("error", onError)
|
||||||
}
|
}
|
||||||
|
|
||||||
send(msg: OutgoingMessage): void {
|
send(msg: OutgoingMessage): void {
|
||||||
if (this.#socket.readyState < WebSocket.OPEN) {
|
|
||||||
this.#pending.push(msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
if (this.#socket.readyState < WebSocket.OPEN) {
|
||||||
|
this.#pending.push(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.#socket.send(serializeOutgoingMessage(msg), (err) => {
|
this.#socket.send(serializeOutgoingMessage(msg), (err) => {
|
||||||
if (err !== undefined && err !== null) {
|
if (err !== undefined && err !== null) {
|
||||||
this.#onError?.(err)
|
this.#onError?.(err)
|
||||||
@ -96,77 +107,19 @@ export class Conn {
|
|||||||
this.#onError?.(err)
|
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.
|
* 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.
|
* Incoming "EVENT" message.
|
||||||
@ -174,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok"
|
|||||||
export interface IncomingEvent {
|
export interface IncomingEvent {
|
||||||
kind: "event"
|
kind: "event"
|
||||||
subscriptionId: SubscriptionId
|
subscriptionId: SubscriptionId
|
||||||
raw: RawEvent
|
event: RawEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -195,6 +148,21 @@ export interface IncomingOk {
|
|||||||
message: string
|
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.
|
* A message sent from the client to a relay.
|
||||||
*/
|
*/
|
||||||
@ -210,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription"
|
|||||||
*/
|
*/
|
||||||
export interface OutgoingEvent {
|
export interface OutgoingEvent {
|
||||||
kind: "event"
|
kind: "event"
|
||||||
event: SignedEvent | RawEvent
|
event: RawEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -230,49 +198,110 @@ export interface OutgoingCloseSubscription {
|
|||||||
id: SubscriptionId
|
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 {
|
function serializeOutgoingMessage(msg: OutgoingMessage): string {
|
||||||
if (msg.kind === "event") {
|
if (msg.kind === "event") {
|
||||||
const raw =
|
return JSON.stringify(["EVENT", msg.event])
|
||||||
msg.event instanceof SignedEvent ? msg.event.serialize() : msg.event
|
|
||||||
return JSON.stringify(["EVENT", raw])
|
|
||||||
} else if (msg.kind === "openSubscription") {
|
} else if (msg.kind === "openSubscription") {
|
||||||
return JSON.stringify([
|
// If there are no filters, the client is expected to specify a single empty filter.
|
||||||
"REQ",
|
const filters = msg.filters.length === 0 ? [{}] : msg.filters
|
||||||
msg.id.toString(),
|
return JSON.stringify(["REQ", msg.id.toString(), ...filters])
|
||||||
...serializeFilters(msg.filters),
|
|
||||||
])
|
|
||||||
} else if (msg.kind === "closeSubscription") {
|
} else if (msg.kind === "closeSubscription") {
|
||||||
return JSON.stringify(["CLOSE", msg.id.toString()])
|
return JSON.stringify(["CLOSE", msg.id.toString()])
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`invalid message: ${JSON.stringify(msg)}`)
|
throw new NostrError(`invalid message: ${JSON.stringify(msg)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeFilters(filters: Filters[]): RawFilters[] {
|
function parseIncomingMessage(data: string): IncomingMessage {
|
||||||
if (filters.length === 0) {
|
// Parse the incoming data as a nonempty JSON array.
|
||||||
return [{}]
|
const json = parseJson(data)
|
||||||
|
if (!(json instanceof Array)) {
|
||||||
|
throw new NostrError(`incoming message is not an array: ${data}`)
|
||||||
}
|
}
|
||||||
return filters.map((filter) => ({
|
if (json.length === 0) {
|
||||||
ids: filter.ids?.map((id) => id.toHex()),
|
throw new NostrError(`incoming message is an empty array: ${data}`)
|
||||||
authors: filter.authors?.map((author) => author),
|
}
|
||||||
kinds: filter.kinds?.map((kind) => kind),
|
|
||||||
["#e"]: filter.eventTags?.map((e) => e.toHex()),
|
// Handle incoming events.
|
||||||
["#p"]: filter.pubkeyTags?.map((p) => p.toHex()),
|
if (json[0] === "EVENT") {
|
||||||
since: filter.since !== undefined ? unixTimestamp(filter.since) : undefined,
|
if (typeof json[1] !== "string") {
|
||||||
until: filter.until !== undefined ? unixTimestamp(filter.until) : undefined,
|
throw new NostrError(
|
||||||
limit: filter.limit,
|
`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 {
|
function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
||||||
@ -288,15 +317,7 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
|||||||
typeof json["content"] !== "string" ||
|
typeof json["content"] !== "string" ||
|
||||||
typeof json["sig"] !== "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
|
return json as unknown as RawEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data)
|
|
||||||
} catch (e) {
|
|
||||||
throw new ProtocolError(`invalid event json: ${data}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,29 +1,43 @@
|
|||||||
import Base from "events"
|
import Base from "events"
|
||||||
import { Nostr, SubscriptionId } from "."
|
import { Nostr, SubscriptionId } from "."
|
||||||
import { EventId, RawEvent, SignedEvent } from "../event"
|
import { Event, EventId } from "../event"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides providing better types for EventEmitter methods.
|
* Overrides providing better types for EventEmitter methods.
|
||||||
*/
|
*/
|
||||||
export class EventEmitter extends Base {
|
export class EventEmitter extends Base {
|
||||||
|
constructor() {
|
||||||
|
super({ captureRejections: true })
|
||||||
|
}
|
||||||
|
|
||||||
override addListener(eventName: "newListener", listener: NewListener): this
|
override addListener(eventName: "newListener", listener: NewListener): this
|
||||||
override addListener(
|
override addListener(
|
||||||
eventName: "removeListener",
|
eventName: "removeListener",
|
||||||
listener: RemoveListener
|
listener: RemoveListener
|
||||||
): this
|
): 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: "notice", listener: NoticeListener): this
|
||||||
override addListener(eventName: "ok", listener: OkListener): 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: "error", listener: ErrorListener): this
|
||||||
override addListener(eventName: "newListener", listener: ErrorListener): this
|
|
||||||
override addListener(eventName: EventName, listener: Listener): this {
|
override addListener(eventName: EventName, listener: Listener): this {
|
||||||
return super.addListener(eventName, listener)
|
return super.addListener(eventName, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
override emit(eventName: "newListener", listener: NewListener): boolean
|
override emit(eventName: "newListener", listener: NewListener): boolean
|
||||||
override emit(eventName: "removeListener", listener: RemoveListener): 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: "event", params: EventParams, nostr: Nostr): boolean
|
||||||
override emit(eventName: "notice", notice: string, nostr: Nostr): boolean
|
override emit(eventName: "notice", notice: string, nostr: Nostr): boolean
|
||||||
override emit(eventName: "ok", params: OkParams, 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: "error", err: unknown, nostr: Nostr): boolean
|
||||||
override emit(eventName: EventName, ...args: unknown[]): boolean {
|
override emit(eventName: EventName, ...args: unknown[]): boolean {
|
||||||
return super.emit(eventName, ...args)
|
return super.emit(eventName, ...args)
|
||||||
@ -35,9 +49,12 @@ export class EventEmitter extends Base {
|
|||||||
|
|
||||||
override listeners(eventName: "newListener"): EventListener[]
|
override listeners(eventName: "newListener"): EventListener[]
|
||||||
override listeners(eventName: "removeListener"): EventListener[]
|
override listeners(eventName: "removeListener"): EventListener[]
|
||||||
|
override listeners(eventName: "open"): OpenListener[]
|
||||||
|
override listeners(eventName: "close"): CloseListener[]
|
||||||
override listeners(eventName: "event"): EventListener[]
|
override listeners(eventName: "event"): EventListener[]
|
||||||
override listeners(eventName: "notice"): NoticeListener[]
|
override listeners(eventName: "notice"): NoticeListener[]
|
||||||
override listeners(eventName: "ok"): OkListener[]
|
override listeners(eventName: "ok"): OkListener[]
|
||||||
|
override listeners(eventName: "eose"): EoseListener[]
|
||||||
override listeners(eventName: "error"): ErrorListener[]
|
override listeners(eventName: "error"): ErrorListener[]
|
||||||
override listeners(eventName: EventName): Listener[] {
|
override listeners(eventName: EventName): Listener[] {
|
||||||
return super.listeners(eventName) as 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: "newListener", listener: NewListener): this
|
||||||
override off(eventName: "removeListener", listener: RemoveListener): 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: "event", listener: EventListener): this
|
||||||
override off(eventName: "notice", listener: NoticeListener): this
|
override off(eventName: "notice", listener: NoticeListener): this
|
||||||
override off(eventName: "ok", listener: OkListener): 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: "error", listener: ErrorListener): this
|
||||||
override off(eventName: EventName, listener: Listener): this {
|
override off(eventName: EventName, listener: Listener): this {
|
||||||
return super.off(eventName, listener)
|
return super.off(eventName, listener)
|
||||||
@ -55,9 +75,12 @@ export class EventEmitter extends Base {
|
|||||||
|
|
||||||
override on(eventName: "newListener", listener: NewListener): this
|
override on(eventName: "newListener", listener: NewListener): this
|
||||||
override on(eventName: "removeListener", listener: RemoveListener): 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: "event", listener: EventListener): this
|
||||||
override on(eventName: "notice", listener: NoticeListener): this
|
override on(eventName: "notice", listener: NoticeListener): this
|
||||||
override on(eventName: "ok", listener: OkListener): 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: "error", listener: ErrorListener): this
|
||||||
override on(eventName: EventName, listener: Listener): this {
|
override on(eventName: EventName, listener: Listener): this {
|
||||||
return super.on(eventName, listener)
|
return super.on(eventName, listener)
|
||||||
@ -65,9 +88,12 @@ export class EventEmitter extends Base {
|
|||||||
|
|
||||||
override once(eventName: "newListener", listener: NewListener): this
|
override once(eventName: "newListener", listener: NewListener): this
|
||||||
override once(eventName: "removeListener", listener: RemoveListener): 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: "event", listener: EventListener): this
|
||||||
override once(eventName: "notice", listener: NoticeListener): this
|
override once(eventName: "notice", listener: NoticeListener): this
|
||||||
override once(eventName: "ok", listener: OkListener): 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: "error", listener: ErrorListener): this
|
||||||
override once(eventName: EventName, listener: Listener): this {
|
override once(eventName: EventName, listener: Listener): this {
|
||||||
return super.once(eventName, listener)
|
return super.once(eventName, listener)
|
||||||
@ -81,9 +107,12 @@ export class EventEmitter extends Base {
|
|||||||
eventName: "removeListener",
|
eventName: "removeListener",
|
||||||
listener: RemoveListener
|
listener: RemoveListener
|
||||||
): this
|
): 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: "event", listener: EventListener): this
|
||||||
override prependListener(eventName: "notice", listener: NoticeListener): this
|
override prependListener(eventName: "notice", listener: NoticeListener): this
|
||||||
override prependListener(eventName: "ok", listener: OkListener): 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: "error", listener: ErrorListener): this
|
||||||
override prependListener(eventName: EventName, listener: Listener): this {
|
override prependListener(eventName: EventName, listener: Listener): this {
|
||||||
return super.prependListener(eventName, listener)
|
return super.prependListener(eventName, listener)
|
||||||
@ -97,6 +126,11 @@ export class EventEmitter extends Base {
|
|||||||
eventName: "removeListener",
|
eventName: "removeListener",
|
||||||
listener: RemoveListener
|
listener: RemoveListener
|
||||||
): this
|
): this
|
||||||
|
override prependOnceListener(eventName: "open", listener: OpenListener): this
|
||||||
|
override prependOnceListener(
|
||||||
|
eventName: "close",
|
||||||
|
listener: CloseListener
|
||||||
|
): this
|
||||||
override prependOnceListener(
|
override prependOnceListener(
|
||||||
eventName: "event",
|
eventName: "event",
|
||||||
listener: EventListener
|
listener: EventListener
|
||||||
@ -106,6 +140,7 @@ export class EventEmitter extends Base {
|
|||||||
listener: NoticeListener
|
listener: NoticeListener
|
||||||
): this
|
): this
|
||||||
override prependOnceListener(eventName: "ok", listener: OkListener): this
|
override prependOnceListener(eventName: "ok", listener: OkListener): this
|
||||||
|
override prependOnceListener(eventName: "eose", listener: EoseListener): this
|
||||||
override prependOnceListener(
|
override prependOnceListener(
|
||||||
eventName: "error",
|
eventName: "error",
|
||||||
listener: ErrorListener
|
listener: ErrorListener
|
||||||
@ -123,9 +158,12 @@ export class EventEmitter extends Base {
|
|||||||
eventName: "removeListener",
|
eventName: "removeListener",
|
||||||
listener: RemoveListener
|
listener: RemoveListener
|
||||||
): this
|
): 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: "event", listener: EventListener): this
|
||||||
override removeListener(eventName: "notice", listener: NoticeListener): this
|
override removeListener(eventName: "notice", listener: NoticeListener): this
|
||||||
override removeListener(eventName: "ok", listener: OkListener): 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: "error", listener: ErrorListener): this
|
||||||
override removeListener(eventName: EventName, listener: Listener): this {
|
override removeListener(eventName: EventName, listener: Listener): this {
|
||||||
return super.removeListener(eventName, listener)
|
return super.removeListener(eventName, listener)
|
||||||
@ -134,41 +172,49 @@ export class EventEmitter extends Base {
|
|||||||
override rawListeners(eventName: EventName): Listener[] {
|
override rawListeners(eventName: EventName): Listener[] {
|
||||||
return super.rawListeners(eventName) as 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 Refactor the params to always be a single interface
|
||||||
// TODO Also add on: ("ok", boolean, eventId) which checks "OK"/"NOTICE" and makes a callback?
|
// 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 =
|
type EventName =
|
||||||
| "newListener"
|
| "newListener"
|
||||||
| "removeListener"
|
| "removeListener"
|
||||||
|
| "open"
|
||||||
|
| "close"
|
||||||
| "event"
|
| "event"
|
||||||
| "notice"
|
| "notice"
|
||||||
| "ok"
|
| "ok"
|
||||||
|
| "eose"
|
||||||
| "error"
|
| "error"
|
||||||
|
|
||||||
type NewListener = (eventName: EventName, listener: Listener) => void
|
type NewListener = (eventName: EventName, listener: Listener) => void
|
||||||
type RemoveListener = (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 EventListener = (params: EventParams, nostr: Nostr) => void
|
||||||
type OkListener = (params: OkParams, nostr: Nostr) => void
|
|
||||||
type NoticeListener = (notice: string, 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 ErrorListener = (error: unknown, nostr: Nostr) => void
|
||||||
|
|
||||||
type Listener =
|
type Listener =
|
||||||
| NewListener
|
| NewListener
|
||||||
| RemoveListener
|
| RemoveListener
|
||||||
|
| OpenListener
|
||||||
|
| CloseListener
|
||||||
| EventListener
|
| EventListener
|
||||||
| NoticeListener
|
| NoticeListener
|
||||||
| OkListener
|
| OkListener
|
||||||
|
| EoseListener
|
||||||
| ErrorListener
|
| ErrorListener
|
||||||
|
|
||||||
// TODO Document this
|
// TODO Document this
|
||||||
export interface EventParams {
|
export interface EventParams {
|
||||||
signed: SignedEvent
|
event: Event
|
||||||
subscriptionId: SubscriptionId
|
subscriptionId: SubscriptionId
|
||||||
raw: RawEvent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Document this
|
// TODO Document this
|
||||||
|
@ -1,37 +1,39 @@
|
|||||||
import { ProtocolError } from "../error"
|
import { NostrError } from "../common"
|
||||||
import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event"
|
import { RawEvent, parseEvent } from "../event"
|
||||||
import { PrivateKey, PublicKey } from "../crypto"
|
|
||||||
import { Conn } from "./conn"
|
import { Conn } from "./conn"
|
||||||
import * as secp from "@noble/secp256k1"
|
import * as secp from "@noble/secp256k1"
|
||||||
import { EventEmitter } from "./emitter"
|
import { EventEmitter } from "./emitter"
|
||||||
import { defined } from "../util"
|
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
|
||||||
|
import { Filters } from "../filters"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A nostr client.
|
* A nostr client.
|
||||||
*
|
*
|
||||||
* TODO Document the events here
|
* 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 {
|
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.
|
* Open connections to relays.
|
||||||
*/
|
*/
|
||||||
readonly #conns: Map<string, ConnState> = new Map()
|
readonly #conns: ConnState[] = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of subscription IDs to corresponding filters.
|
* Mapping of subscription IDs to corresponding filters.
|
||||||
*/
|
*/
|
||||||
readonly #subscriptions: Map<string, Filters[]> = new Map()
|
readonly #subscriptions: Map<SubscriptionId, Filters[]> = new Map()
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional client private key.
|
|
||||||
*/
|
|
||||||
readonly #key?: PrivateKey
|
|
||||||
|
|
||||||
constructor(key?: PrivateKey) {
|
|
||||||
super()
|
|
||||||
this.#key = key
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a connection and start communicating with a relay. This method recreates all existing
|
* 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
|
* this method will only update it with the new options, and an exception will be thrown
|
||||||
* if no options are specified.
|
* 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.
|
// 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 (existingConn !== undefined) {
|
||||||
if (opts === undefined) {
|
if (opts === undefined) {
|
||||||
throw new Error(
|
throw new NostrError(
|
||||||
`called connect with existing connection ${url}, but options were not specified`
|
`called connect with existing connection ${url}, but options were not specified`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -57,59 +66,124 @@ export class Nostr extends EventEmitter {
|
|||||||
return
|
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.
|
// If there is no existing connection, open a new one.
|
||||||
const conn = new Conn({
|
const conn = new Conn({
|
||||||
url: connUrl,
|
url: relayUrl,
|
||||||
|
|
||||||
// Handle messages on this connection.
|
// Handle messages on this connection.
|
||||||
onMessage: async (msg) => {
|
onMessage: async (msg) => {
|
||||||
try {
|
if (msg.kind === "event") {
|
||||||
if (msg.kind === "event") {
|
this.emit(
|
||||||
this.emit(
|
"event",
|
||||||
"event",
|
{
|
||||||
{
|
event: await parseEvent(msg.event),
|
||||||
signed: await SignedEvent.verify(msg.raw, this.#key),
|
subscriptionId: msg.subscriptionId,
|
||||||
subscriptionId: msg.subscriptionId,
|
},
|
||||||
raw: msg.raw,
|
this
|
||||||
},
|
)
|
||||||
this
|
} else if (msg.kind === "notice") {
|
||||||
)
|
this.emit("notice", msg.notice, this)
|
||||||
} else if (msg.kind === "notice") {
|
} else if (msg.kind === "ok") {
|
||||||
this.emit("notice", msg.notice, this)
|
this.emit(
|
||||||
} else if (msg.kind === "ok") {
|
"ok",
|
||||||
this.emit(
|
{
|
||||||
"ok",
|
eventId: msg.eventId,
|
||||||
{
|
relay: relayUrl,
|
||||||
eventId: msg.eventId,
|
ok: msg.ok,
|
||||||
relay: connUrl,
|
message: msg.message,
|
||||||
ok: msg.ok,
|
},
|
||||||
message: msg.message,
|
this
|
||||||
},
|
)
|
||||||
this
|
} else if (msg.kind === "eose") {
|
||||||
)
|
this.emit("eose", msg.subscriptionId, this)
|
||||||
} else {
|
} else if (msg.kind === "auth") {
|
||||||
throw new ProtocolError(`invalid message ${msg}`)
|
// TODO This is incomplete
|
||||||
}
|
} else {
|
||||||
} catch (err) {
|
this.#error(new NostrError(`invalid message ${JSON.stringify(msg)}`))
|
||||||
this.emit("error", err, this)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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.
|
// Forward errors on this connection.
|
||||||
onError: (err) => this.emit("error", err, this),
|
onError: (err) => this.#error(err),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Resend existing subscriptions to this connection.
|
// Resend existing subscriptions to this connection.
|
||||||
for (const [key, filters] of this.#subscriptions.entries()) {
|
for (const [key, filters] of this.#subscriptions.entries()) {
|
||||||
const subscriptionId = new SubscriptionId(key)
|
|
||||||
conn.send({
|
conn.send({
|
||||||
kind: "openSubscription",
|
kind: "openSubscription",
|
||||||
id: subscriptionId,
|
id: key,
|
||||||
filters,
|
filters,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#conns.set(url.toString(), {
|
this.#conns.push({
|
||||||
|
relay: {
|
||||||
|
url: relayUrl,
|
||||||
|
readyState: ReadyState.CONNECTING,
|
||||||
|
},
|
||||||
conn,
|
conn,
|
||||||
auth: false,
|
auth: false,
|
||||||
read: opts?.read ?? true,
|
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
|
* @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
|
* not exist, an exception will be thrown. If this parameter is not specified, all connections
|
||||||
* will be closed.
|
* 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 {
|
close(url?: URL | string): void {
|
||||||
if (url === undefined) {
|
if (url === undefined) {
|
||||||
for (const { conn } of this.#conns.values()) {
|
for (const { conn } of this.#conns.values()) {
|
||||||
conn.close()
|
conn.close()
|
||||||
}
|
}
|
||||||
this.#conns.clear()
|
|
||||||
return
|
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) {
|
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()
|
c.conn.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,9 +231,9 @@ export class Nostr extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
subscribe(
|
subscribe(
|
||||||
filters: Filters[],
|
filters: Filters[],
|
||||||
subscriptionId: SubscriptionId = SubscriptionId.random()
|
subscriptionId: SubscriptionId = randomSubscriptionId()
|
||||||
): SubscriptionId {
|
): SubscriptionId {
|
||||||
this.#subscriptions.set(subscriptionId.toString(), filters)
|
this.#subscriptions.set(subscriptionId, filters)
|
||||||
for (const { conn, read } of this.#conns.values()) {
|
for (const { conn, read } of this.#conns.values()) {
|
||||||
if (!read) {
|
if (!read) {
|
||||||
continue
|
continue
|
||||||
@ -180,9 +252,9 @@ export class Nostr extends EventEmitter {
|
|||||||
*
|
*
|
||||||
* TODO Reference subscribed()
|
* TODO Reference subscribed()
|
||||||
*/
|
*/
|
||||||
async unsubscribe(subscriptionId: SubscriptionId): Promise<void> {
|
unsubscribe(subscriptionId: SubscriptionId): void {
|
||||||
if (!this.#subscriptions.delete(subscriptionId.toString())) {
|
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()) {
|
for (const { conn, read } of this.#conns.values()) {
|
||||||
if (!read) {
|
if (!read) {
|
||||||
@ -198,48 +270,45 @@ export class Nostr extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Publish an event.
|
* Publish an event.
|
||||||
*/
|
*/
|
||||||
async publish(event: SignedEvent): Promise<void>
|
publish(event: RawEvent): 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { conn, write } of this.#conns.values()) {
|
for (const { conn, write } of this.#conns.values()) {
|
||||||
if (!write) {
|
if (!write) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!(event instanceof SignedEvent) && !("sig" in event)) {
|
|
||||||
event = await SignedEvent.sign(event, defined(key))
|
|
||||||
}
|
|
||||||
conn.send({
|
conn.send({
|
||||||
kind: "event",
|
kind: "event",
|
||||||
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 {
|
interface ConnState {
|
||||||
|
relay: Relay
|
||||||
conn: Conn
|
conn: Conn
|
||||||
/**
|
/**
|
||||||
* Has this connection been authenticated via NIP-44 AUTH?
|
* Has this connection been authenticated via NIP-44 AUTH?
|
||||||
@ -258,39 +327,8 @@ interface ConnState {
|
|||||||
/**
|
/**
|
||||||
* A string uniquely identifying a client subscription.
|
* A string uniquely identifying a client subscription.
|
||||||
*/
|
*/
|
||||||
export class SubscriptionId {
|
export type SubscriptionId = string
|
||||||
#id: string
|
|
||||||
|
|
||||||
constructor(subscriptionId: string) {
|
function randomSubscriptionId(): SubscriptionId {
|
||||||
this.#id = subscriptionId
|
return secp.utils.bytesToHex(secp.utils.randomBytes(32))
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
142
packages/nostr/src/client/relay.ts
Normal file
142
packages/nostr/src/client/relay.ts
Normal 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
|
||||||
|
}
|
42
packages/nostr/src/common.ts
Normal file
42
packages/nostr/src/common.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,105 +1,97 @@
|
|||||||
import * as secp from "@noble/secp256k1"
|
import * as secp from "@noble/secp256k1"
|
||||||
import { ProtocolError } from "./error"
|
|
||||||
import base64 from "base64-js"
|
import base64 from "base64-js"
|
||||||
import { bech32 } from "bech32"
|
import { bech32 } from "bech32"
|
||||||
|
import { NostrError } from "./common"
|
||||||
|
|
||||||
// TODO Use toHex as well as toString? Might be more explicit
|
// TODO Use toHex as well as toString? Might be more explicit
|
||||||
// Or maybe replace toString with toHex
|
// Or maybe replace toString with toHex
|
||||||
// TODO Or maybe always store Uint8Array and properly use the format parameter passed into toString
|
// 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 {
|
export type Hex = string
|
||||||
#hex: Hex
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expects the key encoded as an npub-prefixed bech32 string, lowercase hex string, or byte buffer.
|
* A public key encoded as hex.
|
||||||
*/
|
*/
|
||||||
constructor(key: string | Uint8Array) {
|
export type PublicKey = string
|
||||||
this.#hex = parseKey(key, "npub1")
|
|
||||||
if (this.#hex.toString().length !== 64) {
|
|
||||||
throw new ProtocolError(`invalid pubkey: ${key}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
function toHex(data: Uint8Array): Hex {
|
||||||
#hex: Hex
|
return secp.utils.bytesToHex(data).toLowerCase()
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
export function parsePublicKey(key: HexOrBechPublicKey): PublicKey {
|
||||||
if (typeof key === "string") {
|
return parseKey(key, "npub")
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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> {
|
export function parsePrivateKey(key: HexOrBechPrivateKey): PrivateKey {
|
||||||
return await secp.utils.sha256(data)
|
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.
|
* Sign the data using elliptic curve cryptography.
|
||||||
*/
|
*/
|
||||||
export async function schnorrSign(
|
export async function schnorrSign(data: Hex, priv: PrivateKey): Promise<Hex> {
|
||||||
data: Hex,
|
return toHex(await secp.schnorr.sign(data, priv))
|
||||||
key: PrivateKey
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
return secp.schnorr.sign(data.toString(), key.toHexDangerous())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify that the elliptic curve signature is correct.
|
* Verify that the elliptic curve signature is correct.
|
||||||
*/
|
*/
|
||||||
export async function schnorrVerify(
|
export function schnorrVerify(
|
||||||
sig: Hex,
|
sig: Hex,
|
||||||
data: Hex,
|
data: Hex,
|
||||||
key: PublicKey
|
key: PublicKey
|
||||||
@ -107,21 +99,13 @@ export async function schnorrVerify(
|
|||||||
return secp.schnorr.verify(sig.toString(), data.toString(), key.toString())
|
return secp.schnorr.verify(sig.toString(), data.toString(), key.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AesEncryptedBase64 {
|
|
||||||
data: string
|
|
||||||
iv: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function aesEncryptBase64(
|
export async function aesEncryptBase64(
|
||||||
sender: PrivateKey,
|
sender: PrivateKey,
|
||||||
recipient: PublicKey,
|
recipient: PublicKey,
|
||||||
plaintext: string
|
plaintext: string
|
||||||
): Promise<AesEncryptedBase64> {
|
): Promise<AesEncryptedBase64> {
|
||||||
const sharedPoint = secp.getSharedSecret(
|
const sharedPoint = secp.getSharedSecret(sender, "02" + recipient)
|
||||||
sender.toHexDangerous(),
|
const sharedKey = sharedPoint.slice(1, 33)
|
||||||
"02" + recipient.toHex()
|
|
||||||
)
|
|
||||||
const sharedKey = sharedPoint.slice(2, 33)
|
|
||||||
if (typeof window === "object") {
|
if (typeof window === "object") {
|
||||||
const key = await window.crypto.subtle.importKey(
|
const key = await window.crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
@ -158,7 +142,7 @@ export async function aesEncryptBase64(
|
|||||||
)
|
)
|
||||||
let encrypted = cipher.update(plaintext, "utf8", "base64")
|
let encrypted = cipher.update(plaintext, "utf8", "base64")
|
||||||
// TODO Could save an allocation here by avoiding the +=
|
// TODO Could save an allocation here by avoiding the +=
|
||||||
encrypted += cipher.final()
|
encrypted += cipher.final("base64")
|
||||||
return {
|
return {
|
||||||
data: encrypted,
|
data: encrypted,
|
||||||
iv: Buffer.from(iv.buffer).toString("base64"),
|
iv: Buffer.from(iv.buffer).toString("base64"),
|
||||||
@ -166,20 +150,16 @@ export async function aesEncryptBase64(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
|
||||||
export async function aesDecryptBase64(
|
export async function aesDecryptBase64(
|
||||||
sender: PublicKey,
|
sender: PublicKey,
|
||||||
recipient: PrivateKey,
|
recipient: PrivateKey,
|
||||||
{ data, iv }: AesEncryptedBase64
|
{ data, iv }: AesEncryptedBase64
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const sharedPoint = secp.getSharedSecret(
|
const sharedPoint = secp.getSharedSecret(recipient, "02" + sender)
|
||||||
recipient.toHexDangerous(),
|
const sharedKey = sharedPoint.slice(1, 33)
|
||||||
"02" + sender.toHex()
|
|
||||||
)
|
|
||||||
const sharedKey = sharedPoint.slice(2, 33)
|
|
||||||
if (typeof window === "object") {
|
if (typeof window === "object") {
|
||||||
// TODO Can copy this from the legacy code
|
// TODO Can copy this from the legacy code
|
||||||
throw new Error("todo")
|
throw new NostrError("todo")
|
||||||
} else {
|
} else {
|
||||||
const crypto = await import("crypto")
|
const crypto = await import("crypto")
|
||||||
const decipher = crypto.createDecipheriv(
|
const decipher = crypto.createDecipheriv(
|
||||||
@ -192,33 +172,7 @@ export async function aesDecryptBase64(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface AesEncryptedBase64 {
|
||||||
* A string in lowercase hex. This type is not available to the users of the library.
|
data: string
|
||||||
*/
|
iv: string
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
83
packages/nostr/src/event/contact-list.ts
Normal file
83
packages/nostr/src/event/contact-list.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
124
packages/nostr/src/event/direct-message.ts
Normal file
124
packages/nostr/src/event/direct-message.ts
Normal 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]
|
||||||
|
}
|
212
packages/nostr/src/event/index.ts
Normal file
212
packages/nostr/src/event/index.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
50
packages/nostr/src/event/set-metadata.ts
Normal file
50
packages/nostr/src/event/set-metadata.ts
Normal 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
|
||||||
|
}
|
18
packages/nostr/src/event/text.ts
Normal file
18
packages/nostr/src/event/text.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
81
packages/nostr/src/filters.ts
Normal file
81
packages/nostr/src/filters.ts
Normal 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[]
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
56
packages/nostr/test/contact-list.ts
Normal file
56
packages/nostr/test/contact-list.ts
Normal file
@ -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)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
128
packages/nostr/test/dm.ts
Normal file
128
packages/nostr/test/dm.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
// Test that the intended recipient can receive and decrypt the direct message.
|
||||||
|
it("to intended recipient", (done) => {
|
||||||
|
setup(
|
||||||
|
done,
|
||||||
|
({
|
||||||
|
publisher,
|
||||||
|
publisherPubkey,
|
||||||
|
publisherSecret,
|
||||||
|
subscriber,
|
||||||
|
subscriberPubkey,
|
||||||
|
subscriberSecret,
|
||||||
|
timestamp,
|
||||||
|
done,
|
||||||
|
}) => {
|
||||||
|
// Expect the direct message.
|
||||||
|
subscriber.on(
|
||||||
|
"event",
|
||||||
|
async ({ event, subscriptionId: actualSubscriptionId }, nostr) => {
|
||||||
|
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.strictEqual(
|
||||||
|
event.getRecipient(),
|
||||||
|
parsePublicKey(subscriberPubkey)
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
await event.getMessage(subscriberSecret),
|
||||||
|
message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
({
|
||||||
|
publisher,
|
||||||
|
publisherPubkey,
|
||||||
|
publisherSecret,
|
||||||
|
subscriber,
|
||||||
|
subscriberSecret,
|
||||||
|
timestamp,
|
||||||
|
done,
|
||||||
|
}) => {
|
||||||
|
const recipientPubkey =
|
||||||
|
"npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc"
|
||||||
|
|
||||||
|
// Expect the direct message.
|
||||||
|
subscriber.on(
|
||||||
|
"event",
|
||||||
|
async ({ event, subscriptionId: actualSubscriptionId }, nostr) => {
|
||||||
|
try {
|
||||||
|
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.strictEqual(
|
||||||
|
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 createDirectMessage(
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
recipient: recipientPubkey,
|
||||||
|
},
|
||||||
|
publisherSecret
|
||||||
|
)
|
||||||
|
publisher.publish(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
24
packages/nostr/test/ready-state.ts
Normal file
24
packages/nostr/test/ready-state.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import assert from "assert"
|
||||||
|
import { Nostr } from "../src/client"
|
||||||
|
import { relayUrl } from "./setup"
|
||||||
|
|
||||||
|
describe("ready state", () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
26
packages/nostr/test/relay-info.ts
Normal file
26
packages/nostr/test/relay-info.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import assert from "assert"
|
||||||
|
import { Nostr } from "../src/client"
|
||||||
|
import { setup } from "./setup"
|
||||||
|
|
||||||
|
describe("relay info", () => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
98
packages/nostr/test/setup.ts
Normal file
98
packages/nostr/test/setup.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { Nostr } from "../src/client"
|
||||||
|
import { Timestamp, unixTimestamp } from "../src/common"
|
||||||
|
|
||||||
|
export const relayUrl = new URL("ws://localhost:12648")
|
||||||
|
|
||||||
|
export interface Setup {
|
||||||
|
publisher: Nostr
|
||||||
|
publisherSecret: string
|
||||||
|
publisherPubkey: string
|
||||||
|
subscriber: Nostr
|
||||||
|
subscriberSecret: string
|
||||||
|
subscriberPubkey: string
|
||||||
|
timestamp: Timestamp
|
||||||
|
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,
|
||||||
|
test: (setup: Setup) => void | Promise<void>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await restartRelay()
|
||||||
|
const publisher = new Nostr()
|
||||||
|
const subscriber = new Nostr()
|
||||||
|
|
||||||
|
publisher.on("error", done)
|
||||||
|
subscriber.on("error", done)
|
||||||
|
|
||||||
|
const openPromise = Promise.all([
|
||||||
|
new Promise((resolve) => publisher.on("open", resolve)),
|
||||||
|
new Promise((resolve) => subscriber.on("open", resolve)),
|
||||||
|
])
|
||||||
|
|
||||||
|
publisher.open(relayUrl)
|
||||||
|
subscriber.open(relayUrl)
|
||||||
|
|
||||||
|
await openPromise
|
||||||
|
|
||||||
|
const result = test({
|
||||||
|
publisher,
|
||||||
|
publisherSecret:
|
||||||
|
"nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363",
|
||||||
|
publisherPubkey:
|
||||||
|
"npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7",
|
||||||
|
subscriber,
|
||||||
|
subscriberSecret:
|
||||||
|
"nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps",
|
||||||
|
subscriberPubkey:
|
||||||
|
"npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd",
|
||||||
|
timestamp: unixTimestamp(),
|
||||||
|
url: relayUrl,
|
||||||
|
done: (e?: unknown) => {
|
||||||
|
publisher.close()
|
||||||
|
subscriber.close()
|
||||||
|
done(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
await result
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
done(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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", { fetchInfo: false })
|
||||||
|
})
|
||||||
|
if (ok) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
}
|
@ -1,85 +0,0 @@
|
|||||||
import { Nostr } from "../src/client"
|
|
||||||
import { EventKind, SignedEvent } from "../src/event"
|
|
||||||
import { PrivateKey } from "../src/crypto"
|
|
||||||
import assert from "assert"
|
|
||||||
import { EventParams } from "../src/client/emitter"
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
"nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh"
|
|
||||||
)
|
|
||||||
const pubkey = secret.pubkey
|
|
||||||
const timestamp = new Date()
|
|
||||||
const note = "hello world"
|
|
||||||
const url = new URL("ws://localhost:12648")
|
|
||||||
|
|
||||||
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) {
|
|
||||||
function listener({ signed: { 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriber.on("event", listener)
|
|
||||||
subscriber.subscribe([])
|
|
||||||
publisher.publish(
|
|
||||||
{
|
|
||||||
kind: EventKind.TextNote,
|
|
||||||
createdAt: timestamp,
|
|
||||||
content: note,
|
|
||||||
pubkey,
|
|
||||||
},
|
|
||||||
secret
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("publish and ok", function (done) {
|
|
||||||
SignedEvent.sign(
|
|
||||||
{
|
|
||||||
kind: EventKind.TextNote,
|
|
||||||
createdAt: timestamp,
|
|
||||||
content: note,
|
|
||||||
pubkey,
|
|
||||||
},
|
|
||||||
secret
|
|
||||||
).then((event) => {
|
|
||||||
publisher.on("ok", (params, nostr) => {
|
|
||||||
assert.equal(nostr, publisher)
|
|
||||||
assert.equal(params.eventId.toHex(), event.eventId.toHex())
|
|
||||||
assert.equal(params.relay.toString(), url.toString())
|
|
||||||
assert.equal(params.ok, true)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
publisher.on("error", done)
|
|
||||||
publisher.publish(event)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
75
packages/nostr/test/text-note.ts
Normal file
75
packages/nostr/test/text-note.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
// Test that a text note can be published by one client and received by the other.
|
||||||
|
it("publish and receive", (done) => {
|
||||||
|
setup(
|
||||||
|
done,
|
||||||
|
({
|
||||||
|
publisher,
|
||||||
|
publisherSecret,
|
||||||
|
publisherPubkey,
|
||||||
|
subscriber,
|
||||||
|
timestamp,
|
||||||
|
done,
|
||||||
|
}) => {
|
||||||
|
// 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)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const subscriptionId = subscriber.subscribe([])
|
||||||
|
|
||||||
|
// After the subscription event sync is done, publish the test event.
|
||||||
|
subscriber.on("eose", async (id, nostr) => {
|
||||||
|
assert.strictEqual(nostr, subscriber)
|
||||||
|
assert.strictEqual(id, subscriptionId)
|
||||||
|
|
||||||
|
// TODO No signEvent, have a convenient way to do this
|
||||||
|
publisher.publish(
|
||||||
|
await signEvent(
|
||||||
|
{
|
||||||
|
...createTextNote(note),
|
||||||
|
created_at: timestamp,
|
||||||
|
} as Unsigned<TextNote>,
|
||||||
|
publisherSecret
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test that a client interprets an "OK" message after publishing a text note.
|
||||||
|
it("publish and ok", function (done) {
|
||||||
|
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) => {
|
||||||
|
assert.strictEqual(nostr, publisher)
|
||||||
|
assert.strictEqual(params.eventId, event.id)
|
||||||
|
assert.strictEqual(params.relay.toString(), url.toString())
|
||||||
|
assert.strictEqual(params.ok, true)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
publisher.publish(event)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user