Remove nostr pkg
This commit is contained in:
parent
959ec58ec2
commit
e5ecc2aacf
@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
root: true,
|
||||
ignorePatterns: ["dist/", "src/legacy"],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
mocha: true,
|
||||
},
|
||||
rules: {
|
||||
"require-await": "error",
|
||||
eqeqeq: "error",
|
||||
"object-shorthand": "warn",
|
||||
},
|
||||
}
|
1
packages/nostr/.gitignore
vendored
1
packages/nostr/.gitignore
vendored
@ -1 +0,0 @@
|
||||
dist/
|
@ -1,2 +0,0 @@
|
||||
dist/
|
||||
src/legacy
|
@ -1,67 +0,0 @@
|
||||
# `@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: 8/34 (23%)._
|
||||
|
||||
- [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
|
||||
- [x] 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
|
||||
- [x] 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-27: Text Note References
|
||||
- [ ] 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
|
||||
- [x] NIP-94: File Header
|
||||
|
||||
### 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://https://git.v0l.io/Kieran/snort/issues/new?assignees=&labels=&template=feature_request.md&title=)
|
||||
to let us know_.
|
@ -1,33 +0,0 @@
|
||||
version: "3.1"
|
||||
services:
|
||||
well-known:
|
||||
build: ./docker/well-known
|
||||
restart: on-failure
|
||||
|
||||
relay:
|
||||
build: ./docker/relay
|
||||
restart: on-failure
|
||||
|
||||
well-known-proxy:
|
||||
build: ./docker/cors-proxy
|
||||
restart: on-failure
|
||||
environment:
|
||||
TARGET: "well-known:80"
|
||||
ports:
|
||||
- 12647:80
|
||||
|
||||
relay-proxy:
|
||||
build: ./docker/cors-proxy
|
||||
restart: on-failure
|
||||
environment:
|
||||
TARGET: "relay:8080"
|
||||
ports:
|
||||
- 12648:80
|
||||
|
||||
relay-restart-proxy:
|
||||
build: ./docker/cors-proxy
|
||||
restart: on-failure
|
||||
environment:
|
||||
TARGET: "relay:8000"
|
||||
ports:
|
||||
- 12649:80
|
@ -1,4 +0,0 @@
|
||||
# An nginx proxy which adds "Access-Control-Allow-Origin: *" to responses.
|
||||
|
||||
FROM nginx
|
||||
COPY config.sh /docker-entrypoint.d/
|
@ -1,24 +0,0 @@
|
||||
echo "\
|
||||
map \$http_upgrade \$connection_upgrade {\
|
||||
default upgrade;\
|
||||
'' close;\
|
||||
}\
|
||||
\
|
||||
proxy_read_timeout 600s;\
|
||||
\
|
||||
server {\
|
||||
listen 80;\
|
||||
server_name default;\
|
||||
\
|
||||
location / {\
|
||||
proxy_pass http://$TARGET;\
|
||||
proxy_http_version 1.1;\
|
||||
proxy_set_header Upgrade \$http_upgrade;\
|
||||
proxy_set_header Connection \$connection_upgrade;\
|
||||
# The NIP defines that the relay should return Access-Control-Allow-Origin: * here, so don't do it twice.\n\
|
||||
if (\$http_accept != 'application/nostr+json') {\
|
||||
add_header 'Access-Control-Allow-Origin' '*';\
|
||||
}\
|
||||
}\
|
||||
}" > /etc/nginx/conf.d/default.conf
|
||||
cat /etc/nginx/conf.d/default.conf
|
@ -1,2 +0,0 @@
|
||||
Dockerfile
|
||||
node_modules/
|
1
packages/nostr/docker/relay/.gitignore
vendored
1
packages/nostr/docker/relay/.gitignore
vendored
@ -1 +0,0 @@
|
||||
node_modules/
|
@ -1,13 +0,0 @@
|
||||
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
|
||||
EXPOSE 8080
|
||||
|
||||
COPY . .
|
||||
USER $APP_USER
|
||||
RUN yarn
|
||||
CMD ["/bin/bash", "-c", "while :; do yarn app /bin/bash -c 'rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db --config ./config.toml'; done;"]
|
@ -1,11 +0,0 @@
|
||||
[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
|
@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Allows the relay to be shut down with an HTTP request, after which
|
||||
* docker-compose will restart it. This allows each test to have a clean
|
||||
* slate. The drawback is that the tests can't run in parallel, so the
|
||||
* test suite is very slow. A better option would be to have this relay
|
||||
* server manage the relay completely: star/stop isolated relay instances
|
||||
* with HTTP requests and allow multiple instances to run at the same
|
||||
* time so that the tests can be parallelized.
|
||||
*/
|
||||
|
||||
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)
|
@ -1,14 +0,0 @@
|
||||
{
|
||||
"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,4 +0,0 @@
|
||||
# An nginx server to return a .well-known/nostr.json file for testing.
|
||||
FROM nginx
|
||||
RUN mkdir /usr/share/nginx/html/.well-known
|
||||
COPY nostr.json /usr/share/nginx/html/.well-known/nostr.json
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"names": {
|
||||
"bob": "be4be3c0c4f2d18f11215087d7d18fad2c9e0269fc50e19d526bdfb11a522a64"
|
||||
},
|
||||
"relays": {
|
||||
"be4be3c0c4f2d18f11215087d7d18fad2c9e0269fc50e19d526bdfb11a522a64": [
|
||||
"ws://example.com"
|
||||
]
|
||||
}
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
{
|
||||
"name": "@snort/nostr",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "webpack --node-env=production",
|
||||
"watch": "webpack -w",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "ts-mocha --type-check -j 1 --timeout 5s test/test.*.ts",
|
||||
"test-browser": "ts-node test/browser/server.ts",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/expect": "^24.3.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/mocha": "^10.0.1",
|
||||
"@types/node": "^20.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.53.0",
|
||||
"@typescript-eslint/parser": "^5.53.0",
|
||||
"eslint": "^8.34.0",
|
||||
"express": "^4.18.2",
|
||||
"mocha": "^10.2.0",
|
||||
"ts-mocha": "^10.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.0.0",
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@types/chai": "^4.3.4",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"base64-js": "^1.5.1",
|
||||
"bech32": "^2.0.0",
|
||||
"chai": "^4.3.7",
|
||||
"events": "^3.3.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"ts-loader": "^9.4.2",
|
||||
"uuid": "^9.0.0",
|
||||
"webpack": "^5.77.0",
|
||||
"webpack-cli": "^5.0.1",
|
||||
"ws": "^8.12.1"
|
||||
},
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"chrome >= 67",
|
||||
"edge >= 79",
|
||||
"firefox >= 68",
|
||||
"opera >= 54",
|
||||
"safari >= 14"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
}
|
@ -1,323 +0,0 @@
|
||||
import { NostrError, parseJson } from "../common"
|
||||
import { SubscriptionId } from "."
|
||||
import { EventId, RawEvent } from "../event"
|
||||
import WebSocket from "isomorphic-ws"
|
||||
import { Filters } from "../filters"
|
||||
|
||||
/**
|
||||
* The connection to a relay. This is the lowest layer of the nostr protocol.
|
||||
* The only responsibility of this type is to send and receive
|
||||
* well-formatted nostr messages on the underlying websocket. All other details of the protocol
|
||||
* are handled by `Nostr`. This type does not know anything about event semantics.
|
||||
*
|
||||
* @see Nostr
|
||||
*/
|
||||
export class Conn {
|
||||
readonly #socket: WebSocket
|
||||
// TODO This should probably be moved to Nostr (ConnState) because deciding whether or not to send a message
|
||||
// requires looking at relay info which the Conn should know nothing about.
|
||||
/**
|
||||
* Messages which were requested to be sent before the websocket was ready.
|
||||
* Once the websocket becomes ready, these messages will be sent and cleared.
|
||||
*/
|
||||
#pending: OutgoingMessage[] = []
|
||||
/**
|
||||
* Callback for errors.
|
||||
*/
|
||||
readonly #onError: (err: unknown) => void
|
||||
|
||||
get url(): string {
|
||||
return this.#socket.url
|
||||
}
|
||||
|
||||
constructor({
|
||||
url,
|
||||
onMessage,
|
||||
onOpen,
|
||||
onClose,
|
||||
onError,
|
||||
}: {
|
||||
url: URL
|
||||
onMessage: (msg: IncomingMessage) => void
|
||||
onOpen: () => Promise<void>
|
||||
onClose: () => void
|
||||
onError: (err: unknown) => void
|
||||
}) {
|
||||
this.#onError = onError
|
||||
this.#socket = new WebSocket(url)
|
||||
|
||||
// Handle incoming messages.
|
||||
this.#socket.addEventListener("message", async (msgData) => {
|
||||
try {
|
||||
const value = msgData.data.valueOf()
|
||||
// Validate and parse the message.
|
||||
if (typeof value !== "string") {
|
||||
throw new NostrError(`invalid message data: ${value}`)
|
||||
}
|
||||
const msg = parseIncomingMessage(value)
|
||||
onMessage(msg)
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
}
|
||||
})
|
||||
|
||||
// When the connection is ready, send any outstanding messages.
|
||||
this.#socket.addEventListener("open", async () => {
|
||||
try {
|
||||
for (const msg of this.#pending) {
|
||||
this.send(msg)
|
||||
}
|
||||
this.#pending = []
|
||||
await onOpen()
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
|
||||
this.#socket.addEventListener("close", () => {
|
||||
try {
|
||||
onClose()
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
this.#socket.addEventListener("error", onError)
|
||||
}
|
||||
|
||||
send(msg: OutgoingMessage): void {
|
||||
try {
|
||||
if (this.#socket.readyState < WebSocket.OPEN) {
|
||||
this.#pending.push(msg)
|
||||
return
|
||||
}
|
||||
this.#socket.send(serializeOutgoingMessage(msg), (err) => {
|
||||
if (err !== undefined && err !== null) {
|
||||
this.#onError?.(err)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
this.#onError?.(err)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.#socket.close()
|
||||
} catch (err) {
|
||||
this.#onError?.(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A message sent from a relay to the client.
|
||||
*/
|
||||
export type IncomingMessage =
|
||||
| IncomingEvent
|
||||
| IncomingNotice
|
||||
| IncomingOk
|
||||
| IncomingEose
|
||||
| IncomingAuth
|
||||
|
||||
export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth"
|
||||
|
||||
/**
|
||||
* Incoming "EVENT" message.
|
||||
*/
|
||||
export interface IncomingEvent {
|
||||
kind: "event"
|
||||
subscriptionId: SubscriptionId
|
||||
event: RawEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming "NOTICE" message.
|
||||
*/
|
||||
export interface IncomingNotice {
|
||||
kind: "notice"
|
||||
notice: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming "OK" message.
|
||||
*/
|
||||
export interface IncomingOk {
|
||||
kind: "ok"
|
||||
eventId: EventId
|
||||
ok: boolean
|
||||
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.
|
||||
*/
|
||||
export type OutgoingMessage =
|
||||
| OutgoingEvent
|
||||
| OutgoingOpenSubscription
|
||||
| OutgoingCloseSubscription
|
||||
|
||||
export type OutgoingKind = "event" | "openSubscription" | "closeSubscription"
|
||||
|
||||
/**
|
||||
* Outgoing "EVENT" message.
|
||||
*/
|
||||
export interface OutgoingEvent {
|
||||
kind: "event"
|
||||
event: RawEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing "REQ" message, which opens a subscription.
|
||||
*/
|
||||
export interface OutgoingOpenSubscription {
|
||||
kind: "openSubscription"
|
||||
id: SubscriptionId
|
||||
filters: Filters[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing "CLOSE" message, which closes a subscription.
|
||||
*/
|
||||
export interface OutgoingCloseSubscription {
|
||||
kind: "closeSubscription"
|
||||
id: SubscriptionId
|
||||
}
|
||||
|
||||
function serializeOutgoingMessage(msg: OutgoingMessage): string {
|
||||
if (msg.kind === "event") {
|
||||
return JSON.stringify(["EVENT", msg.event])
|
||||
} else if (msg.kind === "openSubscription") {
|
||||
// If there are no filters, the client is expected to specify a single empty filter.
|
||||
const filters = msg.filters.length === 0 ? [{}] : msg.filters
|
||||
return JSON.stringify(["REQ", msg.id.toString(), ...filters])
|
||||
} else if (msg.kind === "closeSubscription") {
|
||||
return JSON.stringify(["CLOSE", msg.id.toString()])
|
||||
} else {
|
||||
throw new NostrError(`invalid message: ${JSON.stringify(msg)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseIncomingMessage(data: string): IncomingMessage {
|
||||
// Parse the incoming data as a nonempty JSON array.
|
||||
const json = parseJson(data)
|
||||
if (!(json instanceof Array)) {
|
||||
throw new NostrError(`incoming message is not an array: ${data}`)
|
||||
}
|
||||
if (json.length === 0) {
|
||||
throw new NostrError(`incoming message is an empty array: ${data}`)
|
||||
}
|
||||
|
||||
// Handle incoming events.
|
||||
if (json[0] === "EVENT") {
|
||||
if (typeof json[1] !== "string") {
|
||||
throw new NostrError(
|
||||
`second element of "EVENT" should be a string, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
if (typeof json[2] !== "object") {
|
||||
throw new NostrError(
|
||||
`second element of "EVENT" should be an object, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
const event = parseEventData(json[2])
|
||||
return {
|
||||
kind: "event",
|
||||
subscriptionId: json[1],
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming notices.
|
||||
if (json[0] === "NOTICE") {
|
||||
if (typeof json[1] !== "string") {
|
||||
throw new NostrError(
|
||||
`second element of "NOTICE" should be a string, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
kind: "notice",
|
||||
notice: json[1],
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming "OK" messages.
|
||||
if (json[0] === "OK") {
|
||||
if (typeof json[1] !== "string") {
|
||||
throw new NostrError(
|
||||
`second element of "OK" should be a string, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
if (typeof json[2] !== "boolean") {
|
||||
throw new NostrError(
|
||||
`third element of "OK" should be a boolean, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
if (typeof json[3] !== "string") {
|
||||
throw new NostrError(
|
||||
`fourth element of "OK" should be a string, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
kind: "ok",
|
||||
eventId: json[1],
|
||||
ok: json[2],
|
||||
message: json[3],
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming "EOSE" messages.
|
||||
if (json[0] === "EOSE") {
|
||||
if (typeof json[1] !== "string") {
|
||||
throw new NostrError(
|
||||
`second element of "EOSE" should be a string, but wasn't: ${data}`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
kind: "eose",
|
||||
subscriptionId: json[1],
|
||||
}
|
||||
}
|
||||
|
||||
// TODO This is incomplete
|
||||
// Handle incoming "AUTH" messages.
|
||||
if (json[0] === "AUTH") {
|
||||
return {
|
||||
kind: "auth",
|
||||
}
|
||||
}
|
||||
|
||||
throw new NostrError(`unknown incoming message: ${data}`)
|
||||
}
|
||||
|
||||
function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
||||
if (
|
||||
typeof json["id"] !== "string" ||
|
||||
typeof json["pubkey"] !== "string" ||
|
||||
typeof json["created_at"] !== "number" ||
|
||||
typeof json["kind"] !== "number" ||
|
||||
!(json["tags"] instanceof Array) ||
|
||||
!json["tags"].every(
|
||||
(x) => x instanceof Array && x.every((y) => typeof y === "string"),
|
||||
) ||
|
||||
typeof json["content"] !== "string" ||
|
||||
typeof json["sig"] !== "string"
|
||||
) {
|
||||
throw new NostrError(`invalid event: ${JSON.stringify(json)}`)
|
||||
}
|
||||
return json as unknown as RawEvent
|
||||
}
|
@ -1,226 +0,0 @@
|
||||
import Base from "events"
|
||||
import { Nostr, SubscriptionId } from "."
|
||||
import { Event, EventId } from "../event"
|
||||
|
||||
/**
|
||||
* Overrides providing better types for EventEmitter methods.
|
||||
*/
|
||||
export class EventEmitter extends Base {
|
||||
constructor() {
|
||||
super({ captureRejections: true })
|
||||
}
|
||||
|
||||
override addListener(eventName: "newListener", listener: NewListener): this
|
||||
override addListener(
|
||||
eventName: "removeListener",
|
||||
listener: RemoveListener,
|
||||
): this
|
||||
override addListener(eventName: "open", listener: OpenListener): this
|
||||
override addListener(eventName: "close", listener: CloseListener): this
|
||||
override addListener(eventName: "event", listener: EventListener): this
|
||||
override addListener(eventName: "notice", listener: NoticeListener): this
|
||||
override addListener(eventName: "ok", listener: OkListener): this
|
||||
override addListener(eventName: "eose", listener: EoseListener): this
|
||||
override addListener(eventName: "error", listener: ErrorListener): this
|
||||
override addListener(eventName: EventName, listener: Listener): this {
|
||||
return super.addListener(eventName, listener)
|
||||
}
|
||||
|
||||
override emit(eventName: "newListener", listener: NewListener): boolean
|
||||
override emit(eventName: "removeListener", listener: RemoveListener): boolean
|
||||
override emit(eventName: "open", relay: URL, nostr: Nostr): boolean
|
||||
override emit(eventName: "close", relay: URL, nostr: Nostr): boolean
|
||||
override emit(eventName: "event", params: EventParams, nostr: Nostr): boolean
|
||||
override emit(eventName: "notice", notice: string, nostr: Nostr): boolean
|
||||
override emit(eventName: "ok", params: OkParams, nostr: Nostr): boolean
|
||||
override emit(
|
||||
eventName: "eose",
|
||||
subscriptionId: SubscriptionId,
|
||||
nostr: Nostr,
|
||||
): boolean
|
||||
override emit(eventName: "error", err: unknown, nostr: Nostr): boolean
|
||||
override emit(eventName: EventName, ...args: unknown[]): boolean {
|
||||
return super.emit(eventName, ...args)
|
||||
}
|
||||
|
||||
override eventNames(): EventName[] {
|
||||
return super.eventNames() as EventName[]
|
||||
}
|
||||
|
||||
override listeners(eventName: "newListener"): EventListener[]
|
||||
override listeners(eventName: "removeListener"): EventListener[]
|
||||
override listeners(eventName: "open"): OpenListener[]
|
||||
override listeners(eventName: "close"): CloseListener[]
|
||||
override listeners(eventName: "event"): EventListener[]
|
||||
override listeners(eventName: "notice"): NoticeListener[]
|
||||
override listeners(eventName: "ok"): OkListener[]
|
||||
override listeners(eventName: "eose"): EoseListener[]
|
||||
override listeners(eventName: "error"): ErrorListener[]
|
||||
override listeners(eventName: EventName): Listener[] {
|
||||
return super.listeners(eventName) as Listener[]
|
||||
}
|
||||
|
||||
override off(eventName: "newListener", listener: NewListener): this
|
||||
override off(eventName: "removeListener", listener: RemoveListener): this
|
||||
override off(eventName: "open", listener: OpenListener): this
|
||||
override off(eventName: "close", listener: CloseListener): this
|
||||
override off(eventName: "event", listener: EventListener): this
|
||||
override off(eventName: "notice", listener: NoticeListener): this
|
||||
override off(eventName: "ok", listener: OkListener): this
|
||||
override off(eventName: "eose", listener: EoseListener): this
|
||||
override off(eventName: "error", listener: ErrorListener): this
|
||||
override off(eventName: EventName, listener: Listener): this {
|
||||
return super.off(eventName, listener)
|
||||
}
|
||||
|
||||
override on(eventName: "newListener", listener: NewListener): this
|
||||
override on(eventName: "removeListener", listener: RemoveListener): this
|
||||
override on(eventName: "open", listener: OpenListener): this
|
||||
override on(eventName: "close", listener: CloseListener): this
|
||||
override on(eventName: "event", listener: EventListener): this
|
||||
override on(eventName: "notice", listener: NoticeListener): this
|
||||
override on(eventName: "ok", listener: OkListener): this
|
||||
override on(eventName: "eose", listener: EoseListener): this
|
||||
override on(eventName: "error", listener: ErrorListener): this
|
||||
override on(eventName: EventName, listener: Listener): this {
|
||||
return super.on(eventName, listener)
|
||||
}
|
||||
|
||||
override once(eventName: "newListener", listener: NewListener): this
|
||||
override once(eventName: "removeListener", listener: RemoveListener): this
|
||||
override once(eventName: "open", listener: OpenListener): this
|
||||
override once(eventName: "close", listener: CloseListener): this
|
||||
override once(eventName: "event", listener: EventListener): this
|
||||
override once(eventName: "notice", listener: NoticeListener): this
|
||||
override once(eventName: "ok", listener: OkListener): this
|
||||
override once(eventName: "eose", listener: EoseListener): this
|
||||
override once(eventName: "error", listener: ErrorListener): this
|
||||
override once(eventName: EventName, listener: Listener): this {
|
||||
return super.once(eventName, listener)
|
||||
}
|
||||
|
||||
override prependListener(
|
||||
eventName: "newListener",
|
||||
listener: NewListener,
|
||||
): this
|
||||
override prependListener(
|
||||
eventName: "removeListener",
|
||||
listener: RemoveListener,
|
||||
): this
|
||||
override prependListener(eventName: "open", listener: OpenListener): this
|
||||
override prependListener(eventName: "close", listener: CloseListener): this
|
||||
override prependListener(eventName: "event", listener: EventListener): this
|
||||
override prependListener(eventName: "notice", listener: NoticeListener): this
|
||||
override prependListener(eventName: "ok", listener: OkListener): this
|
||||
override prependListener(eventName: "eose", listener: EoseListener): this
|
||||
override prependListener(eventName: "error", listener: ErrorListener): this
|
||||
override prependListener(eventName: EventName, listener: Listener): this {
|
||||
return super.prependListener(eventName, listener)
|
||||
}
|
||||
|
||||
override prependOnceListener(
|
||||
eventName: "newListener",
|
||||
listener: NewListener,
|
||||
): this
|
||||
override prependOnceListener(
|
||||
eventName: "removeListener",
|
||||
listener: RemoveListener,
|
||||
): this
|
||||
override prependOnceListener(eventName: "open", listener: OpenListener): this
|
||||
override prependOnceListener(
|
||||
eventName: "close",
|
||||
listener: CloseListener,
|
||||
): this
|
||||
override prependOnceListener(
|
||||
eventName: "event",
|
||||
listener: EventListener,
|
||||
): this
|
||||
override prependOnceListener(
|
||||
eventName: "notice",
|
||||
listener: NoticeListener,
|
||||
): this
|
||||
override prependOnceListener(eventName: "ok", listener: OkListener): this
|
||||
override prependOnceListener(eventName: "eose", listener: EoseListener): this
|
||||
override prependOnceListener(
|
||||
eventName: "error",
|
||||
listener: ErrorListener,
|
||||
): this
|
||||
override prependOnceListener(eventName: EventName, listener: Listener): this {
|
||||
return super.prependOnceListener(eventName, listener)
|
||||
}
|
||||
|
||||
override removeAllListeners(event?: EventName): this {
|
||||
return super.removeAllListeners(event)
|
||||
}
|
||||
|
||||
override removeListener(eventName: "newListener", listener: NewListener): this
|
||||
override removeListener(
|
||||
eventName: "removeListener",
|
||||
listener: RemoveListener,
|
||||
): this
|
||||
override removeListener(eventName: "open", listener: OpenListener): this
|
||||
override removeListener(eventName: "close", listener: CloseListener): this
|
||||
override removeListener(eventName: "event", listener: EventListener): this
|
||||
override removeListener(eventName: "notice", listener: NoticeListener): this
|
||||
override removeListener(eventName: "ok", listener: OkListener): this
|
||||
override removeListener(eventName: "eose", listener: EoseListener): this
|
||||
override removeListener(eventName: "error", listener: ErrorListener): this
|
||||
override removeListener(eventName: EventName, listener: Listener): this {
|
||||
return super.removeListener(eventName, listener)
|
||||
}
|
||||
|
||||
override rawListeners(eventName: EventName): Listener[] {
|
||||
return super.rawListeners(eventName) as Listener[]
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Refactor the params to always be a single interface
|
||||
// TODO Params should always include relay as well
|
||||
// TODO Params should not include Nostr, `this` should be Nostr
|
||||
// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages,
|
||||
// "publish" for published events, "send" for sent messages
|
||||
type EventName =
|
||||
| "newListener"
|
||||
| "removeListener"
|
||||
| "open"
|
||||
| "close"
|
||||
| "event"
|
||||
| "notice"
|
||||
| "ok"
|
||||
| "eose"
|
||||
| "error"
|
||||
|
||||
type NewListener = (eventName: EventName, listener: Listener) => void
|
||||
type RemoveListener = (eventName: EventName, listener: Listener) => void
|
||||
type OpenListener = (relay: URL, nostr: Nostr) => void
|
||||
type CloseListener = (relay: URL, nostr: Nostr) => void
|
||||
type EventListener = (params: EventParams, nostr: Nostr) => void
|
||||
type NoticeListener = (notice: string, nostr: Nostr) => void
|
||||
type OkListener = (params: OkParams, nostr: Nostr) => void
|
||||
type EoseListener = (subscriptionId: SubscriptionId, nostr: Nostr) => void
|
||||
type ErrorListener = (error: unknown, nostr: Nostr) => void
|
||||
|
||||
type Listener =
|
||||
| NewListener
|
||||
| RemoveListener
|
||||
| OpenListener
|
||||
| CloseListener
|
||||
| EventListener
|
||||
| NoticeListener
|
||||
| OkListener
|
||||
| EoseListener
|
||||
| ErrorListener
|
||||
|
||||
// TODO Document this
|
||||
export interface EventParams {
|
||||
event: Event
|
||||
subscriptionId: SubscriptionId
|
||||
}
|
||||
|
||||
// TODO Document this
|
||||
export interface OkParams {
|
||||
eventId: EventId
|
||||
relay: URL
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
@ -1,334 +0,0 @@
|
||||
import { NostrError } from "../common"
|
||||
import { RawEvent, parseEvent } from "../event"
|
||||
import { Conn } from "./conn"
|
||||
import * as utils from "@noble/curves/abstract/utils"
|
||||
import { EventEmitter } from "./emitter"
|
||||
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
|
||||
import { Filters } from "../filters"
|
||||
|
||||
/**
|
||||
* A nostr client.
|
||||
*
|
||||
* TODO Document the events here
|
||||
* TODO When document this type, remember to explicitly say that promise rejections will also be routed to "error"!
|
||||
*/
|
||||
export class Nostr extends EventEmitter {
|
||||
static get CONNECTING(): ReadyState.CONNECTING {
|
||||
return ReadyState.CONNECTING
|
||||
}
|
||||
|
||||
static get OPEN(): ReadyState.OPEN {
|
||||
return ReadyState.OPEN
|
||||
}
|
||||
|
||||
static get CLOSED(): ReadyState.CLOSED {
|
||||
return ReadyState.CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* Open connections to relays.
|
||||
*/
|
||||
readonly #conns: ConnState[] = []
|
||||
|
||||
/**
|
||||
* Mapping of subscription IDs to corresponding filters.
|
||||
*/
|
||||
readonly #subscriptions: Map<SubscriptionId, Filters[]> = new Map()
|
||||
|
||||
/**
|
||||
* Open a connection and start communicating with a relay. This method recreates all existing
|
||||
* subscriptions on the new relay as well. If there is already an existing connection,
|
||||
* this method will only update it with the new options, and an exception will be thrown
|
||||
* if no options are specified.
|
||||
*/
|
||||
open(
|
||||
url: URL | string,
|
||||
opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean },
|
||||
): void {
|
||||
const relayUrl = new URL(url)
|
||||
|
||||
// If the connection already exists, update the options.
|
||||
const existingConn = this.#conns.find(
|
||||
(c) => c.relay.url.toString() === relayUrl.toString(),
|
||||
)
|
||||
if (existingConn !== undefined) {
|
||||
if (opts === undefined) {
|
||||
throw new NostrError(
|
||||
`called connect with existing connection ${url}, but options were not specified`,
|
||||
)
|
||||
}
|
||||
if (opts.read !== undefined) {
|
||||
existingConn.read = opts.read
|
||||
}
|
||||
if (opts.write !== undefined) {
|
||||
existingConn.write = opts.write
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the relay info in parallel to opening the WebSocket connection.
|
||||
const fetchInfo =
|
||||
opts?.fetchInfo === false
|
||||
? Promise.resolve({})
|
||||
: fetchRelayInfo(relayUrl).catch((e) => {
|
||||
this.#error(e)
|
||||
return {}
|
||||
})
|
||||
|
||||
// If there is no existing connection, open a new one.
|
||||
const conn = new Conn({
|
||||
url: relayUrl,
|
||||
|
||||
// Handle messages on this connection.
|
||||
onMessage: (msg) => {
|
||||
if (msg.kind === "event") {
|
||||
this.emit(
|
||||
"event",
|
||||
{
|
||||
event: parseEvent(msg.event),
|
||||
subscriptionId: msg.subscriptionId,
|
||||
},
|
||||
this,
|
||||
)
|
||||
} else if (msg.kind === "notice") {
|
||||
this.emit("notice", msg.notice, this)
|
||||
} else if (msg.kind === "ok") {
|
||||
this.emit(
|
||||
"ok",
|
||||
{
|
||||
eventId: msg.eventId,
|
||||
relay: relayUrl,
|
||||
ok: msg.ok,
|
||||
message: msg.message,
|
||||
},
|
||||
this,
|
||||
)
|
||||
} else if (msg.kind === "eose") {
|
||||
this.emit("eose", msg.subscriptionId, this)
|
||||
} else if (msg.kind === "auth") {
|
||||
// TODO This is incomplete
|
||||
} else {
|
||||
this.#error(new NostrError(`invalid message ${JSON.stringify(msg)}`))
|
||||
}
|
||||
},
|
||||
|
||||
// Handle "open" events.
|
||||
onOpen: async () => {
|
||||
// Update the connection readyState.
|
||||
const conn = this.#conns.find(
|
||||
(c) => c.relay.url.toString() === relayUrl.toString(),
|
||||
)
|
||||
if (conn === undefined) {
|
||||
this.#error(
|
||||
new NostrError(
|
||||
`bug: expected connection to ${relayUrl.toString()} to be in the map`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
if (conn.relay.readyState !== ReadyState.CONNECTING) {
|
||||
this.#error(
|
||||
new NostrError(
|
||||
`bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${
|
||||
conn.relay.readyState
|
||||
}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
conn.relay = {
|
||||
...conn.relay,
|
||||
readyState: ReadyState.OPEN,
|
||||
info: await fetchInfo,
|
||||
}
|
||||
}
|
||||
// Forward the event to the user.
|
||||
this.emit("open", relayUrl, this)
|
||||
},
|
||||
|
||||
// Handle "close" events.
|
||||
onClose: () => {
|
||||
// Update the connection readyState.
|
||||
const conn = this.#conns.find(
|
||||
(c) => c.relay.url.toString() === relayUrl.toString(),
|
||||
)
|
||||
if (conn === undefined) {
|
||||
this.#error(
|
||||
new NostrError(
|
||||
`bug: expected connection to ${relayUrl.toString()} to be in the map`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
conn.relay.readyState = ReadyState.CLOSED
|
||||
}
|
||||
// Forward the event to the user.
|
||||
this.emit("close", relayUrl, this)
|
||||
},
|
||||
|
||||
// TODO If there is no error handler, this will silently swallow the error. Maybe have an
|
||||
// #onError method which re-throws if emit() returns false? This should at least make
|
||||
// some noise.
|
||||
// Forward errors on this connection.
|
||||
onError: (err) => this.#error(err),
|
||||
})
|
||||
|
||||
// Resend existing subscriptions to this connection.
|
||||
for (const [key, filters] of this.#subscriptions.entries()) {
|
||||
conn.send({
|
||||
kind: "openSubscription",
|
||||
id: key,
|
||||
filters,
|
||||
})
|
||||
}
|
||||
|
||||
this.#conns.push({
|
||||
relay: {
|
||||
url: relayUrl,
|
||||
readyState: ReadyState.CONNECTING,
|
||||
},
|
||||
conn,
|
||||
auth: false,
|
||||
read: opts?.read ?? true,
|
||||
write: opts?.write ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connections to relays.
|
||||
*
|
||||
* @param url If specified, only close the connection to this relay. If the connection does
|
||||
* not exist, an exception will be thrown. If this parameter is not specified, all connections
|
||||
* will be closed.
|
||||
*/
|
||||
close(url?: URL | string): void {
|
||||
if (url === undefined) {
|
||||
for (const { conn } of this.#conns.values()) {
|
||||
conn.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
const relayUrl = new URL(url)
|
||||
const c = this.#conns.find(
|
||||
(c) => c.relay.url.toString() === relayUrl.toString(),
|
||||
)
|
||||
if (c === undefined) {
|
||||
throw new NostrError(`connection to ${url} doesn't exist`)
|
||||
}
|
||||
c.conn.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a subscription exists.
|
||||
*/
|
||||
subscribed(subscriptionId: SubscriptionId): boolean {
|
||||
return this.#subscriptions.has(subscriptionId.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription. If the subscription already exists, it will be overwritten (as per NIP-01).
|
||||
*
|
||||
* @param filters The filters to apply to this message. If any filter passes, the message is let through.
|
||||
* @param subscriptionId An optional subscription ID, otherwise a random subscription ID will be used.
|
||||
* @returns The subscription ID.
|
||||
*/
|
||||
subscribe(
|
||||
filters: Filters[],
|
||||
subscriptionId: SubscriptionId = randomSubscriptionId(),
|
||||
): SubscriptionId {
|
||||
this.#subscriptions.set(subscriptionId, filters)
|
||||
for (const { conn, read } of this.#conns.values()) {
|
||||
if (!read) {
|
||||
continue
|
||||
}
|
||||
conn.send({
|
||||
kind: "openSubscription",
|
||||
id: subscriptionId,
|
||||
filters,
|
||||
})
|
||||
}
|
||||
return subscriptionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription. If the subscription does not exist, an exception is thrown.
|
||||
*
|
||||
* TODO Reference subscribed()
|
||||
*/
|
||||
unsubscribe(subscriptionId: SubscriptionId): void {
|
||||
if (!this.#subscriptions.delete(subscriptionId)) {
|
||||
throw new NostrError(`subscription ${subscriptionId} does not exist`)
|
||||
}
|
||||
for (const { conn, read } of this.#conns.values()) {
|
||||
if (!read) {
|
||||
continue
|
||||
}
|
||||
conn.send({
|
||||
kind: "closeSubscription",
|
||||
id: subscriptionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event.
|
||||
*/
|
||||
publish(event: RawEvent): void {
|
||||
for (const { conn, write } of this.#conns.values()) {
|
||||
if (!write) {
|
||||
continue
|
||||
}
|
||||
conn.send({
|
||||
kind: "event",
|
||||
event,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relays which this client has tried to open connections to.
|
||||
*/
|
||||
get relays(): Relay[] {
|
||||
return this.#conns.map(({ relay }) => {
|
||||
if (relay.readyState === ReadyState.CONNECTING) {
|
||||
return { ...relay }
|
||||
} else {
|
||||
const info =
|
||||
relay.info === undefined
|
||||
? undefined
|
||||
: // Deep copy of the info.
|
||||
JSON.parse(JSON.stringify(relay.info))
|
||||
return { ...relay, info }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#error(e: unknown) {
|
||||
if (!this.emit("error", e, this)) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ConnState {
|
||||
relay: Relay
|
||||
conn: Conn
|
||||
/**
|
||||
* Has this connection been authenticated via NIP-44 AUTH?
|
||||
*/
|
||||
auth: boolean
|
||||
/**
|
||||
* Should this connection be used for receiving events?
|
||||
*/
|
||||
read: boolean
|
||||
/**
|
||||
* Should this connection be used for publishing events?
|
||||
*/
|
||||
write: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A string uniquely identifying a client subscription.
|
||||
*/
|
||||
export type SubscriptionId = string
|
||||
|
||||
function randomSubscriptionId(): SubscriptionId {
|
||||
return utils.bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(32)))
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
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
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
/**
|
||||
* 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,186 +0,0 @@
|
||||
import * as secp from "@noble/curves/secp256k1"
|
||||
import * as utils from "@noble/curves/abstract/utils"
|
||||
import { sha256 as sha } from "@noble/hashes/sha256"
|
||||
import base64 from "base64-js"
|
||||
import { bech32 } from "bech32"
|
||||
|
||||
// TODO Use toHex as well as toString? Might be more explicit
|
||||
// Or maybe replace toString with toHex
|
||||
// TODO Or maybe always store Uint8Array and properly use the format parameter passed into toString
|
||||
|
||||
/**
|
||||
* A lowercase hex string.
|
||||
*/
|
||||
export type Hex = string
|
||||
|
||||
/**
|
||||
* A public key encoded as hex.
|
||||
*/
|
||||
export type PublicKey = string
|
||||
|
||||
/**
|
||||
* A private key encoded as hex or bech32 with the "nsec" prefix.
|
||||
*/
|
||||
export type HexOrBechPublicKey = string
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the data to lowercase hex.
|
||||
*/
|
||||
function toHex(data: Uint8Array): Hex {
|
||||
return utils.bytesToHex(data).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the public key to hex. Accepts a hex or bech32 string with the "npub" prefix.
|
||||
*/
|
||||
export function parsePublicKey(key: HexOrBechPublicKey): PublicKey {
|
||||
return parseKey(key, "npub")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the private key to hex. Accepts a hex or bech32 string with the "nsec" prefix.
|
||||
*/
|
||||
export function parsePrivateKey(key: HexOrBechPrivateKey): PrivateKey {
|
||||
return parseKey(key, "nsec")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a public or private key into its hex representation.
|
||||
*/
|
||||
function parseKey(key: string, bechPrefix: string): Hex {
|
||||
// If the key is bech32-encoded, decode it.
|
||||
if (key.startsWith(bechPrefix)) {
|
||||
const { words } = bech32.decode(key)
|
||||
const bytes = Uint8Array.from(bech32.fromWords(words))
|
||||
return toHex(bytes)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SHA256 hash of the data, in hex format.
|
||||
*/
|
||||
export function sha256(data: Uint8Array): Hex {
|
||||
return toHex(sha(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the data using elliptic curve cryptography.
|
||||
*/
|
||||
export function schnorrSign(data: Hex, priv: PrivateKey): Hex {
|
||||
return toHex(secp.schnorr.sign(data, priv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the elliptic curve signature is correct.
|
||||
*/
|
||||
export function schnorrVerify(sig: Hex, data: Hex, key: PublicKey): boolean {
|
||||
return secp.schnorr.verify(sig.toString(), data.toString(), key.toString())
|
||||
}
|
||||
|
||||
export async function aesEncryptBase64(
|
||||
sender: PrivateKey,
|
||||
recipient: PublicKey,
|
||||
plaintext: string,
|
||||
): Promise<AesEncryptedBase64> {
|
||||
const sharedPoint = secp.secp256k1.getSharedSecret(sender, "02" + recipient)
|
||||
const sharedKey = sharedPoint.slice(1, 33)
|
||||
if (typeof window === "object") {
|
||||
const key = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedKey,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
["encrypt"],
|
||||
)
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(16))
|
||||
const data = new TextEncoder().encode(plaintext)
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-CBC",
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
data,
|
||||
)
|
||||
return {
|
||||
data: base64.fromByteArray(new Uint8Array(encrypted)),
|
||||
iv: base64.fromByteArray(iv),
|
||||
}
|
||||
} else {
|
||||
const crypto = await import("crypto")
|
||||
const iv = crypto.randomFillSync(new Uint8Array(16))
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-256-cbc",
|
||||
Buffer.from(sharedKey),
|
||||
iv,
|
||||
)
|
||||
let encrypted = cipher.update(plaintext, "utf8", "base64")
|
||||
encrypted += cipher.final("base64")
|
||||
return {
|
||||
data: encrypted,
|
||||
iv: Buffer.from(iv.buffer).toString("base64"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function aesDecryptBase64(
|
||||
sender: PublicKey,
|
||||
recipient: PrivateKey,
|
||||
{ data, iv }: AesEncryptedBase64,
|
||||
): Promise<string> {
|
||||
const sharedPoint = secp.secp256k1.getSharedSecret(recipient, "02" + sender)
|
||||
const sharedKey = sharedPoint.slice(1, 33)
|
||||
if (typeof window === "object") {
|
||||
const decodedData = base64.toByteArray(data)
|
||||
const decodedIv = base64.toByteArray(iv)
|
||||
const importedKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedKey,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
["decrypt"],
|
||||
)
|
||||
const plaintext = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-CBC",
|
||||
iv: decodedIv,
|
||||
},
|
||||
importedKey,
|
||||
decodedData,
|
||||
)
|
||||
return new TextDecoder().decode(plaintext)
|
||||
} else {
|
||||
const crypto = await import("crypto")
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-cbc",
|
||||
Buffer.from(sharedKey),
|
||||
base64.toByteArray(iv),
|
||||
)
|
||||
const plaintext = decipher.update(data, "base64", "utf8")
|
||||
return plaintext + decipher.final()
|
||||
}
|
||||
}
|
||||
|
||||
interface AesEncryptedBase64 {
|
||||
data: string
|
||||
iv: string
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
import { EventKind, RawEvent, signEvent } from "."
|
||||
import { NostrError } from "../common"
|
||||
import { HexOrBechPrivateKey, 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[],
|
||||
priv?: HexOrBechPrivateKey,
|
||||
): Promise<ContactList> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.ContactList,
|
||||
tags: contacts.map((contact) => [
|
||||
"p",
|
||||
contact.pubkey,
|
||||
contact.relay?.toString() ?? "",
|
||||
contact.petname ?? "",
|
||||
]),
|
||||
content: "",
|
||||
getContacts,
|
||||
},
|
||||
priv,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { EventId, EventKind, RawEvent, signEvent } from "."
|
||||
import { NostrError } from "../common"
|
||||
import { HexOrBechPrivateKey } from "../crypto"
|
||||
|
||||
/**
|
||||
* A deletion event. Used for marking published events as deleted.
|
||||
*
|
||||
* Related NIPs: NIP-09.
|
||||
*/
|
||||
export interface Deletion extends RawEvent {
|
||||
kind: EventKind.Deletion
|
||||
|
||||
/**
|
||||
* The IDs of events to delete.
|
||||
*/
|
||||
getEvents(): EventId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deletion event.
|
||||
*/
|
||||
export function createDeletion(
|
||||
{ events, content }: { events: EventId[]; content?: string },
|
||||
priv?: HexOrBechPrivateKey,
|
||||
): Promise<Deletion> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.Deletion,
|
||||
tags: events.map((id) => ["e", id]),
|
||||
content: content ?? "",
|
||||
getEvents,
|
||||
},
|
||||
priv,
|
||||
)
|
||||
}
|
||||
|
||||
export function getEvents(this: Deletion): EventId[] {
|
||||
return this.tags
|
||||
.filter((tag) => tag[0] === "e")
|
||||
.map((tag) => {
|
||||
if (tag[1] === undefined) {
|
||||
throw new NostrError(
|
||||
`invalid deletion event tag: ${JSON.stringify(tag)}`,
|
||||
)
|
||||
}
|
||||
return tag[1]
|
||||
})
|
||||
}
|
@ -1,138 +0,0 @@
|
||||
import { EventId, EventKind, RawEvent, signEvent } from "."
|
||||
import { 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) {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.nostr?.nip04?.encrypt === undefined
|
||||
) {
|
||||
throw new NostrError("private key not specified")
|
||||
}
|
||||
const content = await window.nostr.nip04.encrypt(recipient, message)
|
||||
return await signEvent(
|
||||
{
|
||||
kind: EventKind.DirectMessage,
|
||||
tags: [["p", recipient]],
|
||||
content,
|
||||
getMessage,
|
||||
getRecipient,
|
||||
getPrevious,
|
||||
},
|
||||
priv,
|
||||
)
|
||||
} 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: 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) {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.nostr?.nip04?.decrypt === undefined
|
||||
) {
|
||||
throw new NostrError("private key not specified")
|
||||
}
|
||||
return await window.nostr.nip04.decrypt(this.pubkey, this.content)
|
||||
} else if (getPublicKey(priv) === this.getRecipient()) {
|
||||
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getRecipient(this: DirectMessage): 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: DirectMessage): 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]
|
||||
}
|
@ -1,240 +0,0 @@
|
||||
import {
|
||||
PublicKey,
|
||||
sha256,
|
||||
schnorrSign,
|
||||
schnorrVerify,
|
||||
getPublicKey,
|
||||
HexOrBechPrivateKey,
|
||||
parsePrivateKey,
|
||||
} from "../crypto"
|
||||
import { Timestamp, unixTimestamp, NostrError } from "../common"
|
||||
import { TextNote } from "./text"
|
||||
import {
|
||||
getUserMetadata,
|
||||
SetMetadata,
|
||||
verifyInternetIdentifier,
|
||||
} from "./set-metadata"
|
||||
import {
|
||||
DirectMessage,
|
||||
getMessage,
|
||||
getPrevious,
|
||||
getRecipient,
|
||||
} from "./direct-message"
|
||||
import { ContactList, getContacts } from "./contact-list"
|
||||
import { Deletion, getEvents } from "./deletion"
|
||||
import "../nostr-object"
|
||||
|
||||
// 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.ContactList
|
||||
| EventKind.DirectMessage
|
||||
| EventKind.Deletion
|
||||
>
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| SetMetadata
|
||||
| TextNote
|
||||
| ContactList
|
||||
| DirectMessage
|
||||
| Deletion
|
||||
| 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as @see {@link Unsigned}, but with the pubkey field.
|
||||
*/
|
||||
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 RawEvent>(
|
||||
event: Unsigned<T>,
|
||||
priv?: HexOrBechPrivateKey,
|
||||
): Promise<T> {
|
||||
event.created_at ??= unixTimestamp()
|
||||
if (priv !== undefined) {
|
||||
priv = parsePrivateKey(priv)
|
||||
event.pubkey = getPublicKey(priv)
|
||||
const id = serializeEventId(
|
||||
// This conversion is safe because the pubkey field is set above.
|
||||
event as unknown as UnsignedWithPubkey<T>,
|
||||
)
|
||||
event.id = id
|
||||
event.sig = schnorrSign(id, priv)
|
||||
return event as T
|
||||
} else {
|
||||
if (typeof window === "undefined" || window.nostr === undefined) {
|
||||
throw new NostrError("no private key provided")
|
||||
}
|
||||
// Extensions like nos2x expect to receive only the event data, without any of the methods.
|
||||
const methods: { [key: string]: unknown } = {}
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
if (typeof value === "function") {
|
||||
methods[key] = value
|
||||
delete event[key]
|
||||
}
|
||||
}
|
||||
const signed = await window.nostr.signEvent(event)
|
||||
return {
|
||||
...signed,
|
||||
...methods,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an event from its raw format.
|
||||
*/
|
||||
export function parseEvent(event: RawEvent): Event {
|
||||
if (event.id !== serializeEventId(event)) {
|
||||
throw new NostrError(
|
||||
`invalid id ${event.id} for event ${JSON.stringify(
|
||||
event,
|
||||
)}, expected ${serializeEventId(event)}`,
|
||||
)
|
||||
}
|
||||
if (!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,
|
||||
verifyInternetIdentifier,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.DirectMessage) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.DirectMessage,
|
||||
getMessage,
|
||||
getRecipient,
|
||||
getPrevious,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.ContactList) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.ContactList,
|
||||
getContacts,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.Deletion) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.Deletion,
|
||||
getEvents,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
kind: event.kind,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEventId(event: UnsignedWithPubkey<RawEvent>): EventId {
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
])
|
||||
return sha256(Uint8Array.from(charCodes(serialized)))
|
||||
}
|
||||
|
||||
function* charCodes(data: string): Iterable<number> {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
yield data.charCodeAt(i)
|
||||
}
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
import { EventKind, RawEvent, signEvent } from "."
|
||||
import { NostrError, parseJson } from "../common"
|
||||
import { HexOrBechPrivateKey } from "../crypto"
|
||||
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Verify the NIP-05 DNS-based internet identifier associated with the user metadata.
|
||||
* Throws if the internet identifier is invalid or fails verification.
|
||||
* @param pubkey The public key to use if the event does not specify a pubkey. If the event
|
||||
* does specify a pubkey
|
||||
* @return The internet identifier. `undefined` if there is no internet identifier.
|
||||
*/
|
||||
verifyInternetIdentifier(
|
||||
opts?: VerificationOptions,
|
||||
): Promise<InternetIdentifier | undefined>
|
||||
}
|
||||
|
||||
export interface UserMetadata {
|
||||
name: string
|
||||
about: string
|
||||
picture: string
|
||||
nip05?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a set metadata event.
|
||||
*/
|
||||
export function createSetMetadata(
|
||||
content: UserMetadata,
|
||||
priv?: HexOrBechPrivateKey,
|
||||
): Promise<SetMetadata> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.SetMetadata,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
getUserMetadata,
|
||||
verifyInternetIdentifier,
|
||||
},
|
||||
priv,
|
||||
)
|
||||
}
|
||||
|
||||
export function getUserMetadata(this: SetMetadata): 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
|
||||
}
|
||||
|
||||
export async function verifyInternetIdentifier(
|
||||
this: SetMetadata,
|
||||
opts?: VerificationOptions,
|
||||
): Promise<InternetIdentifier | undefined> {
|
||||
const metadata = this.getUserMetadata()
|
||||
if (metadata.nip05 === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const [name, domain] = metadata.nip05.split("@")
|
||||
if (
|
||||
name === undefined ||
|
||||
domain === undefined ||
|
||||
!/^[a-zA-Z0-9-_]+$/.test(name)
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid NIP-05 internet identifier: ${metadata.nip05}`,
|
||||
)
|
||||
}
|
||||
const res = await fetch(
|
||||
`${
|
||||
opts?.https === false ? "http" : "https"
|
||||
}://${domain}/.well-known/nostr.json?name=${name}`,
|
||||
{ redirect: "error" },
|
||||
)
|
||||
const wellKnown = await res.json()
|
||||
const pubkey = wellKnown.names?.[name]
|
||||
if (pubkey !== this.pubkey) {
|
||||
throw new NostrError(
|
||||
`invalid NIP-05 internet identifier: ${
|
||||
metadata.nip05
|
||||
} pubkey does not match, ${JSON.stringify(wellKnown)}`,
|
||||
)
|
||||
}
|
||||
const relays = wellKnown.relays?.[pubkey]
|
||||
if (
|
||||
relays !== undefined &&
|
||||
(!(relays instanceof Array) ||
|
||||
relays.some((relay) => typeof relay !== "string"))
|
||||
) {
|
||||
throw new NostrError(`invalid NIP-05 relays: ${wellKnown}`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
relays,
|
||||
}
|
||||
}
|
||||
|
||||
export interface InternetIdentifier {
|
||||
name: string
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export interface VerificationOptions {
|
||||
https?: boolean
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { EventKind, RawEvent, signEvent } from "."
|
||||
import { HexOrBechPrivateKey } from "../crypto"
|
||||
|
||||
/**
|
||||
* 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,
|
||||
priv?: HexOrBechPrivateKey,
|
||||
): Promise<TextNote> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.TextNote,
|
||||
tags: [],
|
||||
content,
|
||||
},
|
||||
priv,
|
||||
)
|
||||
}
|
@ -1,81 +0,0 @@
|
||||
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,3 +0,0 @@
|
||||
import "./nostr-object"
|
||||
|
||||
// TODO This file should only contain re-exports and only re-export what is needed
|
@ -1,20 +0,0 @@
|
||||
import { PublicKey } from "./crypto"
|
||||
import { RawEvent, Unsigned } from "./event"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<PublicKey>
|
||||
signEvent: <T extends RawEvent>(event: Unsigned<T>) => Promise<T>
|
||||
|
||||
getRelays?: () => Promise<
|
||||
Record<string, { read: boolean; write: boolean }>
|
||||
>
|
||||
|
||||
nip04?: {
|
||||
encrypt?: (pubkey: PublicKey, plaintext: string) => Promise<string>
|
||||
decrypt?: (pubkey: PublicKey, ciphertext: string) => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Tests</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div style="margin-left: 5px; margin-top: 15px">
|
||||
<a style="color: var(--mocha-color)" id="window.nostr"></a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const a = document.getElementById("window.nostr")
|
||||
if (window.location.pathname === "/nostr-object") {
|
||||
a.href = "/"
|
||||
a.innerHTML = "disable window.nostr"
|
||||
} else {
|
||||
a.href = "/nostr-object"
|
||||
a.innerHTML = "enable window.nostr"
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="mocha"></div>
|
||||
|
||||
<script src="https://unpkg.com/mocha/mocha.js"></script>
|
||||
|
||||
<script class="mocha-init">
|
||||
mocha.setup({
|
||||
ui: "bdd",
|
||||
timeout: "5s",
|
||||
global: ["nostr"],
|
||||
})
|
||||
mocha.checkLeaks()
|
||||
</script>
|
||||
|
||||
<!-- The server replaces the following line with <script> tags for all tests. -->
|
||||
<!-- TESTS -->
|
||||
|
||||
<script class="mocha-exec">
|
||||
mocha.run()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Serve tests in the browser.
|
||||
*/
|
||||
|
||||
import express from "express"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
|
||||
const port = 33543
|
||||
const app = express()
|
||||
|
||||
app.use("/", (req: express.Request, res: express.Response) => {
|
||||
if (req.path === "/" || req.path === "/nostr-object") {
|
||||
const index = fs.readFileSync(path.join(__dirname, "index.html"), {
|
||||
encoding: "utf8",
|
||||
})
|
||||
const tests = fs
|
||||
.readdirSync(path.join(__dirname, "..", "..", "dist", "test"))
|
||||
.filter(
|
||||
(f) =>
|
||||
f.startsWith("test.") && !f.endsWith(".map") && !f.endsWith(".d.ts"),
|
||||
)
|
||||
.map((src) => `<script src="${src}"></script>`)
|
||||
.join("\n")
|
||||
res.set("Content-Type", "text/html")
|
||||
res.send(index.replace("<!-- TESTS -->", tests))
|
||||
res.end()
|
||||
} else if (req.path === "/favicon.ico") {
|
||||
res.status(404)
|
||||
res.end()
|
||||
} else {
|
||||
const file = path.join(__dirname, "..", "..", "dist", "test", req.path)
|
||||
res.sendFile(file, (err) => {
|
||||
if (err) {
|
||||
console.error(err)
|
||||
res.status(404)
|
||||
}
|
||||
res.end()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Browser tests: http://localhost:${port}`)
|
||||
})
|
@ -1,148 +0,0 @@
|
||||
import "../src/nostr-object"
|
||||
import { Nostr } from "../src/client"
|
||||
import { Timestamp, unixTimestamp } from "../src/common"
|
||||
import {
|
||||
aesDecryptBase64,
|
||||
aesEncryptBase64,
|
||||
parsePrivateKey,
|
||||
parsePublicKey,
|
||||
PublicKey,
|
||||
} from "../src/crypto"
|
||||
import { RawEvent, signEvent, Unsigned } from "../src/event"
|
||||
|
||||
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: (e?: unknown) => void,
|
||||
test: (setup: Setup) => void | Promise<void>,
|
||||
) {
|
||||
try {
|
||||
await restartRelay()
|
||||
|
||||
const publisherPubkey =
|
||||
"npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7"
|
||||
const publisherSecret =
|
||||
"nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363"
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.location.pathname === "/nostr-object") {
|
||||
// Mock the global window.nostr object for the publisher.
|
||||
window.nostr = {
|
||||
getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)),
|
||||
signEvent: <T extends RawEvent>(event: Unsigned<T>) =>
|
||||
signEvent(event, publisherSecret),
|
||||
|
||||
getRelays: () => Promise.resolve({}),
|
||||
|
||||
nip04: {
|
||||
encrypt: async (pubkey: PublicKey, plaintext: string) => {
|
||||
const { data, iv } = await aesEncryptBase64(
|
||||
parsePrivateKey(publisherSecret),
|
||||
pubkey,
|
||||
plaintext,
|
||||
)
|
||||
return `${data}?iv=${iv}`
|
||||
},
|
||||
decrypt: async (pubkey: PublicKey, ciphertext: string) => {
|
||||
const [data, iv] = ciphertext.split("?iv=")
|
||||
return await aesDecryptBase64(
|
||||
pubkey,
|
||||
parsePrivateKey(publisherSecret),
|
||||
{
|
||||
data,
|
||||
iv,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Disable the user's nostr extension if they have one.
|
||||
window.nostr = undefined
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
typeof window === "undefined" || window.nostr === undefined
|
||||
? publisherSecret
|
||||
: undefined,
|
||||
publisherPubkey,
|
||||
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 exit the process and cause it to restart.
|
||||
await fetch("http://localhost:12649")
|
||||
|
||||
// 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,54 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } 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 createContactList(contacts, subscriberSecret))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@ -1,72 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createTextNote } from "../src/event/text"
|
||||
import { createDeletion } from "../src/event/deletion"
|
||||
|
||||
describe("deletion", () => {
|
||||
// Test that a deletion event deletes existing events. Test that the deletion event
|
||||
// is propagated to subscribers.
|
||||
it("deletes existing events", (done) => {
|
||||
setup(
|
||||
done,
|
||||
({
|
||||
publisher,
|
||||
publisherSecret,
|
||||
publisherPubkey,
|
||||
subscriber,
|
||||
timestamp,
|
||||
done,
|
||||
}) => {
|
||||
// The event ID to delete.
|
||||
let textNoteId: string
|
||||
// The deletion event ID.
|
||||
let deletionId: string
|
||||
|
||||
// Expect the deletion event (and not the text note event).
|
||||
subscriber.on("event", ({ event }) => {
|
||||
assert.strictEqual(event.kind, EventKind.Deletion)
|
||||
assert.strictEqual(event.id, deletionId)
|
||||
assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
|
||||
assert.strictEqual(event.created_at, timestamp)
|
||||
assert.strictEqual(event.content, "")
|
||||
if (event.kind === EventKind.Deletion) {
|
||||
assert.deepStrictEqual(event.getEvents(), [textNoteId])
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
createTextNote("hello world", publisherSecret).then((textNote) => {
|
||||
textNoteId = textNote.id
|
||||
publisher.publish({
|
||||
...textNote,
|
||||
created_at: timestamp,
|
||||
})
|
||||
})
|
||||
|
||||
publisher.on("ok", async ({ eventId, ok }) => {
|
||||
assert.strictEqual(ok, true)
|
||||
|
||||
if (eventId === textNoteId) {
|
||||
// After the text note has been published, delete it.
|
||||
const deletion = await createDeletion(
|
||||
{ events: [textNoteId] },
|
||||
publisherSecret,
|
||||
)
|
||||
deletionId = deletion.id
|
||||
publisher.publish({
|
||||
...deletion,
|
||||
created_at: timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
if (eventId === deletionId) {
|
||||
// After the deletion has been published, subscribe to the publisher.
|
||||
subscriber.subscribe([])
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
@ -1,126 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createDirectMessage } from "../src/event/direct-message"
|
||||
|
||||
describe("direct-message", () => {
|
||||
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 () => {
|
||||
const event = await createDirectMessage(
|
||||
{
|
||||
message,
|
||||
recipient: subscriberPubkey,
|
||||
},
|
||||
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)
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
@ -1,75 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
import { defined } from "../src/common"
|
||||
import { EventKind } from "../src/event"
|
||||
import { createSetMetadata } from "../src/event/set-metadata"
|
||||
import { setup } from "./setup"
|
||||
|
||||
describe("internet-identifier", () => {
|
||||
it("present", (done) => {
|
||||
setup(done, ({ publisher, publisherSecret, subscriber, done }) => {
|
||||
subscriber.on("event", async ({ event }) => {
|
||||
// Assert that the internet identifier can be verified.
|
||||
assert.strictEqual(event.kind, EventKind.SetMetadata)
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
const identifier = await event.verifyInternetIdentifier({
|
||||
https: false,
|
||||
})
|
||||
assert.ok(identifier)
|
||||
const { name, relays } = defined(identifier)
|
||||
assert.strictEqual(name, "bob")
|
||||
assert.deepStrictEqual(relays, ["ws://example.com"])
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
subscriber.subscribe([])
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
publisher.publish({
|
||||
...(await createSetMetadata(
|
||||
{
|
||||
about: "",
|
||||
name: "",
|
||||
picture: "",
|
||||
nip05: "bob@localhost:12647",
|
||||
},
|
||||
publisherSecret,
|
||||
)),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("missing", (done) => {
|
||||
setup(done, ({ publisher, publisherSecret, subscriber, done }) => {
|
||||
subscriber.on("event", async ({ event }) => {
|
||||
// Assert that undefined is returned if the internet identifier is missing.
|
||||
assert.strictEqual(event.kind, EventKind.SetMetadata)
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
const identifier = await event.verifyInternetIdentifier({
|
||||
https: false,
|
||||
})
|
||||
assert.strictEqual(identifier, undefined)
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
subscriber.subscribe([])
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
publisher.publish({
|
||||
...(await createSetMetadata(
|
||||
{
|
||||
about: "",
|
||||
name: "",
|
||||
picture: "",
|
||||
},
|
||||
publisherSecret,
|
||||
)),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
@ -1,24 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
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)
|
||||
})
|
||||
})
|
@ -1,26 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
@ -1,54 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createSetMetadata } from "../src/event/set-metadata"
|
||||
|
||||
describe("set metadata", () => {
|
||||
const name = "bob"
|
||||
const about = "this is bob"
|
||||
const picture = "https://example.com/bob.jpg"
|
||||
|
||||
// Test that a set metadata event 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 }) => {
|
||||
assert.strictEqual(event.kind, EventKind.SetMetadata)
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
const user = event.getUserMetadata()
|
||||
assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
|
||||
assert.strictEqual(event.created_at, timestamp)
|
||||
assert.strictEqual(event.tags.length, 0)
|
||||
assert.strictEqual(user.name, name)
|
||||
assert.strictEqual(user.about, about)
|
||||
assert.strictEqual(user.picture, picture)
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
subscriber.subscribe([])
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
publisher.publish({
|
||||
...(await createSetMetadata(
|
||||
{ name, about, picture },
|
||||
publisherSecret,
|
||||
)),
|
||||
created_at: timestamp,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
@ -1,66 +0,0 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createTextNote } 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)
|
||||
|
||||
publisher.publish({
|
||||
...(await createTextNote(note, publisherSecret)),
|
||||
created_at: timestamp,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Test that a client interprets an "OK" message after publishing a text note.
|
||||
it("publish and ok", function (done) {
|
||||
setup(done, async ({ publisher, publisherSecret, url, done }) => {
|
||||
const event = await createTextNote(note, publisherSecret)
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"noImplicitOverride": true,
|
||||
"module": "CommonJS",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
const fs = require("fs")
|
||||
|
||||
const isProduction = process.env.NODE_ENV == "production"
|
||||
|
||||
const entry = {
|
||||
lib: "./src/index.ts",
|
||||
}
|
||||
if (!isProduction) {
|
||||
for (const file of fs.readdirSync("./test/")) {
|
||||
if (/.ts$/.test(file)) {
|
||||
const name = file.replace(/.ts$/, "")
|
||||
entry[`test/${name}`] = `./test/${file}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mode: process.env.NODE_ENV || "development",
|
||||
target: "browserslist",
|
||||
devtool: isProduction ? "source-map" : "eval",
|
||||
entry,
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
fallback: {
|
||||
crypto: false,
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [{ test: /\.ts$/, use: "ts-loader" }],
|
||||
},
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: `${__dirname}/dist`,
|
||||
clean: true,
|
||||
library: {
|
||||
type: "umd",
|
||||
name: "Nostr",
|
||||
},
|
||||
},
|
||||
optimization: {
|
||||
usedExports: true,
|
||||
},
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user