mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
241 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba9c8886b | ||
|
|
7dbd86eb5c | ||
|
|
3e839db6f2 | ||
|
|
cb370fbf4f | ||
|
|
c015b6e794 | ||
|
|
52079f6e75 | ||
|
|
ef28b2eb73 | ||
|
|
2a422774fb | ||
|
|
b80f8a0bcc | ||
|
|
dd603e47d8 | ||
|
|
ba26b92973 | ||
|
|
aec8ff5946 | ||
|
|
e498c9144d | ||
|
|
42d47abba1 | ||
|
|
303c35120c | ||
|
|
4a738c93d0 | ||
|
|
2a11c9ec91 | ||
|
|
cbe3a9d683 | ||
|
|
2944a932b8 | ||
|
|
6b39de04d7 | ||
|
|
9a612e59a2 | ||
|
|
266dbdf766 | ||
|
|
19ae9837a7 | ||
|
|
4188f2c596 | ||
|
|
97bded8f5b | ||
|
|
174d36a440 | ||
|
|
0177b130c3 | ||
|
|
05eb62da5b | ||
|
|
3c4019a154 | ||
|
|
e7e8db1dbd | ||
|
|
44a679e642 | ||
|
|
c1172caf1d | ||
|
|
86f37d6003 | ||
|
|
3daade322c | ||
|
|
fcf10541c8 | ||
|
|
548abb5d4a | ||
|
|
1e5bfe856b | ||
|
|
3266b4d4c2 | ||
|
|
a0b950ab12 | ||
|
|
be741159d7 | ||
|
|
9c50b2c655 | ||
|
|
bbb09420fe | ||
|
|
2e85f7a5fe | ||
|
|
b22e2465cc | ||
|
|
43ce7f9377 | ||
|
|
5a55c670fb | ||
|
|
bf0c4d4988 | ||
|
|
50fe7c2a8b | ||
|
|
29270c8c9d | ||
|
|
cb29d62033 | ||
|
|
0d237405d9 | ||
|
|
659ad36b62 | ||
|
|
d062ab8afd | ||
|
|
94f841f347 | ||
|
|
c1d03cf00b | ||
|
|
29ecdfc5ec | ||
|
|
d3fc4734b4 | ||
|
|
66d0b8a4e1 | ||
|
|
e2ec7a4b55 | ||
|
|
a72e47135a | ||
|
|
de7bbfc6a2 | ||
|
|
f2d421fa4f | ||
|
|
cae06fc4fe | ||
|
|
5c538efa38 | ||
|
|
013daae91b | ||
|
|
75660e7ff1 | ||
|
|
4c2d2b5ce6 | ||
|
|
aba266b8e6 | ||
|
|
d7dcc75ebe | ||
|
|
b18510b460 | ||
|
|
b04e0d16c0 | ||
|
|
633696bf46 | ||
|
|
bf975c9a87 | ||
|
|
7aa4f09769 | ||
|
|
f646fcd889 | ||
|
|
1d89038375 | ||
|
|
0b5b35714c | ||
|
|
e398617fdc | ||
|
|
1b236faa7b | ||
|
|
7064e0b828 | ||
|
|
4f6976f6f8 | ||
|
|
a61cde77ea | ||
|
|
23d95acb26 | ||
|
|
13ac04b8f8 | ||
|
|
45b25c5bf5 | ||
|
|
ee76d69b4b | ||
|
|
21433049b8 | ||
|
|
e8ff68f0b3 | ||
|
|
1b77d6e080 | ||
|
|
76d3a91600 | ||
|
|
6f334f31a7 | ||
|
|
9c009ac543 | ||
|
|
a87099fa5c | ||
|
|
475a22a95f | ||
|
|
54e352d8e2 | ||
|
|
235a1c50cb | ||
|
|
dfc2107569 | ||
|
|
986b9d0cce | ||
|
|
753ff323ea | ||
|
|
f8c3e20f3d | ||
|
|
87a91c2daf | ||
|
|
4f1dc9ef1c | ||
|
|
faa1a9d556 | ||
|
|
97d838f254 | ||
|
|
260400b24d | ||
|
|
6e5ab34a54 | ||
|
|
9562c408b3 | ||
|
|
4f4de458e9 | ||
|
|
88454de628 | ||
|
|
9f5984d78d | ||
|
|
80df21d47f | ||
|
|
296e99d2a4 | ||
|
|
1cd9847ad5 | ||
|
|
fa31fdca78 | ||
|
|
5876acd67a | ||
|
|
44efd49bc0 | ||
|
|
f4f9bece6e | ||
|
|
e217f751da | ||
|
|
d0ae8b36a2 | ||
|
|
fd945757be | ||
|
|
c12ddd3c53 | ||
|
|
1e9f828e3e | ||
|
|
0a5eaac088 | ||
|
|
e858698cb9 | ||
|
|
b349ee577d | ||
|
|
849a2ac3f3 | ||
|
|
c18b94677c | ||
|
|
f306cec716 | ||
|
|
5c7e9c8f36 | ||
|
|
1d7620a057 | ||
|
|
88247e56c1 | ||
|
|
e5cda3509c | ||
|
|
02da1dc036 | ||
|
|
7aed747bb2 | ||
|
|
747a7944d7 | ||
|
|
9f8b7274b3 | ||
|
|
ee565db7f5 | ||
|
|
e9ee8258e7 | ||
|
|
ad07d260ab | ||
|
|
632184afb8 | ||
|
|
d7d5d30f41 | ||
|
|
387ce2c335 | ||
|
|
b62b8f88af | ||
|
|
6b43533f2e | ||
|
|
e30e08d8e2 | ||
|
|
59426d9f35 | ||
|
|
5429142858 | ||
|
|
564c9bca17 | ||
|
|
0190ae94a7 | ||
|
|
e1bde08ff3 | ||
|
|
71456feb20 | ||
|
|
ca928c697b | ||
|
|
7b98cae7fa | ||
|
|
db53f37161 | ||
|
|
1691f0b51d | ||
|
|
3b582a0206 | ||
|
|
8ed2c13c28 | ||
|
|
27a536f41d | ||
|
|
fbc82d0b73 | ||
|
|
9c0ade1329 | ||
|
|
63ccc8b4c8 | ||
|
|
7cf7df88db | ||
|
|
bded539122 | ||
|
|
3647bbd68a | ||
|
|
fb085ffdf7 | ||
|
|
280d483ef4 | ||
|
|
54b55b98f1 | ||
|
|
84f9881812 | ||
|
|
db6baf2e6b | ||
|
|
bb1e6f4356 | ||
|
|
5626d3048b | ||
|
|
058d0276e2 | ||
|
|
37b046c047 | ||
|
|
846654b449 | ||
|
|
b676dc0987 | ||
|
|
b1ce901555 | ||
|
|
62e5730965 | ||
|
|
01f13292bb | ||
|
|
7b0458db72 | ||
|
|
3aab7121f7 | ||
|
|
ce059d4608 | ||
|
|
b72b0dc1f0 | ||
|
|
29e5b71473 | ||
|
|
b4e54d679f | ||
|
|
9d78c90a79 | ||
|
|
566a2deea3 | ||
|
|
177e673d83 | ||
|
|
cf766cd835 | ||
|
|
7d332605ee | ||
|
|
72f9b482ef | ||
|
|
d14830a8ff | ||
|
|
943cc4fb48 | ||
|
|
04252aaaec | ||
|
|
8c78649d5c | ||
|
|
b9435af708 | ||
|
|
ea5d00beed | ||
|
|
7ec6d127b0 | ||
|
|
7a9d432686 | ||
|
|
744a930ccf | ||
|
|
c6a521e73c | ||
|
|
6aebe0d38c | ||
|
|
16cdf40112 | ||
|
|
e36ea11f41 | ||
|
|
31a35a8008 | ||
|
|
0f5b3f397c | ||
|
|
d156f3c0ac | ||
|
|
d656c84ab5 | ||
|
|
2f0ef90bd5 | ||
|
|
967d7fe63a | ||
|
|
12147d4fee | ||
|
|
c453bc5ec3 | ||
|
|
2017b3cabd | ||
|
|
fbcfccda01 | ||
|
|
0357e035f4 | ||
|
|
dd0014aee3 | ||
|
|
2e9798b8ab | ||
|
|
10b800db3a | ||
|
|
dbad25b2fa | ||
|
|
829633b0d6 | ||
|
|
b1bbcd6c46 | ||
|
|
6a9940c850 | ||
|
|
9b08550885 | ||
|
|
3b81e5e762 | ||
|
|
8b2b050c0d | ||
|
|
d4090dae2b | ||
|
|
49596d24c3 | ||
|
|
ac83eeff1c | ||
|
|
85b741b39a | ||
|
|
c69c528ab0 | ||
|
|
1aad9ad0bd | ||
|
|
f6ed374f2f | ||
|
|
6d7ad22677 | ||
|
|
340a4a6799 | ||
|
|
5ec136a365 | ||
|
|
75eb08b170 | ||
|
|
677b679c2c | ||
|
|
7b79d6a899 | ||
|
|
c1efbbd919 | ||
|
|
7d58705e9a | ||
|
|
f1d315632c | ||
|
|
348d118ce4 |
19
.devcontainer/Dockerfile
Executable file
19
.devcontainer/Dockerfile
Executable file
@@ -0,0 +1,19 @@
|
||||
FROM node:20
|
||||
|
||||
RUN npm install typescript eslint prettier -g
|
||||
|
||||
# Install bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Install just
|
||||
WORKDIR /usr/bin
|
||||
RUN wget https://github.com/casey/just/releases/download/1.26.0/just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||
RUN tar -xzf just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||
RUN chmod +x ./just
|
||||
RUN rm just-1.26.0-x86_64-unknown-linux-musl.tar.gz
|
||||
|
||||
WORKDIR /nostr-tools
|
||||
ENV LANG C.UTF-8
|
||||
|
||||
# The run the start script
|
||||
CMD [ "/bin/bash" ]
|
||||
19
.devcontainer/devcontainer.json
Executable file
19
.devcontainer/devcontainer.json
Executable file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Nostr Tools",
|
||||
"dockerComposeFile": [
|
||||
"docker-compose.yml"
|
||||
],
|
||||
"service": "nostr-tools-dev",
|
||||
"workspaceFolder": "/nostr-tools",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"eamodio.gitlens",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"manishsencha.readme-preview",
|
||||
"wix.vscode-import-cost"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
.devcontainer/docker-compose.yml
Executable file
13
.devcontainer/docker-compose.yml
Executable file
@@ -0,0 +1,13 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
nostr-tools-dev:
|
||||
image: nostr-tools-dev
|
||||
container_name: nostr-tools-dev
|
||||
build:
|
||||
context: ../.
|
||||
dockerfile: ./.devcontainer/Dockerfile
|
||||
working_dir: /nostr-tools
|
||||
volumes:
|
||||
- ..:/nostr-tools:cached
|
||||
tty: true
|
||||
@@ -3,7 +3,7 @@
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
@@ -45,7 +45,6 @@
|
||||
"curly": [0, "multi-line"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
@@ -117,7 +116,8 @@
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||
"no-unreachable": 2,
|
||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-with": 2,
|
||||
|
||||
261
README.md
261
README.md
@@ -1,34 +1,51 @@
|
||||
#  nostr-tools
|
||||
#  [](https://jsr.io/@nostr/tools) nostr-tools
|
||||
|
||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
Only depends on _@scure_ and _@noble_ packages.
|
||||
|
||||
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||
This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install nostr-tools # or yarn add nostr-tools
|
||||
# npm
|
||||
npm install --save nostr-tools
|
||||
|
||||
# jsr
|
||||
npx jsr add @nostr/tools
|
||||
```
|
||||
|
||||
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
|
||||
## Documentation
|
||||
|
||||
https://jsr.io/@nostr/tools/doc
|
||||
|
||||
## Usage
|
||||
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
|
||||
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
```
|
||||
|
||||
To get the secret key in hex format, use
|
||||
|
||||
```js
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
|
||||
|
||||
let skHex = bytesToHex(sk)
|
||||
let backToBytes = hexToBytes(skHex)
|
||||
```
|
||||
|
||||
### Creating, signing and verifying events
|
||||
|
||||
```js
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||
|
||||
let event = finalizeEvent({
|
||||
kind: 1,
|
||||
@@ -40,42 +57,43 @@ let event = finalizeEvent({
|
||||
let isGood = verifyEvent(event)
|
||||
```
|
||||
|
||||
### Interacting with a relay
|
||||
### Interacting with one or multiple relays
|
||||
|
||||
Doesn't matter what you do, you always should be using a `SimplePool`:
|
||||
|
||||
```js
|
||||
import { Relay, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
|
||||
const relay = await Relay.connect('wss://relay.example.com')
|
||||
console.log(`connected to ${relay.url}`)
|
||||
const pool = new SimplePool()
|
||||
|
||||
// let's query for an event that exists
|
||||
const sub = relay.subscribe([
|
||||
const event = relay.get(
|
||||
['wss://relay.example.com'],
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('we got the event we wanted:', event)
|
||||
},
|
||||
oneose() {
|
||||
sub.close()
|
||||
}
|
||||
})
|
||||
)
|
||||
if (event) {
|
||||
console.log('it exists indeed on this relay:', event)
|
||||
}
|
||||
|
||||
// let's publish a new event while simultaneously monitoring the relay for it
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
relay.sub([
|
||||
pool.subscribe(
|
||||
['wss://a.com', 'wss://b.com', 'wss://c.com'],
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
{
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
let eventTemplate = {
|
||||
kind: 1,
|
||||
@@ -86,79 +104,140 @@ let eventTemplate = {
|
||||
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
await relay.publish(signedEvent)
|
||||
await Promise.any(pool.publish(['wss://a.com', 'wss://b.com'], signedEvent))
|
||||
|
||||
relay.close()
|
||||
```
|
||||
|
||||
To use this on Node.js you first must install `websocket-polyfill` and import it:
|
||||
To use this on Node.js you first must install `ws` and call something like this:
|
||||
|
||||
```js
|
||||
import 'websocket-polyfill'
|
||||
import { useWebSocketImplementation } from 'nostr-tools/pool'
|
||||
// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
|
||||
|
||||
import WebSocket from 'ws'
|
||||
useWebSocketImplementation(WebSocket)
|
||||
```
|
||||
|
||||
### Interacting with multiple relays
|
||||
### Parsing references (mentions) from a content based on NIP-27
|
||||
|
||||
```js
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import * as nip27 from '@nostr/tools/nip27'
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||
|
||||
let h = pool.subscribeMany(
|
||||
[...relays, 'wss://relay.example3.com'],
|
||||
[
|
||||
{
|
||||
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
// this will only be called once the first time the event is received
|
||||
// ...
|
||||
},
|
||||
oneose() {
|
||||
h.close()
|
||||
for (let block of nip27.parse(evt.content)) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
console.log(block.text)
|
||||
break
|
||||
case 'reference': {
|
||||
if ('id' in block.pointer) {
|
||||
console.log("it's a nevent1 uri", block.pointer)
|
||||
} else if ('identifier' in block.pointer) {
|
||||
console.log("it's a naddr1 uri", block.pointer)
|
||||
} else {
|
||||
console.log("it's an npub1 or nprofile1 uri", block.pointer)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'url': {
|
||||
console.log("it's a normal url:", block.url)
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'audio':
|
||||
console.log("it's a media url:", block.url)
|
||||
case 'relay':
|
||||
console.log("it's a websocket url, probably a relay address:", block.url)
|
||||
default:
|
||||
break
|
||||
}
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, newEvent))
|
||||
console.log('published to at least one relay!')
|
||||
|
||||
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||
### Connecting to a bunker using NIP-46
|
||||
|
||||
```js
|
||||
import { parseReferences } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
|
||||
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
|
||||
import { SimplePool } from '@nostr/tools/pool'
|
||||
|
||||
let references = parseReferences(event)
|
||||
let simpleAugmentedContent = event.content
|
||||
for (let i = 0; i < references.length; i++) {
|
||||
let { text, profile, event, address } = references[i]
|
||||
let augmentedReference = profile
|
||||
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||
: event
|
||||
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
|
||||
: address
|
||||
? `<a href="${text}">[link]</a>`
|
||||
: text
|
||||
simpleAugmentedContent.replaceAll(text, augmentedReference)
|
||||
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
|
||||
const localSecretKey = generateSecretKey()
|
||||
|
||||
// parse a bunker URI
|
||||
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
|
||||
if (!bunkerPointer) {
|
||||
throw new Error('Invalid bunker input')
|
||||
}
|
||||
|
||||
// create the bunker instance
|
||||
const pool = new SimplePool()
|
||||
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
|
||||
await bunker.connect()
|
||||
|
||||
// and use it
|
||||
const pubkey = await bunker.getPublicKey()
|
||||
const event = await bunker.signEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Hello from bunker!'
|
||||
})
|
||||
|
||||
// cleanup
|
||||
await signer.close()
|
||||
pool.close([])
|
||||
```
|
||||
|
||||
### Parsing thread from any note based on NIP-10
|
||||
|
||||
```js
|
||||
import * as nip10 from '@nostr/tools/nip10'
|
||||
|
||||
// event is a nostr event with tags
|
||||
const refs = nip10.parse(event)
|
||||
|
||||
// get the root event of the thread
|
||||
if (refs.root) {
|
||||
console.log('root event:', refs.root.id)
|
||||
console.log('root event relay hints:', refs.root.relays)
|
||||
console.log('root event author:', refs.root.author)
|
||||
}
|
||||
|
||||
// get the immediate parent being replied to
|
||||
if (refs.reply) {
|
||||
console.log('reply to:', refs.reply.id)
|
||||
console.log('reply relay hints:', refs.reply.relays)
|
||||
console.log('reply author:', refs.reply.author)
|
||||
}
|
||||
|
||||
// get any mentioned events
|
||||
for (let mention of refs.mentions) {
|
||||
console.log('mentioned event:', mention.id)
|
||||
console.log('mention relay hints:', mention.relays)
|
||||
console.log('mention author:', mention.author)
|
||||
}
|
||||
|
||||
// get any quoted events
|
||||
for (let quote of refs.quotes) {
|
||||
console.log('quoted event:', quote.id)
|
||||
console.log('quote relay hints:', quote.relays)
|
||||
}
|
||||
|
||||
// get any referenced profiles
|
||||
for (let profile of refs.profiles) {
|
||||
console.log('referenced profile:', profile.pubkey)
|
||||
console.log('profile relay hints:', profile.relays)
|
||||
}
|
||||
```
|
||||
|
||||
### Querying profile data from a NIP-05 address
|
||||
|
||||
```js
|
||||
import { nip05 } from 'nostr-tools'
|
||||
import { queryProfile } from 'nostr-tools/nip05'
|
||||
|
||||
let profile = await nip05.queryProfile('jb55.com')
|
||||
let profile = await queryProfile('jb55.com')
|
||||
console.log(profile.pubkey)
|
||||
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||
console.log(profile.relays)
|
||||
@@ -168,13 +247,26 @@ console.log(profile.relays)
|
||||
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||
|
||||
```js
|
||||
nip05.useFetchImplementation(require('node-fetch'))
|
||||
import { useFetchImplementation } from 'nostr-tools/nip05'
|
||||
useFetchImplementation(require('node-fetch'))
|
||||
```
|
||||
|
||||
### Including NIP-07 types
|
||||
```js
|
||||
import type { WindowNostr } from 'nostr-tools/nip07'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: WindowNostr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import * as nip19 from 'nostr-tools/nip19'
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
@@ -197,21 +289,6 @@ assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
```
|
||||
|
||||
## Import modes
|
||||
|
||||
### Using just the packages you want
|
||||
|
||||
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
|
||||
|
||||
```js
|
||||
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
import { Relay, Subscription } from 'nostr-tools/relay'
|
||||
import { matchFilter } from 'nostr-tools/filter'
|
||||
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
|
||||
// and so on and so forth
|
||||
```
|
||||
|
||||
### Using it with `nostr-wasm`
|
||||
|
||||
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
|
||||
@@ -274,3 +351,11 @@ To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just
|
||||
## License
|
||||
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
## Contributing to this repository
|
||||
|
||||
Use NIP-34 to send your patches to:
|
||||
|
||||
```
|
||||
naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq
|
||||
```
|
||||
|
||||
243
abstract-pool.ts
243
abstract-pool.ts
@@ -1,28 +1,42 @@
|
||||
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
|
||||
/* global WebSocket */
|
||||
|
||||
import {
|
||||
AbstractRelay as AbstractRelay,
|
||||
SubscriptionParams,
|
||||
Subscription,
|
||||
type AbstractRelayConstructorOptions,
|
||||
} from './abstract-relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event, Nostr } from './core.ts'
|
||||
import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
|
||||
import { type Filter } from './filter.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
export type SubCloser = { close: () => void }
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
|
||||
maxWait?: number
|
||||
onclose?: (reasons: string[]) => void
|
||||
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
|
||||
id?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export class AbstractSimplePool {
|
||||
private relays = new Map<string, AbstractRelay>()
|
||||
public seenOn = new Map<string, Set<AbstractRelay>>()
|
||||
protected relays: Map<string, AbstractRelay> = new Map()
|
||||
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
|
||||
public trackRelays: boolean = false
|
||||
|
||||
public verifyEvent: Nostr['verifyEvent']
|
||||
public trustedRelayURLs = new Set<string>()
|
||||
public trustedRelayURLs: Set<string> = new Set()
|
||||
|
||||
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
private _WebSocket?: typeof WebSocket
|
||||
|
||||
constructor(opts: AbstractPoolConstructorOptions) {
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
this._WebSocket = opts.websocketImplementation
|
||||
}
|
||||
|
||||
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||
@@ -32,6 +46,7 @@ export class AbstractSimplePool {
|
||||
if (!relay) {
|
||||
relay = new AbstractRelay(url, {
|
||||
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||
websocketImplementation: this._WebSocket,
|
||||
})
|
||||
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||
this.relays.set(url, relay)
|
||||
@@ -47,7 +62,21 @@ export class AbstractSimplePool {
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
|
||||
return this.subscribeMap(
|
||||
relays.map(url => ({ url, filter })),
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||
return this.subscribeMap(
|
||||
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
|
||||
if (this.trackRelays) {
|
||||
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||
let set = this.seenOn.get(id)
|
||||
@@ -65,8 +94,9 @@ export class AbstractSimplePool {
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
if (eosesReceived[i]) return // do not act twice for the same relay
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||
if (eosesReceived.filter(a => a).length === requests.length) {
|
||||
params.oneose?.()
|
||||
handleEose = () => {}
|
||||
}
|
||||
@@ -74,9 +104,10 @@ export class AbstractSimplePool {
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
if (closesReceived[i]) return // do not act twice for the same relay
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relays.length) {
|
||||
if (closesReceived.filter(a => a).length === requests.length) {
|
||||
params.onclose?.(closesReceived)
|
||||
handleClose = () => {}
|
||||
}
|
||||
@@ -93,12 +124,8 @@ export class AbstractSimplePool {
|
||||
|
||||
// open a subscription in all given relays
|
||||
const allOpened = Promise.all(
|
||||
relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||
if (arr.indexOf(url) !== i) {
|
||||
// duplicate
|
||||
handleClose(i, 'duplicate url')
|
||||
return
|
||||
}
|
||||
requests.map(async ({ url, filter }, i) => {
|
||||
url = normalizeURL(url)
|
||||
|
||||
let relay: AbstractRelay
|
||||
try {
|
||||
@@ -110,10 +137,31 @@ export class AbstractSimplePool {
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = relay.subscribe(filters, {
|
||||
let subscription = relay.subscribe([filter], {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => handleClose(i, reason),
|
||||
onclose: reason => {
|
||||
if (reason.startsWith('auth-required:') && params.doauth) {
|
||||
relay
|
||||
.auth(params.doauth)
|
||||
.then(() => {
|
||||
relay.subscribe([filter], {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => {
|
||||
handleClose(i, reason) // the second time we won't try to auth anymore
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
|
||||
})
|
||||
} else {
|
||||
handleClose(i, reason)
|
||||
}
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
@@ -132,10 +180,139 @@ export class AbstractSimplePool {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use subscribeMap instead.
|
||||
*/
|
||||
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser {
|
||||
if (this.trackRelays) {
|
||||
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||
let set = this.seenOn.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(id, set)
|
||||
}
|
||||
set.add(relay)
|
||||
}
|
||||
}
|
||||
|
||||
const _knownIds = new Set<string>()
|
||||
const subs: Subscription[] = []
|
||||
const relaysLength = Object.keys(requests).length
|
||||
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
if (eosesReceived[i]) return // do not act twice for the same relay
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relaysLength) {
|
||||
params.oneose?.()
|
||||
handleEose = () => {}
|
||||
}
|
||||
}
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
if (closesReceived[i]) return // do not act twice for the same relay
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relaysLength) {
|
||||
params.onclose?.(closesReceived)
|
||||
handleClose = () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const localAlreadyHaveEventHandler = (id: string) => {
|
||||
if (params.alreadyHaveEvent?.(id)) {
|
||||
return true
|
||||
}
|
||||
const have = _knownIds.has(id)
|
||||
_knownIds.add(id)
|
||||
return have
|
||||
}
|
||||
|
||||
// open a subscription in all given relays
|
||||
const allOpened = Promise.all(
|
||||
Object.entries(requests).map(async (req, i, arr) => {
|
||||
if (arr.indexOf(req) !== i) {
|
||||
// duplicate
|
||||
handleClose(i, 'duplicate url')
|
||||
return
|
||||
}
|
||||
|
||||
let [url, filters] = req
|
||||
url = normalizeURL(url)
|
||||
|
||||
let relay: AbstractRelay
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
handleClose(i, (err as any)?.message || String(err))
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => {
|
||||
if (reason.startsWith('auth-required:') && params.doauth) {
|
||||
relay
|
||||
.auth(params.doauth)
|
||||
.then(() => {
|
||||
relay.subscribe(filters, {
|
||||
...params,
|
||||
oneose: () => handleEose(i),
|
||||
onclose: reason => {
|
||||
handleClose(i, reason) // the second time we won't try to auth anymore
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
|
||||
})
|
||||
} else {
|
||||
handleClose(i, reason)
|
||||
}
|
||||
},
|
||||
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||
eoseTimeout: params.maxWait,
|
||||
})
|
||||
|
||||
subs.push(subscription)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
async close() {
|
||||
await allOpened
|
||||
subs.forEach(sub => {
|
||||
sub.close()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
subscribeEose(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribe(relays, filter, {
|
||||
...params,
|
||||
oneose() {
|
||||
subcloser.close()
|
||||
},
|
||||
})
|
||||
return subcloser
|
||||
}
|
||||
|
||||
subscribeManyEose(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filters, {
|
||||
...params,
|
||||
@@ -149,11 +326,11 @@ export class AbstractSimplePool {
|
||||
async querySync(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event[]> {
|
||||
return new Promise(async resolve => {
|
||||
const events: Event[] = []
|
||||
this.subscribeManyEose(relays, [filter], {
|
||||
this.subscribeEose(relays, filter, {
|
||||
...params,
|
||||
onevent(event: Event) {
|
||||
events.push(event)
|
||||
@@ -168,7 +345,7 @@ export class AbstractSimplePool {
|
||||
async get(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event | null> {
|
||||
filter.limit = 1
|
||||
const events = await this.querySync(relays, filter, params)
|
||||
@@ -184,7 +361,29 @@ export class AbstractSimplePool {
|
||||
}
|
||||
|
||||
let r = await this.ensureRelay(url)
|
||||
return r.publish(event)
|
||||
return r.publish(event).then(reason => {
|
||||
if (this.trackRelays) {
|
||||
let set = this.seenOn.get(event.id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.seenOn.set(event.id, set)
|
||||
}
|
||||
set.add(r)
|
||||
}
|
||||
return reason
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
listConnectionStatus(): Map<string, boolean> {
|
||||
const map = new Map<string, boolean>()
|
||||
this.relays.forEach((relay, url) => map.set(url, relay.connected))
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.relays.forEach(conn => conn.close())
|
||||
this.relays = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import { Queue, normalizeURL } from './utils.ts'
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { yieldThread } from './helpers.ts'
|
||||
|
||||
export type AbstractRelayConstructorOptions = {
|
||||
verifyEvent: Nostr['verifyEvent']
|
||||
websocketImplementation?: typeof WebSocket
|
||||
}
|
||||
|
||||
export class AbstractRelay {
|
||||
public readonly url: string
|
||||
private _connected: boolean = false
|
||||
@@ -14,9 +19,13 @@ export class AbstractRelay {
|
||||
public onclose: (() => void) | null = null
|
||||
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||
|
||||
// this is exposed just to help in ndk migration, shouldn't be relied upon
|
||||
public _onauth: ((challenge: string) => void) | null = null
|
||||
|
||||
public baseEoseTimeout: number = 4400
|
||||
public connectionTimeout: number = 4400
|
||||
public openSubs = new Map<string, Subscription>()
|
||||
public publishTimeout: number = 4400
|
||||
public openSubs: Map<string, Subscription> = new Map()
|
||||
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
private connectionPromise: Promise<void> | undefined
|
||||
@@ -26,15 +35,19 @@ export class AbstractRelay {
|
||||
private incomingMessageQueue = new Queue<string>()
|
||||
private queueRunning = false
|
||||
private challenge: string | undefined
|
||||
private authPromise: Promise<string> | undefined
|
||||
private serial: number = 0
|
||||
private verifyEvent: Nostr['verifyEvent']
|
||||
|
||||
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
private _WebSocket: typeof WebSocket
|
||||
|
||||
constructor(url: string, opts: AbstractRelayConstructorOptions) {
|
||||
this.url = normalizeURL(url)
|
||||
this.verifyEvent = opts.verifyEvent
|
||||
this._WebSocket = opts.websocketImplementation || WebSocket
|
||||
}
|
||||
|
||||
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
|
||||
const relay = new AbstractRelay(url, opts)
|
||||
await relay.connect()
|
||||
return relay
|
||||
@@ -65,6 +78,7 @@ export class AbstractRelay {
|
||||
if (this.connectionPromise) return this.connectionPromise
|
||||
|
||||
this.challenge = undefined
|
||||
this.authPromise = undefined
|
||||
this.connectionPromise = new Promise((resolve, reject) => {
|
||||
this.connectionTimeoutHandle = setTimeout(() => {
|
||||
reject('connection timed out')
|
||||
@@ -74,8 +88,9 @@ export class AbstractRelay {
|
||||
}, this.connectionTimeout)
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
this.ws = new this._WebSocket(this.url)
|
||||
} catch (err) {
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
@@ -87,19 +102,23 @@ export class AbstractRelay {
|
||||
}
|
||||
|
||||
this.ws.onerror = ev => {
|
||||
reject((ev as any).message)
|
||||
clearTimeout(this.connectionTimeoutHandle)
|
||||
reject((ev as any).message || 'websocket error')
|
||||
if (this._connected) {
|
||||
this._connected = false
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection errored')
|
||||
this._connected = false
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = async () => {
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection closed')
|
||||
this._connected = false
|
||||
if (this._connected) {
|
||||
this._connected = false
|
||||
this.connectionPromise = undefined
|
||||
this.onclose?.()
|
||||
this.closeAllSubscriptions('relay connection closed')
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onmessage = this._onmessage.bind(this)
|
||||
@@ -184,9 +203,12 @@ export class AbstractRelay {
|
||||
const ok: boolean = data[2]
|
||||
const reason: string = data[3]
|
||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
if (ep) {
|
||||
clearTimeout(ep.timeout)
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'CLOSED': {
|
||||
@@ -202,6 +224,7 @@ export class AbstractRelay {
|
||||
return
|
||||
case 'AUTH': {
|
||||
this.challenge = data[1] as string
|
||||
this._onauth?.(data[1] as string)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -218,19 +241,36 @@ export class AbstractRelay {
|
||||
})
|
||||
}
|
||||
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
|
||||
const challenge = this.challenge
|
||||
if (!challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
if (this.authPromise) return this.authPromise
|
||||
|
||||
this.authPromise = new Promise<string>(async (resolve, reject) => {
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
|
||||
const timeout = setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('auth timed out'))
|
||||
this.openEventPublishes.delete(evt.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
})
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
return ret
|
||||
return this.authPromise
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||
const timeout = setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('publish timed out'))
|
||||
this.openEventPublishes.delete(event.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
|
||||
})
|
||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||
return ret
|
||||
@@ -242,19 +282,25 @@ export class AbstractRelay {
|
||||
const ret = new Promise<number>((resolve, reject) => {
|
||||
this.openCountRequests.set(id, { resolve, reject })
|
||||
})
|
||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||
this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
|
||||
return ret
|
||||
}
|
||||
|
||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||
public subscribe(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
const subscription = this.prepareSubscription(filters, params)
|
||||
subscription.fire()
|
||||
return subscription
|
||||
}
|
||||
|
||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
public prepareSubscription(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
this.serial++
|
||||
const id = params.id || 'sub:' + this.serial
|
||||
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
|
||||
const subscription = new Subscription(this, id, filters, params)
|
||||
this.openSubs.set(id, subscription)
|
||||
return subscription
|
||||
@@ -328,7 +374,7 @@ export class Subscription {
|
||||
}
|
||||
|
||||
public close(reason: string = 'closed by caller') {
|
||||
if (!this.closed) {
|
||||
if (!this.closed && this.relay.connected) {
|
||||
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||
// otherwise this._open will be already set to false so we will skip this
|
||||
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||
@@ -356,4 +402,5 @@ export type CountResolver = {
|
||||
export type EventPublishResolver = {
|
||||
resolve: (reason: string) => void
|
||||
reject: (err: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { run, bench, group, baseline } from 'mitata'
|
||||
import { initNostrWasm } from 'nostr-wasm'
|
||||
import { NostrEvent } from './core'
|
||||
import { finalizeEvent, generateSecretKey } from './pure'
|
||||
import { setNostrWasm, verifyEvent } from './wasm'
|
||||
import { NostrEvent } from './core.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
import { setNostrWasm, verifyEvent } from './wasm.ts'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
import { Relay as PureRelay } from './relay.ts'
|
||||
import { alwaysTrue } from './helpers.ts'
|
||||
|
||||
305
core.test.ts
305
core.test.ts
@@ -1,293 +1,18 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { test, expect } from 'bun:test'
|
||||
import { sortEvents } from './core.ts'
|
||||
|
||||
import {
|
||||
finalizeEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifyEvent,
|
||||
verifiedSymbol,
|
||||
getPublicKey,
|
||||
generateSecretKey,
|
||||
} from './pure.ts'
|
||||
import { ShortTextNote } from './kinds.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
test('sortEvents', () => {
|
||||
const events = [
|
||||
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
|
||||
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
|
||||
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
|
||||
]
|
||||
|
||||
test('private key generation', () => {
|
||||
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(getPublicKey(sk)).toEqual(pk)
|
||||
}
|
||||
})
|
||||
|
||||
describe('finalizeEvent', () => {
|
||||
test('should create a signed event from a template', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finalizeEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
test('should serialize a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
test('should return the correct event hash', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid properties', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid tags', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEvent', () => {
|
||||
test('should return true for a valid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifyEvent({
|
||||
...event,
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event id', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the id
|
||||
event.id = event.id.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
const sortedEvents = sortEvents(events)
|
||||
|
||||
expect(sortedEvents).toEqual([
|
||||
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
|
||||
{ id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
|
||||
{ id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
|
||||
])
|
||||
})
|
||||
|
||||
16
core.ts
16
core.ts
@@ -43,9 +43,23 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||
let tag = event.tags[i]
|
||||
if (!Array.isArray(tag)) return false
|
||||
for (let j = 0; j < tag.length; j++) {
|
||||
if (typeof tag[j] === 'object') return false
|
||||
if (typeof tag[j] !== 'string') return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort events in reverse-chronological order by the `created_at` timestamp,
|
||||
* and then by the event `id` (lexicographically) in case of ties.
|
||||
* This mutates the array.
|
||||
*/
|
||||
export function sortEvents(events: Event[]): Event[] {
|
||||
return events.sort((a: NostrEvent, b: NostrEvent): number => {
|
||||
if (a.created_at !== b.created_at) {
|
||||
return b.created_at - a.created_at
|
||||
}
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
@@ -13,7 +13,6 @@ describe('Filter', () => {
|
||||
until: 200,
|
||||
'#tag': ['value'],
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
@@ -21,39 +20,21 @@ describe('Filter', () => {
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when the event id is not in the filter', () => {
|
||||
const filter = { ids: ['123', '456'] }
|
||||
|
||||
const event = buildEvent({ id: '789' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return true when the event id starts with a prefix', () => {
|
||||
const filter = { ids: ['22', '00'] }
|
||||
|
||||
const event = buildEvent({ id: '001' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false when the event kind is not in the filter', () => {
|
||||
const filter = { kinds: [1, 2, 3] }
|
||||
|
||||
const event = buildEvent({ kind: 4 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
@@ -154,25 +135,8 @@ describe('Filter', () => {
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return true when at least one prefix matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['1'], kinds: [1], authors: ['a'] },
|
||||
{ ids: ['4'], kinds: [2], authors: ['d'] },
|
||||
{ ids: ['9'], kinds: [3], authors: ['g'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
@@ -201,11 +165,8 @@ describe('Filter', () => {
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
@@ -221,9 +182,7 @@ describe('Filter', () => {
|
||||
pubkey: 'def',
|
||||
created_at: 200,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -241,4 +200,41 @@ describe('Filter', () => {
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilterLimit', () => {
|
||||
test('should handle ids', () => {
|
||||
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||
})
|
||||
|
||||
test('should count the authors times replaceable kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||
})
|
||||
|
||||
test('should handle parameterized replaceable events', () => {
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
|
||||
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
|
||||
expect(
|
||||
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
|
||||
).toEqual(8)
|
||||
})
|
||||
|
||||
test('should return Infinity for authors with regular kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||
})
|
||||
|
||||
test('should return Infinity for empty filters', () => {
|
||||
expect(getFilterLimit({})).toEqual(Infinity)
|
||||
})
|
||||
|
||||
test('empty tags return 0', () => {
|
||||
expect(getFilterLimit({ '#p': [] })).toEqual(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
49
filter.ts
49
filter.ts
@@ -1,4 +1,5 @@
|
||||
import { Event } from './core.ts'
|
||||
import { isAddressableKind, isReplaceableKind } from './kinds.ts'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
@@ -13,15 +14,13 @@ export type Filter = {
|
||||
|
||||
export function matchFilter(filter: Filter, event: Event): boolean {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) {
|
||||
return false
|
||||
}
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
|
||||
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (let f in filter) {
|
||||
@@ -40,7 +39,9 @@ export function matchFilter(filter: Filter, event: Event): boolean {
|
||||
|
||||
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
if (matchFilter(filters[i], event)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -70,3 +71,35 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the intrinsic limit of a filter.
|
||||
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
|
||||
*/
|
||||
export function getFilterLimit(filter: Filter): number {
|
||||
if (filter.ids && !filter.ids.length) return 0
|
||||
if (filter.kinds && !filter.kinds.length) return 0
|
||||
if (filter.authors && !filter.authors.length) return 0
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
// The `limit` property creates an artificial limit.
|
||||
Math.max(0, filter.limit ?? Infinity),
|
||||
|
||||
// There can only be one event per `id`.
|
||||
filter.ids?.length ?? Infinity,
|
||||
|
||||
// Replaceable events are limited by the number of authors and kinds.
|
||||
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||
? filter.authors.length * filter.kinds.length
|
||||
: Infinity,
|
||||
|
||||
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
|
||||
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
|
||||
? filter.authors.length * filter.kinds.length * filter['#d'].length
|
||||
: Infinity,
|
||||
)
|
||||
}
|
||||
|
||||
7
index.ts
7
index.ts
@@ -1,7 +1,7 @@
|
||||
export * from './pure.ts'
|
||||
export * from './relay.ts'
|
||||
export { Relay } from './relay.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export { SimplePool } from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04.ts'
|
||||
@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip11 from './nip11.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip17 from './nip17.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
@@ -20,7 +21,9 @@ export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip54 from './nip54.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip59 from './nip59.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
export * as kinds from './kinds.ts'
|
||||
|
||||
48
jsr.json
Normal file
48
jsr.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@nostr/tools",
|
||||
"version": "2.13.0",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./core": "./core.ts",
|
||||
"./pure": "./pure.ts",
|
||||
"./wasm": "./wasm.ts",
|
||||
"./kinds": "./kinds.ts",
|
||||
"./filter": "./filter.ts",
|
||||
"./abstract-relay": "./abstract-relay.ts",
|
||||
"./relay": "./relay.ts",
|
||||
"./abstract-pool": "./abstract-pool.ts",
|
||||
"./pool": "./pool.ts",
|
||||
"./references": "./references.ts",
|
||||
"./nip04": "./nip04.ts",
|
||||
"./nip05": "./nip05.ts",
|
||||
"./nip06": "./nip06.ts",
|
||||
"./nip07": "./nip07.ts",
|
||||
"./nip10": "./nip10.ts",
|
||||
"./nip11": "./nip11.ts",
|
||||
"./nip13": "./nip13.ts",
|
||||
"./nip17": "./nip17.ts",
|
||||
"./nip18": "./nip18.ts",
|
||||
"./nip19": "./nip19.ts",
|
||||
"./nip21": "./nip21.ts",
|
||||
"./nip25": "./nip25.ts",
|
||||
"./nip27": "./nip27.ts",
|
||||
"./nip28": "./nip28.ts",
|
||||
"./nip29": "./nip29.ts",
|
||||
"./nip30": "./nip30.ts",
|
||||
"./nip39": "./nip39.ts",
|
||||
"./nip42": "./nip42.ts",
|
||||
"./nip44": "./nip44.ts",
|
||||
"./nip46": "./nip46.ts",
|
||||
"./nip49": "./nip49.ts",
|
||||
"./nip54": "./nip54.ts",
|
||||
"./nip57": "./nip57.ts",
|
||||
"./nip58": "./nip58.ts",
|
||||
"./nip59": "./nip59.ts",
|
||||
"./nip75": "./nip75.ts",
|
||||
"./nip94": "./nip94.ts",
|
||||
"./nip98": "./nip98.ts",
|
||||
"./nip99": "./nip99.ts",
|
||||
"./fakejson": "./fakejson.ts",
|
||||
"./utils": "./utils.ts"
|
||||
}
|
||||
}
|
||||
9
justfile
9
justfile
@@ -3,6 +3,7 @@ export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
build:
|
||||
rm -rf lib
|
||||
bun run build.js
|
||||
tsc
|
||||
|
||||
test:
|
||||
bun test --timeout 20000
|
||||
@@ -10,11 +11,11 @@ test:
|
||||
test-only file:
|
||||
bun test {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
|
||||
publish: build emit-types
|
||||
publish: build
|
||||
npm publish
|
||||
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
|
||||
jsr publish --allow-dirty
|
||||
git checkout -- package.json
|
||||
|
||||
format:
|
||||
eslint --ext .ts --fix *.ts
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { classifyKind } from './kinds.ts'
|
||||
import { expect, test } from 'bun:test'
|
||||
import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
test('kind classification', () => {
|
||||
expect(classifyKind(1)).toBe('regular')
|
||||
@@ -19,3 +20,22 @@ test('kind classification', () => {
|
||||
expect(classifyKind(40000)).toBe('unknown')
|
||||
expect(classifyKind(255)).toBe('unknown')
|
||||
})
|
||||
|
||||
test('kind type guard', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
|
||||
expect(isKind(repostedEvent, Repost)).toBeFalse()
|
||||
})
|
||||
|
||||
129
kinds.ts
129
kinds.ts
@@ -1,23 +1,28 @@
|
||||
import { NostrEvent, validateEvent } from './pure.ts'
|
||||
|
||||
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||
export function isRegularKind(kind: number) {
|
||||
export function isRegularKind(kind: number): boolean {
|
||||
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||
}
|
||||
|
||||
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
|
||||
export function isReplaceableKind(kind: number) {
|
||||
export function isReplaceableKind(kind: number): boolean {
|
||||
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
|
||||
}
|
||||
|
||||
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||
export function isEphemeralKind(kind: number) {
|
||||
export function isEphemeralKind(kind: number): boolean {
|
||||
return 20000 <= kind && kind < 30000
|
||||
}
|
||||
|
||||
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isParameterizedReplaceableKind(kind: number) {
|
||||
/** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isAddressableKind(kind: number): boolean {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
/** @deprecated use isAddressableKind instead */
|
||||
export const isParameterizedReplaceableKind = isAddressableKind
|
||||
|
||||
/** Classification of the event kind. */
|
||||
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
|
||||
@@ -26,80 +31,166 @@ export function classifyKind(kind: number): KindClassification {
|
||||
if (isRegularKind(kind)) return 'regular'
|
||||
if (isReplaceableKind(kind)) return 'replaceable'
|
||||
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||
if (isAddressableKind(kind)) return 'parameterized'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function isKind<T extends number>(event: unknown, kind: T | Array<T>): event is NostrEvent & { kind: T } {
|
||||
const kindAsArray: number[] = kind instanceof Array ? kind : [kind]
|
||||
return (validateEvent(event) && kindAsArray.includes(event.kind)) || false
|
||||
}
|
||||
|
||||
export const Metadata = 0
|
||||
export type Metadata = typeof Metadata
|
||||
export const ShortTextNote = 1
|
||||
export type ShortTextNote = typeof ShortTextNote
|
||||
export const RecommendRelay = 2
|
||||
export type RecommendRelay = typeof RecommendRelay
|
||||
export const Contacts = 3
|
||||
export type Contacts = typeof Contacts
|
||||
export const EncryptedDirectMessage = 4
|
||||
export type EncryptedDirectMessage = typeof EncryptedDirectMessage
|
||||
export const EventDeletion = 5
|
||||
export type EventDeletion = typeof EventDeletion
|
||||
export const Repost = 6
|
||||
export type Repost = typeof Repost
|
||||
export const Reaction = 7
|
||||
export type Reaction = typeof Reaction
|
||||
export const BadgeAward = 8
|
||||
export const ChannelCreation = 40
|
||||
export const ChannelMetadata = 41
|
||||
export const ChannelMessage = 42
|
||||
export const ChannelHideMessage = 43
|
||||
export const ChannelMuteUser = 44
|
||||
export const Report = 1984
|
||||
export const ZapRequest = 9734
|
||||
export const Zap = 9735
|
||||
export const RelayList = 10002
|
||||
export const ClientAuth = 22242
|
||||
export const BadgeDefinition = 30009
|
||||
export const FileMetadata = 1063
|
||||
export const EncryptedDirectMessages = 4
|
||||
export type BadgeAward = typeof BadgeAward
|
||||
export const Seal = 13
|
||||
export type Seal = typeof Seal
|
||||
export const PrivateDirectMessage = 14
|
||||
export type PrivateDirectMessage = typeof PrivateDirectMessage
|
||||
export const GenericRepost = 16
|
||||
export type GenericRepost = typeof GenericRepost
|
||||
export const ChannelCreation = 40
|
||||
export type ChannelCreation = typeof ChannelCreation
|
||||
export const ChannelMetadata = 41
|
||||
export type ChannelMetadata = typeof ChannelMetadata
|
||||
export const ChannelMessage = 42
|
||||
export type ChannelMessage = typeof ChannelMessage
|
||||
export const ChannelHideMessage = 43
|
||||
export type ChannelHideMessage = typeof ChannelHideMessage
|
||||
export const ChannelMuteUser = 44
|
||||
export type ChannelMuteUser = typeof ChannelMuteUser
|
||||
export const OpenTimestamps = 1040
|
||||
export type OpenTimestamps = typeof OpenTimestamps
|
||||
export const GiftWrap = 1059
|
||||
export type GiftWrap = typeof GiftWrap
|
||||
export const FileMetadata = 1063
|
||||
export type FileMetadata = typeof FileMetadata
|
||||
export const LiveChatMessage = 1311
|
||||
export type LiveChatMessage = typeof LiveChatMessage
|
||||
export const ProblemTracker = 1971
|
||||
export type ProblemTracker = typeof ProblemTracker
|
||||
export const Report = 1984
|
||||
export type Report = typeof Report
|
||||
export const Reporting = 1984
|
||||
export type Reporting = typeof Reporting
|
||||
export const Label = 1985
|
||||
export type Label = typeof Label
|
||||
export const CommunityPostApproval = 4550
|
||||
export type CommunityPostApproval = typeof CommunityPostApproval
|
||||
export const JobRequest = 5999
|
||||
export type JobRequest = typeof JobRequest
|
||||
export const JobResult = 6999
|
||||
export type JobResult = typeof JobResult
|
||||
export const JobFeedback = 7000
|
||||
export type JobFeedback = typeof JobFeedback
|
||||
export const ZapGoal = 9041
|
||||
export type ZapGoal = typeof ZapGoal
|
||||
export const ZapRequest = 9734
|
||||
export type ZapRequest = typeof ZapRequest
|
||||
export const Zap = 9735
|
||||
export type Zap = typeof Zap
|
||||
export const Highlights = 9802
|
||||
export type Highlights = typeof Highlights
|
||||
export const Mutelist = 10000
|
||||
export type Mutelist = typeof Mutelist
|
||||
export const Pinlist = 10001
|
||||
export type Pinlist = typeof Pinlist
|
||||
export const RelayList = 10002
|
||||
export type RelayList = typeof RelayList
|
||||
export const BookmarkList = 10003
|
||||
export type BookmarkList = typeof BookmarkList
|
||||
export const CommunitiesList = 10004
|
||||
export type CommunitiesList = typeof CommunitiesList
|
||||
export const PublicChatsList = 10005
|
||||
export type PublicChatsList = typeof PublicChatsList
|
||||
export const BlockedRelaysList = 10006
|
||||
export type BlockedRelaysList = typeof BlockedRelaysList
|
||||
export const SearchRelaysList = 10007
|
||||
export type SearchRelaysList = typeof SearchRelaysList
|
||||
export const InterestsList = 10015
|
||||
export type InterestsList = typeof InterestsList
|
||||
export const UserEmojiList = 10030
|
||||
export type UserEmojiList = typeof UserEmojiList
|
||||
export const DirectMessageRelaysList = 10050
|
||||
export type DirectMessageRelaysList = typeof DirectMessageRelaysList
|
||||
export const FileServerPreference = 10096
|
||||
export type FileServerPreference = typeof FileServerPreference
|
||||
export const NWCWalletInfo = 13194
|
||||
export type NWCWalletInfo = typeof NWCWalletInfo
|
||||
export const LightningPubRPC = 21000
|
||||
export type LightningPubRPC = typeof LightningPubRPC
|
||||
export const ClientAuth = 22242
|
||||
export type ClientAuth = typeof ClientAuth
|
||||
export const NWCWalletRequest = 23194
|
||||
export type NWCWalletRequest = typeof NWCWalletRequest
|
||||
export const NWCWalletResponse = 23195
|
||||
export type NWCWalletResponse = typeof NWCWalletResponse
|
||||
export const NostrConnect = 24133
|
||||
export type NostrConnect = typeof NostrConnect
|
||||
export const HTTPAuth = 27235
|
||||
export type HTTPAuth = typeof HTTPAuth
|
||||
export const Followsets = 30000
|
||||
export type Followsets = typeof Followsets
|
||||
export const Genericlists = 30001
|
||||
export type Genericlists = typeof Genericlists
|
||||
export const Relaysets = 30002
|
||||
export type Relaysets = typeof Relaysets
|
||||
export const Bookmarksets = 30003
|
||||
export type Bookmarksets = typeof Bookmarksets
|
||||
export const Curationsets = 30004
|
||||
export type Curationsets = typeof Curationsets
|
||||
export const ProfileBadges = 30008
|
||||
export type ProfileBadges = typeof ProfileBadges
|
||||
export const BadgeDefinition = 30009
|
||||
export type BadgeDefinition = typeof BadgeDefinition
|
||||
export const Interestsets = 30015
|
||||
export type Interestsets = typeof Interestsets
|
||||
export const CreateOrUpdateStall = 30017
|
||||
export type CreateOrUpdateStall = typeof CreateOrUpdateStall
|
||||
export const CreateOrUpdateProduct = 30018
|
||||
export type CreateOrUpdateProduct = typeof CreateOrUpdateProduct
|
||||
export const LongFormArticle = 30023
|
||||
export type LongFormArticle = typeof LongFormArticle
|
||||
export const DraftLong = 30024
|
||||
export type DraftLong = typeof DraftLong
|
||||
export const Emojisets = 30030
|
||||
export type Emojisets = typeof Emojisets
|
||||
export const Application = 30078
|
||||
export type Application = typeof Application
|
||||
export const LiveEvent = 30311
|
||||
export type LiveEvent = typeof LiveEvent
|
||||
export const UserStatuses = 30315
|
||||
export type UserStatuses = typeof UserStatuses
|
||||
export const ClassifiedListing = 30402
|
||||
export type ClassifiedListing = typeof ClassifiedListing
|
||||
export const DraftClassifiedListing = 30403
|
||||
export type DraftClassifiedListing = typeof DraftClassifiedListing
|
||||
export const Date = 31922
|
||||
export type Date = typeof Date
|
||||
export const Time = 31923
|
||||
export type Time = typeof Time
|
||||
export const Calendar = 31924
|
||||
export type Calendar = typeof Calendar
|
||||
export const CalendarEventRSVP = 31925
|
||||
export type CalendarEventRSVP = typeof CalendarEventRSVP
|
||||
export const Handlerrecommendation = 31989
|
||||
export type Handlerrecommendation = typeof Handlerrecommendation
|
||||
export const Handlerinformation = 31990
|
||||
export type Handlerinformation = typeof Handlerinformation
|
||||
export const CommunityDefinition = 34550
|
||||
export type CommunityDefinition = typeof CommunityDefinition
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { encrypt, decrypt } from './nip04.ts'
|
||||
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
|
||||
test('encrypt and decrypt message', async () => {
|
||||
let sk1 = generateSecretKey()
|
||||
let sk2 = generateSecretKey()
|
||||
|
||||
24
nip04.ts
24
nip04.ts
@@ -1,44 +1,38 @@
|
||||
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { cbc } from '@noble/ciphers/aes'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
// @ts-ignore
|
||||
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
// @ts-ignore
|
||||
crypto.subtle = crypto.webcrypto.subtle
|
||||
}
|
||||
|
||||
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
|
||||
export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
let plaintext = utf8Encoder.encode(text)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||
|
||||
let ciphertext = cbc(normalizedKey, iv).encrypt(plaintext)
|
||||
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||
export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
|
||||
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||
let [ctb64, ivb64] = data.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
let normalizedKey = getNormalizedX(key)
|
||||
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
let iv = base64.decode(ivb64)
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
return utf8Decoder.decode(plaintext)
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
|
||||
|
||||
test('validate NIP05_REGEX', () => {
|
||||
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
|
||||
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
|
||||
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
|
||||
|
||||
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
|
||||
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
|
||||
|
||||
expect(isNip05('bob@bob.com.br')).toBeTrue()
|
||||
expect(isNip05('b&b@bob.com.br')).toBeFalse()
|
||||
})
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
const fetchStub = async (url: string) => ({
|
||||
status: 200,
|
||||
async json() {
|
||||
return {
|
||||
'https://compile-error.net/.well-known/nostr.json?name=_': {
|
||||
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
|
||||
},
|
||||
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
|
||||
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
|
||||
relays: {
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
|
||||
'wss://pyramid.fiatjaf.com',
|
||||
'wss://nos.lol',
|
||||
],
|
||||
},
|
||||
},
|
||||
}[url]
|
||||
},
|
||||
})
|
||||
|
||||
let p1 = await queryProfile('jb55.com')
|
||||
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||
useFetchImplementation(fetchStub)
|
||||
|
||||
let p2 = await queryProfile('jb55@jb55.com')
|
||||
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||
let p2 = await queryProfile('compile-error.net')
|
||||
expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
|
||||
|
||||
let p3 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
|
||||
73
nip05.ts
73
nip05.ts
@@ -1,5 +1,7 @@
|
||||
import { ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type Nip05 = `${string}@${string}`
|
||||
|
||||
/**
|
||||
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||
*
|
||||
@@ -7,23 +9,31 @@ import { ProfilePointer } from './nip19.ts'
|
||||
* - 1: name (optional)
|
||||
* - 2: domain
|
||||
*/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
||||
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
|
||||
|
||||
var _fetch: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
} catch (_) {
|
||||
null
|
||||
}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
export function useFetchImplementation(fetchImplementation: unknown) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||
try {
|
||||
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
||||
|
||||
return res.names
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
|
||||
const res = await _fetch(url, { redirect: 'manual' })
|
||||
if (res.status !== 200) {
|
||||
throw Error('Wrong response code')
|
||||
}
|
||||
const json = await res.json()
|
||||
return json.names
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
@@ -33,49 +43,24 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
|
||||
const match = fullname.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const [_, name = '_', domain] = match
|
||||
const [, name = '_', domain] = match
|
||||
|
||||
try {
|
||||
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
const { names, relays } = parseNIP05Result(await res.json())
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
const res = await _fetch(url, { redirect: 'manual' })
|
||||
if (res.status !== 200) {
|
||||
throw Error('Wrong response code')
|
||||
}
|
||||
const json = await res.json()
|
||||
|
||||
const pubkey = names[name]
|
||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||
const pubkey = json.names[name]
|
||||
return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** nostr.json result. */
|
||||
export interface NIP05Result {
|
||||
names: {
|
||||
[name: string]: string
|
||||
}
|
||||
relays?: {
|
||||
[pubkey: string]: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the nostr.json and throw if it's not valid. */
|
||||
function parseNIP05Result(json: any): NIP05Result {
|
||||
const result: NIP05Result = {
|
||||
names: {},
|
||||
}
|
||||
|
||||
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||
result.names[name] = pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if (json.relays) {
|
||||
result.relays = {}
|
||||
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
|
||||
const res = await queryProfile(nip05)
|
||||
return res ? res.pubkey === pubkey : false
|
||||
}
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
import {
|
||||
privateKeyFromSeedWords,
|
||||
accountFromSeedWords,
|
||||
extendedKeysFromSeedWords,
|
||||
accountFromExtendedKey,
|
||||
} from './nip06.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
expect(privateKey).toEqual(hexToBytes('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'))
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||
expect(privateKey).toEqual(hexToBytes('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b'))
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
expect(privateKey).toEqual(hexToBytes('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'))
|
||||
})
|
||||
|
||||
test('generate private key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
|
||||
})
|
||||
|
||||
test('generate private and public key for account 1 from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const { privateKey, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
|
||||
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
|
||||
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
|
||||
})
|
||||
|
||||
test('generate extended keys from mnemonic', () => {
|
||||
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
const passphrase = ''
|
||||
const extendedAccountIndex = 0
|
||||
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(
|
||||
mnemonic,
|
||||
passphrase,
|
||||
extendedAccountIndex,
|
||||
)
|
||||
|
||||
expect(privateExtendedKey).toBe(
|
||||
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH',
|
||||
)
|
||||
expect(publicExtendedKey).toBe(
|
||||
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN',
|
||||
)
|
||||
})
|
||||
|
||||
test('generate account from extended private key', () => {
|
||||
const xprv =
|
||||
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
|
||||
const { privateKey, publicKey } = accountFromExtendedKey(xprv)
|
||||
|
||||
expect(privateKey).toEqual(hexToBytes('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731'))
|
||||
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
|
||||
})
|
||||
|
||||
test('generate account from extended public key', () => {
|
||||
const xpub =
|
||||
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN'
|
||||
const { publicKey } = accountFromExtendedKey(xpub)
|
||||
|
||||
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
|
||||
})
|
||||
|
||||
62
nip06.ts
62
nip06.ts
@@ -3,11 +3,67 @@ import { wordlist } from '@scure/bip39/wordlists/english'
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||
import { HDKey } from '@scure/bip32'
|
||||
|
||||
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
|
||||
const DERIVATION_PATH = `m/44'/1237'`
|
||||
|
||||
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
|
||||
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return bytesToHex(privateKey)
|
||||
return privateKey
|
||||
}
|
||||
|
||||
export function accountFromSeedWords(
|
||||
mnemonic: string,
|
||||
passphrase?: string,
|
||||
accountIndex = 0,
|
||||
): {
|
||||
privateKey: Uint8Array
|
||||
publicKey: string
|
||||
} {
|
||||
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
|
||||
const publicKey = bytesToHex(seed.publicKey!.slice(1))
|
||||
const privateKey = seed.privateKey
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error('could not derive key pair')
|
||||
}
|
||||
return { privateKey, publicKey }
|
||||
}
|
||||
|
||||
export function extendedKeysFromSeedWords(
|
||||
mnemonic: string,
|
||||
passphrase?: string,
|
||||
extendedAccountIndex = 0,
|
||||
): {
|
||||
privateExtendedKey: string
|
||||
publicExtendedKey: string
|
||||
} {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let seed = root.derive(`${DERIVATION_PATH}/${extendedAccountIndex}'`)
|
||||
let privateExtendedKey = seed.privateExtendedKey
|
||||
let publicExtendedKey = seed.publicExtendedKey
|
||||
if (!privateExtendedKey && !publicExtendedKey) throw new Error('could not derive extended key pair')
|
||||
return { privateExtendedKey, publicExtendedKey }
|
||||
}
|
||||
|
||||
export function accountFromExtendedKey(
|
||||
base58key: string,
|
||||
accountIndex = 0,
|
||||
): {
|
||||
privateKey?: Uint8Array
|
||||
publicKey: string
|
||||
} {
|
||||
let extendedKey = HDKey.fromExtendedKey(base58key)
|
||||
let version = base58key.slice(0, 4)
|
||||
let child = extendedKey.deriveChild(0).deriveChild(accountIndex)
|
||||
let publicKey = bytesToHex(child.publicKey!.slice(1))
|
||||
if (!publicKey) throw new Error('could not derive public key')
|
||||
if (version === 'xprv') {
|
||||
let privateKey = child.privateKey!
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return { privateKey, publicKey }
|
||||
}
|
||||
return { publicKey }
|
||||
}
|
||||
|
||||
export function generateSeedWords(): string {
|
||||
|
||||
14
nip07.ts
Normal file
14
nip07.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EventTemplate, VerifiedEvent } from './core.ts'
|
||||
|
||||
export interface WindowNostr {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||
nip04?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
nip44?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
}
|
||||
187
nip10.test.ts
187
nip10.test.ts
@@ -5,20 +5,21 @@ describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
@@ -55,33 +56,80 @@ describe('parse NIP10-referenced events', () => {
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
root: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
reply: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
test('modern', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
root: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
reply: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('modern, inverted, author hint', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
'wss://banana.com',
|
||||
'root',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
],
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [
|
||||
{
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||
},
|
||||
],
|
||||
root: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: ['wss://banana.com', 'wss://goiaba.com'],
|
||||
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
},
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
test('1 event, relay hint from author', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
|
||||
[
|
||||
'e',
|
||||
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
'',
|
||||
'root',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: [],
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
root: {
|
||||
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: ['wss://banana.com'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
test('many p 1 reply', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
[
|
||||
'e',
|
||||
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
'wss://relay.mostr.pub',
|
||||
'reply',
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
quotes: [],
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
},
|
||||
root: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
},
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
152
nip10.ts
152
nip10.ts
@@ -1,7 +1,7 @@
|
||||
import type { Event } from './core.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
export function parse(event: Pick<Event, 'tags'>): {
|
||||
/**
|
||||
* Pointer to the root of the thread.
|
||||
*/
|
||||
@@ -13,29 +13,80 @@ export type NIP10Result = {
|
||||
reply: EventPointer | undefined
|
||||
|
||||
/**
|
||||
* Pointers to events which may or may not be in the reply chain.
|
||||
* Pointers to events that may or may not be in the reply chain.
|
||||
*/
|
||||
mentions: EventPointer[]
|
||||
|
||||
/**
|
||||
* Pointers to events that were directly quoted.
|
||||
*/
|
||||
quotes: EventPointer[]
|
||||
|
||||
/**
|
||||
* List of pubkeys that are involved in the thread in no particular order.
|
||||
*/
|
||||
profiles: ProfilePointer[]
|
||||
}
|
||||
|
||||
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
const result: NIP10Result = {
|
||||
} {
|
||||
const result: ReturnType<typeof parse> = {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: [],
|
||||
quotes: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
let maybeParent: EventPointer | undefined
|
||||
let maybeRoot: EventPointer | undefined
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0; i--) {
|
||||
const tag = event.tags[i]
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
eTags.push(tag)
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
|
||||
string,
|
||||
string,
|
||||
undefined | string,
|
||||
undefined | string,
|
||||
undefined | string,
|
||||
]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
author: eTagAuthor,
|
||||
}
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'reply') {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'mention') {
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!maybeParent) {
|
||||
maybeParent = eventPointer
|
||||
} else {
|
||||
maybeRoot = eventPointer
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (tag[0] === 'q' && tag[1]) {
|
||||
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
|
||||
result.quotes.push({
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
})
|
||||
}
|
||||
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
@@ -43,49 +94,54 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
pubkey: tag[1],
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
||||
const eTag = eTags[eTagIndex]
|
||||
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
const isLastETag = eTagIndex === eTags.length - 1
|
||||
|
||||
if (eTagMarker === 'root') {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'reply') {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (eTagMarker === 'mention') {
|
||||
result.mentions.push(eventPointer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isFirstETag) {
|
||||
result.root = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLastETag) {
|
||||
result.reply = eventPointer
|
||||
continue
|
||||
}
|
||||
|
||||
result.mentions.push(eventPointer)
|
||||
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
|
||||
if (!result.root) {
|
||||
result.root = maybeRoot || maybeParent || result.reply
|
||||
}
|
||||
if (!result.reply) {
|
||||
result.reply = maybeParent || result.root
|
||||
}
|
||||
|
||||
// remove root and reply from mentions, inherit relay hints from authors if any
|
||||
;[result.reply, result.root].forEach(ref => {
|
||||
if (!ref) return
|
||||
|
||||
let idx = result.mentions.indexOf(ref)
|
||||
if (idx !== -1) {
|
||||
result.mentions.splice(idx, 1)
|
||||
}
|
||||
if (ref.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result.mentions.forEach(ref => {
|
||||
if (ref!.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11.ts'
|
||||
|
||||
// TODO: replace with a mock
|
||||
describe('requesting relay as for NIP11', () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
test('testing a relay', async () => {
|
||||
const info = await fetchRelayInformation('wss://atlas.nostr.land')
|
||||
expect(info.name).toEqual('nostr.land')
|
||||
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
|
||||
expect(info.fees).toBeTruthy()
|
||||
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||
expect(info.software).toEqual('custom')
|
||||
const info = await fetchRelayInformation('wss://nos.lol')
|
||||
expect(info.name).toEqual('nos.lol')
|
||||
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
|
||||
expect(info.supported_nips).toContain(1)
|
||||
expect(info.supported_nips).toContain(11)
|
||||
expect(info.supported_nips).toContain(70)
|
||||
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
|
||||
})
|
||||
})
|
||||
|
||||
18
nip11.ts
18
nip11.ts
@@ -4,11 +4,11 @@ try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
export function useFetchImplementation(fetchImplementation: any): void {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function fetchRelayInformation(url: string) {
|
||||
export async function fetchRelayInformation(url: string): Promise<RelayInformation> {
|
||||
return (await (
|
||||
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||
headers: { Accept: 'application/nostr+json' },
|
||||
@@ -68,7 +68,7 @@ export interface BasicRelayInformation {
|
||||
* from `[` to `]` and is after UTF-8 serialization (so some
|
||||
* unicode characters will cost 2-3 bytes). It is equal to
|
||||
* the maximum size of the WebSocket message frame.
|
||||
* @param max_subscription total number of subscriptions
|
||||
* @param max_subscriptions total number of subscriptions
|
||||
* that may be active on a single websocket connection to
|
||||
* this relay. It's possible that authenticated clients with
|
||||
* a (paid) relationship to the relay may have higher limits.
|
||||
@@ -101,12 +101,17 @@ export interface BasicRelayInformation {
|
||||
* authentication to happen before a new connection may
|
||||
* perform any other action. Even if set to False,
|
||||
* authentication may be required for specific actions.
|
||||
* @param restricted_writes: this relay requires some kind
|
||||
* of condition to be fulfilled in order to accept events
|
||||
* (not necessarily, but including
|
||||
* @param payment_required this relay requires payment
|
||||
* before a new connection may perform any action.
|
||||
* @param created_at_lower_limit: 'created_at' lower limit
|
||||
* @param created_at_upper_limit: 'created_at' upper limit
|
||||
*/
|
||||
export interface Limitations {
|
||||
max_message_length: number
|
||||
max_subscription: number
|
||||
max_subscriptions: number
|
||||
max_filters: number
|
||||
max_limit: number
|
||||
max_subid_length: number
|
||||
@@ -116,9 +121,12 @@ export interface Limitations {
|
||||
min_pow_difficulty: number
|
||||
auth_required: boolean
|
||||
payment_required: boolean
|
||||
created_at_lower_limit: number
|
||||
created_at_upper_limit: number
|
||||
restricted_writes: boolean
|
||||
}
|
||||
|
||||
interface RetentionDetails {
|
||||
export interface RetentionDetails {
|
||||
kinds: (number | number[])[]
|
||||
time?: number | null
|
||||
count?: number | null
|
||||
|
||||
@@ -2,9 +2,14 @@ import { test, expect } from 'bun:test'
|
||||
import { getPow, minePow } from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||
const difficulty = getPow(id)
|
||||
expect(difficulty).toEqual(21)
|
||||
;[
|
||||
['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
|
||||
['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
|
||||
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
|
||||
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
|
||||
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
|
||||
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
|
||||
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
|
||||
})
|
||||
|
||||
test('mines POW for an event', async () => {
|
||||
|
||||
24
nip13.ts
24
nip13.ts
@@ -1,15 +1,19 @@
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { type UnsignedEvent, type Event } from './pure.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(hex: string): number {
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
const nibble = parseInt(hex[i], 16)
|
||||
for (let i = 0; i < 64; i += 8) {
|
||||
const nibble = parseInt(hex.substring(i, i + 8), 16)
|
||||
if (nibble === 0) {
|
||||
count += 4
|
||||
count += 32
|
||||
} else {
|
||||
count += Math.clz32(nibble) - 28
|
||||
count += Math.clz32(nibble)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -20,8 +24,6 @@ export function getPow(hex: string): number {
|
||||
/**
|
||||
* Mine an event with the desired POW. This function mutates the event.
|
||||
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
|
||||
*
|
||||
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
|
||||
*/
|
||||
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
|
||||
let count = 0
|
||||
@@ -41,7 +43,7 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
|
||||
|
||||
tag[1] = (++count).toString()
|
||||
|
||||
event.id = getEventHash(event)
|
||||
event.id = fastEventHash(event)
|
||||
|
||||
if (getPow(event.id) >= difficulty) {
|
||||
break
|
||||
@@ -50,3 +52,9 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export function fastEventHash(evt: UnsignedEvent): string {
|
||||
return bytesToHex(
|
||||
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
|
||||
)
|
||||
}
|
||||
|
||||
97
nip17.test.ts
Normal file
97
nip17.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { getPublicKey } from './pure.ts'
|
||||
import { decode } from './nip19.ts'
|
||||
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
|
||||
|
||||
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
|
||||
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
|
||||
|
||||
const recipients = [
|
||||
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
|
||||
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
|
||||
]
|
||||
const message = 'Hello, this is a direct message!'
|
||||
const conversationTitle = 'Private Group Conversation' // Optional
|
||||
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
|
||||
|
||||
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
|
||||
|
||||
test('wrapEvent', () => {
|
||||
const expected = {
|
||||
content: '',
|
||||
id: '',
|
||||
created_at: 1728537932,
|
||||
kind: 1059,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
|
||||
[Symbol('verified')]: true,
|
||||
}
|
||||
|
||||
expect(wrappedEvent.kind).toEqual(expected.kind)
|
||||
expect(wrappedEvent.tags).toEqual(expected.tags)
|
||||
})
|
||||
|
||||
test('wrapManyEvents', () => {
|
||||
const expected = [
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729581521,
|
||||
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729594619,
|
||||
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729560014,
|
||||
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
]
|
||||
|
||||
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
|
||||
|
||||
wrappedEvents.forEach((event, index) => {
|
||||
expect(event.kind).toEqual(expected[index].kind)
|
||||
expect(event.tags).toEqual(expected[index].tags)
|
||||
})
|
||||
})
|
||||
|
||||
test('unwrapEvent', () => {
|
||||
const expected = {
|
||||
kind: 14,
|
||||
content: 'Hello, this is a direct message!',
|
||||
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
|
||||
tags: [
|
||||
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
|
||||
['e', 'previousEventId123', '', 'reply'],
|
||||
['subject', 'Private Group Conversation'],
|
||||
],
|
||||
}
|
||||
const result = unwrapEvent(wrappedEvent, sk1)
|
||||
|
||||
expect(result.kind).toEqual(expected.kind)
|
||||
expect(result.content).toEqual(expected.content)
|
||||
expect(result.pubkey).toEqual(expected.pubkey)
|
||||
expect(result.tags).toEqual(expected.tags)
|
||||
})
|
||||
77
nip17.ts
Normal file
77
nip17.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PrivateDirectMessage } from './kinds.ts'
|
||||
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
|
||||
import * as nip59 from './nip59.ts'
|
||||
|
||||
type Recipient = {
|
||||
publicKey: string
|
||||
relayUrl?: string
|
||||
}
|
||||
|
||||
type ReplyTo = {
|
||||
eventId: string
|
||||
relayUrl?: string
|
||||
}
|
||||
|
||||
function createEvent(
|
||||
recipients: Recipient | Recipient[],
|
||||
message: string,
|
||||
conversationTitle?: string,
|
||||
replyTo?: ReplyTo,
|
||||
): EventTemplate {
|
||||
const baseEvent: EventTemplate = {
|
||||
created_at: Math.ceil(Date.now() / 1000),
|
||||
kind: PrivateDirectMessage,
|
||||
tags: [],
|
||||
content: message,
|
||||
}
|
||||
|
||||
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
|
||||
|
||||
recipientsArray.forEach(({ publicKey, relayUrl }) => {
|
||||
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
|
||||
})
|
||||
|
||||
if (replyTo) {
|
||||
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
|
||||
}
|
||||
|
||||
if (conversationTitle) {
|
||||
baseEvent.tags.push(['subject', conversationTitle])
|
||||
}
|
||||
|
||||
return baseEvent
|
||||
}
|
||||
|
||||
export function wrapEvent(
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipient: Recipient,
|
||||
message: string,
|
||||
conversationTitle?: string,
|
||||
replyTo?: ReplyTo,
|
||||
): NostrEvent {
|
||||
const event = createEvent(recipient, message, conversationTitle, replyTo)
|
||||
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
|
||||
}
|
||||
|
||||
export function wrapManyEvents(
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipients: Recipient[],
|
||||
message: string,
|
||||
conversationTitle?: string,
|
||||
replyTo?: ReplyTo,
|
||||
): NostrEvent[] {
|
||||
if (!recipients || recipients.length === 0) {
|
||||
throw new Error('At least one recipient is required.')
|
||||
}
|
||||
|
||||
const senderPublicKey = getPublicKey(senderPrivateKey)
|
||||
|
||||
// wrap the event for the sender and then for each recipient
|
||||
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
|
||||
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
|
||||
)
|
||||
}
|
||||
|
||||
export const unwrapEvent = nip59.unwrapEvent
|
||||
|
||||
export const unwrapManyEvents = nip59.unwrapManyEvents
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericRepost', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: 1617932114,
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
|
||||
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
|
||||
test('should create a generic reposted event', () => {
|
||||
const template = { created_at: 1617932115 }
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(GenericRepost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
['k', '30009'],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
test('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishRepostEvent', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
test('should create an event with empty content if the reposted event is protected', () => {
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [['-']],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
21
nip18.ts
21
nip18.ts
@@ -1,6 +1,6 @@
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
@@ -25,11 +25,20 @@ export function finishRepostEvent(
|
||||
relayUrl: string,
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
let kind: Repost | GenericRepost
|
||||
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
|
||||
if (reposted.kind === ShortTextNote) {
|
||||
kind = Repost
|
||||
} else {
|
||||
kind = GenericRepost
|
||||
tags.push(['k', String(reposted.kind)])
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
kind,
|
||||
tags,
|
||||
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
@@ -37,7 +46,7 @@ export function finishRepostEvent(
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Repost) {
|
||||
if (![Repost, GenericRepost].includes(event.kind)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
141
nip19.test.ts
141
nip19.test.ts
@@ -1,16 +1,16 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { test, expect, describe } from 'bun:test'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
neventEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
EventPointer,
|
||||
NostrTypeGuard,
|
||||
} from './nip19.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
@@ -153,11 +153,134 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nrelay', () => {
|
||||
let url = 'wss://relay.nostr.example'
|
||||
let nrelay = nrelayEncode(url)
|
||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||
let { type, data } = decode(nrelay)
|
||||
expect(type).toEqual('nrelay')
|
||||
expect(data).toEqual(url)
|
||||
describe('NostrTypeGuard', () => {
|
||||
test('isNProfile', () => {
|
||||
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNProfile invalid nprofile', () => {
|
||||
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNProfile with invalid nprofile', () => {
|
||||
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNEvent', () => {
|
||||
const is = NostrTypeGuard.isNEvent(
|
||||
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
|
||||
)
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNEvent with invalid nevent', () => {
|
||||
const is = NostrTypeGuard.isNEvent(
|
||||
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
|
||||
)
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNEvent with invalid nevent', () => {
|
||||
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNAddr', () => {
|
||||
const is = NostrTypeGuard.isNAddr(
|
||||
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
|
||||
)
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNAddr with invalid nadress', () => {
|
||||
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNSec', () => {
|
||||
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNSec with invalid nsec', () => {
|
||||
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNSec with invalid nsec', () => {
|
||||
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNPub', () => {
|
||||
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNPub with invalid npub', () => {
|
||||
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNPub with invalid npub', () => {
|
||||
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNote', () => {
|
||||
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNote with invalid note', () => {
|
||||
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNote with invalid note', () => {
|
||||
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNcryptsec', () => {
|
||||
const is = NostrTypeGuard.isNcryptsec(
|
||||
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||
)
|
||||
|
||||
expect(is).toBeTrue()
|
||||
})
|
||||
|
||||
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||
const is = NostrTypeGuard.isNcryptsec(
|
||||
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||
)
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
|
||||
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||
|
||||
expect(is).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
105
nip19.ts
105
nip19.ts
@@ -3,7 +3,25 @@ import { bech32 } from '@scure/base'
|
||||
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
export type NProfile = `nprofile1${string}`
|
||||
export type NEvent = `nevent1${string}`
|
||||
export type NAddr = `naddr1${string}`
|
||||
export type NSec = `nsec1${string}`
|
||||
export type NPub = `npub1${string}`
|
||||
export type Note = `note1${string}`
|
||||
export type Ncryptsec = `ncryptsec1${string}`
|
||||
|
||||
export const NostrTypeGuard = {
|
||||
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
|
||||
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
|
||||
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
|
||||
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
|
||||
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
|
||||
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
|
||||
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
|
||||
}
|
||||
|
||||
export const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
@@ -43,29 +61,41 @@ export type AddressPointer = {
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
type Prefixes = {
|
||||
nprofile: ProfilePointer
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: Uint8Array
|
||||
npub: string
|
||||
note: string
|
||||
export function decodeNostrURI(nip19code: string): ReturnType<typeof decode> | { type: 'invalid'; data: null } {
|
||||
try {
|
||||
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
|
||||
return decode(nip19code)
|
||||
} catch (_err) {
|
||||
return { type: 'invalid', data: null }
|
||||
}
|
||||
}
|
||||
|
||||
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||
type: Prefix
|
||||
data: Prefixes[Prefix]
|
||||
}
|
||||
|
||||
export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||
export function decode(code: string):
|
||||
| {
|
||||
type: 'nevent'
|
||||
data: EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'nprofile'
|
||||
data: ProfilePointer
|
||||
}
|
||||
| {
|
||||
type: 'naddr'
|
||||
data: AddressPointer
|
||||
}
|
||||
| {
|
||||
type: 'npub'
|
||||
data: string
|
||||
}
|
||||
| {
|
||||
type: 'nsec'
|
||||
data: Uint8Array
|
||||
}
|
||||
| {
|
||||
type: 'note'
|
||||
data: string
|
||||
} {
|
||||
let { prefix, words } = bech32.decode(code, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
@@ -119,16 +149,6 @@ export function decode(nip19: string): DecodeResult {
|
||||
}
|
||||
}
|
||||
|
||||
case 'nrelay': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
||||
|
||||
return {
|
||||
type: 'nrelay',
|
||||
data: utf8Decoder.decode(tlv[0][0]),
|
||||
}
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
return { type: prefix, data }
|
||||
|
||||
@@ -158,15 +178,15 @@ function parseTLV(data: Uint8Array): TLV {
|
||||
return result
|
||||
}
|
||||
|
||||
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
|
||||
export function nsecEncode(key: Uint8Array): NSec {
|
||||
return encodeBytes('nsec', key)
|
||||
}
|
||||
|
||||
export function npubEncode(hex: string): `npub1${string}` {
|
||||
export function npubEncode(hex: string): NPub {
|
||||
return encodeBytes('npub', hexToBytes(hex))
|
||||
}
|
||||
|
||||
export function noteEncode(hex: string): `note1${string}` {
|
||||
export function noteEncode(hex: string): Note {
|
||||
return encodeBytes('note', hexToBytes(hex))
|
||||
}
|
||||
|
||||
@@ -175,11 +195,11 @@ function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array):
|
||||
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||
}
|
||||
|
||||
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||
return encodeBech32(prefix, bytes)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
export function nprofileEncode(profile: ProfilePointer): NProfile {
|
||||
let data = encodeTLV({
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
@@ -187,7 +207,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
return encodeBech32('nprofile', data)
|
||||
}
|
||||
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
export function neventEncode(event: EventPointer): NEvent {
|
||||
let kindArray
|
||||
if (event.kind !== undefined) {
|
||||
kindArray = integerToUint8Array(event.kind)
|
||||
@@ -203,7 +223,7 @@ export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
return encodeBech32('nevent', data)
|
||||
}
|
||||
|
||||
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
export function naddrEncode(addr: AddressPointer): NAddr {
|
||||
let kind = new ArrayBuffer(4)
|
||||
new DataView(kind).setUint32(0, addr.kind, false)
|
||||
|
||||
@@ -216,13 +236,6 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
return encodeBech32('naddr', data)
|
||||
}
|
||||
|
||||
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(url)],
|
||||
})
|
||||
return encodeBech32('nrelay', data)
|
||||
}
|
||||
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
|
||||
30
nip21.ts
30
nip21.ts
@@ -1,7 +1,7 @@
|
||||
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||
import { AddressPointer, BECH32_REGEX, decode, EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
export const NOSTR_URI_REGEX: RegExp = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
@@ -15,7 +15,31 @@ export interface NostrURI {
|
||||
/** The bech32-encoded data (eg `npub1...`). */
|
||||
value: string
|
||||
/** Decoded bech32 string, according to NIP-19. */
|
||||
decoded: DecodeResult
|
||||
decoded:
|
||||
| {
|
||||
type: 'nevent'
|
||||
data: EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'nprofile'
|
||||
data: ProfilePointer
|
||||
}
|
||||
| {
|
||||
type: 'naddr'
|
||||
data: AddressPointer
|
||||
}
|
||||
| {
|
||||
type: 'npub'
|
||||
data: string
|
||||
}
|
||||
| {
|
||||
type: 'nsec'
|
||||
data: Uint8Array
|
||||
}
|
||||
| {
|
||||
type: 'note'
|
||||
data: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse and decode a Nostr URI. */
|
||||
|
||||
117
nip27.test.ts
117
nip27.test.ts
@@ -1,68 +1,77 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
import { parse } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('first: parse simple content with 1 url and 1 nostr uri', () => {
|
||||
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147,
|
||||
},
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||
{ type: 'text', text: ' check out my profile:' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '; and this cool image ' },
|
||||
{ type: 'image', url: 'https://images.com/image.jpg' },
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('second: parse content with 3 urls of different types', () => {
|
||||
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
|
||||
http://music.com/song.mp3
|
||||
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: ':' },
|
||||
{ type: 'relay', url: 'wss://oa.ao/' },
|
||||
{ type: 'text', text: "; this was a relay and now here's a video -> " },
|
||||
{ type: 'video', url: 'https://videos.com/video.mp4' },
|
||||
{ type: 'text', text: '! and some music:\n' },
|
||||
{ type: 'audio', url: 'http://music.com/song.mp3' },
|
||||
{ type: 'text', text: '\nand a regular link: ' },
|
||||
{ type: 'url', url: 'https://regular.com/page?ok=true' },
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
type: 'text',
|
||||
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
|
||||
},
|
||||
{ type: 'url', url: 'https://ok.com/' },
|
||||
{ type: 'text', text: '!' },
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
|
||||
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
|
||||
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
|
||||
with a video https://example.com/vid.webm and finally https://example.com/docs`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: 'Look at these profiles ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: ' ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
|
||||
relays: ['wss://qwieu.com'],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' check this event ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
|
||||
relays: ['wss://zjbdksa.aswjdkn'],
|
||||
author: undefined,
|
||||
kind: undefined,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: "\n here's an image " },
|
||||
{ type: 'image', url: 'https://example.com/pic.png' },
|
||||
{ type: 'text', text: ' and another profile ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '\n with a video ' },
|
||||
{ type: 'video', url: 'https://example.com/vid.webm' },
|
||||
{ type: 'text', text: ' and finally ' },
|
||||
{ type: 'url', url: 'https://example.com/docs' },
|
||||
])
|
||||
})
|
||||
|
||||
212
nip27.ts
212
nip27.ts
@@ -1,63 +1,169 @@
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
export type Block =
|
||||
| {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: 'reference'
|
||||
pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'url'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'relay'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'image'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'audio'
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
/** Index where the URI begins in the event content. */
|
||||
start: number
|
||||
/** Index where the URI ends in the event content. */
|
||||
end: number
|
||||
}
|
||||
const noCharacter = /\W/m
|
||||
const noURLCharacter = /\W |\W$|$|,| /m
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
export function* parse(content: string): Iterable<Block> {
|
||||
const max = content.length
|
||||
let prevIndex = 0
|
||||
let index = 0
|
||||
while (index < max) {
|
||||
let u = content.indexOf(':', index)
|
||||
if (u === -1) {
|
||||
// reached end
|
||||
break
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [uri, value] = match
|
||||
if (content.substring(u - 5, u) === 'nostr') {
|
||||
const m = content.substring(u + 60).match(noCharacter)
|
||||
const end = m ? u + 60 + m.index! : max
|
||||
try {
|
||||
let pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
let { data, type } = decode(content.substring(u + 1, end))
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length,
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pointer = { pubkey: data } as ProfilePointer
|
||||
break
|
||||
case 'nsec':
|
||||
case 'note':
|
||||
// ignore this, treat it as not a valid uri
|
||||
index = end + 1
|
||||
continue
|
||||
default:
|
||||
pointer = data as any
|
||||
}
|
||||
|
||||
if (prevIndex !== u - 5) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
|
||||
}
|
||||
yield { type: 'reference', pointer }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid nostr uri
|
||||
index = u + 1
|
||||
continue
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
|
||||
const m = content.substring(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 5 : 4
|
||||
try {
|
||||
let url = new URL(content.substring(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith('.png') ||
|
||||
url.pathname.endsWith('.jpg') ||
|
||||
url.pathname.endsWith('.jpeg') ||
|
||||
url.pathname.endsWith('.gif') ||
|
||||
url.pathname.endsWith('.webp')
|
||||
) {
|
||||
yield { type: 'image', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
if (
|
||||
url.pathname.endsWith('.mp4') ||
|
||||
url.pathname.endsWith('.avi') ||
|
||||
url.pathname.endsWith('.webm') ||
|
||||
url.pathname.endsWith('.mkv')
|
||||
) {
|
||||
yield { type: 'video', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
if (
|
||||
url.pathname.endsWith('.mp3') ||
|
||||
url.pathname.endsWith('.aac') ||
|
||||
url.pathname.endsWith('.ogg') ||
|
||||
url.pathname.endsWith('.opus')
|
||||
) {
|
||||
yield { type: 'audio', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
|
||||
yield { type: 'url', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
|
||||
const m = content.substring(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 3 : 2
|
||||
try {
|
||||
let url = new URL(content.substring(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid ws url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||
}
|
||||
yield { type: 'relay', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// ignore this, it is nothing
|
||||
index = u + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of Nostr URIs in the text.
|
||||
*
|
||||
* WARNING: using this on an HTML string is potentially unsafe!
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||
* switch(decoded.type) {
|
||||
* case 'npub':
|
||||
* return renderMention(decoded)
|
||||
* case 'note':
|
||||
* return renderNote(decoded)
|
||||
* default:
|
||||
* return value
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
|
||||
return content.replaceAll(regex(), (uri, value: string) => {
|
||||
return replacer({
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
})
|
||||
})
|
||||
if (prevIndex !== max) {
|
||||
yield { type: 'text', text: content.substring(prevIndex) }
|
||||
}
|
||||
}
|
||||
|
||||
10
nip28.ts
10
nip28.ts
@@ -1,5 +1,11 @@
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||
import {
|
||||
ChannelCreation,
|
||||
ChannelHideMessage,
|
||||
ChannelMessage,
|
||||
ChannelMetadata as KindChannelMetadata,
|
||||
ChannelMuteUser,
|
||||
} from './kinds.ts'
|
||||
|
||||
export interface ChannelMetadata {
|
||||
name: string
|
||||
@@ -78,7 +84,7 @@ export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: ChannelMetadata,
|
||||
kind: KindChannelMetadata,
|
||||
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||
content: content,
|
||||
created_at: t.created_at,
|
||||
|
||||
614
nip29.ts
Normal file
614
nip29.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
import { Subscription } from './abstract-relay.ts'
|
||||
import type { Event, EventTemplate } from './core.ts'
|
||||
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
|
||||
import { AddressPointer, decode } from './nip19.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group.
|
||||
*/
|
||||
export type Group = {
|
||||
relay: string
|
||||
metadata: GroupMetadata
|
||||
admins?: GroupAdmin[]
|
||||
members?: GroupMember[]
|
||||
reference: GroupReference
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the metadata for a NIP29 group.
|
||||
*/
|
||||
export type GroupMetadata = {
|
||||
id: string
|
||||
pubkey: string
|
||||
name?: string
|
||||
picture?: string
|
||||
about?: string
|
||||
isPublic?: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group reference.
|
||||
*/
|
||||
export type GroupReference = {
|
||||
id: string
|
||||
host: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group member.
|
||||
*/
|
||||
export type GroupMember = {
|
||||
pubkey: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a NIP29 group admin.
|
||||
*/
|
||||
export type GroupAdmin = {
|
||||
pubkey: string
|
||||
label?: string
|
||||
permissions: GroupAdminPermission[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the permissions that a NIP29 group admin can have.
|
||||
*/
|
||||
export enum GroupAdminPermission {
|
||||
/** @deprecated use PutUser instead */
|
||||
AddUser = 'add-user',
|
||||
EditMetadata = 'edit-metadata',
|
||||
DeleteEvent = 'delete-event',
|
||||
RemoveUser = 'remove-user',
|
||||
/** @deprecated removed from NIP */
|
||||
AddPermission = 'add-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
RemovePermission = 'remove-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
EditGroupStatus = 'edit-group-status',
|
||||
PutUser = 'put-user',
|
||||
CreateGroup = 'create-group',
|
||||
DeleteGroup = 'delete-group',
|
||||
CreateInvite = 'create-invite',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a group metadata event template.
|
||||
*
|
||||
* @param group - The group object.
|
||||
* @returns An event template with the generated group metadata that can be signed later.
|
||||
*/
|
||||
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
|
||||
const tags: string[][] = [['d', group.metadata.id]]
|
||||
group.metadata.name && tags.push(['name', group.metadata.name])
|
||||
group.metadata.picture && tags.push(['picture', group.metadata.picture])
|
||||
group.metadata.about && tags.push(['about', group.metadata.about])
|
||||
group.metadata.isPublic && tags.push(['public'])
|
||||
group.metadata.isOpen && tags.push(['open'])
|
||||
|
||||
return {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 39000,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a group metadata event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is valid.
|
||||
*/
|
||||
export function validateGroupMetadataEvent(event: Event): boolean {
|
||||
if (event.kind !== 39000) return false
|
||||
|
||||
if (!event.pubkey) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template for group admins.
|
||||
*
|
||||
* @param group - The group object.
|
||||
* @param admins - An array of group admins.
|
||||
* @returns The generated event template with the group admins that can be signed later.
|
||||
*/
|
||||
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
|
||||
const tags: string[][] = [['d', group.metadata.id]]
|
||||
for (const admin of admins) {
|
||||
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 39001,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a group admins event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateGroupAdminsEvent(event: Event): boolean {
|
||||
if (event.kind !== 39001) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
// validate permissions
|
||||
for (const [tag, _value, _label, ...permissions] of event.tags) {
|
||||
if (tag !== 'p') continue
|
||||
|
||||
for (let i = 0; i < permissions.length; i += 1) {
|
||||
if (typeof permissions[i] !== 'string') return false
|
||||
|
||||
// validate permission name from the GroupAdminPermission enum
|
||||
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template for a group with its members.
|
||||
*
|
||||
* @param group - The group object.
|
||||
* @param members - An array of group members.
|
||||
* @returns The generated event template with the group members that can be signed later.
|
||||
*/
|
||||
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
|
||||
const tags: string[][] = [['d', group.metadata.id]]
|
||||
for (const member of members) {
|
||||
tags.push(['p', member.pubkey, member.label || ''])
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 39002,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a group members event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
|
||||
*/
|
||||
export function validateGroupMembersEvent(event: Event): boolean {
|
||||
if (event.kind !== 39002) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized relay URL based on the provided group reference.
|
||||
*
|
||||
* @param groupReference - The group reference object containing the host.
|
||||
* @returns The normalized relay URL.
|
||||
*/
|
||||
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
|
||||
return normalizeURL(groupReference.host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches relay information by group reference.
|
||||
*
|
||||
* @param groupReference The group reference.
|
||||
* @returns A promise that resolves to the relay information.
|
||||
*/
|
||||
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
|
||||
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
|
||||
return fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the group metadata event from the specified pool.
|
||||
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
|
||||
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
|
||||
* @throws {Error} If the group is not found on the specified relay.
|
||||
*/
|
||||
export async function fetchGroupMetadataEvent({
|
||||
pool,
|
||||
groupReference,
|
||||
relayInformation,
|
||||
normalizedRelayURL,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Event> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
|
||||
kinds: [39000],
|
||||
authors: [relayInformation.pubkey],
|
||||
'#d': [groupReference.id],
|
||||
})
|
||||
|
||||
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||
|
||||
return groupMetadataEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group metadata event and returns the corresponding GroupMetadata object.
|
||||
*
|
||||
* @param event - The event to parse.
|
||||
* @returns The parsed GroupMetadata object.
|
||||
* @throws An error if the group metadata event is invalid.
|
||||
*/
|
||||
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
|
||||
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
|
||||
|
||||
const metadata: GroupMetadata = {
|
||||
id: '',
|
||||
pubkey: event.pubkey,
|
||||
}
|
||||
|
||||
for (const [tag, value] of event.tags) {
|
||||
switch (tag) {
|
||||
case 'd':
|
||||
metadata.id = value
|
||||
break
|
||||
case 'name':
|
||||
metadata.name = value
|
||||
break
|
||||
case 'picture':
|
||||
metadata.picture = value
|
||||
break
|
||||
case 'about':
|
||||
metadata.about = value
|
||||
break
|
||||
case 'public':
|
||||
metadata.isPublic = true
|
||||
break
|
||||
case 'open':
|
||||
metadata.isOpen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the group admins event from the specified pool.
|
||||
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
|
||||
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
|
||||
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information.
|
||||
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
|
||||
* @throws {Error} If the group admins event is not found on the specified relay.
|
||||
*/
|
||||
export async function fetchGroupAdminsEvent({
|
||||
pool,
|
||||
groupReference,
|
||||
relayInformation,
|
||||
normalizedRelayURL,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Event> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
|
||||
kinds: [39001],
|
||||
authors: [relayInformation.pubkey],
|
||||
'#d': [groupReference.id],
|
||||
})
|
||||
|
||||
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||
|
||||
return groupAdminsEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group admins event and returns an array of GroupAdmin objects.
|
||||
*
|
||||
* @param event - The event to parse.
|
||||
* @returns An array of GroupAdmin objects.
|
||||
* @throws Throws an error if the group admins event is invalid.
|
||||
*/
|
||||
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
|
||||
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
|
||||
|
||||
const admins: GroupAdmin[] = []
|
||||
|
||||
for (const [tag, value, label, ...permissions] of event.tags) {
|
||||
if (tag !== 'p') continue
|
||||
|
||||
admins.push({
|
||||
pubkey: value,
|
||||
label,
|
||||
permissions: permissions as GroupAdminPermission[],
|
||||
})
|
||||
}
|
||||
|
||||
return admins
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the group members event from the specified relay.
|
||||
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||
* @param {GroupReference} options.groupReference - The group reference object.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
|
||||
* @throws {Error} If the group members event is not found.
|
||||
*/
|
||||
export async function fetchGroupMembersEvent({
|
||||
pool,
|
||||
groupReference,
|
||||
relayInformation,
|
||||
normalizedRelayURL,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Event> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const groupMembersEvent = await pool.get([normalizedRelayURL], {
|
||||
kinds: [39002],
|
||||
authors: [relayInformation.pubkey],
|
||||
'#d': [groupReference.id],
|
||||
})
|
||||
|
||||
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||
|
||||
return groupMembersEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group members event and returns an array of GroupMember objects.
|
||||
* @param event - The event to parse.
|
||||
* @returns An array of GroupMember objects.
|
||||
* @throws Throws an error if the group members event is invalid.
|
||||
*/
|
||||
export function parseGroupMembersEvent(event: Event): GroupMember[] {
|
||||
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
|
||||
|
||||
const members: GroupMember[] = []
|
||||
|
||||
for (const [tag, value, label] of event.tags) {
|
||||
if (tag !== 'p') continue
|
||||
|
||||
members.push({
|
||||
pubkey: value,
|
||||
label,
|
||||
})
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
|
||||
* If the normalized relay URL is not provided, it will be obtained using the group reference.
|
||||
* If the relay information is not provided, it will be fetched using the normalized relay URL.
|
||||
*
|
||||
* @param {Object} options - The options for loading the group.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
|
||||
* @param {GroupReference} options.groupReference - The reference of the group to load.
|
||||
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
|
||||
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
|
||||
* @returns {Promise<Group>} A promise that resolves to the loaded group.
|
||||
*/
|
||||
export async function loadGroup({
|
||||
pool,
|
||||
groupReference,
|
||||
normalizedRelayURL,
|
||||
relayInformation,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
groupReference: GroupReference
|
||||
normalizedRelayURL?: string
|
||||
relayInformation?: RelayInformation
|
||||
}): Promise<Group> {
|
||||
if (!normalizedRelayURL) {
|
||||
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||
}
|
||||
|
||||
if (!relayInformation) {
|
||||
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||
}
|
||||
|
||||
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||
const metadata = parseGroupMetadataEvent(metadataEvent)
|
||||
|
||||
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||
const admins = parseGroupAdminsEvent(adminsEvent)
|
||||
|
||||
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||
const members = parseGroupMembersEvent(membersEvent)
|
||||
|
||||
const group: Group = {
|
||||
relay: normalizedRelayURL,
|
||||
metadata,
|
||||
admins,
|
||||
members,
|
||||
reference: groupReference,
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a group from the specified pool using the provided group code.
|
||||
*
|
||||
* @param {AbstractSimplePool} pool - The pool to load the group from.
|
||||
* @param {string} code - The code representing the group.
|
||||
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
|
||||
* @throws {Error} - If the group code is invalid.
|
||||
*/
|
||||
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||
const groupReference = parseGroupCode(code)
|
||||
|
||||
if (!groupReference) throw new Error('invalid group code')
|
||||
|
||||
return loadGroup({ pool, groupReference })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a group code and returns a GroupReference object.
|
||||
*
|
||||
* @param code The group code to parse.
|
||||
* @returns A GroupReference object if the code is valid, otherwise null.
|
||||
*/
|
||||
export function parseGroupCode(code: string): null | GroupReference {
|
||||
if (code.startsWith('naddr1')) {
|
||||
try {
|
||||
let { data } = decode(code)
|
||||
|
||||
let { relays, identifier } = data as AddressPointer
|
||||
if (!relays || relays.length === 0) return null
|
||||
|
||||
let host = relays![0]
|
||||
if (host.startsWith('wss://')) {
|
||||
host = host.slice(6)
|
||||
}
|
||||
return { host, id: identifier }
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
} else if (code.split("'").length === 2) {
|
||||
let spl = code.split("'")
|
||||
return { host: spl[0], id: spl[1] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a group reference into a string.
|
||||
*
|
||||
* @param gr - The group reference to encode.
|
||||
* @returns The encoded group reference as a string.
|
||||
*/
|
||||
export function encodeGroupReference(gr: GroupReference): string {
|
||||
const { host, id } = gr
|
||||
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
|
||||
|
||||
return `${normalizedHost}'${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to relay groups metadata events and calls the provided event handler function
|
||||
* when an event is received.
|
||||
*
|
||||
* @param {Object} options - The options for subscribing to relay groups metadata events.
|
||||
* @param {AbstractSimplePool} options.pool - The pool to subscribe to.
|
||||
* @param {string} options.relayURL - The URL of the relay.
|
||||
* @param {Function} options.onError - The error handler function.
|
||||
* @param {Function} options.onEvent - The event handler function.
|
||||
* @param {Function} [options.onConnect] - The connect handler function.
|
||||
* @returns {Function} - A function to close the subscription
|
||||
*/
|
||||
export function subscribeRelayGroupsMetadataEvents({
|
||||
pool,
|
||||
relayURL,
|
||||
onError,
|
||||
onEvent,
|
||||
onConnect,
|
||||
}: {
|
||||
pool: AbstractSimplePool
|
||||
relayURL: string
|
||||
onError: (err: Error) => void
|
||||
onEvent: (event: Event) => void
|
||||
onConnect?: () => void
|
||||
}): () => void {
|
||||
let sub: Subscription
|
||||
|
||||
const normalizedRelayURL = normalizeURL(relayURL)
|
||||
|
||||
fetchRelayInformation(normalizedRelayURL)
|
||||
.then(async info => {
|
||||
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
|
||||
|
||||
onConnect?.()
|
||||
|
||||
sub = abstractedRelay.prepareSubscription(
|
||||
[
|
||||
{
|
||||
kinds: [39000],
|
||||
limit: 50,
|
||||
authors: [info.pubkey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event: Event) {
|
||||
onEvent(event)
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
sub.close()
|
||||
|
||||
onError(err)
|
||||
})
|
||||
|
||||
return () => sub.close()
|
||||
}
|
||||
2
nip30.ts
2
nip30.ts
@@ -2,7 +2,7 @@
|
||||
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
|
||||
|
||||
/** Regex to find emoji shortcodes in content. */
|
||||
export const regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||
export const regex = (): RegExp => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||
|
||||
/** Represents a Nostr custom emoji. */
|
||||
export interface CustomEmoji {
|
||||
|
||||
44
nip40.test.ts
Normal file
44
nip40.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, test, expect, jest } from 'bun:test'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
|
||||
|
||||
describe('getExpiration', () => {
|
||||
test('returns the expiration as a Date object', () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = getExpiration(event)
|
||||
expect(result).toEqual(new Date(123000))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEventExpired', () => {
|
||||
test('returns true when the event has expired', () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = isEventExpired(event)
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
test('returns false when the event has not expired', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 10
|
||||
const event = buildEvent({ tags: [['expiration', future.toString()]] })
|
||||
const result = isEventExpired(event)
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForExpire', () => {
|
||||
test('returns a promise that resolves when the event expires', async () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const result = await waitForExpire(event)
|
||||
expect(result).toEqual(event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExpire', () => {
|
||||
test('calls the callback when the event expires', async () => {
|
||||
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||
const callback = jest.fn()
|
||||
onExpire(event, callback)
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
49
nip40.ts
Normal file
49
nip40.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Event } from './core.ts'
|
||||
|
||||
/** Get the expiration of the event as a `Date` object, if any. */
|
||||
function getExpiration(event: Event): Date | undefined {
|
||||
const tag = event.tags.find(([name]) => name === 'expiration')
|
||||
if (tag) {
|
||||
return new Date(parseInt(tag[1]) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if the event has expired. */
|
||||
function isEventExpired(event: Event): boolean {
|
||||
const expiration = getExpiration(event)
|
||||
if (expiration) {
|
||||
return Date.now() > expiration.getTime()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a promise that resolves when the event expires. */
|
||||
async function waitForExpire(event: Event): Promise<Event> {
|
||||
const expiration = getExpiration(event)
|
||||
if (expiration) {
|
||||
const diff = expiration.getTime() - Date.now()
|
||||
if (diff > 0) {
|
||||
await sleep(diff)
|
||||
return event
|
||||
} else {
|
||||
return event
|
||||
}
|
||||
} else {
|
||||
throw new Error('Event has no expiration')
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls the callback when the event expires. */
|
||||
function onExpire(event: Event, callback: (event: Event) => void): void {
|
||||
waitForExpire(event)
|
||||
.then(callback)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Resolves when the given number of milliseconds have elapsed. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export { getExpiration, isEventExpired, waitForExpire, onExpire }
|
||||
@@ -1,14 +1,16 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
import { MockRelay } from './test-helpers.ts'
|
||||
|
||||
test('auth flow', async () => {
|
||||
const relay = await Relay.connect('wss://nostr.wine')
|
||||
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||
|
||||
expect(auth.tags).toHaveLength(2)
|
||||
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
|
||||
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { v2 } from './nip44.js'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||
import { default as vec } from './nip44.vectors.json' with { type: 'json' }
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
const v2vec = vec.v2
|
||||
|
||||
test('get_conversation_key', () => {
|
||||
for (const v of v2vec.valid.get_conversation_key) {
|
||||
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
}
|
||||
})
|
||||
@@ -15,7 +15,7 @@ test('get_conversation_key', () => {
|
||||
test('encrypt_decrypt', () => {
|
||||
for (const v of v2vec.valid.encrypt_decrypt) {
|
||||
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
|
||||
const key = v2.utils.getConversationKey(v.sec1, pub2)
|
||||
const key = v2.utils.getConversationKey(hexToBytes(v.sec1), pub2)
|
||||
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||
expect(ciphertext).toEqual(v.payload)
|
||||
@@ -39,6 +39,8 @@ test('decrypt', async () => {
|
||||
|
||||
test('get_conversation_key', async () => {
|
||||
for (const v of v2vec.invalid.get_conversation_key) {
|
||||
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/)
|
||||
expect(() => v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)).toThrow(
|
||||
/(Point is not on curve|Cannot find square root)/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
212
nip44.ts
212
nip44.ts
@@ -1,131 +1,127 @@
|
||||
import { chacha20 } from '@noble/ciphers/chacha'
|
||||
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||
import { equalBytes } from '@noble/ciphers/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||
import { hmac } from '@noble/hashes/hmac'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const u = {
|
||||
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
|
||||
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
utf8Encode: utf8ToBytes,
|
||||
utf8Decode(bytes: Uint8Array) {
|
||||
return decoder.decode(bytes)
|
||||
},
|
||||
const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
|
||||
const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
|
||||
|
||||
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||
},
|
||||
|
||||
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||
ensureBytes(conversationKey, 32)
|
||||
ensureBytes(nonce, 32)
|
||||
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||
return {
|
||||
chacha_key: keys.subarray(0, 32),
|
||||
chacha_nonce: keys.subarray(32, 44),
|
||||
hmac_key: keys.subarray(44, 76),
|
||||
}
|
||||
},
|
||||
|
||||
calcPaddedLen(len: number): number {
|
||||
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||
if (len <= 32) return 32
|
||||
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||
},
|
||||
|
||||
writeU16BE(num: number) {
|
||||
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
|
||||
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||
const arr = new Uint8Array(2)
|
||||
new DataView(arr.buffer).setUint16(0, num, false)
|
||||
return arr
|
||||
},
|
||||
|
||||
pad(plaintext: string): Uint8Array {
|
||||
const unpadded = u.utf8Encode(plaintext)
|
||||
const unpaddedLen = unpadded.length
|
||||
const prefix = u.writeU16BE(unpaddedLen)
|
||||
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||
return concatBytes(prefix, unpadded, suffix)
|
||||
},
|
||||
|
||||
unpad(padded: Uint8Array): string {
|
||||
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||
if (
|
||||
unpaddedLen < u.minPlaintextSize ||
|
||||
unpaddedLen > u.maxPlaintextSize ||
|
||||
unpadded.length !== unpaddedLen ||
|
||||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
|
||||
)
|
||||
throw new Error('invalid padding')
|
||||
return u.utf8Decode(unpadded)
|
||||
},
|
||||
|
||||
hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array) {
|
||||
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||
const combined = concatBytes(aad, message)
|
||||
return hmac(sha256, key, combined)
|
||||
},
|
||||
|
||||
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
// plaintext: 1b to 0xffff
|
||||
// padded plaintext: 32b to 0xffff
|
||||
// ciphertext: 32b+2 to 0xffff+2
|
||||
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||
// compressed payload (base64): 132b to 87472b
|
||||
decodePayload(payload: string) {
|
||||
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||
const plen = payload.length
|
||||
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||
if (payload[0] === '#') throw new Error('unknown encryption version')
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = base64.decode(payload)
|
||||
} catch (error) {
|
||||
throw new Error('invalid base64: ' + (error as any).message)
|
||||
}
|
||||
const dlen = data.length
|
||||
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
|
||||
const vers = data[0]
|
||||
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||
return {
|
||||
nonce: data.subarray(1, 33),
|
||||
ciphertext: data.subarray(33, -32),
|
||||
mac: data.subarray(-32),
|
||||
}
|
||||
},
|
||||
export function getConversationKey(privkeyA: Uint8Array, pubkeyB: string): Uint8Array {
|
||||
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
|
||||
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||
const padded = u.pad(plaintext)
|
||||
function getMessageKeys(
|
||||
conversationKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } {
|
||||
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
|
||||
return {
|
||||
chacha_key: keys.subarray(0, 32),
|
||||
chacha_nonce: keys.subarray(32, 44),
|
||||
hmac_key: keys.subarray(44, 76),
|
||||
}
|
||||
}
|
||||
|
||||
function calcPaddedLen(len: number): number {
|
||||
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
|
||||
if (len <= 32) return 32
|
||||
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
|
||||
const chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||
return chunk * (Math.floor((len - 1) / chunk) + 1)
|
||||
}
|
||||
|
||||
function writeU16BE(num: number): Uint8Array {
|
||||
if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > maxPlaintextSize)
|
||||
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
|
||||
const arr = new Uint8Array(2)
|
||||
new DataView(arr.buffer).setUint16(0, num, false)
|
||||
return arr
|
||||
}
|
||||
|
||||
function pad(plaintext: string): Uint8Array {
|
||||
const unpadded = utf8Encoder.encode(plaintext)
|
||||
const unpaddedLen = unpadded.length
|
||||
const prefix = writeU16BE(unpaddedLen)
|
||||
const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen)
|
||||
return concatBytes(prefix, unpadded, suffix)
|
||||
}
|
||||
|
||||
function unpad(padded: Uint8Array): string {
|
||||
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
|
||||
const unpadded = padded.subarray(2, 2 + unpaddedLen)
|
||||
if (
|
||||
unpaddedLen < minPlaintextSize ||
|
||||
unpaddedLen > maxPlaintextSize ||
|
||||
unpadded.length !== unpaddedLen ||
|
||||
padded.length !== 2 + calcPaddedLen(unpaddedLen)
|
||||
)
|
||||
throw new Error('invalid padding')
|
||||
return utf8Decoder.decode(unpadded)
|
||||
}
|
||||
|
||||
function hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
|
||||
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
|
||||
const combined = concatBytes(aad, message)
|
||||
return hmac(sha256, key, combined)
|
||||
}
|
||||
|
||||
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
// plaintext: 1b to 0xffff
|
||||
// padded plaintext: 32b to 0xffff
|
||||
// ciphertext: 32b+2 to 0xffff+2
|
||||
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||
// compressed payload (base64): 132b to 87472b
|
||||
function decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
|
||||
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
|
||||
const plen = payload.length
|
||||
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
|
||||
if (payload[0] === '#') throw new Error('unknown encryption version')
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = base64.decode(payload)
|
||||
} catch (error) {
|
||||
throw new Error('invalid base64: ' + (error as any).message)
|
||||
}
|
||||
const dlen = data.length
|
||||
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
|
||||
const vers = data[0]
|
||||
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
|
||||
return {
|
||||
nonce: data.subarray(1, 33),
|
||||
ciphertext: data.subarray(33, -32),
|
||||
mac: data.subarray(-32),
|
||||
}
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = randomBytes(32)): string {
|
||||
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
|
||||
const padded = pad(plaintext)
|
||||
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
const mac = hmacAad(hmac_key, ciphertext, nonce)
|
||||
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||
}
|
||||
|
||||
function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||
const { nonce, ciphertext, mac } = u.decodePayload(payload)
|
||||
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||
export function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||
const { nonce, ciphertext, mac } = decodePayload(payload)
|
||||
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
|
||||
const calculatedMac = hmacAad(hmac_key, ciphertext, nonce)
|
||||
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||
return u.unpad(padded)
|
||||
return unpad(padded)
|
||||
}
|
||||
|
||||
export const v2 = {
|
||||
utils: u,
|
||||
utils: {
|
||||
getConversationKey,
|
||||
calcPaddedLen,
|
||||
},
|
||||
encrypt,
|
||||
decrypt,
|
||||
}
|
||||
|
||||
export default { v2 }
|
||||
|
||||
369
nip46.ts
Normal file
369
nip46.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
|
||||
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||
import { NIP05_REGEX } from './nip05.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { Handlerinformation, NostrConnect } from './kinds.ts'
|
||||
import type { RelayRecord } from './relay.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
try {
|
||||
_fetch = fetch
|
||||
} catch {}
|
||||
|
||||
export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export type BunkerPointer = {
|
||||
relays: string[]
|
||||
pubkey: string
|
||||
secret: null | string
|
||||
}
|
||||
|
||||
export function toBunkerURL(bunkerPointer: BunkerPointer): string {
|
||||
let bunkerURL = new URL(`bunker://${bunkerPointer.pubkey}`)
|
||||
bunkerPointer.relays.forEach(relay => {
|
||||
bunkerURL.searchParams.append('relay', relay)
|
||||
})
|
||||
if (bunkerPointer.secret) {
|
||||
bunkerURL.searchParams.set('secret', bunkerPointer.secret)
|
||||
}
|
||||
return bunkerURL.toString()
|
||||
}
|
||||
|
||||
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
|
||||
and returns a BunkerPointer -- or null in case of error */
|
||||
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
|
||||
let match = input.match(BUNKER_REGEX)
|
||||
if (match) {
|
||||
try {
|
||||
const pubkey = match[1]
|
||||
const qs = new URLSearchParams(match[2])
|
||||
return {
|
||||
pubkey,
|
||||
relays: qs.getAll('relay'),
|
||||
secret: qs.get('secret'),
|
||||
}
|
||||
} catch (_err) {
|
||||
/* just move to the next case */
|
||||
}
|
||||
}
|
||||
|
||||
return queryBunkerProfile(input)
|
||||
}
|
||||
|
||||
export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
|
||||
const match = nip05.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
try {
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||
|
||||
let pubkey = res.names[name]
|
||||
let relays = res.nip46[pubkey] || []
|
||||
|
||||
return { pubkey, relays, secret: null }
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type BunkerSignerParams = {
|
||||
pool?: AbstractSimplePool
|
||||
onauth?: (url: string) => void
|
||||
}
|
||||
|
||||
export class BunkerSigner {
|
||||
private params: BunkerSignerParams
|
||||
private pool: AbstractSimplePool
|
||||
private subCloser: SubCloser | undefined
|
||||
private isOpen: boolean
|
||||
private serial: number
|
||||
private idPrefix: string
|
||||
private listeners: {
|
||||
[id: string]: {
|
||||
resolve: (_: string) => void
|
||||
reject: (_: string) => void
|
||||
}
|
||||
}
|
||||
private waitingForAuth: { [id: string]: boolean }
|
||||
private secretKey: Uint8Array
|
||||
private conversationKey: Uint8Array
|
||||
public bp: BunkerPointer
|
||||
|
||||
private cachedPubKey: string | undefined
|
||||
|
||||
/**
|
||||
* Creates a new instance of the Nip46 class.
|
||||
* @param relays - An array of relay addresses.
|
||||
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||
* @param secretKey - An optional key pair.
|
||||
*/
|
||||
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
|
||||
if (bp.relays.length === 0) {
|
||||
throw new Error('no relays are specified for this bunker')
|
||||
}
|
||||
|
||||
this.params = params
|
||||
this.pool = params.pool || new SimplePool()
|
||||
this.secretKey = clientSecretKey
|
||||
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
|
||||
this.bp = bp
|
||||
this.isOpen = false
|
||||
this.idPrefix = Math.random().toString(36).substring(7)
|
||||
this.serial = 0
|
||||
this.listeners = {}
|
||||
this.waitingForAuth = {}
|
||||
|
||||
this.setupSubscription(params)
|
||||
}
|
||||
|
||||
private setupSubscription(params: BunkerSignerParams) {
|
||||
const listeners = this.listeners
|
||||
const waitingForAuth = this.waitingForAuth
|
||||
const convKey = this.conversationKey
|
||||
|
||||
this.subCloser = this.pool.subscribe(
|
||||
this.bp.relays,
|
||||
{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
|
||||
{
|
||||
onevent: async (event: NostrEvent) => {
|
||||
const o = JSON.parse(decrypt(event.content, convKey))
|
||||
const { id, result, error } = o
|
||||
|
||||
if (result === 'auth_url' && waitingForAuth[id]) {
|
||||
delete waitingForAuth[id]
|
||||
|
||||
if (params.onauth) {
|
||||
params.onauth(error)
|
||||
} else {
|
||||
console.warn(
|
||||
`nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let handler = listeners[id]
|
||||
if (handler) {
|
||||
if (error) handler.reject(error)
|
||||
else if (result) handler.resolve(result)
|
||||
delete listeners[id]
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.subCloser = undefined
|
||||
},
|
||||
},
|
||||
)
|
||||
this.isOpen = true
|
||||
}
|
||||
|
||||
// closes the subscription -- this object can't be used anymore after this
|
||||
async close() {
|
||||
this.isOpen = false
|
||||
this.subCloser!.close()
|
||||
}
|
||||
|
||||
async sendRequest(method: string, params: string[]): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
|
||||
if (!this.subCloser) this.setupSubscription(this.params)
|
||||
|
||||
this.serial++
|
||||
const id = `${this.idPrefix}-${this.serial}`
|
||||
|
||||
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
|
||||
|
||||
// the request event
|
||||
const verifiedEvent: VerifiedEvent = finalizeEvent(
|
||||
{
|
||||
kind: NostrConnect,
|
||||
tags: [['p', this.bp.pubkey]],
|
||||
content: encryptedContent,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.secretKey,
|
||||
)
|
||||
|
||||
// setup callback listener
|
||||
this.listeners[id] = { resolve, reject }
|
||||
this.waitingForAuth[id] = true
|
||||
|
||||
// publish the event
|
||||
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "connect" method on the bunker.
|
||||
* The promise will be rejected if the response is not "pong".
|
||||
*/
|
||||
async ping(): Promise<void> {
|
||||
let resp = await this.sendRequest('ping', [])
|
||||
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "connect" method on the bunker.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || ''])
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "get_public_key" method on the bunker.
|
||||
* (before we would return the public key hardcoded in the bunker parameters, but
|
||||
* that is not correct as that may be the bunker pubkey and the actual signer
|
||||
* pubkey may be different.)
|
||||
*/
|
||||
async getPublicKey(): Promise<string> {
|
||||
if (!this.cachedPubKey) {
|
||||
this.cachedPubKey = await this.sendRequest('get_public_key', [])
|
||||
}
|
||||
return this.cachedPubKey
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated removed from NIP
|
||||
*/
|
||||
async getRelays(): Promise<RelayRecord> {
|
||||
return JSON.parse(await this.sendRequest('get_relays', []))
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event using the remote private key.
|
||||
* @param event - The event to sign.
|
||||
* @returns A Promise that resolves to the signed event.
|
||||
*/
|
||||
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
|
||||
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||
let signed: NostrEvent = JSON.parse(resp)
|
||||
if (verifyEvent(signed)) {
|
||||
return signed
|
||||
} else {
|
||||
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
|
||||
}
|
||||
|
||||
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
|
||||
}
|
||||
|
||||
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
|
||||
}
|
||||
|
||||
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account with the specified username, domain, and optional email.
|
||||
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
|
||||
* @param username - The username for the account.
|
||||
* @param domain - The domain for the account.
|
||||
* @param email - The optional email for the account.
|
||||
* @param localSecretKey - Optionally pass a local secret key that will be used to communicate with the bunker,
|
||||
this will default to generating a random key.
|
||||
* @throws Error if the email is present but invalid.
|
||||
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
|
||||
*/
|
||||
export async function createAccount(
|
||||
bunker: BunkerProfile,
|
||||
params: BunkerSignerParams,
|
||||
username: string,
|
||||
domain: string,
|
||||
email?: string,
|
||||
localSecretKey: Uint8Array = generateSecretKey(),
|
||||
): Promise<BunkerSigner> {
|
||||
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||
|
||||
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
|
||||
|
||||
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
|
||||
|
||||
// once we get the newly created pubkey back, we hijack this signer instance
|
||||
// and turn it into the main instance for this newly created pubkey
|
||||
rpc.bp.pubkey = pubkey
|
||||
await rpc.connect()
|
||||
|
||||
return rpc
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches info on available providers that announce themselves using NIP-89 events.
|
||||
* @returns A promise that resolves to an array of available bunker objects.
|
||||
*/
|
||||
export async function fetchBunkerProviders(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
|
||||
const events = await pool.querySync(relays, {
|
||||
kinds: [Handlerinformation],
|
||||
'#k': [NostrConnect.toString()],
|
||||
})
|
||||
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// validate bunkers by checking their NIP-05 and pubkey
|
||||
// map to a more useful object
|
||||
const validatedBunkers = await Promise.all(
|
||||
events.map(async (event, i) => {
|
||||
try {
|
||||
const content = JSON.parse(event.content)
|
||||
|
||||
// skip duplicates
|
||||
try {
|
||||
if (events.findIndex(ev => JSON.parse(ev.content).nip05 === content.nip05) !== i) return undefined
|
||||
} catch (err) {
|
||||
/***/
|
||||
}
|
||||
|
||||
const bp = await queryBunkerProfile(content.nip05)
|
||||
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
|
||||
return {
|
||||
bunkerPointer: bp,
|
||||
nip05: content.nip05,
|
||||
domain: content.nip05.split('@')[1],
|
||||
name: content.name || content.display_name,
|
||||
picture: content.picture,
|
||||
about: content.about,
|
||||
website: content.website,
|
||||
local: false,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
|
||||
}
|
||||
|
||||
export type BunkerProfile = {
|
||||
bunkerPointer: BunkerPointer
|
||||
domain: string
|
||||
nip05: string
|
||||
name: string
|
||||
picture: string
|
||||
about: string
|
||||
website: string
|
||||
local: boolean
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47.ts'
|
||||
import { decrypt } from './nip04.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
|
||||
describe('parseConnectionString', () => {
|
||||
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||
const connectionString =
|
||||
|
||||
16
nip47.ts
16
nip47.ts
@@ -1,8 +1,14 @@
|
||||
import { finalizeEvent } from './pure.ts'
|
||||
import { type VerifiedEvent, finalizeEvent } from './pure.ts'
|
||||
import { NWCWalletRequest } from './kinds.ts'
|
||||
import { encrypt } from './nip04.ts'
|
||||
|
||||
export function parseConnectionString(connectionString: string) {
|
||||
interface NWCConnection {
|
||||
pubkey: string
|
||||
relay: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export function parseConnectionString(connectionString: string): NWCConnection {
|
||||
const { pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname
|
||||
const relay = searchParams.get('relay')
|
||||
@@ -15,7 +21,11 @@ export function parseConnectionString(connectionString: string) {
|
||||
return { pubkey, relay, secret }
|
||||
}
|
||||
|
||||
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
|
||||
export async function makeNwcRequestEvent(
|
||||
pubkey: string,
|
||||
secretKey: Uint8Array,
|
||||
invoice: string,
|
||||
): Promise<VerifiedEvent> {
|
||||
const content = {
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
|
||||
95
nip49.test.ts
Normal file
95
nip49.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { decrypt, encrypt } from './nip49.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('encrypt and decrypt', () => {
|
||||
for (let i = 0; i < vectors.length; i++) {
|
||||
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
|
||||
let sec = hexToBytes(secret)
|
||||
let there = encrypt(sec, password, logn, ksb)
|
||||
let back = decrypt(there, password)
|
||||
let again = decrypt(ncryptsec, password)
|
||||
expect(back).toEqual(again)
|
||||
expect(again).toEqual(sec)
|
||||
}
|
||||
})
|
||||
|
||||
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
|
||||
[
|
||||
'.ksjabdk.aselqwe',
|
||||
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||
1,
|
||||
0x00,
|
||||
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
|
||||
],
|
||||
[
|
||||
'skjdaklrnçurbç l',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
2,
|
||||
0x01,
|
||||
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
|
||||
],
|
||||
[
|
||||
'777z7z7z7z7z7z7z',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
3,
|
||||
0x02,
|
||||
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
|
||||
],
|
||||
[
|
||||
'.ksjabdk.aselqwe',
|
||||
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||
7,
|
||||
0x00,
|
||||
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
|
||||
],
|
||||
[
|
||||
'skjdaklrnçurbç l',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
8,
|
||||
0x01,
|
||||
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
|
||||
],
|
||||
[
|
||||
'777z7z7z7z7z7z7z',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x02,
|
||||
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
4,
|
||||
0x00,
|
||||
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
5,
|
||||
0x01,
|
||||
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||
1,
|
||||
0x00,
|
||||
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
|
||||
],
|
||||
[
|
||||
'ÅΩẛ̣',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x01,
|
||||
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
|
||||
],
|
||||
[
|
||||
'ÅΩṩ',
|
||||
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||
9,
|
||||
0x01,
|
||||
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
|
||||
],
|
||||
]
|
||||
50
nip49.ts
Normal file
50
nip49.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { scrypt } from '@noble/hashes/scrypt'
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
export function encrypt(
|
||||
sec: Uint8Array,
|
||||
password: string,
|
||||
logn: number = 16,
|
||||
ksb: 0x00 | 0x01 | 0x02 = 0x02,
|
||||
): Ncryptsec {
|
||||
let salt = randomBytes(16)
|
||||
let n = 2 ** logn
|
||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||
let nonce = randomBytes(24)
|
||||
let aad = Uint8Array.from([ksb])
|
||||
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||
let ciphertext = xc2p1.encrypt(sec)
|
||||
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
|
||||
return encodeBytes('ncryptsec', b)
|
||||
}
|
||||
|
||||
export function decrypt(ncryptsec: string, password: string): Uint8Array {
|
||||
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
|
||||
if (prefix !== 'ncryptsec') {
|
||||
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
|
||||
}
|
||||
let b = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
let version = b[0]
|
||||
if (version !== 0x02) {
|
||||
throw new Error(`invalid version ${version}, expected 0x02`)
|
||||
}
|
||||
|
||||
let logn = b[1]
|
||||
let n = 2 ** logn
|
||||
|
||||
let salt = b.slice(2, 2 + 16)
|
||||
let nonce = b.slice(2 + 16, 2 + 16 + 24)
|
||||
let ksb = b[2 + 16 + 24]
|
||||
let aad = Uint8Array.from([ksb])
|
||||
let ciphertext = b.slice(2 + 16 + 24 + 1)
|
||||
|
||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||
let sec = xc2p1.decrypt(ciphertext)
|
||||
|
||||
return sec
|
||||
}
|
||||
42
nip54.test.ts
Normal file
42
nip54.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { normalizeIdentifier } from './nip54.ts'
|
||||
|
||||
describe('normalizeIdentifier', () => {
|
||||
test('converts to lowercase', () => {
|
||||
expect(normalizeIdentifier('HELLO')).toBe('hello')
|
||||
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
|
||||
})
|
||||
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeIdentifier(' hello ')).toBe('hello')
|
||||
expect(normalizeIdentifier('\thello\n')).toBe('hello')
|
||||
})
|
||||
|
||||
test('normalizes Unicode to NFKC form', () => {
|
||||
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
|
||||
expect(normalizeIdentifier('café')).toBe('café')
|
||||
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
|
||||
})
|
||||
|
||||
test('replaces non-alphanumeric characters with hyphens', () => {
|
||||
expect(normalizeIdentifier('hello world')).toBe('hello-world')
|
||||
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
|
||||
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
|
||||
})
|
||||
|
||||
test('preserves numbers', () => {
|
||||
expect(normalizeIdentifier('user123')).toBe('user123')
|
||||
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
|
||||
})
|
||||
|
||||
test('handles multiple consecutive special characters', () => {
|
||||
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
|
||||
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
|
||||
})
|
||||
|
||||
test('handles Unicode letters from different scripts', () => {
|
||||
expect(normalizeIdentifier('привет')).toBe('привет')
|
||||
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
|
||||
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
|
||||
})
|
||||
})
|
||||
19
nip54.ts
Normal file
19
nip54.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function normalizeIdentifier(name: string): string {
|
||||
// Trim and lowercase
|
||||
name = name.trim().toLowerCase()
|
||||
|
||||
// Normalize Unicode to NFKC form
|
||||
name = name.normalize('NFKC')
|
||||
|
||||
// Convert to array of characters and map each one
|
||||
return Array.from(name)
|
||||
.map(char => {
|
||||
// Check if character is letter or number using Unicode ranges
|
||||
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
|
||||
return char
|
||||
}
|
||||
|
||||
return '-'
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
166
nip55.test.ts
Normal file
166
nip55.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import * as nip55 from './nip55.js'
|
||||
|
||||
// Function to parse the NostrSigner URI
|
||||
function parseNostrSignerUri(uri: string) {
|
||||
const [base, query] = uri.split('?')
|
||||
const basePart = base.replace('nostrsigner:', '')
|
||||
|
||||
let jsonObject = null
|
||||
if (basePart) {
|
||||
try {
|
||||
jsonObject = JSON.parse(decodeURIComponent(basePart))
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse base JSON:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const urlSearchParams = new URLSearchParams(query)
|
||||
const queryParams = Object.fromEntries(urlSearchParams.entries())
|
||||
if (queryParams.permissions) {
|
||||
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
|
||||
}
|
||||
|
||||
return {
|
||||
base: jsonObject,
|
||||
...queryParams,
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases
|
||||
test('Get Public Key URI', () => {
|
||||
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.getPublicKeyUri({
|
||||
permissions,
|
||||
callbackUrl,
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'get_public_key')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
|
||||
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
|
||||
})
|
||||
|
||||
test('Sign Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.signEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('base.content', 'test')
|
||||
expect(jsonObject).toHaveProperty('type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-04 URI', () => {
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.encryptNip04Uri({
|
||||
callbackUrl,
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-04 URI', () => {
|
||||
const uri = nip55.decryptNip04Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-44 URI', () => {
|
||||
const uri = nip55.encryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-44 URI', () => {
|
||||
const uri = nip55.decryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Decrypt Zap Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.decryptZapEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
returnType: 'event',
|
||||
compressionType: 'gzip',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'event')
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
123
nip55.ts
Normal file
123
nip55.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
type BaseParams = {
|
||||
callbackUrl?: string
|
||||
returnType?: 'signature' | 'event'
|
||||
compressionType?: 'none' | 'gzip'
|
||||
}
|
||||
|
||||
type PermissionsParams = BaseParams & {
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
}
|
||||
|
||||
type EventUriParams = BaseParams & {
|
||||
eventJson: Record<string, unknown>
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type EncryptDecryptParams = BaseParams & {
|
||||
pubKey: string
|
||||
content: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type UriParams = BaseParams & {
|
||||
base: string
|
||||
type: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
pubKey?: string
|
||||
plainText?: string
|
||||
encryptedText?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
function encodeParams(params: Record<string, unknown>): string {
|
||||
return new URLSearchParams(params as Record<string, string>).toString()
|
||||
}
|
||||
|
||||
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
|
||||
}
|
||||
|
||||
function buildUri({
|
||||
base,
|
||||
type,
|
||||
callbackUrl,
|
||||
returnType = 'signature',
|
||||
compressionType = 'none',
|
||||
...params
|
||||
}: UriParams): string {
|
||||
const baseParams = {
|
||||
type,
|
||||
compressionType,
|
||||
returnType,
|
||||
callbackUrl,
|
||||
id: params.id,
|
||||
current_user: params.currentUser,
|
||||
permissions:
|
||||
params.permissions && params.permissions.length > 0
|
||||
? encodeURIComponent(JSON.stringify(params.permissions))
|
||||
: undefined,
|
||||
pubKey: params.pubKey,
|
||||
plainText: params.plainText,
|
||||
encryptedText: params.encryptedText,
|
||||
appName: params.appName,
|
||||
}
|
||||
|
||||
const filteredParams = filterUndefined(baseParams)
|
||||
return `${base}?${encodeParams(filteredParams)}`
|
||||
}
|
||||
|
||||
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
|
||||
return buildUri({
|
||||
base: 'nostrsigner:',
|
||||
type,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
|
||||
return buildDefaultUri('get_public_key', { permissions, ...params })
|
||||
}
|
||||
|
||||
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'sign_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, plainText: params.content })
|
||||
}
|
||||
|
||||
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, encryptedText: params.content })
|
||||
}
|
||||
|
||||
export function encryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip04_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip04_decrypt', params)
|
||||
}
|
||||
|
||||
export function encryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip44_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip44_decrypt', params)
|
||||
}
|
||||
|
||||
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'decrypt_zap_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
20
nip57.ts
20
nip57.ts
@@ -2,6 +2,7 @@ import { bech32 } from '@scure/base'
|
||||
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -23,7 +24,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export function makeZapRequest({
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
event: string | Event | null
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
@@ -68,9 +69,22 @@ export function makeZapRequest({
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
if (event && typeof event === 'string') {
|
||||
zr.tags.push(['e', event])
|
||||
}
|
||||
if (event && typeof event === 'object') {
|
||||
// replacable event
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
const a = ['a', `${event.kind}:${event.pubkey}:`]
|
||||
zr.tags.push(a)
|
||||
// addressable event
|
||||
} else if (isAddressableKind(event.kind)) {
|
||||
let d = event.tags.find(([t, v]) => t === 'd' && v)
|
||||
if (!d) throw new Error('d tag not found or is empty')
|
||||
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
|
||||
zr.tags.push(a)
|
||||
}
|
||||
}
|
||||
|
||||
return zr
|
||||
}
|
||||
|
||||
357
nip58.test.ts
Normal file
357
nip58.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { EventTemplate } from './core.ts'
|
||||
import {
|
||||
BadgeAward as BadgeAwardKind,
|
||||
BadgeDefinition as BadgeDefinitionKind,
|
||||
ProfileBadges as ProfileBadgesKind,
|
||||
} from './kinds.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
import {
|
||||
BadgeAward,
|
||||
BadgeDefinition,
|
||||
ProfileBadges,
|
||||
generateBadgeAwardEventTemplate,
|
||||
generateBadgeDefinitionEventTemplate,
|
||||
generateProfileBadgesEventTemplate,
|
||||
validateBadgeAwardEvent,
|
||||
validateBadgeDefinitionEvent,
|
||||
validateProfileBadgesEvent,
|
||||
} from './nip58.ts'
|
||||
|
||||
test('BadgeDefinition has required property "d"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
}
|
||||
expect(badge.d).toEqual('badge-id')
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "name"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
name: 'Badge Name',
|
||||
}
|
||||
expect(badge.name).toEqual('Badge Name')
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "description"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
description: 'Badge Description',
|
||||
}
|
||||
expect(badge.description).toEqual('Badge Description')
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "image"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
image: ['https://example.com/badge.png', '1024x1024'],
|
||||
}
|
||||
expect(badge.image).toEqual(['https://example.com/badge.png', '1024x1024'])
|
||||
})
|
||||
|
||||
test('BadgeDefinition has optional property "thumbs"', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
thumbs: [
|
||||
['https://example.com/thumb.png', '100x100'],
|
||||
['https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
expect(badge.thumbs).toEqual([
|
||||
['https://example.com/thumb.png', '100x100'],
|
||||
['https://example.com/thumb2.png', '200x200'],
|
||||
])
|
||||
})
|
||||
|
||||
test('BadgeAward has required property "a"', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
expect(badgeAward.a).toEqual('badge-definition-address')
|
||||
})
|
||||
|
||||
test('BadgeAward has required property "p"', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
expect(badgeAward.p).toEqual([
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('ProfileBadges has required property "d"', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [],
|
||||
}
|
||||
expect(profileBadges.d).toEqual('profile_badges')
|
||||
})
|
||||
|
||||
test('ProfileBadges has required property "badges"', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [],
|
||||
}
|
||||
expect(profileBadges.badges).toEqual([])
|
||||
})
|
||||
|
||||
test('ProfileBadges badges array contains objects with required properties "a" and "e"', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [
|
||||
{
|
||||
a: 'badge-definition-address',
|
||||
e: ['badge-award-event-id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(profileBadges.badges[0].a).toEqual('badge-definition-address')
|
||||
expect(profileBadges.badges[0].e).toEqual(['badge-award-event-id'])
|
||||
})
|
||||
|
||||
test('generateBadgeDefinitionEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
}
|
||||
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
|
||||
})
|
||||
|
||||
test('generateBadgeDefinitionEventTemplate generates EventTemplate with optional tags', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
name: 'Badge Name',
|
||||
description: 'Badge Description',
|
||||
image: ['https://example.com/badge.png', '1024x1024'],
|
||||
thumbs: [
|
||||
['https://example.com/thumb.png', '100x100'],
|
||||
['https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateBadgeDefinitionEventTemplate generates EventTemplate without optional tags', () => {
|
||||
const badge: BadgeDefinition = {
|
||||
d: 'badge-id',
|
||||
}
|
||||
const eventTemplate = generateBadgeDefinitionEventTemplate(badge)
|
||||
expect(eventTemplate.tags).toEqual([['d', 'badge-id']])
|
||||
})
|
||||
|
||||
test('validateBadgeDefinitionEvent returns true for valid BadgeDefinition event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeDefinitionEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateBadgeDefinitionEvent returns false for invalid BadgeDefinition event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeDefinitionEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
test('generateBadgeAwardEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateBadgeAwardEventTemplate generates EventTemplate without optional tags', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateBadgeAwardEventTemplate generates EventTemplate with optional tags', () => {
|
||||
const badgeAward: BadgeAward = {
|
||||
a: 'badge-definition-address',
|
||||
p: [
|
||||
['pubkey1', 'relay1'],
|
||||
['pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateBadgeAwardEventTemplate(badgeAward)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
])
|
||||
})
|
||||
|
||||
test('validateBadgeAwardEvent returns true for valid BadgeAward event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeAwardKind,
|
||||
tags: [
|
||||
['a', 'badge-definition-address'],
|
||||
['p', 'pubkey1', 'relay1'],
|
||||
['p', 'pubkey2', 'relay2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeAwardEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateBadgeAwardEvent returns false for invalid BadgeAward event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeAwardKind,
|
||||
tags: [],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateBadgeAwardEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
test('generateProfileBadgesEventTemplate generates EventTemplate with mandatory tags', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [],
|
||||
}
|
||||
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||
expect(eventTemplate.tags).toEqual([['d', 'profile_badges']])
|
||||
})
|
||||
|
||||
test('generateProfileBadgesEventTemplate generates EventTemplate with optional tags', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [
|
||||
{
|
||||
a: 'badge-definition-address',
|
||||
e: ['badge-award-event-id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['d', 'profile_badges'],
|
||||
['a', 'badge-definition-address'],
|
||||
['e', 'badge-award-event-id'],
|
||||
])
|
||||
})
|
||||
|
||||
test('generateProfileBadgesEventTemplate generates EventTemplate with multiple optional tags', () => {
|
||||
const profileBadges: ProfileBadges = {
|
||||
d: 'profile_badges',
|
||||
badges: [
|
||||
{
|
||||
a: 'badge-definition-address1',
|
||||
e: ['badge-award-event-id1', 'badge-award-event-id2'],
|
||||
},
|
||||
{
|
||||
a: 'badge-definition-address2',
|
||||
e: ['badge-award-event-id3'],
|
||||
},
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateProfileBadgesEventTemplate(profileBadges)
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['d', 'profile_badges'],
|
||||
['a', 'badge-definition-address1'],
|
||||
['e', 'badge-award-event-id1', 'badge-award-event-id2'],
|
||||
['a', 'badge-definition-address2'],
|
||||
['e', 'badge-award-event-id3'],
|
||||
])
|
||||
})
|
||||
|
||||
test('validateProfileBadgesEvent returns true for valid ProfileBadges event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ProfileBadgesKind,
|
||||
tags: [
|
||||
['d', 'profile_badges'],
|
||||
['a', 'badge-definition-address'],
|
||||
['e', 'badge-award-event-id'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateProfileBadgesEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateProfileBadgesEvent returns false for invalid ProfileBadges event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ProfileBadgesKind,
|
||||
tags: [],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateProfileBadgesEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
245
nip58.ts
Normal file
245
nip58.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import {
|
||||
BadgeAward as BadgeAwardKind,
|
||||
BadgeDefinition as BadgeDefinitionKind,
|
||||
ProfileBadges as ProfileBadgesKind,
|
||||
} from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents the structure for defining a badge within the Nostr network.
|
||||
* This structure is used to create templates for badge definition events,
|
||||
* facilitating the recognition and awarding of badges to users for various achievements.
|
||||
*/
|
||||
export type BadgeDefinition = {
|
||||
/**
|
||||
* A unique identifier for the badge. This is used to distinguish badges
|
||||
* from one another and should be unique across all badge definitions.
|
||||
* Typically, this could be a short, descriptive string.
|
||||
*/
|
||||
d: string
|
||||
|
||||
/**
|
||||
* An optional short name for the badge. This provides a human-readable
|
||||
* title for the badge, making it easier to recognize and refer to.
|
||||
*/
|
||||
name?: string
|
||||
|
||||
/**
|
||||
* An optional description for the badge. This field can be used to
|
||||
* provide more detailed information about the badge, such as the criteria
|
||||
* for its awarding or its significance.
|
||||
*/
|
||||
description?: string
|
||||
|
||||
/**
|
||||
* An optional image URL and dimensions for the badge. The first element
|
||||
* of the tuple is the URL pointing to a high-resolution image representing
|
||||
* the badge, and the second element specifies the image's dimensions in
|
||||
* the format "widthxheight". The recommended dimensions are 1024x1024 pixels.
|
||||
*/
|
||||
image?: [string, string]
|
||||
|
||||
/**
|
||||
* An optional list of thumbnail images for the badge. Each element in the
|
||||
* array is a tuple, where the first element is the URL pointing to a thumbnail
|
||||
* version of the badge image, and the second element specifies the thumbnail's
|
||||
* dimensions in the format "widthxheight". Multiple thumbnails can be provided
|
||||
* to support different display sizes.
|
||||
*/
|
||||
thumbs?: Array<[string, string]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the structure for awarding a badge to one or more recipients
|
||||
* within the Nostr network. This structure is used to create templates for
|
||||
* badge award events, which are immutable and signify the recognition of
|
||||
* individuals' achievements or contributions.
|
||||
*/
|
||||
export type BadgeAward = {
|
||||
/**
|
||||
* A reference to the Badge Definition event. This is typically composed
|
||||
* of the event ID of the badge definition. It establishes a clear linkage
|
||||
* between the badge being awarded and its original definition, ensuring
|
||||
* that recipients are awarded the correct badge.
|
||||
*/
|
||||
a: string
|
||||
|
||||
/**
|
||||
* An array of p tags, each containing a pubkey and its associated relays.
|
||||
*/
|
||||
p: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the collection of badges a user chooses to display on their profile.
|
||||
* This structure is crucial for applications that allow users to showcase achievements
|
||||
* or recognitions in the form of badges, following the specifications of NIP-58.
|
||||
*/
|
||||
export type ProfileBadges = {
|
||||
/**
|
||||
* A unique identifier for the profile badges collection. According to NIP-58,
|
||||
* this should be set to "profile_badges" to differentiate it from other event types.
|
||||
*/
|
||||
d: 'profile_badges'
|
||||
|
||||
/**
|
||||
* A list of badges that the user has elected to display on their profile. Each item
|
||||
* in the array represents a specific badge, including references to both its definition
|
||||
* and the award event.
|
||||
*/
|
||||
badges: Array<{
|
||||
/**
|
||||
* The event address of the badge definition. This is a reference to the specific badge
|
||||
* being displayed, linking back to the badge's original definition event. It allows
|
||||
* clients to fetch and display the badge's details, such as its name, description,
|
||||
* and image.
|
||||
*/
|
||||
a: string
|
||||
|
||||
/**
|
||||
* The event id of the badge award with corresponding relays. This references the event
|
||||
* in which the badge was awarded to the user. It is crucial for verifying the
|
||||
* authenticity of the badge display, ensuring that the user was indeed awarded the
|
||||
* badge they are choosing to display.
|
||||
*/
|
||||
e: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate based on the provided BadgeDefinition.
|
||||
*
|
||||
* @param {BadgeDefinition} badgeDefinition - The BadgeDefinition object.
|
||||
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||
*/
|
||||
export function generateBadgeDefinitionEventTemplate({
|
||||
d,
|
||||
description,
|
||||
image,
|
||||
name,
|
||||
thumbs,
|
||||
}: BadgeDefinition): EventTemplate {
|
||||
// Mandatory tags
|
||||
const tags: string[][] = [['d', d]]
|
||||
|
||||
// Append optional tags
|
||||
name && tags.push(['name', name])
|
||||
description && tags.push(['description', description])
|
||||
image && tags.push(['image', ...image])
|
||||
if (thumbs) {
|
||||
for (const thumb of thumbs) {
|
||||
tags.push(['thumb', ...thumb])
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeDefinitionKind,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a badge definition event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is a valid badge definition event.
|
||||
*/
|
||||
export function validateBadgeDefinitionEvent(event: Event): boolean {
|
||||
if (event.kind !== BadgeDefinitionKind) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate based on the provided BadgeAward.
|
||||
*
|
||||
* @param {BadgeAward} badgeAward - The BadgeAward object.
|
||||
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||
*/
|
||||
export function generateBadgeAwardEventTemplate({ a, p }: BadgeAward): EventTemplate {
|
||||
// Mandatory tags
|
||||
const tags: string[][] = [['a', a]]
|
||||
for (const _p of p) {
|
||||
tags.push(['p', ..._p])
|
||||
}
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: BadgeAwardKind,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a badge award event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is a valid badge award event.
|
||||
*/
|
||||
export function validateBadgeAwardEvent(event: Event): boolean {
|
||||
if (event.kind !== BadgeAwardKind) return false
|
||||
|
||||
const requiredTags = ['a', 'p'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate based on the provided ProfileBadges.
|
||||
*
|
||||
* @param {ProfileBadges} profileBadges - The ProfileBadges object.
|
||||
* @returns {EventTemplate} - The generated EventTemplate object.
|
||||
*/
|
||||
export function generateProfileBadgesEventTemplate({ badges }: ProfileBadges): EventTemplate {
|
||||
// Mandatory tags
|
||||
const tags: string[][] = [['d', 'profile_badges']]
|
||||
|
||||
// Append optional tags
|
||||
for (const badge of badges) {
|
||||
tags.push(['a', badge.a], ['e', ...badge.e])
|
||||
}
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ProfileBadgesKind,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a profile badges event.
|
||||
*
|
||||
* @param event - The event to validate.
|
||||
* @returns A boolean indicating whether the event is a valid profile badges event.
|
||||
*/
|
||||
export function validateProfileBadgesEvent(event: Event): boolean {
|
||||
if (event.kind !== ProfileBadgesKind) return false
|
||||
|
||||
const requiredTags = ['d'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
113
nip59.test.ts
Normal file
113
nip59.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
|
||||
import { decode } from './nip19.ts'
|
||||
import { NostrEvent, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { GiftWrap } from './kinds.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data as Uint8Array
|
||||
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data as Uint8Array
|
||||
const recipientPublicKey = getPublicKey(recipientPrivateKey)
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: 'Are you going to the party tonight?',
|
||||
}
|
||||
|
||||
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
|
||||
|
||||
test('wrapEvent', () => {
|
||||
const expected = {
|
||||
content: '',
|
||||
id: '',
|
||||
created_at: 1728537932,
|
||||
kind: 1059,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
|
||||
[Symbol('verified')]: true,
|
||||
}
|
||||
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
|
||||
|
||||
expect(result.kind).toEqual(expected.kind)
|
||||
expect(result.tags).toEqual(expected.tags)
|
||||
})
|
||||
|
||||
test('wrapManyEvent', () => {
|
||||
const expected = [
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729581521,
|
||||
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
{
|
||||
kind: 1059,
|
||||
content: '',
|
||||
created_at: 1729594619,
|
||||
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
|
||||
pubkey: '',
|
||||
id: '',
|
||||
sig: '',
|
||||
[Symbol('verified')]: true,
|
||||
},
|
||||
]
|
||||
|
||||
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
|
||||
|
||||
wrappedEvents.forEach((event, index) => {
|
||||
expect(event.kind).toEqual(expected[index].kind)
|
||||
expect(event.tags).toEqual(expected[index].tags)
|
||||
})
|
||||
})
|
||||
|
||||
test('unwrapEvent', () => {
|
||||
const expected = {
|
||||
kind: 1,
|
||||
content: 'Are you going to the party tonight?',
|
||||
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
|
||||
tags: [],
|
||||
}
|
||||
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
|
||||
|
||||
expect(result.kind).toEqual(expected.kind)
|
||||
expect(result.content).toEqual(expected.content)
|
||||
expect(result.pubkey).toEqual(expected.pubkey)
|
||||
expect(result.tags).toEqual(expected.tags)
|
||||
})
|
||||
|
||||
test('getWrappedEvents and unwrapManyEvents', async () => {
|
||||
const expected = [
|
||||
{
|
||||
created_at: 1729721879,
|
||||
content: 'Hello!',
|
||||
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
|
||||
kind: 14,
|
||||
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
|
||||
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
|
||||
},
|
||||
{
|
||||
created_at: 1729722025,
|
||||
content: 'How are you?',
|
||||
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
|
||||
kind: 14,
|
||||
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
|
||||
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
|
||||
},
|
||||
]
|
||||
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
|
||||
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
|
||||
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
|
||||
|
||||
const pool = new SimplePool()
|
||||
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
|
||||
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
|
||||
|
||||
unwrappedEvents.forEach((event, index) => {
|
||||
expect(event).toEqual(expected[index])
|
||||
})
|
||||
})
|
||||
107
nip59.ts
Normal file
107
nip59.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
|
||||
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
|
||||
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Seal, GiftWrap } from './kinds.ts'
|
||||
|
||||
type Rumor = UnsignedEvent & { id: string }
|
||||
|
||||
const TWO_DAYS = 2 * 24 * 60 * 60
|
||||
|
||||
const now = () => Math.round(Date.now() / 1000)
|
||||
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
|
||||
|
||||
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
|
||||
|
||||
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
|
||||
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
|
||||
|
||||
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
|
||||
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
|
||||
|
||||
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
|
||||
const rumor = {
|
||||
created_at: now(),
|
||||
content: '',
|
||||
tags: [],
|
||||
...event,
|
||||
pubkey: getPublicKey(privateKey),
|
||||
} as any
|
||||
|
||||
rumor.id = getEventHash(rumor)
|
||||
|
||||
return rumor as Rumor
|
||||
}
|
||||
|
||||
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Seal,
|
||||
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
|
||||
created_at: randomNow(),
|
||||
tags: [],
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
|
||||
const randomKey = generateSecretKey()
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: GiftWrap,
|
||||
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
|
||||
created_at: randomNow(),
|
||||
tags: [['p', recipientPublicKey]],
|
||||
},
|
||||
randomKey,
|
||||
) as NostrEvent
|
||||
}
|
||||
|
||||
export function wrapEvent(
|
||||
event: Partial<UnsignedEvent>,
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipientPublicKey: string,
|
||||
): NostrEvent {
|
||||
const rumor = createRumor(event, senderPrivateKey)
|
||||
|
||||
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
|
||||
return createWrap(seal, recipientPublicKey)
|
||||
}
|
||||
|
||||
export function wrapManyEvents(
|
||||
event: Partial<UnsignedEvent>,
|
||||
senderPrivateKey: Uint8Array,
|
||||
recipientsPublicKeys: string[],
|
||||
): NostrEvent[] {
|
||||
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
|
||||
throw new Error('At least one recipient is required.')
|
||||
}
|
||||
|
||||
const senderPublicKey = getPublicKey(senderPrivateKey)
|
||||
|
||||
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
|
||||
|
||||
recipientsPublicKeys.forEach(recipientPublicKey => {
|
||||
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
|
||||
})
|
||||
|
||||
return wrappeds
|
||||
}
|
||||
|
||||
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
|
||||
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
|
||||
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
|
||||
}
|
||||
|
||||
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
|
||||
let unwrappedEvents: Rumor[] = []
|
||||
|
||||
wrappedEvents.forEach(e => {
|
||||
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
|
||||
})
|
||||
|
||||
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
return unwrappedEvents
|
||||
}
|
||||
203
nip75.test.ts
Normal file
203
nip75.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { ZapGoal } from './kinds.ts'
|
||||
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('Goal Type', () => {
|
||||
it('should create a proper Goal object', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(goal.content).toBe('Fundraising for a new project')
|
||||
expect(goal.amount).toBe('100000000')
|
||||
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
|
||||
expect(goal.closedAt).toBe(1671150419)
|
||||
expect(goal.image).toBe('https://example.com/goal-image.jpg')
|
||||
expect(goal.summary).toBe('Help us reach our fundraising goal!')
|
||||
expect(goal.r).toBe('https://example.com/additional-info')
|
||||
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
|
||||
expect(goal.zapTags).toEqual([
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateGoalEventTemplate', () => {
|
||||
it('should generate an EventTemplate for a fundraising goal', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
|
||||
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
}
|
||||
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
|
||||
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate an EventTemplate that is valid', () => {
|
||||
const sk = generateSecretKey()
|
||||
const goal: Goal = {
|
||||
content: 'Fundraising for a new project',
|
||||
amount: '100000000',
|
||||
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
closedAt: 1671150419,
|
||||
image: 'https://example.com/goal-image.jpg',
|
||||
summary: 'Help us reach our fundraising goal!',
|
||||
r: 'https://example.com/additional-info',
|
||||
zapTags: [
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const eventTemplate = generateGoalEventTemplate(goal)
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateZapGoalEvent', () => {
|
||||
it('should validate a proper Goal event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should not validate an event with an incorrect kind', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // Incorrect kind
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should not validate an event with missing required "amount" tag', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
// Missing "amount" tag
|
||||
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should not validate an event with missing required "relays" tag', () => {
|
||||
const sk = generateSecretKey()
|
||||
const eventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content: 'Fundraising for a new project',
|
||||
tags: [
|
||||
['amount', '100000000'],
|
||||
// Missing "relays" tag
|
||||
['closed_at', '1671150419'],
|
||||
['image', 'https://example.com/goal-image.jpg'],
|
||||
['summary', 'Help us reach our fundraising goal!'],
|
||||
['r', 'https://example.com/additional-info'],
|
||||
['zap', 'beneficiary1'],
|
||||
['zap', 'beneficiary2'],
|
||||
],
|
||||
}
|
||||
const event = finalizeEvent(eventTemplate, sk)
|
||||
const isValid = validateZapGoalEvent(event)
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
115
nip75.ts
Normal file
115
nip75.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { ZapGoal } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
|
||||
* This type is used to structure the information needed to create a goal event (`kind:9041`).
|
||||
*/
|
||||
export type Goal = {
|
||||
/**
|
||||
* A human-readable description of the fundraising goal.
|
||||
* This content should provide clear information about the purpose of the fundraising.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The target amount for the fundraising goal in milisats.
|
||||
* This defines the financial target that the fundraiser aims to reach.
|
||||
*/
|
||||
amount: string
|
||||
|
||||
/**
|
||||
* A list of relays where the zaps towards this goal will be sent to and tallied from.
|
||||
* Each relay is represented by its WebSocket URL.
|
||||
*/
|
||||
relays: string[]
|
||||
|
||||
/**
|
||||
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
|
||||
* Zaps published after this timestamp should not count towards the goal progress.
|
||||
* If not provided, the goal remains open indefinitely or until manually closed.
|
||||
*/
|
||||
closedAt?: number
|
||||
|
||||
/**
|
||||
* An optional URL to an image related to the goal.
|
||||
* This can be used to visually represent the goal on client interfaces.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* An optional brief description or summary of the goal.
|
||||
* This can provide a quick overview of the goal, separate from the detailed `content`.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
|
||||
* This is a single URL, as per NIP-75 specifications for linking additional resources.
|
||||
*/
|
||||
r?: string
|
||||
|
||||
/**
|
||||
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
|
||||
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
|
||||
*/
|
||||
a?: string
|
||||
|
||||
/**
|
||||
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
|
||||
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
|
||||
*/
|
||||
zapTags?: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
|
||||
* This function is tailored to fit the structure of EventTemplate as defined in the library.
|
||||
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
|
||||
* @returns An EventTemplate object structured for creating a Nostr event.
|
||||
*/
|
||||
export function generateGoalEventTemplate({
|
||||
amount,
|
||||
content,
|
||||
relays,
|
||||
a,
|
||||
closedAt,
|
||||
image,
|
||||
r,
|
||||
summary,
|
||||
zapTags,
|
||||
}: Goal): EventTemplate {
|
||||
const tags: string[][] = [
|
||||
['amount', amount],
|
||||
['relays', ...relays],
|
||||
]
|
||||
|
||||
// Append optional tags based on the presence of optional properties in zapGoal
|
||||
closedAt && tags.push(['closed_at', closedAt.toString()])
|
||||
image && tags.push(['image', image])
|
||||
summary && tags.push(['summary', summary])
|
||||
r && tags.push(['r', r])
|
||||
a && tags.push(['a', a])
|
||||
zapTags && tags.push(...zapTags)
|
||||
|
||||
// Construct the EventTemplate object
|
||||
const eventTemplate: EventTemplate = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ZapGoal,
|
||||
content,
|
||||
tags,
|
||||
}
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
export function validateZapGoalEvent(event: Event): boolean {
|
||||
if (event.kind !== ZapGoal) return false
|
||||
|
||||
const requiredTags = ['amount', 'relays'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
390
nip94.test.ts
Normal file
390
nip94.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
it('should generate the correct event template', () => {
|
||||
const fileMetadataObject: FileMetadataObject = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
|
||||
}
|
||||
|
||||
const expectedEventTemplate: EventTemplate = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: expect.any(Number),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback1.example.com/image.jpg'],
|
||||
['fallback', 'https://fallback2.example.com/image.jpg'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateEventTemplate(fileMetadataObject)
|
||||
|
||||
expect(eventTemplate).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if kind is not FileMetadataKind', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // not FileMetadataKind
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if content is empty', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // empty
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if required tags are missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithoutUrl: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
// missing url
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutM: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
// missing m
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutX: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
// missing x
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutOx: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
// missing ox
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithoutUrl)).toBe(false)
|
||||
expect(validateEvent(eventWithoutM)).toBe(false)
|
||||
expect(validateEvent(eventWithoutX)).toBe(false)
|
||||
expect(validateEvent(eventWithoutOx)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if size is not a number', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', 'abc'], // not a number
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if dim is not a valid dimension string', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithInvalidDim: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', 'abc'], // invalid dim
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithInvalidDim)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
it('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback1.example.com/image.jpg'],
|
||||
['fallback', 'https://fallback2.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const parsedEvent = parseEvent(event)
|
||||
|
||||
expect(parsedEvent).toEqual({
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the event is invalid', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // invalid
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
['fallback', 'https://fallback.example.com/image.jpg'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow('Invalid event')
|
||||
})
|
||||
})
|
||||
211
nip94.ts
Normal file
211
nip94.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Type definition for File Metadata as specified in NIP-94.
|
||||
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
|
||||
*/
|
||||
export type FileMetadataObject = {
|
||||
/**
|
||||
* A description or caption for the file content.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The URL to download the file.
|
||||
*/
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The MIME type of the file, in lowercase.
|
||||
*/
|
||||
m: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the file.
|
||||
*/
|
||||
x: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
|
||||
*/
|
||||
ox: string
|
||||
|
||||
/**
|
||||
* Optional: The size of the file in bytes.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
|
||||
*/
|
||||
dim?: string
|
||||
|
||||
/**
|
||||
* Optional: The URI to the magnet file.
|
||||
*/
|
||||
magnet?: string
|
||||
|
||||
/**
|
||||
* Optional: The torrent infohash.
|
||||
*/
|
||||
i?: string
|
||||
|
||||
/**
|
||||
* Optional: The blurhash string to show while the file is being loaded by the client.
|
||||
*/
|
||||
blurhash?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
|
||||
*/
|
||||
thumb?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of a preview image with the same dimensions as the original file.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* Optional: A text excerpt or summary of the file's content.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* Optional: A description for accessibility, providing context or a brief description of the file.
|
||||
*/
|
||||
alt?: string
|
||||
|
||||
/**
|
||||
* Optional: fallback URLs in case url fails.
|
||||
*/
|
||||
fallback?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a file metadata object.
|
||||
*
|
||||
* @param fileMetadata - The file metadata object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: fileMetadata.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', fileMetadata.url],
|
||||
['m', fileMetadata.m],
|
||||
['x', fileMetadata.x],
|
||||
['ox', fileMetadata.ox],
|
||||
],
|
||||
}
|
||||
|
||||
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
|
||||
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
|
||||
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
|
||||
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
|
||||
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
|
||||
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
|
||||
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
|
||||
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
|
||||
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid file metadata event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (event.kind !== FileMetadataKind) return false
|
||||
|
||||
if (!event.content) return false
|
||||
|
||||
const requiredTags = ['url', 'm', 'x', 'ox'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
// validate optional size tag
|
||||
const sizeTag = event.tags.find(([t]) => t == 'size')
|
||||
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
|
||||
|
||||
// validate optional dim tag
|
||||
const dimTag = event.tags.find(([t]) => t == 'dim')
|
||||
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a file metadata object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The file metadata object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): FileMetadataObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const fileMetadata: FileMetadataObject = {
|
||||
content: event.content,
|
||||
url: '',
|
||||
m: '',
|
||||
x: '',
|
||||
ox: '',
|
||||
}
|
||||
|
||||
for (const [tag, value] of event.tags) {
|
||||
switch (tag) {
|
||||
case 'url':
|
||||
fileMetadata.url = value
|
||||
break
|
||||
case 'm':
|
||||
fileMetadata.m = value
|
||||
break
|
||||
case 'x':
|
||||
fileMetadata.x = value
|
||||
break
|
||||
case 'ox':
|
||||
fileMetadata.ox = value
|
||||
break
|
||||
case 'size':
|
||||
fileMetadata.size = value
|
||||
break
|
||||
case 'dim':
|
||||
fileMetadata.dim = value
|
||||
break
|
||||
case 'magnet':
|
||||
fileMetadata.magnet = value
|
||||
break
|
||||
case 'i':
|
||||
fileMetadata.i = value
|
||||
break
|
||||
case 'blurhash':
|
||||
fileMetadata.blurhash = value
|
||||
break
|
||||
case 'thumb':
|
||||
fileMetadata.thumb = value
|
||||
break
|
||||
case 'image':
|
||||
fileMetadata.image = value
|
||||
break
|
||||
case 'summary':
|
||||
fileMetadata.summary = value
|
||||
break
|
||||
case 'alt':
|
||||
fileMetadata.alt = value
|
||||
break
|
||||
case 'fallback':
|
||||
fileMetadata.fallback ??= []
|
||||
fileMetadata.fallback.push(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
397
nip98.test.ts
397
nip98.test.ts
@@ -1,76 +1,83 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
||||
import { Event, finalizeEvent } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const sk = generateSecretKey()
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import {
|
||||
getToken,
|
||||
hashPayload,
|
||||
unpackEventFromToken,
|
||||
validateEvent,
|
||||
validateEventKind,
|
||||
validateEventMethodTag,
|
||||
validateEventPayloadTag,
|
||||
validateEventTimestamp,
|
||||
validateEventUrlTag,
|
||||
validateToken,
|
||||
} from './nip98.ts'
|
||||
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('returns without authorization scheme for GET', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
test('returns token without authorization scheme for POST', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
test('returns token WITH authorization scheme for POST', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
||||
test('returns token with a valid payload tag when payload is present', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const payload = { test: 'payload' }
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
const payloadHash = hashPayload(payload)
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
['payload', payloadHash],
|
||||
@@ -79,81 +86,265 @@ describe('getToken', () => {
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('returns true for valid token without authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
expect(isTokenValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
test('returns true for valid token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
test('throws an error for invalid token', async () => {
|
||||
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
test('throws an error for missing token', async () => {
|
||||
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.kind = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.created_at = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
test('throws an error for invalid url', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
test('throws an error for invalid method', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent returns false for invalid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('returns true for valid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventTimestamp', () => {
|
||||
test('returns true for valid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventKind', () => {
|
||||
test('returns true for valid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventUrlTag', () => {
|
||||
test('returns true for valid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventMethodTag', () => {
|
||||
test('returns true for valid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventPayloadTag', () => {
|
||||
test('returns true for valid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for missing payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashPayload', () => {
|
||||
test('returns hash for valid payload', async () => {
|
||||
const payload = { test: 'payload' }
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
|
||||
test('returns hash for empty payload', async () => {
|
||||
const payload = {}
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
})
|
||||
|
||||
141
nip98.ts
141
nip98.ts
@@ -1,17 +1,13 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
|
||||
const _authorizationScheme = 'Nostr '
|
||||
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token for NIP-98 flow.
|
||||
*
|
||||
@@ -37,7 +33,7 @@ export async function getToken(
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
||||
event.tags.push(['payload', hashPayload(payload)])
|
||||
}
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
@@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks an event from a token.
|
||||
*
|
||||
* @param token - The token to unpack.
|
||||
* @returns A promise that resolves to the unpacked event.
|
||||
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||
*/
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
/**
|
||||
* Validates the timestamp of an event.
|
||||
* @param event - The event object to validate.
|
||||
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||
*/
|
||||
export function validateEventTimestamp(event: Event): boolean {
|
||||
if (!event.created_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the kind of an event.
|
||||
* @param event The event to validate.
|
||||
* @returns A boolean indicating whether the event kind is valid.
|
||||
*/
|
||||
export function validateEventKind(event: Event): boolean {
|
||||
return event.kind === HTTPAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given URL matches the URL tag of the event.
|
||||
* @param event - The event object.
|
||||
* @param url - The URL to validate.
|
||||
* @returns A boolean indicating whether the URL is valid or not.
|
||||
*/
|
||||
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
|
||||
if (!urlTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return urlTag.length > 0 && urlTag[1] === url
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given event has a method tag that matches the specified method.
|
||||
* @param event - The event to validate.
|
||||
* @param method - The method to match against the method tag.
|
||||
* @returns A boolean indicating whether the event has a matching method tag.
|
||||
*/
|
||||
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
|
||||
if (!methodTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the hash of a payload.
|
||||
* @param payload - The payload to be hashed.
|
||||
* @returns The hash value as a string.
|
||||
*/
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the event payload tag against the provided payload.
|
||||
* @param event The event object.
|
||||
* @param payload The payload to validate.
|
||||
* @returns A boolean indicating whether the payload tag is valid.
|
||||
*/
|
||||
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
|
||||
if (!payloadTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
const payloadHash = hashPayload(payload)
|
||||
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Nostr event for the NIP-98 flow.
|
||||
*
|
||||
* @param event - The Nostr event to validate.
|
||||
* @param url - The URL associated with the event.
|
||||
* @param method - The HTTP method associated with the event.
|
||||
* @param body - The request body associated with the event (optional).
|
||||
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||
* @throws An error if the event is invalid.
|
||||
*/
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!verifyEvent(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== HTTPAuth) {
|
||||
|
||||
if (!validateEventKind(event)) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
if (!validateEventTimestamp(event)) {
|
||||
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||
}
|
||||
|
||||
// Event must be less than 60 seconds old
|
||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||
throw new Error('Invalid nostr event, expired')
|
||||
}
|
||||
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||
if (!validateEventUrlTag(event, url)) {
|
||||
throw new Error('Invalid nostr event, url tag invalid')
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
if (!validateEventMethodTag(event, method)) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
||||
if (payloadTag?.[1] !== payloadHash) {
|
||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
||||
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||
if (!validateEventPayloadTag(event, body)) {
|
||||
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { Event } from './core.ts'
|
||||
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid classified listing event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false when the "d" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
// Missing 'd' tag
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "title" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
// Missing 'title' tag
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "summary" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
// Missing 'summary' tag
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "published_at" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
// Missing 'published_at' tag
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "location" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
// Missing 'location' tag
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag is missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
// Missing 'price' tag
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "published_at" tag is not a valid timestamp', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', 'not-a-valid-timestamp'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid price', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', 'not-a-valid-price', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid currency', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'not-a-valid-currency'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "price" tag has not a valid number of elements', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event1: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event1)).toBe(false)
|
||||
})
|
||||
|
||||
test('should return false when the "a" tag has not a valid number of elements', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event1: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['a', 'extra1'],
|
||||
['a', 'extra2', 'value2', 'extra3'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const event2: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: ClassifiedListing,
|
||||
content: 'Lorem ipsum dolor sit amet.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['e', 'extra1'],
|
||||
['e', 'extra2', 'value2', 'extra3'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event1)).toBe(false)
|
||||
expect(validateEvent(event2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
test('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const expectedListing = {
|
||||
title: 'Sample Title',
|
||||
summary: 'Sample Summary',
|
||||
publishedAt: '1296962229',
|
||||
location: 'NYC',
|
||||
price: {
|
||||
amount: '100',
|
||||
currency: 'USD',
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: 'https://example.com/image1.jpg',
|
||||
dimensions: '800x600',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/image2.jpg',
|
||||
},
|
||||
],
|
||||
hashtags: ['tag1', 'tag2'],
|
||||
additionalTags: {
|
||||
e: ['value1', 'value2'],
|
||||
a: ['value1', 'value2'],
|
||||
},
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
isDraft: true,
|
||||
}
|
||||
|
||||
expect(parseEvent(event)).toEqual(expectedListing)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
// Missing 'd' tag
|
||||
['title', 'Sample Title'],
|
||||
['summary', 'Sample Summary'],
|
||||
['published_at', '1296962229'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['e', 'value1', 'value2'],
|
||||
['a', 'value1', 'value2'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
test('should generate the correct event template for a classified listing', () => {
|
||||
const listing: ClassifiedListingObject = {
|
||||
title: 'Sample Title',
|
||||
summary: 'Sample Summary',
|
||||
publishedAt: '1296962229',
|
||||
location: 'NYC',
|
||||
price: {
|
||||
amount: '100',
|
||||
currency: 'USD',
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: 'https://example.com/image1.jpg',
|
||||
dimensions: '800x600',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/image2.jpg',
|
||||
},
|
||||
],
|
||||
hashtags: ['tag1', 'tag2'],
|
||||
additionalTags: {
|
||||
extra1: 'value1',
|
||||
extra2: 'value2',
|
||||
},
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
isDraft: true,
|
||||
}
|
||||
|
||||
const expectedEventTemplate = {
|
||||
kind: DraftClassifiedListing,
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||
tags: [
|
||||
['d', 'sample-title'],
|
||||
['title', 'Sample Title'],
|
||||
['published_at', '1296962229'],
|
||||
['summary', 'Sample Summary'],
|
||||
['location', 'NYC'],
|
||||
['price', '100', 'USD'],
|
||||
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||
['image', 'https://example.com/image2.jpg'],
|
||||
['t', 'tag1'],
|
||||
['t', 'tag2'],
|
||||
['extra1', 'value1'],
|
||||
['extra2', 'value2'],
|
||||
],
|
||||
created_at: expect.any(Number),
|
||||
}
|
||||
|
||||
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
228
nip99.ts
Normal file
228
nip99.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Represents the details of a price.
|
||||
* @example { amount: '100', currency: 'USD', frequency: 'month' }
|
||||
* @example { amount: '100', currency: 'EUR' }
|
||||
*/
|
||||
export type PriceDetails = {
|
||||
/**
|
||||
* The amount of the price.
|
||||
*/
|
||||
amount: string
|
||||
/**
|
||||
* The currency of the price in 3-letter ISO 4217 format.
|
||||
* @example 'USD'
|
||||
*/
|
||||
currency: string
|
||||
/**
|
||||
* The optional frequency of payment.
|
||||
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
|
||||
*/
|
||||
frequency?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a classified listing object.
|
||||
*/
|
||||
export type ClassifiedListingObject = {
|
||||
/**
|
||||
* Whether the listing is a draft or not.
|
||||
*/
|
||||
isDraft: boolean
|
||||
/**
|
||||
* A title of the listing.
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* A short summary or tagline.
|
||||
*/
|
||||
summary: string
|
||||
/**
|
||||
* A description in Markdown format.
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* Timestamp in unix seconds of when the listing was published.
|
||||
*/
|
||||
publishedAt: string
|
||||
/**
|
||||
* Location of the listing.
|
||||
* @example 'NYC'
|
||||
*/
|
||||
location: string
|
||||
/**
|
||||
* Price details.
|
||||
*/
|
||||
price: PriceDetails
|
||||
/**
|
||||
* Images of the listing with optional dimensions.
|
||||
*/
|
||||
images: Array<{
|
||||
url: string
|
||||
dimensions?: string
|
||||
}>
|
||||
/**
|
||||
* Tags/Hashtags (i.e. categories, keywords, etc.)
|
||||
*/
|
||||
hashtags: string[]
|
||||
/**
|
||||
* Other standard tags.
|
||||
* @example "g", a geohash for more precise location
|
||||
*/
|
||||
additionalTags: Record<string, string | string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid classified listing event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
|
||||
|
||||
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
|
||||
const requiredTagCount = requiredTags.length
|
||||
const tagCounts: Record<string, number> = {}
|
||||
|
||||
if (event.tags.length < requiredTagCount) return false
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag.length < 2) return false
|
||||
|
||||
const [tagName, ...tagValues] = tag
|
||||
|
||||
if (tagName == 'published_at') {
|
||||
const timestamp = parseInt(tagValues[0])
|
||||
if (isNaN(timestamp)) return false
|
||||
} else if (tagName == 'price') {
|
||||
if (tagValues.length < 2) return false
|
||||
|
||||
const price = parseInt(tagValues[0])
|
||||
if (isNaN(price) || tagValues[1].length != 3) return false
|
||||
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (requiredTags.includes(tagName)) {
|
||||
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a classified listing object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The classified listing object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): ClassifiedListingObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const listing: ClassifiedListingObject = {
|
||||
isDraft: event.kind === DraftClassifiedListing,
|
||||
title: '',
|
||||
summary: '',
|
||||
content: event.content,
|
||||
publishedAt: '',
|
||||
location: '',
|
||||
price: {
|
||||
amount: '',
|
||||
currency: '',
|
||||
},
|
||||
images: [],
|
||||
hashtags: [],
|
||||
additionalTags: {},
|
||||
}
|
||||
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
const [tagName, ...tagValues] = tag
|
||||
|
||||
if (tagName == 'title') {
|
||||
listing.title = tagValues[0]
|
||||
} else if (tagName == 'summary') {
|
||||
listing.summary = tagValues[0]
|
||||
} else if (tagName == 'published_at') {
|
||||
listing.publishedAt = tagValues[0]
|
||||
} else if (tagName == 'location') {
|
||||
listing.location = tagValues[0]
|
||||
} else if (tagName == 'price') {
|
||||
listing.price.amount = tagValues[0]
|
||||
listing.price.currency = tagValues[1]
|
||||
|
||||
if (tagValues.length == 3) {
|
||||
listing.price.frequency = tagValues[2]
|
||||
}
|
||||
} else if (tagName == 'image') {
|
||||
listing.images.push({
|
||||
url: tagValues[0],
|
||||
dimensions: tagValues?.[1] ?? undefined,
|
||||
})
|
||||
} else if (tagName == 't') {
|
||||
listing.hashtags.push(tagValues[0])
|
||||
} else if (tagName == 'e' || tagName == 'a') {
|
||||
listing.additionalTags[tagName] = [...tagValues]
|
||||
}
|
||||
}
|
||||
|
||||
return listing
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a classified listing object.
|
||||
*
|
||||
* @param listing - The classified listing object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
|
||||
const priceTag = ['price', listing.price.amount, listing.price.currency]
|
||||
if (listing.price.frequency) priceTag.push(listing.price.frequency)
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
|
||||
['title', listing.title],
|
||||
['published_at', listing.publishedAt],
|
||||
['summary', listing.summary],
|
||||
['location', listing.location],
|
||||
priceTag,
|
||||
]
|
||||
|
||||
for (let i = 0; i < listing.images.length; i++) {
|
||||
const image = listing.images[i]
|
||||
const imageTag = ['image', image.url]
|
||||
if (image.dimensions) imageTag.push(image.dimensions)
|
||||
|
||||
tags.push(imageTag)
|
||||
}
|
||||
|
||||
for (let i = 0; i < listing.hashtags.length; i++) {
|
||||
const t = listing.hashtags[i]
|
||||
|
||||
tags.push(['t', t])
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(listing.additionalTags)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const val = value[i]
|
||||
|
||||
tags.push([key, val])
|
||||
}
|
||||
} else {
|
||||
tags.push([key, value])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
|
||||
content: listing.content,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
85
package.json
85
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "2.1.2",
|
||||
"version": "2.13.0",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,6 +20,11 @@
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./core": {
|
||||
"import": "./lib/esm/core.js",
|
||||
"require": "./lib/cjs/core.js",
|
||||
"types": "./lib/types/core.d.ts"
|
||||
},
|
||||
"./pure": {
|
||||
"import": "./lib/esm/pure.js",
|
||||
"require": "./lib/cjs/pure.js",
|
||||
@@ -70,11 +75,6 @@
|
||||
"require": "./lib/cjs/nip04.js",
|
||||
"types": "./lib/types/nip04.d.ts"
|
||||
},
|
||||
"./nip44": {
|
||||
"import": "./lib/esm/nip44.js",
|
||||
"require": "./lib/cjs/nip44.js",
|
||||
"types": "./lib/types/nip44.d.ts"
|
||||
},
|
||||
"./nip05": {
|
||||
"import": "./lib/esm/nip05.js",
|
||||
"require": "./lib/cjs/nip05.js",
|
||||
@@ -85,6 +85,9 @@
|
||||
"require": "./lib/cjs/nip06.js",
|
||||
"types": "./lib/types/nip06.d.ts"
|
||||
},
|
||||
"./nip07": {
|
||||
"types": "./lib/types/nip07.d.ts"
|
||||
},
|
||||
"./nip10": {
|
||||
"import": "./lib/esm/nip10.js",
|
||||
"require": "./lib/cjs/nip10.js",
|
||||
@@ -100,6 +103,11 @@
|
||||
"require": "./lib/cjs/nip13.js",
|
||||
"types": "./lib/types/nip13.d.ts"
|
||||
},
|
||||
"./nip17": {
|
||||
"import": "./lib/esm/nip17.js",
|
||||
"require": "./lib/cjs/nip17.js",
|
||||
"types": "./lib/types/nip17.d.ts"
|
||||
},
|
||||
"./nip18": {
|
||||
"import": "./lib/esm/nip18.js",
|
||||
"require": "./lib/cjs/nip18.js",
|
||||
@@ -130,6 +138,11 @@
|
||||
"require": "./lib/cjs/nip28.js",
|
||||
"types": "./lib/types/nip28.d.ts"
|
||||
},
|
||||
"./nip29": {
|
||||
"import": "./lib/esm/nip29.js",
|
||||
"require": "./lib/cjs/nip29.js",
|
||||
"types": "./lib/types/nip29.d.ts"
|
||||
},
|
||||
"./nip30": {
|
||||
"import": "./lib/esm/nip30.js",
|
||||
"require": "./lib/cjs/nip30.js",
|
||||
@@ -145,16 +158,61 @@
|
||||
"require": "./lib/cjs/nip42.js",
|
||||
"types": "./lib/types/nip42.d.ts"
|
||||
},
|
||||
"./nip44": {
|
||||
"import": "./lib/esm/nip44.js",
|
||||
"require": "./lib/cjs/nip44.js",
|
||||
"types": "./lib/types/nip44.d.ts"
|
||||
},
|
||||
"./nip46": {
|
||||
"import": "./lib/esm/nip46.js",
|
||||
"require": "./lib/cjs/nip46.js",
|
||||
"types": "./lib/types/nip46.d.ts"
|
||||
},
|
||||
"./nip49": {
|
||||
"import": "./lib/esm/nip49.js",
|
||||
"require": "./lib/cjs/nip49.js",
|
||||
"types": "./lib/types/nip49.d.ts"
|
||||
},
|
||||
"./nip54": {
|
||||
"import": "./lib/esm/nip54.js",
|
||||
"require": "./lib/cjs/nip54.js",
|
||||
"types": "./lib/types/nip54.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
"types": "./lib/types/nip57.d.ts"
|
||||
},
|
||||
"./nip59": {
|
||||
"import": "./lib/esm/nip59.js",
|
||||
"require": "./lib/cjs/nip59.js",
|
||||
"types": "./lib/types/nip59.d.ts"
|
||||
},
|
||||
"./nip58": {
|
||||
"import": "./lib/esm/nip58.js",
|
||||
"require": "./lib/cjs/nip58.js",
|
||||
"types": "./lib/types/nip58.d.ts"
|
||||
},
|
||||
"./nip75": {
|
||||
"import": "./lib/esm/nip75.js",
|
||||
"require": "./lib/cjs/nip75.js",
|
||||
"types": "./lib/types/nip75.d.ts"
|
||||
},
|
||||
"./nip94": {
|
||||
"import": "./lib/esm/nip94.js",
|
||||
"require": "./lib/cjs/nip94.js",
|
||||
"types": "./lib/types/nip94.d.ts"
|
||||
},
|
||||
"./nip98": {
|
||||
"import": "./lib/esm/nip98.js",
|
||||
"require": "./lib/cjs/nip98.js",
|
||||
"types": "./lib/types/nip98.d.ts"
|
||||
},
|
||||
"./nip99": {
|
||||
"import": "./lib/esm/nip99.js",
|
||||
"require": "./lib/cjs/nip99.js",
|
||||
"types": "./lib/types/nip99.d.ts"
|
||||
},
|
||||
"./fakejson": {
|
||||
"import": "./lib/esm/fakejson.js",
|
||||
"require": "./lib/cjs/fakejson.js",
|
||||
@@ -168,14 +226,13 @@
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"mitata": "^0.1.6",
|
||||
"nostr-wasm": "v0.1.0"
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
@@ -199,18 +256,16 @@
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"mitata": "^0.1.6",
|
||||
"mock-socket": "^9.3.1",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^5.0.4"
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "just build && just emit-types"
|
||||
"prepublish": "just build"
|
||||
}
|
||||
}
|
||||
|
||||
199
pool.test.ts
199
pool.test.ts
@@ -1,31 +1,30 @@
|
||||
import { test, expect, afterAll } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { finalizeEvent, type Event } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { SimplePool, useWebSocketImplementation } from './pool.ts'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
let pool = new SimplePool()
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
|
||||
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
|
||||
let pool: SimplePool
|
||||
let mockRelays: MockRelay[]
|
||||
let relayURLs: string[]
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
beforeEach(() => {
|
||||
pool = new SimplePool()
|
||||
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
|
||||
relayURLs = mockRelays.map(mr => mr.url)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
pool.close(relayURLs)
|
||||
})
|
||||
|
||||
test('removing duplicates when subscribing', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
let received: Event[] = []
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
@@ -36,8 +35,17 @@ test('removing duplicates when subscribing', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be caught and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event)
|
||||
@@ -47,12 +55,12 @@ test('same with double subs', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
@@ -70,47 +78,160 @@ test('same with double subs', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('subscribe many map', async () => {
|
||||
let priv = hexToBytes('8ea002840d413ccdd5be98df5dd89d799eaa566355ede83ca0bbdbb4b145e0d3')
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let received: Event[] = []
|
||||
let event1 = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test1',
|
||||
kind: 20001,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
let event2 = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 20002,
|
||||
tags: [['t', 'biloba']],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
let event3 = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test3',
|
||||
kind: 20003,
|
||||
tags: [['t', 'biloba']],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
const [relayA, relayB, relayC] = relayURLs
|
||||
|
||||
pool.subscribeManyMap(
|
||||
{
|
||||
[relayA]: [{ authors: [pub], kinds: [20001] }],
|
||||
[relayB]: [{ authors: [pub], kinds: [20002] }],
|
||||
[relayC]: [{ kinds: [20003], '#t': ['biloba'] }],
|
||||
},
|
||||
{
|
||||
onevent(event: Event) {
|
||||
received.push(event)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// publish the first
|
||||
await Promise.all(pool.publish([relayA, relayB], event1))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event1)
|
||||
|
||||
// publish the second
|
||||
await pool.publish([relayB], event2)[0]
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received[1]).toEqual(event2)
|
||||
|
||||
// publish a events that shouldn't match our filters
|
||||
await Promise.all([
|
||||
...pool.publish([relayA, relayB], event3),
|
||||
...pool.publish([relayA, relayB, relayC], event1),
|
||||
pool.publish([relayA, relayB, relayC], event2),
|
||||
])
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
|
||||
// publsih the third
|
||||
await pool.publish([relayC], event3)[0]
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
expect(received).toHaveLength(3)
|
||||
expect(received[2]).toEqual(event3)
|
||||
})
|
||||
|
||||
test('query a bunch of events and cancel on eose', async () => {
|
||||
let events = new Set<string>()
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
pool.subscribeManyEose(
|
||||
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
||||
[{ kinds: [0, 1], limit: 40 }],
|
||||
{
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
onclose: resolve as any,
|
||||
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
)
|
||||
onclose: resolve as any,
|
||||
})
|
||||
})
|
||||
|
||||
expect(events.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
test('querySync()', async () => {
|
||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||
|
||||
let events = await pool.querySync(relayURLs, {
|
||||
authors: authors,
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toBeGreaterThan(2)
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
expect(events).toHaveLength(uniqueEventCount)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
||||
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||
|
||||
let event = await pool.get(relayURLs, {
|
||||
ids: [ids[0]],
|
||||
})
|
||||
|
||||
expect(event).not.toBeNull()
|
||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
||||
expect(event).toHaveProperty('id', ids[0])
|
||||
})
|
||||
|
||||
test('track relays when publishing', async () => {
|
||||
let event1 = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
},
|
||||
generateSecretKey(),
|
||||
)
|
||||
let event2 = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
},
|
||||
generateSecretKey(),
|
||||
)
|
||||
|
||||
pool.trackRelays = true
|
||||
await Promise.all(pool.publish(relayURLs, event1))
|
||||
expect(pool.seenOn.get(event1.id)).toBeDefined()
|
||||
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
|
||||
|
||||
pool.trackRelays = false
|
||||
await Promise.all(pool.publish(relayURLs, event2))
|
||||
expect(pool.seenOn.get(event2.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
14
pool.ts
14
pool.ts
@@ -1,9 +1,21 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
try {
|
||||
_WebSocket = WebSocket
|
||||
} catch {}
|
||||
|
||||
export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
_WebSocket = websocketImplementation
|
||||
}
|
||||
|
||||
export class SimplePool extends AbstractSimplePool {
|
||||
constructor() {
|
||||
super({ verifyEvent })
|
||||
super({ verifyEvent, websocketImplementation: _WebSocket })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
293
pure.test.ts
Normal file
293
pure.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import {
|
||||
finalizeEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifyEvent,
|
||||
verifiedSymbol,
|
||||
getPublicKey,
|
||||
generateSecretKey,
|
||||
} from './pure.ts'
|
||||
import { ShortTextNote } from './kinds.ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
test('private key generation', () => {
|
||||
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(getPublicKey(sk)).toEqual(pk)
|
||||
}
|
||||
})
|
||||
|
||||
describe('finalizeEvent', () => {
|
||||
test('should create a signed event from a template', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finalizeEvent(template, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(template.kind)
|
||||
expect(event.tags).toEqual(template.tags)
|
||||
expect(event.content).toEqual(template.content)
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
test('should serialize a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: publicKey,
|
||||
created_at: 1617932115,
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
|
||||
expect(serializedEvent).toEqual(
|
||||
JSON.stringify([
|
||||
0,
|
||||
publicKey,
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('should throw an error for an invalid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventHash', () => {
|
||||
test('should return the correct event hash', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
|
||||
expect(typeof eventHash).toEqual('string')
|
||||
expect(eventHash.length).toEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('should return true for a valid event object', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for a non object event', () => {
|
||||
const nonObjectEvent = ''
|
||||
const isValid = validateEvent(nonObjectEvent)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an event object with missing properties', () => {
|
||||
const invalidEvent = {
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an empty object', () => {
|
||||
const emptyObj = {}
|
||||
|
||||
const isValid = validateEvent(emptyObj)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid properties', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with an invalid public key', () => {
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an object with invalid tags', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyEvent', () => {
|
||||
test('should return true for a valid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(true)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event signature', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifyEvent({
|
||||
...event,
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
|
||||
test('should return false for an invalid event id', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the id
|
||||
event.id = event.id.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifyEvent(event)
|
||||
expect(isValid).toEqual(false)
|
||||
})
|
||||
})
|
||||
4
pure.ts
4
pure.ts
@@ -1,6 +1,6 @@
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
|
||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core.ts'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
@@ -50,7 +50,7 @@ export function getEventHash(event: UnsignedEvent): string {
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
const i = new JS()
|
||||
const i: JS = new JS()
|
||||
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
|
||||
154
relay.test.ts
154
relay.test.ts
@@ -1,121 +1,119 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
import { expect, test } from 'bun:test'
|
||||
import { Server } from 'mock-socket'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay, useWebSocketImplementation } from './relay.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
|
||||
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
let relay = new Relay('wss://relay.nostr.bg')
|
||||
|
||||
afterEach(() => {
|
||||
relay.close()
|
||||
})
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
|
||||
test('connectivity', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity, with Relay.connect()', async () => {
|
||||
const relay = await Relay.connect('wss://public.relaying.io')
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
expect(relay.connected).toBeTrue()
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
test('querying', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
const kind = 0
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let resolveEvent: () => void
|
||||
let resolveEose: () => void
|
||||
|
||||
const evented = new Promise<void>(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const eosed = new Promise<void>(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
|
||||
kinds: [0],
|
||||
authors: mockRelay.authors,
|
||||
kinds: [kind],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
|
||||
expect(event).toHaveProperty('kind', 0)
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
expect(mockRelay.authors).toContain(event.pubkey)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await eosed
|
||||
await evented
|
||||
}, 10000)
|
||||
test('listening and publishing and closing', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
test('listening and publishing and closing', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const pk = getPublicKey(sk)
|
||||
const kind = 23571
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
let resolveEose: (_: void) => void
|
||||
let resolveEvent: (_: void) => void
|
||||
let resolveClose: (_: void) => void
|
||||
let eventReceived: NostrEvent | undefined
|
||||
|
||||
const eosed = new Promise(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
const evented = new Promise(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const closed = new Promise(resolve => {
|
||||
resolveClose = resolve
|
||||
})
|
||||
|
||||
let sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [23571],
|
||||
kinds: [kind],
|
||||
authors: [pk],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
eventReceived = event
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
expect(event).toHaveProperty('content', 'content')
|
||||
|
||||
sub.close() // close the subscription and will trigger onclose()
|
||||
},
|
||||
onclose() {
|
||||
resolveClose()
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await eosed
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
kind: 23571,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
relay.publish(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
content: 'content',
|
||||
created_at: 0,
|
||||
tags: [],
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
await evented
|
||||
sub.close()
|
||||
await closed
|
||||
|
||||
expect(eventReceived).toBeDefined()
|
||||
expect(eventReceived).toHaveProperty('pubkey', pk)
|
||||
expect(eventReceived).toHaveProperty('kind', 23571)
|
||||
expect(eventReceived).toHaveProperty('content', 'nostr-tools test suite')
|
||||
})
|
||||
|
||||
test('publish timeout', async () => {
|
||||
const url = 'wss://relay.example.com'
|
||||
new Server(url)
|
||||
|
||||
const relay = new Relay(url)
|
||||
relay.publishTimeout = 100
|
||||
await relay.connect()
|
||||
|
||||
setTimeout(() => relay.close(), 20000) // close the relay to fail the test on timeout
|
||||
|
||||
expect(
|
||||
relay.publish(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
},
|
||||
generateSecretKey(),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow('publish timed out')
|
||||
})
|
||||
|
||||
18
relay.ts
18
relay.ts
@@ -1,3 +1,5 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import { verifyEvent } from './pure.ts'
|
||||
import { AbstractRelay } from './abstract-relay.ts'
|
||||
|
||||
@@ -8,16 +10,28 @@ export function relayConnect(url: string): Promise<Relay> {
|
||||
return Relay.connect(url)
|
||||
}
|
||||
|
||||
var _WebSocket: typeof WebSocket
|
||||
|
||||
try {
|
||||
_WebSocket = WebSocket
|
||||
} catch {}
|
||||
|
||||
export function useWebSocketImplementation(websocketImplementation: any) {
|
||||
_WebSocket = websocketImplementation
|
||||
}
|
||||
|
||||
export class Relay extends AbstractRelay {
|
||||
constructor(url: string) {
|
||||
super(url, { verifyEvent })
|
||||
super(url, { verifyEvent, websocketImplementation: _WebSocket })
|
||||
}
|
||||
|
||||
static async connect(url: string) {
|
||||
static async connect(url: string): Promise<Relay> {
|
||||
const relay = new Relay(url)
|
||||
await relay.connect()
|
||||
return relay
|
||||
}
|
||||
}
|
||||
|
||||
export type RelayRecord = Record<string, { read: boolean; write: boolean }>
|
||||
|
||||
export * from './abstract-relay.ts'
|
||||
|
||||
109
test-helpers.ts
109
test-helpers.ts
@@ -1,6 +1,10 @@
|
||||
import type { Event } from './pure.ts'
|
||||
import { Server, WebSocket } from 'mock-socket'
|
||||
|
||||
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
|
||||
export const MockWebSocketClient = WebSocket
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent(params: Partial<Event>): Event {
|
||||
return {
|
||||
id: '',
|
||||
@@ -13,3 +17,104 @@ export function buildEvent(params: Partial<Event>): Event {
|
||||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
let serial = 0
|
||||
|
||||
export class MockRelay {
|
||||
private _server: Server
|
||||
|
||||
public url: string
|
||||
public secretKeys: Uint8Array[]
|
||||
public preloadedEvents: Event[]
|
||||
|
||||
constructor(url?: string | undefined) {
|
||||
serial++
|
||||
this.url = url ?? `wss://random.mock.relay/${serial}`
|
||||
this.secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||
this.preloadedEvents = this.secretKeys.map(sk =>
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
content: 'autogenerated by relay',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['t', 'auto']],
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
|
||||
this._server = new Server(this.url)
|
||||
this._server.on('connection', (conn: any) => {
|
||||
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||
|
||||
conn.on('message', (message: string) => {
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data[0]) {
|
||||
case 'REQ': {
|
||||
let subId = data[1]
|
||||
let filters = data.slice(2)
|
||||
subs[subId] = { conn, filters }
|
||||
|
||||
this.preloadedEvents.forEach(event => {
|
||||
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||
})
|
||||
|
||||
filters.forEach((filter: Filter) => {
|
||||
const kinds = filter.kinds?.length ? filter.kinds : [1]
|
||||
|
||||
kinds.forEach(kind => {
|
||||
this.secretKeys.forEach(sk => {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
content: 'kind-aware autogenerated by relay',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['t', 'auto']],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
conn.send(JSON.stringify(['EOSE', subId]))
|
||||
|
||||
break
|
||||
}
|
||||
case 'CLOSE': {
|
||||
let subId = data[1]
|
||||
delete subs[subId]
|
||||
|
||||
break
|
||||
}
|
||||
case 'EVENT': {
|
||||
let event = data[1]
|
||||
|
||||
conn.send(JSON.stringify(['OK', event.id, 'true']))
|
||||
|
||||
for (let subId in subs) {
|
||||
const { filters, conn: listener } = subs[subId]
|
||||
|
||||
if (matchFilters(filters, event)) {
|
||||
listener.send(JSON.stringify(['EVENT', subId, event]))
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get authors() {
|
||||
return this.secretKeys.map(getPublicKey)
|
||||
}
|
||||
|
||||
get ids() {
|
||||
return this.preloadedEvents.map(evt => evt.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"module": "NodeNext",
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "NodeNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
30
utils.ts
30
utils.ts
@@ -1,19 +1,24 @@
|
||||
import type { Event } from './core.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
export const utf8Decoder: TextDecoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder: TextEncoder = new TextEncoder()
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
try {
|
||||
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid URL: ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
|
||||
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event): Event[] {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
@@ -25,7 +30,7 @@ export function insertEventIntoDescendingList(sortedArray: Event[], event: Event
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
|
||||
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event): Event[] {
|
||||
const [idx, found] = binarySearch(sortedArray, b => {
|
||||
if (event.id === b.id) return 0
|
||||
if (event.created_at === b.created_at) return -1
|
||||
@@ -110,6 +115,9 @@ export class Queue<V> {
|
||||
|
||||
const target = this.first
|
||||
this.first = target.next
|
||||
if (this.first) {
|
||||
this.first.prev = null // fix: clean up prev pointer
|
||||
}
|
||||
|
||||
return target.value
|
||||
}
|
||||
|
||||
4
wasm.ts
4
wasm.ts
@@ -1,6 +1,6 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { Nostr as NostrWasm } from 'nostr-wasm'
|
||||
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
|
||||
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core.ts'
|
||||
|
||||
let nw: NostrWasm
|
||||
|
||||
@@ -30,7 +30,7 @@ class Wasm implements Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
const i = new Wasm()
|
||||
const i: Wasm = new Wasm()
|
||||
export const generateSecretKey = i.generateSecretKey
|
||||
export const getPublicKey = i.getPublicKey
|
||||
export const finalizeEvent = i.finalizeEvent
|
||||
|
||||
Reference in New Issue
Block a user