Compare commits

...

53 Commits

Author SHA1 Message Date
fiatjaf
13bc2ad5a8 trick typescript into accepting our types. 2023-09-10 15:44:22 -03:00
fiatjaf
55f032d0a4 tag v1.15.0 2023-09-10 15:16:04 -03:00
Alex Gleason
c890e29290 nip13: use a simpler implementation 2023-09-10 15:15:33 -03:00
Alex Gleason
c18f050468 relay: sub.events async iterator 2023-09-09 19:05:21 -03:00
Alex Gleason
401b9c7864 Make TypeScript >= 5.0.0 an optional peer dependency 2023-09-03 20:56:05 -03:00
fiatjaf_
c175f6c804 Merge pull request #289 from alexgleason/verified 2023-09-03 15:47:50 -03:00
Alex Gleason
41265a19f5 event.test: tamper with things in a more evil way 2023-09-03 12:12:42 -05:00
Alex Gleason
d88761907a verifySignature: set verifiedSymbol to false on failure, DRY return values 2023-09-02 18:08:09 -05:00
Alex Gleason
8325d4351e just format 2023-09-02 17:40:00 -05:00
Alex Gleason
62bf592d72 finishEvent: return a VerifiedEvent 2023-09-02 17:39:35 -05:00
Alex Gleason
54f3bedf38 verifySignature: return false if the id is invalid 2023-09-02 17:39:28 -05:00
Alex Gleason
34e0ad8c41 Add a symbol to verified events 2023-09-02 18:04:10 -03:00
Egge
e9eac28bab Added eoseSubTimeout to pool's SubscriptionOptions (#284)
* added timeout sub option

* made eoseSubTimeout optional
2023-09-01 07:50:12 -03:00
fiatjaf_
85035d61f2 Merge pull request #287 from alexgleason/prettier
Fix tests, format everything with prettier and enforce prettier+eslint in the CI
2023-09-01 07:48:06 -03:00
Alex Gleason
cf46560619 ci: ensure just is available to the runner 2023-08-31 13:52:56 -05:00
Alex Gleason
e7aa23cb1d README: add a note about typescript 5.0 2023-08-31 13:51:17 -05:00
Alex Gleason
5977d68ec2 nip98.test: remove outdated/failing test 2023-08-31 13:47:16 -05:00
Alex Gleason
48767d382d relay.test: increase querying timeout to 10s 2023-08-31 13:45:39 -05:00
Alex Gleason
718032022c just format 2023-08-31 13:42:15 -05:00
Alex Gleason
2a70bb18ff pool: use triple-equals 2023-08-31 13:41:40 -05:00
Alex Gleason
9effe807d1 filter: remove unused import for Kind 2023-08-31 13:41:25 -05:00
Alex Gleason
899c2bd0dc eslint: remove conflicting generator-star-spacing rule 2023-08-31 13:41:06 -05:00
Alex Gleason
918d514a25 Upgrade all eslint deps 2023-08-31 13:37:45 -05:00
Alex Gleason
48cb9046c4 Add eslint-config-prettier to solve conflicts between prettier and eslint 2023-08-31 13:27:28 -05:00
Alex Gleason
864dd28b26 justfile: improve lint/format commands 2023-08-31 13:25:30 -05:00
Alex Gleason
fa085367c9 Add eslint to just format 2023-08-31 13:22:43 -05:00
Alex Gleason
350951b88e Add eslint to just lint 2023-08-31 13:21:10 -05:00
Alex Gleason
c6133f7160 ci: run prettier on every commit 2023-08-31 13:14:16 -05:00
Alex Gleason
470512bbeb prettier: increase printWidth, enable bracketSpacing, alphabetize 2023-08-31 13:00:50 -05:00
Alex Gleason
c3acb82464 Upgrade Prettier to v3.0.3 2023-08-31 12:59:54 -05:00
Alex Gleason
fc23d05764 Merge pull request #283 from jiftechnify/fix-code-samples
Fix code samples in README
2023-08-27 12:14:32 -05:00
jiftechnify
8296ce897c fix a code sample in README
- add pool.close() usage
2023-08-28 01:54:33 +09:00
jiftechnify
3ca78c0e13 fix code samples in README 2023-08-28 01:47:35 +09:00
Alex Gleason
837a05e54d Add kinds module to classify events by kind 2023-08-26 22:26:04 -03:00
ffaex
32fd25556b added new event kind 1063
see https://github.com/nostr-protocol/nips/blob/master/94.md
2023-08-21 15:03:23 -03:00
Sepehr Safari
0925f5db81 add batchedList method to SimplePool 2023-08-21 10:44:33 -03:00
fiatjaf
bce976fecd get rid of httpmethod enum. 2023-08-16 14:07:26 -03:00
fiatjaf
45e479d7aa let it throw. 2023-08-16 13:59:31 -03:00
Jon Staab
b92407b156 nip44 updates (#278)
Co-authored-by: Jonathan Staab <shtaab@gmail.com>
2023-08-16 13:53:37 -03:00
Pierre Buyle
2431896921 fix(nip98): Add support for HEAD, PUT, CONNECT, OPTIONS, TRACE and PATCH http methods
This PR adds common HTTP methods (as listed on https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods)
2023-08-15 11:03:47 -03:00
Jonathan Staab
d13eecad4a Add support for nip44 2023-08-12 20:46:32 -03:00
Alex Gleason
df6f887d7e Event, Filter: allow any kind number
Fixes https://github.com/nbd-wtf/nostr-tools/issues/275
2023-08-12 20:13:53 -03:00
Alex Gleason
e00362e7c9 Filter: let tag queries be undefined 2023-08-12 16:30:24 -03:00
fiatjaf
9efdd16e26 fix check for undefined ws
fixes https://github.com/nbd-wtf/nostr-tools/issues/271
2023-08-11 07:09:40 -03:00
Alex Gleason
de7e128818 Merge pull request #267 from Airtune/nip98-extract-pubkey
+nip98.unpackEventFromToken +nip98.validateEvent
2023-08-08 08:48:29 -05:00
Airtune
4978c858e7 Update nip98.ts examples 2023-08-08 02:45:23 -04:00
Airtune
16c7ae2a70 +nip98.unpackEventFromToken +nip98.validateEvent 2023-08-07 22:16:23 -04:00
fiatjaf
3368e8c00e bump minor version because of the breaking change on publish()
yes, I don't understand semver
2023-07-31 23:05:36 -03:00
Airtune
e5a3ad9855 Export nip28 functions in index.ts and bump version (#265) 2023-07-31 23:04:45 -03:00
Airtune
03185c654b Create nip28.ts and nip28.test.ts (#264) 2023-07-31 08:29:45 -03:00
fiatjaf
9d690814ca turn .publish() into a normal async function returning a promise.
this simplifies the code and makes the API more intuitive.

we used to need the event emitter thing because we were subscribing to the same relay
to check if the event had been published, but that is not necessary now that we assume
an OK response will always come.

closes https://github.com/nbd-wtf/nostr-tools/issues/262
2023-07-30 18:23:05 -03:00
fiatjaf
17590cce91 tag v1.13.1 2023-07-23 10:15:00 -03:00
Pavan Joshi
ee9f37e192 Update package.json to upgrade scure/bip39 (#254)
* Update package.json to upgrade scure/bip39

scure/bip39 1.2.0 causing problem of "Can't resolve '@scure/bip39/wordlists/english' ... because it was resolved as fully specified "

* Update package.json
2023-07-23 09:41:53 -03:00
61 changed files with 2312 additions and 2269 deletions

View File

@@ -1,5 +1,6 @@
{ {
"root": true, "root": true,
"extends": ["prettier"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"], "plugins": ["@typescript-eslint", "babel"],
@@ -34,23 +35,22 @@
"rules": { "rules": {
"accessor-pairs": 2, "accessor-pairs": 2,
"arrow-spacing": [2, {"before": true, "after": true}], "arrow-spacing": [2, { "before": true, "after": true }],
"block-spacing": [2, "always"], "block-spacing": [2, "always"],
"brace-style": [2, "1tbs", {"allowSingleLine": true}], "brace-style": [2, "1tbs", { "allowSingleLine": true }],
"comma-dangle": 0, "comma-dangle": 0,
"comma-spacing": [2, {"before": false, "after": true}], "comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"], "comma-style": [2, "last"],
"constructor-super": 2, "constructor-super": 2,
"curly": [0, "multi-line"], "curly": [0, "multi-line"],
"dot-location": [2, "property"], "dot-location": [2, "property"],
"eol-last": 2, "eol-last": 2,
"eqeqeq": [2, "allow-null"], "eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, {"before": true, "after": true}],
"handle-callback-err": [2, "^(err|error)$"], "handle-callback-err": [2, "^(err|error)$"],
"indent": 0, "indent": 0,
"jsx-quotes": [2, "prefer-double"], "jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, {"beforeColon": false, "afterColon": true}], "key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [2, {"before": true, "after": true}], "keyword-spacing": [2, { "before": true, "after": true }],
"new-cap": 0, "new-cap": 0,
"new-parens": 0, "new-parens": 0,
"no-array-constructor": 2, "no-array-constructor": 2,
@@ -82,12 +82,12 @@
"no-irregular-whitespace": 2, "no-irregular-whitespace": 2,
"no-iterator": 2, "no-iterator": 2,
"no-label-var": 2, "no-label-var": 2,
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}], "no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
"no-lone-blocks": 2, "no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2, "no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2, "no-multi-spaces": 2,
"no-multi-str": 2, "no-multi-str": 2,
"no-multiple-empty-lines": [2, {"max": 2}], "no-multiple-empty-lines": [2, { "max": 2 }],
"no-native-reassign": 2, "no-native-reassign": 2,
"no-negated-in-lhs": 2, "no-negated-in-lhs": 2,
"no-new": 0, "no-new": 0,
@@ -115,34 +115,23 @@
"no-undef": 2, "no-undef": 2,
"no-undef-init": 2, "no-undef-init": 2,
"no-unexpected-multiline": 2, "no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, {"defaultAssignment": false}], "no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2, "no-unreachable": 2,
"no-unused-vars": [ "no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
2,
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
],
"no-useless-call": 2, "no-useless-call": 2,
"no-useless-constructor": 2, "no-useless-constructor": 2,
"no-with": 2, "no-with": 2,
"one-var": [0, {"initialized": "never"}], "one-var": [0, { "initialized": "never" }],
"operator-linebreak": [ "operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
2,
"after",
{"overrides": {"?": "before", ":": "before"}}
],
"padded-blocks": [2, "never"], "padded-blocks": [2, "never"],
"quotes": [ "quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
2,
"single",
{"avoidEscape": true, "allowTemplateLiterals": true}
],
"semi": [2, "never"], "semi": [2, "never"],
"semi-spacing": [2, {"before": false, "after": true}], "semi-spacing": [2, { "before": false, "after": true }],
"space-before-blocks": [2, "always"], "space-before-blocks": [2, "always"],
"space-before-function-paren": 0, "space-before-function-paren": 0,
"space-in-parens": [2, "never"], "space-in-parens": [2, "never"],
"space-infix-ops": 2, "space-infix-ops": 2,
"space-unary-ops": [2, {"words": true, "nonwords": false}], "space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": 0, "spaced-comment": 0,
"template-curly-spacing": [2, "never"], "template-curly-spacing": [2, "never"],
"use-isnan": 2, "use-isnan": 2,

View File

@@ -16,3 +16,13 @@ jobs:
- uses: extractions/setup-just@v1 - uses: extractions/setup-just@v1
- run: just install-dependencies - run: just install-dependencies
- run: just test - run: just test
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: just install-dependencies
- run: just lint

View File

@@ -1,10 +1,9 @@
semi: false
arrowParens: avoid arrowParens: avoid
bracketSpacing: true
insertPragma: false insertPragma: false
printWidth: 80 printWidth: 120
proseWrap: preserve proseWrap: preserve
semi: false
singleQuote: true singleQuote: true
trailingComma: none trailingComma: all
useTabs: false useTabs: false
jsxBracketSameLine: false
bracketSpacing: false

108
README.md
View File

@@ -12,12 +12,14 @@ This package is only providing lower-level functionality. If you want an easy-to
npm install nostr-tools # or yarn add nostr-tools npm install nostr-tools # or yarn add nostr-tools
``` ```
If using TypeScript, this package requires TypeScript >= 5.0.
## Usage ## Usage
### Generating a private key and a public key ### Generating a private key and a public key
```js ```js
import {generatePrivateKey, getPublicKey} from 'nostr-tools' import { generatePrivateKey, getPublicKey } from 'nostr-tools'
let sk = generatePrivateKey() // `sk` is a hex string let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string let pk = getPublicKey(sk) // `pk` is a hex string
@@ -26,20 +28,14 @@ let pk = getPublicKey(sk) // `pk` is a hex string
### Creating, signing and verifying events ### Creating, signing and verifying events
```js ```js
import { import { validateEvent, verifySignature, getSignature, getEventHash, getPublicKey } from 'nostr-tools'
validateEvent,
verifySignature,
getSignature,
getEventHash,
getPublicKey
} from 'nostr-tools'
let event = { let event = {
kind: 1, kind: 1,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [], tags: [],
content: 'hello', content: 'hello',
pubkey: getPublicKey(privateKey) pubkey: getPublicKey(privateKey),
} }
event.id = getEventHash(event) event.id = getEventHash(event)
@@ -52,13 +48,7 @@ let veryOk = verifySignature(event)
### Interacting with a relay ### Interacting with a relay
```js ```js
import { import { relayInit, finishEvent, generatePrivateKey, getPublicKey } from 'nostr-tools'
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
getSignature
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com') const relay = relayInit('wss://relay.example.com')
relay.on('connect', () => { relay.on('connect', () => {
@@ -73,8 +63,8 @@ await relay.connect()
// let's query for an event that exists // let's query for an event that exists
let sub = relay.sub([ let sub = relay.sub([
{ {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'] ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
} },
]) ])
sub.on('event', event => { sub.on('event', event => {
console.log('we got the event we wanted:', event) console.log('we got the event we wanted:', event)
@@ -90,8 +80,8 @@ let pk = getPublicKey(sk)
let sub = relay.sub([ let sub = relay.sub([
{ {
kinds: [1], kinds: [1],
authors: [pk] authors: [pk],
} },
]) ])
sub.on('event', event => { sub.on('event', event => {
@@ -103,22 +93,16 @@ let event = {
pubkey: pk, pubkey: pk,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [], tags: [],
content: 'hello world' content: 'hello world',
} }
event.id = getEventHash(event)
event.sig = getSignature(event, sk)
let pub = relay.publish(event) // this calculates the event id and signs the event in a single step
pub.on('ok', () => { const signedEvent = finishEvent(event, sk)
console.log(`${relay.url} has accepted our event`) await relay.publish(signedEvent)
})
pub.on('failed', reason => {
console.log(`failed to publish to ${relay.url}: ${reason}`)
})
let events = await relay.list([{kinds: [0, 1]}]) let events = await relay.list([{ kinds: [0, 1] }])
let event = await relay.get({ let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'] ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
}) })
relay.close() relay.close()
@@ -133,7 +117,7 @@ import 'websocket-polyfill'
### Interacting with multiple relays ### Interacting with multiple relays
```js ```js
import {SimplePool} from 'nostr-tools' import { SimplePool } from 'nostr-tools'
const pool = new SimplePool() const pool = new SimplePool()
@@ -143,11 +127,9 @@ let sub = pool.sub(
[...relays, 'wss://relay.example3.com'], [...relays, 'wss://relay.example3.com'],
[ [
{ {
authors: [ authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' },
] ],
}
]
) )
sub.on('event', event => { sub.on('event', event => {
@@ -156,31 +138,28 @@ sub.on('event', event => {
}) })
let pubs = pool.publish(relays, newEvent) let pubs = pool.publish(relays, newEvent)
pubs.on('ok', () => { await Promise.all(pubs)
// this may be called multiple times, once for every relay that accepts the event
// ...
})
let events = await pool.list(relays, [{kinds: [0, 1]}]) let events = await pool.list(relays, [{ kinds: [0, 1] }])
let event = await pool.get(relays, { let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'] ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
}) })
let relaysForEvent = pool.seenOn( let relaysForEvent = pool.seenOn('44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
// relaysForEvent will be an array of URLs from relays a given event was seen on // relaysForEvent will be an array of URLs from relays a given event was seen on
pool.close()
``` ```
### Parsing references (mentions) from a content using NIP-10 and NIP-27 ### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js ```js
import {parseReferences} from 'nostr-tools' import { parseReferences } from 'nostr-tools'
let references = parseReferences(event) let references = parseReferences(event)
let simpleAugmentedContent = event.content let simpleAugmentedContent = event.content
for (let i = 0; i < references.length; i++) { for (let i = 0; i < references.length; i++) {
let {text, profile, event, address} = references[i] let { text, profile, event, address } = references[i]
let augmentedReference = profile let augmentedReference = profile
? `<strong>@${profilesCache[profile.pubkey].name}</strong>` ? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
: event : event
@@ -195,7 +174,7 @@ for (let i = 0; i < references.length; i++) {
### Querying profile data from a NIP-05 address ### Querying profile data from a NIP-05 address
```js ```js
import {nip05} from 'nostr-tools' import { nip05 } from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com') let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey) console.log(profile.pubkey)
@@ -204,7 +183,7 @@ console.log(profile.relays)
// prints: [wss://relay.damus.io] // prints: [wss://relay.damus.io]
``` ```
To use this on Node.js you first must install `node-fetch@2` and call something like this: To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
```js ```js
nip05.useFetchImplementation(require('node-fetch')) nip05.useFetchImplementation(require('node-fetch'))
@@ -213,27 +192,24 @@ nip05.useFetchImplementation(require('node-fetch'))
### Encoding and decoding NIP-19 codes ### Encoding and decoding NIP-19 codes
```js ```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools' import { nip19, generatePrivateKey, getPublicKey } from 'nostr-tools'
let sk = generatePrivateKey() let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk) let nsec = nip19.nsecEncode(sk)
let {type, data} = nip19.decode(nsec) let { type, data } = nip19.decode(nsec)
assert(type === 'nsec') assert(type === 'nsec')
assert(data === sk) assert(data === sk)
let pk = getPublicKey(generatePrivateKey()) let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk) let npub = nip19.npubEncode(pk)
let {type, data} = nip19.decode(npub) let { type, data } = nip19.decode(npub)
assert(type === 'npub') assert(type === 'npub')
assert(data === pk) assert(data === pk)
let pk = getPublicKey(generatePrivateKey()) let pk = getPublicKey(generatePrivateKey())
let relays = [ let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
'wss://relay.nostr.example.mydomain.example.com', let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
'wss://nostr.banana.com' let { type, data } = nip19.decode(nprofile)
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
let {type, data} = nip19.decode(nprofile)
assert(type === 'nprofile') assert(type === 'nprofile')
assert(data.pubkey === pk) assert(data.pubkey === pk)
assert(data.relays.length === 2) assert(data.relays.length === 2)
@@ -242,7 +218,7 @@ assert(data.relays.length === 2)
### Encrypting and decrypting direct messages ### Encrypting and decrypting direct messages
```js ```js
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools' import { nip04, getPublicKey, generatePrivateKey } from 'nostr-tools'
// sender // sender
let sk1 = generatePrivateKey() let sk1 = generatePrivateKey()
@@ -261,13 +237,13 @@ let event = {
pubkey: pk1, pubkey: pk1,
tags: [['p', pk2]], tags: [['p', pk2]],
content: ciphertext, content: ciphertext,
...otherProperties ...otherProperties,
} }
sendEvent(event) sendEvent(event)
// on the receiver side // on the receiver side
sub.on('event', event => { sub.on('event', async event => {
let sender = event.pubkey let sender = event.pubkey
pk1 === sender pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content) let plaintext = await nip04.decrypt(sk2, pk1, event.content)
@@ -277,7 +253,7 @@ sub.on('event', event => {
### Performing and checking for delegation ### Performing and checking for delegation
```js ```js
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools' import { nip26, getPublicKey, generatePrivateKey } from 'nostr-tools'
// delegator // delegator
let sk1 = generatePrivateKey() let sk1 = generatePrivateKey()
@@ -292,7 +268,7 @@ let delegation = nip26.createDelegation(sk1, {
pubkey: pk2, pubkey: pk2,
kind: 1, kind: 1,
since: Math.round(Date.now() / 1000), since: Math.round(Date.now() / 1000),
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */ until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */,
}) })
// the delegatee uses the delegation when building an event // the delegatee uses the delegation when building an event
@@ -301,7 +277,7 @@ let event = {
kind: 1, kind: 1,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: 'hello from a delegated key', content: 'hello from a delegated key',
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]] tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
} }
// finally any receiver of this event can check for the presence of a valid delegation tag // finally any receiver of this event can check for the presence of a valid delegation tag

View File

@@ -6,7 +6,7 @@ const esbuild = require('esbuild')
let common = { let common = {
entryPoints: ['index.ts'], entryPoints: ['index.ts'],
bundle: true, bundle: true,
sourcemap: 'external' sourcemap: 'external',
} }
esbuild esbuild
@@ -14,10 +14,10 @@ esbuild
...common, ...common,
outfile: 'lib/esm/nostr.mjs', outfile: 'lib/esm/nostr.mjs',
format: 'esm', format: 'esm',
packages: 'external' packages: 'external',
}) })
.then(() => { .then(() => {
const packageJson = JSON.stringify({type: 'module'}) const packageJson = JSON.stringify({ type: 'module' })
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8') fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.') console.log('esm build success.')
@@ -28,7 +28,7 @@ esbuild
...common, ...common,
outfile: 'lib/nostr.cjs.js', outfile: 'lib/nostr.cjs.js',
format: 'cjs', format: 'cjs',
packages: 'external' packages: 'external',
}) })
.then(() => console.log('cjs build success.')) .then(() => console.log('cjs build success.'))
@@ -41,7 +41,7 @@ esbuild
define: { define: {
window: 'self', window: 'self',
global: 'self', global: 'self',
process: '{"env": {}}' process: '{"env": {}}',
} },
}) })
.then(() => console.log('standalone build success.')) .then(() => console.log('standalone build success.'))

View File

@@ -7,8 +7,9 @@ import {
verifySignature, verifySignature,
getSignature, getSignature,
Kind, Kind,
verifiedSymbol,
} from './event.ts' } from './event.ts'
import {getPublicKey} from './keys.ts' import { getPublicKey } from './keys.ts'
describe('Event', () => { describe('Event', () => {
describe('getBlankEvent', () => { describe('getBlankEvent', () => {
@@ -17,7 +18,7 @@ describe('Event', () => {
kind: 255, kind: 255,
content: '', content: '',
tags: [], tags: [],
created_at: 0 created_at: 0,
}) })
}) })
@@ -26,22 +27,21 @@ describe('Event', () => {
kind: 1, kind: 1,
content: '', content: '',
tags: [], tags: [],
created_at: 0 created_at: 0,
}) })
}) })
}) })
describe('finishEvent', () => { describe('finishEvent', () => {
it('should create a signed event from a template', () => { it('should create a signed event from a template', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const template = { const template = {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115 created_at: 1617932115,
} }
const event = finishEvent(template, privateKey) const event = finishEvent(template, privateKey)
@@ -58,8 +58,7 @@ describe('Event', () => {
describe('serializeEvent', () => { describe('serializeEvent', () => {
it('should serialize a valid event object', () => { it('should serialize a valid event object', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const unsignedEvent = { const unsignedEvent = {
@@ -67,7 +66,7 @@ describe('Event', () => {
created_at: 1617932115, created_at: 1617932115,
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
content: 'Hello, world!' content: 'Hello, world!',
} }
const serializedEvent = serializeEvent(unsignedEvent) const serializedEvent = serializeEvent(unsignedEvent)
@@ -79,21 +78,20 @@ describe('Event', () => {
unsignedEvent.created_at, unsignedEvent.created_at,
unsignedEvent.kind, unsignedEvent.kind,
unsignedEvent.tags, unsignedEvent.tags,
unsignedEvent.content unsignedEvent.content,
]) ]),
) )
}) })
it('should throw an error for an invalid event object', () => { it('should throw an error for an invalid event object', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const invalidEvent = { const invalidEvent = {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
created_at: 1617932115, created_at: 1617932115,
pubkey: publicKey // missing content pubkey: publicKey, // missing content
} }
expect(() => { expect(() => {
@@ -105,8 +103,7 @@ describe('Event', () => {
describe('getEventHash', () => { describe('getEventHash', () => {
it('should return the correct event hash', () => { it('should return the correct event hash', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const unsignedEvent = { const unsignedEvent = {
@@ -114,7 +111,7 @@ describe('Event', () => {
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115, created_at: 1617932115,
pubkey: publicKey pubkey: publicKey,
} }
const eventHash = getEventHash(unsignedEvent) const eventHash = getEventHash(unsignedEvent)
@@ -126,8 +123,7 @@ describe('Event', () => {
describe('validateEvent', () => { describe('validateEvent', () => {
it('should return true for a valid event object', () => { it('should return true for a valid event object', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const unsignedEvent = { const unsignedEvent = {
@@ -135,7 +131,7 @@ describe('Event', () => {
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115, created_at: 1617932115,
pubkey: publicKey pubkey: publicKey,
} }
const isValid = validateEvent(unsignedEvent) const isValid = validateEvent(unsignedEvent)
@@ -155,7 +151,7 @@ describe('Event', () => {
const invalidEvent = { const invalidEvent = {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
created_at: 1617932115 // missing content and pubkey created_at: 1617932115, // missing content and pubkey
} }
const isValid = validateEvent(invalidEvent) const isValid = validateEvent(invalidEvent)
@@ -172,15 +168,14 @@ describe('Event', () => {
}) })
it('should return false for an object with invalid properties', () => { it('should return false for an object with invalid properties', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const invalidEvent = { const invalidEvent = {
kind: 1, kind: 1,
tags: [], tags: [],
created_at: '1617932115', // should be a number created_at: '1617932115', // should be a number
pubkey: publicKey pubkey: publicKey,
} }
const isValid = validateEvent(invalidEvent) const isValid = validateEvent(invalidEvent)
@@ -194,7 +189,7 @@ describe('Event', () => {
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115, created_at: 1617932115,
pubkey: 'invalid_pubkey' pubkey: 'invalid_pubkey',
} }
const isValid = validateEvent(invalidEvent) const isValid = validateEvent(invalidEvent)
@@ -203,8 +198,7 @@ describe('Event', () => {
}) })
it('should return false for an object with invalid tags', () => { it('should return false for an object with invalid tags', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const invalidEvent = { const invalidEvent = {
@@ -212,7 +206,7 @@ describe('Event', () => {
tags: {}, // should be an array tags: {}, // should be an array
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115, created_at: 1617932115,
pubkey: publicKey pubkey: publicKey,
} }
const isValid = validateEvent(invalidEvent) const isValid = validateEvent(invalidEvent)
@@ -223,17 +217,16 @@ describe('Event', () => {
describe('verifySignature', () => { describe('verifySignature', () => {
it('should return true for a valid event signature', () => { it('should return true for a valid event signature', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent( const event = finishEvent(
{ {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115 created_at: 1617932115,
}, },
privateKey privateKey,
) )
const isValid = verifySignature(event) const isValid = verifySignature(event)
@@ -242,21 +235,20 @@ describe('Event', () => {
}) })
it('should return false for an invalid event signature', () => { it('should return false for an invalid event signature', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent( const { [verifiedSymbol]: _, ...event } = finishEvent(
{ {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115 created_at: 1617932115,
}, },
privateKey privateKey,
) )
// tamper with the signature // tamper with the signature
event.sig = event.sig.replace(/0/g, '1') event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifySignature(event) const isValid = verifySignature(event)
@@ -264,37 +256,55 @@ describe('Event', () => {
}) })
it('should return false when verifying an event with a different private key', () => { it('should return false when verifying an event with a different private key', () => {
const privateKey1 = const privateKey1 = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 = const privateKey2 = '5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2) const publicKey2 = getPublicKey(privateKey2)
const event = finishEvent( const { [verifiedSymbol]: _, ...event } = finishEvent(
{ {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115 created_at: 1617932115,
}, },
privateKey1 privateKey1,
) )
// verify with different private key // verify with different private key
const isValid = verifySignature({ const isValid = verifySignature({
...event, ...event,
pubkey: publicKey2 pubkey: publicKey2,
}) })
expect(isValid).toEqual(false) expect(isValid).toEqual(false)
}) })
it('should return false for an invalid event id', () => {
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const { [verifiedSymbol]: _, ...event } = finishEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
}) })
describe('getSignature', () => { describe('getSignature', () => {
it('should produce the correct signature for an event object', () => { it('should produce the correct signature for an event object', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const unsignedEvent = { const unsignedEvent = {
@@ -302,16 +312,16 @@ describe('Event', () => {
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115, created_at: 1617932115,
pubkey: publicKey pubkey: publicKey,
} }
const sig = getSignature(unsignedEvent, privateKey) const sig = getSignature(unsignedEvent, privateKey)
// verify the signature // verify the signature
// @ts-expect-error
const isValid = verifySignature({ const isValid = verifySignature({
...unsignedEvent, ...unsignedEvent,
sig id: getEventHash(unsignedEvent),
sig,
}) })
expect(typeof sig).toEqual('string') expect(typeof sig).toEqual('string')
@@ -320,19 +330,17 @@ describe('Event', () => {
}) })
it('should not sign an event with different private key', () => { it('should not sign an event with different private key', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
const wrongPrivateKey = const wrongPrivateKey = 'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = { const unsignedEvent = {
kind: Kind.Text, kind: Kind.Text,
tags: [], tags: [],
content: 'Hello, world!', content: 'Hello, world!',
created_at: 1617932115, created_at: 1617932115,
pubkey: publicKey pubkey: publicKey,
} }
const sig = getSignature(unsignedEvent, wrongPrivateKey) const sig = getSignature(unsignedEvent, wrongPrivateKey)
@@ -341,7 +349,7 @@ describe('Event', () => {
// @ts-expect-error // @ts-expect-error
const isValid = verifySignature({ const isValid = verifySignature({
...unsignedEvent, ...unsignedEvent,
sig sig,
}) })
expect(typeof sig).toEqual('string') expect(typeof sig).toEqual('string')

View File

@@ -1,10 +1,14 @@
import {schnorr} from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import {sha256} from '@noble/hashes/sha256' import { sha256 } from '@noble/hashes/sha256'
import {bytesToHex} from '@noble/hashes/utils' import { bytesToHex } from '@noble/hashes/utils'
import {getPublicKey} from './keys.ts' import { getPublicKey } from './keys.ts'
import {utf8Encoder} from './utils.ts' import { utf8Encoder } from './utils.ts'
/** Designates a verified event signature. */
export const verifiedSymbol = Symbol('verified')
/** @deprecated Use numbers instead. */
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
export enum Kind { export enum Kind {
Metadata = 0, Metadata = 0,
@@ -30,23 +34,30 @@ export enum Kind {
HttpAuth = 27235, HttpAuth = 27235,
ProfileBadge = 30008, ProfileBadge = 30008,
BadgeDefinition = 30009, BadgeDefinition = 30009,
Article = 30023 Article = 30023,
FileMetadata = 1063,
} }
export type EventTemplate<K extends number = Kind> = { export interface Event<K extends number = number> {
kind: K kind: K
tags: string[][] tags: string[][]
content: string content: string
created_at: number created_at: number
}
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & {
pubkey: string pubkey: string
}
export type Event<K extends number = Kind> = UnsignedEvent<K> & {
id: string id: string
sig: string sig: string
[verifiedSymbol]?: boolean
}
export type EventTemplate<K extends number = number> = Pick<Event<K>, 'kind' | 'tags' | 'content' | 'created_at'>
export type UnsignedEvent<K extends number = number> = Pick<
Event<K>,
'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'
>
/** An event whose signature has been verified. */
export interface VerifiedEvent<K extends number = number> extends Event<K> {
[verifiedSymbol]: true
} }
export function getBlankEvent(): EventTemplate<Kind.Blank> export function getBlankEvent(): EventTemplate<Kind.Blank>
@@ -56,33 +67,23 @@ export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
kind, kind,
content: '', content: '',
tags: [], tags: [],
created_at: 0 created_at: 0,
} }
} }
export function finishEvent<K extends number = Kind>( export function finishEvent<K extends number = number>(t: EventTemplate<K>, privateKey: string): VerifiedEvent<K> {
t: EventTemplate<K>, const event = t as VerifiedEvent<K>
privateKey: string
): Event<K> {
let event = t as Event<K>
event.pubkey = getPublicKey(privateKey) event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = getSignature(event, privateKey) event.sig = getSignature(event, privateKey)
event[verifiedSymbol] = true
return event return event
} }
export function serializeEvent(evt: UnsignedEvent<number>): string { export function serializeEvent(evt: UnsignedEvent<number>): string {
if (!validateEvent(evt)) if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([ return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
} }
export function getEventHash(event: UnsignedEvent<number>): string { export function getEventHash(event: UnsignedEvent<number>): string {
@@ -90,8 +91,7 @@ export function getEventHash(event: UnsignedEvent<number>): string {
return bytesToHex(eventHash) return bytesToHex(eventHash)
} }
const isRecord = (obj: unknown): obj is Record<string, unknown> => const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> { export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
if (!isRecord(event)) return false if (!isRecord(event)) return false
@@ -113,26 +113,31 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
return true return true
} }
export function verifySignature(event: Event<number>): boolean { /** Verify the event's signature. This function mutates the event with a `verified` symbol, making it idempotent. */
export function verifySignature<K extends number>(event: Event<K>): event is VerifiedEvent<K> {
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
const hash = getEventHash(event)
if (hash !== event.id) {
return (event[verifiedSymbol] = false)
}
try { try {
return schnorr.verify(event.sig, getEventHash(event), event.pubkey) return (event[verifiedSymbol] = schnorr.verify(event.sig, hash, event.pubkey))
} catch (err) { } catch (err) {
return false return (event[verifiedSymbol] = false)
} }
} }
/** @deprecated Use `getSignature` instead. */ /** @deprecated Use `getSignature` instead. */
export function signEvent(event: UnsignedEvent<number>, key: string): string { export function signEvent(event: UnsignedEvent<number>, key: string): string {
console.warn( console.warn(
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.' 'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.',
) )
return getSignature(event, key) return getSignature(event, key)
} }
/** Calculate the signature for an event. */ /** Calculate the signature for an event. */
export function getSignature( export function getSignature(event: UnsignedEvent<number>, key: string): string {
event: UnsignedEvent<number>,
key: string
): string {
return bytesToHex(schnorr.sign(getEventHash(event), key)) return bytesToHex(schnorr.sign(getEventHash(event), key))
} }

View File

@@ -1,18 +1,18 @@
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts' import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
test('match id', () => { test('match id', () => {
expect( expect(
matchEventId( matchEventId(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146' 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
) ),
).toBeTruthy() ).toBeTruthy()
expect( expect(
matchEventId( matchEventId(
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`, `["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146' 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
) ),
).toBeFalsy() ).toBeFalsy()
}) })
@@ -20,15 +20,15 @@ test('match kind', () => {
expect( expect(
matchEventKind( matchEventKind(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
1 1,
) ),
).toBeTruthy() ).toBeTruthy()
expect( expect(
matchEventKind( matchEventKind(
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`, `["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
12720 12720,
) ),
).toBeTruthy() ).toBeTruthy()
}) })
@@ -36,12 +36,8 @@ test('match subscription id', () => {
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('') expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_') expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname') expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual( expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
'kasjbdjkav'
)
expect( expect(
getSubscriptionId( getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
)
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH') ).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
}) })

View File

@@ -1,5 +1,5 @@
import {matchFilter, matchFilters, mergeFilters} from './filter.ts' import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
import {buildEvent} from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
describe('Filter', () => { describe('Filter', () => {
describe('matchFilter', () => { describe('matchFilter', () => {
@@ -10,7 +10,7 @@ describe('Filter', () => {
authors: ['abc'], authors: ['abc'],
since: 100, since: 100,
until: 200, until: 200,
'#tag': ['value'] '#tag': ['value'],
} }
const event = buildEvent({ const event = buildEvent({
@@ -18,7 +18,7 @@ describe('Filter', () => {
kind: 1, kind: 1,
pubkey: 'abc', pubkey: 'abc',
created_at: 150, created_at: 150,
tags: [['tag', 'value']] tags: [['tag', 'value']],
}) })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -27,9 +27,9 @@ describe('Filter', () => {
}) })
it('should return false when the event id is not in the filter', () => { it('should return false when the event id is not in the filter', () => {
const filter = {ids: ['123', '456']} const filter = { ids: ['123', '456'] }
const event = buildEvent({id: '789'}) const event = buildEvent({ id: '789' })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -37,9 +37,9 @@ describe('Filter', () => {
}) })
it('should return true when the event id starts with a prefix', () => { it('should return true when the event id starts with a prefix', () => {
const filter = {ids: ['22', '00']} const filter = { ids: ['22', '00'] }
const event = buildEvent({id: '001'}) const event = buildEvent({ id: '001' })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -47,9 +47,9 @@ describe('Filter', () => {
}) })
it('should return false when the event kind is not in the filter', () => { it('should return false when the event kind is not in the filter', () => {
const filter = {kinds: [1, 2, 3]} const filter = { kinds: [1, 2, 3] }
const event = buildEvent({kind: 4}) const event = buildEvent({ kind: 4 })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -57,9 +57,9 @@ describe('Filter', () => {
}) })
it('should return false when the event author is not in the filter', () => { it('should return false when the event author is not in the filter', () => {
const filter = {authors: ['abc', 'def']} const filter = { authors: ['abc', 'def'] }
const event = buildEvent({pubkey: 'ghi'}) const event = buildEvent({ pubkey: 'ghi' })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -67,9 +67,9 @@ describe('Filter', () => {
}) })
it('should return false when a tag is not present in the event', () => { it('should return false when a tag is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']} const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({tags: [['not_tag', 'value1']]}) const event = buildEvent({ tags: [['not_tag', 'value1']] })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -77,9 +77,9 @@ describe('Filter', () => {
}) })
it('should return false when a tag value is not present in the event', () => { it('should return false when a tag value is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']} const filter = { '#tag': ['value1', 'value2'] }
const event = buildEvent({tags: [['tag', 'value3']]}) const event = buildEvent({ tags: [['tag', 'value3']] })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -87,7 +87,7 @@ describe('Filter', () => {
}) })
it('should return true when filter has tags that is present in the event', () => { it('should return true when filter has tags that is present in the event', () => {
const filter = {'#tag1': ['foo']} const filter = { '#tag1': ['foo'] }
const event = buildEvent({ const event = buildEvent({
id: '123', id: '123',
@@ -96,8 +96,8 @@ describe('Filter', () => {
created_at: 150, created_at: 150,
tags: [ tags: [
['tag1', 'foo'], ['tag1', 'foo'],
['tag2', 'bar'] ['tag2', 'bar'],
] ],
}) })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -106,9 +106,9 @@ describe('Filter', () => {
}) })
it('should return false when the event is before the filter since value', () => { it('should return false when the event is before the filter since value', () => {
const filter = {since: 100} const filter = { since: 100 }
const event = buildEvent({created_at: 50}) const event = buildEvent({ created_at: 50 })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -116,9 +116,9 @@ describe('Filter', () => {
}) })
it('should return true when the timestamp of event is equal to the filter since value', () => { it('should return true when the timestamp of event is equal to the filter since value', () => {
const filter = {since: 100} const filter = { since: 100 }
const event = buildEvent({created_at: 100}) const event = buildEvent({ created_at: 100 })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -126,9 +126,9 @@ describe('Filter', () => {
}) })
it('should return false when the event is after the filter until value', () => { it('should return false when the event is after the filter until value', () => {
const filter = {until: 100} const filter = { until: 100 }
const event = buildEvent({created_at: 150}) const event = buildEvent({ created_at: 150 })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -136,9 +136,9 @@ describe('Filter', () => {
}) })
it('should return true when the timestamp of event is equal to the filter until value', () => { it('should return true when the timestamp of event is equal to the filter until value', () => {
const filter = {until: 100} const filter = { until: 100 }
const event = buildEvent({created_at: 100}) const event = buildEvent({ created_at: 100 })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -149,12 +149,12 @@ describe('Filter', () => {
describe('matchFilters', () => { describe('matchFilters', () => {
it('should return true when at least one filter matches the event', () => { it('should return true when at least one filter matches the event', () => {
const filters = [ const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']}, { ids: ['123'], kinds: [1], authors: ['abc'] },
{ids: ['456'], kinds: [2], authors: ['def']}, { ids: ['456'], kinds: [2], authors: ['def'] },
{ids: ['789'], kinds: [3], authors: ['ghi']} { ids: ['789'], kinds: [3], authors: ['ghi'] },
] ]
const event = buildEvent({id: '789', kind: 3, pubkey: 'ghi'}) const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -163,12 +163,12 @@ describe('Filter', () => {
it('should return true when at least one prefix matches the event', () => { it('should return true when at least one prefix matches the event', () => {
const filters = [ const filters = [
{ids: ['1'], kinds: [1], authors: ['a']}, { ids: ['1'], kinds: [1], authors: ['a'] },
{ids: ['4'], kinds: [2], authors: ['d']}, { ids: ['4'], kinds: [2], authors: ['d'] },
{ids: ['9'], kinds: [3], authors: ['g']} { ids: ['9'], kinds: [3], authors: ['g'] },
] ]
const event = buildEvent({id: '987', kind: 3, pubkey: 'ghi'}) const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -177,16 +177,16 @@ describe('Filter', () => {
it('should return true when event matches one or more filters and some have limit set', () => { it('should return true when event matches one or more filters and some have limit set', () => {
const filters = [ const filters = [
{ids: ['123'], limit: 1}, { ids: ['123'], limit: 1 },
{kinds: [1], limit: 2}, { kinds: [1], limit: 2 },
{authors: ['abc'], limit: 3} { authors: ['abc'], limit: 3 },
] ]
const event = buildEvent({ const event = buildEvent({
id: '123', id: '123',
kind: 1, kind: 1,
pubkey: 'abc', pubkey: 'abc',
created_at: 150 created_at: 150,
}) })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -196,12 +196,12 @@ describe('Filter', () => {
it('should return false when no filters match the event', () => { it('should return false when no filters match the event', () => {
const filters = [ const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']}, { ids: ['123'], kinds: [1], authors: ['abc'] },
{ids: ['456'], kinds: [2], authors: ['def']}, { ids: ['456'], kinds: [2], authors: ['def'] },
{ids: ['789'], kinds: [3], authors: ['ghi']} { ids: ['789'], kinds: [3], authors: ['ghi'] },
] ]
const event = buildEvent({id: '100', kind: 4, pubkey: 'jkl'}) const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -210,15 +210,15 @@ describe('Filter', () => {
it('should return false when event matches none of the filters and some have limit set', () => { it('should return false when event matches none of the filters and some have limit set', () => {
const filters = [ const filters = [
{ids: ['123'], limit: 1}, { ids: ['123'], limit: 1 },
{kinds: [1], limit: 2}, { kinds: [1], limit: 2 },
{authors: ['abc'], limit: 3} { authors: ['abc'], limit: 3 },
] ]
const event = buildEvent({ const event = buildEvent({
id: '456', id: '456',
kind: 2, kind: 2,
pubkey: 'def', pubkey: 'def',
created_at: 200 created_at: 200,
}) })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -229,20 +229,15 @@ describe('Filter', () => {
describe('mergeFilters', () => { describe('mergeFilters', () => {
it('should merge filters', () => { it('should merge filters', () => {
expect( expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
mergeFilters( ids: ['a', 'b', 'c'],
{ids: ['a', 'b'], limit: 3}, limit: 3,
{authors: ['x'], ids: ['b', 'c']} authors: ['x'],
) })
).toEqual({ids: ['a', 'b', 'c'], limit: 3, authors: ['x']})
expect( expect(
mergeFilters( mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
{kinds: [1], since: 15, until: 30}, ).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
{since: 10, kinds: [7], until: 15},
{kinds: [9, 10]}
)
).toEqual({kinds: [1, 7, 9, 10], since: 10, until: 30})
}) })
}) })
}) })

View File

@@ -1,6 +1,6 @@
import {Event, type Kind} from './event.ts' import { Event } from './event.ts'
export type Filter<K extends number = Kind> = { export type Filter<K extends number = number> = {
ids?: string[] ids?: string[]
kinds?: K[] kinds?: K[]
authors?: string[] authors?: string[]
@@ -8,13 +8,10 @@ export type Filter<K extends number = Kind> = {
until?: number until?: number
limit?: number limit?: number
search?: string search?: string
[key: `#${string}`]: string[] [key: `#${string}`]: string[] | undefined
} }
export function matchFilter( export function matchFilter(filter: Filter<number>, event: Event<number>): boolean {
filter: Filter<number>,
event: Event<number>
): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) { if (filter.ids && filter.ids.indexOf(event.id) === -1) {
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) { if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false return false
@@ -31,13 +28,7 @@ export function matchFilter(
if (f[0] === '#') { if (f[0] === '#') {
let tagName = f.slice(1) let tagName = f.slice(1)
let values = filter[`#${tagName}`] let values = filter[`#${tagName}`]
if ( if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
values &&
!event.tags.find(
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
)
)
return false
} }
} }
@@ -47,10 +38,7 @@ export function matchFilter(
return true return true
} }
export function matchFilters( export function matchFilters(filters: Filter<number>[], event: Event<number>): boolean {
filters: Filter<number>[],
event: Event<number>
): boolean {
for (let i = 0; i < filters.length; i++) { for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true if (matchFilter(filters[i], event)) return true
} }
@@ -62,12 +50,7 @@ export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
for (let i = 0; i < filters.length; i++) { for (let i = 0; i < filters.length; i++) {
let filter = filters[i] let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => { Object.entries(filter).forEach(([property, values]) => {
if ( if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
property === 'kinds' ||
property === 'ids' ||
property === 'authors' ||
property[0] === '#'
) {
// @ts-ignore // @ts-ignore
result[property] = result[property] || [] result[property] = result[property] || []
// @ts-ignore // @ts-ignore
@@ -80,12 +63,9 @@ export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
} }
}) })
if (filter.limit && (!result.limit || filter.limit > result.limit)) if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
result.limit = filter.limit if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
if (filter.until && (!result.until || filter.until > result.until)) if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
result.until = filter.until
if (filter.since && (!result.since || filter.since < result.since))
result.since = filter.since
} }
return result return result

View File

@@ -16,8 +16,10 @@ export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts' export * as nip25 from './nip25.ts'
export * as nip26 from './nip26.ts' export * as nip26 from './nip26.ts'
export * as nip27 from './nip27.ts' export * as nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip39 from './nip39.ts' export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts' export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts'
export * as nip57 from './nip57.ts' export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts' export * as nip98 from './nip98.ts'

View File

@@ -20,4 +20,9 @@ publish: build emit-types
npm publish npm publish
format: format:
prettier --plugin-search-dir . --write . eslint --ext .ts --fix .
prettier --write .
lint:
eslint --ext .ts .
prettier --check .

View File

@@ -1,4 +1,4 @@
import {generatePrivateKey, getPublicKey} from './keys.ts' import { generatePrivateKey, getPublicKey } from './keys.ts'
test('private key generation', () => { test('private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/) expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)

View File

@@ -1,5 +1,5 @@
import {schnorr} from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import {bytesToHex} from '@noble/hashes/utils' import { bytesToHex } from '@noble/hashes/utils'
export function generatePrivateKey(): string { export function generatePrivateKey(): string {
return bytesToHex(schnorr.utils.randomPrivateKey()) return bytesToHex(schnorr.utils.randomPrivateKey())

20
kinds.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { classifyKind } from './kinds.ts'
test('kind classification', () => {
expect(classifyKind(1)).toBe('regular')
expect(classifyKind(5)).toBe('regular')
expect(classifyKind(6)).toBe('regular')
expect(classifyKind(7)).toBe('regular')
expect(classifyKind(1000)).toBe('regular')
expect(classifyKind(9999)).toBe('regular')
expect(classifyKind(0)).toBe('replaceable')
expect(classifyKind(3)).toBe('replaceable')
expect(classifyKind(10000)).toBe('replaceable')
expect(classifyKind(19999)).toBe('replaceable')
expect(classifyKind(20000)).toBe('ephemeral')
expect(classifyKind(29999)).toBe('ephemeral')
expect(classifyKind(30000)).toBe('parameterized')
expect(classifyKind(39999)).toBe('parameterized')
expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown')
})

40
kinds.ts Normal file
View File

@@ -0,0 +1,40 @@
/** Events are **regular**, which means they're all expected to be stored by relays. */
function isRegularKind(kind: number) {
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. */
function isReplaceableKind(kind: number) {
return (10000 <= kind && kind < 20000) || [0, 3].includes(kind)
}
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
function isEphemeralKind(kind: number) {
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. */
function isParameterizedReplaceableKind(kind: number) {
return 30000 <= kind && kind < 40000
}
/** Classification of the event kind. */
type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
/** Determine the classification of this kind of event if known, or `unknown`. */
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'
return 'unknown'
}
export {
classifyKind,
isEphemeralKind,
isParameterizedReplaceableKind,
isRegularKind,
isReplaceableKind,
type KindClassification,
}

View File

@@ -1,7 +1,7 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import {encrypt, decrypt} from './nip04.ts' import { encrypt, decrypt } from './nip04.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts' import { getPublicKey, generatePrivateKey } from './keys.ts'
// @ts-ignore // @ts-ignore
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
@@ -13,7 +13,5 @@ test('encrypt and decrypt message', async () => {
let pk1 = getPublicKey(sk1) let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2) let pk2 = getPublicKey(sk2)
expect( expect(await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))).toEqual('hello')
await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
}) })

View File

@@ -1,8 +1,8 @@
import {randomBytes} from '@noble/hashes/utils' import { randomBytes } from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1' import { secp256k1 } from '@noble/curves/secp256k1'
import {base64} from '@scure/base' import { base64 } from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts' import { utf8Decoder, utf8Encoder } from './utils.ts'
// @ts-ignore // @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) { if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
@@ -10,58 +10,30 @@ if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
crypto.subtle = crypto.webcrypto.subtle crypto.subtle = crypto.webcrypto.subtle
} }
export async function encrypt( export async function encrypt(privkey: string, pubkey: string, text: string): Promise<string> {
privkey: string,
pubkey: string,
text: string
): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key) const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16)) let iv = Uint8Array.from(randomBytes(16))
let plaintext = utf8Encoder.encode(text) let plaintext = utf8Encoder.encode(text)
let cryptoKey = await crypto.subtle.importKey( let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
'raw', let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
normalizedKey,
{name: 'AES-CBC'},
false,
['encrypt']
)
let ciphertext = await crypto.subtle.encrypt(
{name: 'AES-CBC', iv},
cryptoKey,
plaintext
)
let ctb64 = base64.encode(new Uint8Array(ciphertext)) let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer)) let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}` return `${ctb64}?iv=${ivb64}`
} }
export async function decrypt( export async function decrypt(privkey: string, pubkey: string, data: string): Promise<string> {
privkey: string,
pubkey: string,
data: string
): Promise<string> {
let [ctb64, ivb64] = data.split('?iv=') let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key) let normalizedKey = getNormalizedX(key)
let cryptoKey = await crypto.subtle.importKey( let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['decrypt']
)
let ciphertext = base64.decode(ctb64) let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64) let iv = base64.decode(ivb64)
let plaintext = await crypto.subtle.decrypt( let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext) let text = utf8Decoder.decode(plaintext)
return text return text

View File

@@ -1,26 +1,20 @@
import fetch from 'node-fetch' import fetch from 'node-fetch'
import {useFetchImplementation, queryProfile} from './nip05.ts' import { useFetchImplementation, queryProfile } from './nip05.ts'
test('fetch nip05 profiles', async () => { test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch) useFetchImplementation(fetch)
let p1 = await queryProfile('jb55.com') let p1 = await queryProfile('jb55.com')
expect(p1!.pubkey).toEqual( expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1!.relays).toEqual(['wss://relay.damus.io']) expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com') let p2 = await queryProfile('jb55@jb55.com')
expect(p2!.pubkey).toEqual( expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2!.relays).toEqual(['wss://relay.damus.io']) expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com') let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual( expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(p3!.relays).toEqual([ expect(p3!.relays).toEqual([
'wss://relay.nostr.bg', 'wss://relay.nostr.bg',
'wss://nos.lol', 'wss://nos.lol',

View File

@@ -1,4 +1,4 @@
import {ProfilePointer} from './nip19.ts' import { ProfilePointer } from './nip19.ts'
/** /**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise. * NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
@@ -19,14 +19,9 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export async function searchDomain( export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
domain: string,
query = ''
): Promise<{[name: string]: string}> {
try { try {
let res = await ( let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
).json()
return res.names return res.names
} catch (_) { } catch (_) {

View File

@@ -1,18 +1,14 @@
import {privateKeyFromSeedWords} from './nip06.ts' import { privateKeyFromSeedWords } from './nip06.ts'
test('generate private key from a mnemonic', async () => { test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic) const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual( expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
)
}) })
test('generate private key from a mnemonic and passphrase', async () => { 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 mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase) const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual( expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
)
}) })

View File

@@ -1,16 +1,9 @@
import {bytesToHex} from '@noble/hashes/utils' import { bytesToHex } from '@noble/hashes/utils'
import {wordlist} from '@scure/bip39/wordlists/english' import { wordlist } from '@scure/bip39/wordlists/english'
import { import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
generateMnemonic, import { HDKey } from '@scure/bip32'
mnemonicToSeedSync,
validateMnemonic
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
export function privateKeyFromSeedWords( export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
mnemonic: string,
passphrase?: string
): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)) let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key') if (!privateKey) throw new Error('could not derive private key')

View File

@@ -1,252 +1,173 @@
import {parse} from './nip10.ts' import { parse } from './nip10.ts'
describe('parse NIP10-referenced events', () => { describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => { test('legacy + a lot of events', () => {
let event = { let event = {
tags: [ tags: [
[ ['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
'e', ['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c' ['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
], ['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
[ ['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
'e', ['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631' ['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
], ['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
[ ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
'e', ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64' ],
],
[
'e',
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
],
[
'e',
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
],
[
'e',
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
],
[
'e',
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [] relays: [],
}, },
{ {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [] relays: [],
}, },
{ {
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4', id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
relays: [] relays: [],
}, },
{ {
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976', id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
relays: [] relays: [],
}, },
{ {
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051', id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
relays: [] relays: [],
} },
], ],
profiles: [ profiles: [
{ {
pubkey: pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7', relays: [],
relays: []
}, },
{ {
pubkey: pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', relays: [],
relays: []
}, },
{ {
pubkey: pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', relays: [],
relays: [] },
}
], ],
reply: { reply: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d', id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [] relays: [],
}, },
root: { root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [] relays: [],
} },
}) })
}) })
test('legacy + 3 events', () => { test('legacy + 3 events', () => {
let event = { let event = {
tags: [ tags: [
[ ['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
'e', ['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c' ['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
], ['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
[ ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
'e', ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631' ],
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [] relays: [],
} },
], ],
profiles: [ profiles: [
{ {
pubkey: pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7', relays: [],
relays: []
}, },
{ {
pubkey: pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', relays: [],
relays: []
}, },
{ {
pubkey: pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', relays: [],
relays: [] },
}
], ],
reply: { reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [] relays: [],
}, },
root: { root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [] relays: [],
} },
}) })
}) })
test('legacy + 2 events', () => { test('legacy + 2 events', () => {
let event = { let event = {
tags: [ tags: [
[ ['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
'e', ['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c' ['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
], ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
[ ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
'e', ],
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
pubkey: pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7', relays: [],
relays: []
}, },
{ {
pubkey: pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', relays: [],
relays: []
}, },
{ {
pubkey: pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', relays: [],
relays: [] },
}
], ],
reply: { reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [] relays: [],
}, },
root: { root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [] relays: [],
} },
}) })
}) })
test('legacy + 1 event', () => { test('legacy + 1 event', () => {
let event = { let event = {
tags: [ tags: [
[ ['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
'e', ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590' ],
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
]
]
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
pubkey: pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', relays: [],
relays: [] },
}
], ],
reply: undefined, reply: undefined,
root: { root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590', id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: [] relays: [],
} },
}) })
}) })
@@ -257,95 +178,55 @@ describe('parse NIP10-referenced events', () => {
test('recommended + 1 event', () => { test('recommended + 1 event', () => {
let event = { let event = {
tags: [ tags: [
[ ['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
'p', ['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', ['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
'wss://relay.mostr.pub' ['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
], ['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
[ ['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
'p', ['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', ['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
'wss://relay.mostr.pub' ['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
], ],
[
'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'
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
]
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
pubkey: pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub']
}, },
{ {
pubkey: pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub']
}, },
{ {
pubkey: pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub']
}, },
{ {
pubkey: pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub']
}, },
{ {
pubkey: pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub']
}, },
{ {
pubkey: pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub']
}, },
{ {
pubkey: pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', relays: ['wss://relay.mostr.pub'],
relays: ['wss://relay.mostr.pub'] },
}
], ],
reply: { reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'] relays: ['wss://relay.mostr.pub'],
}, },
root: undefined root: undefined,
}) })
}) })
}) })

View File

@@ -1,5 +1,5 @@
import type {Event} from './event.ts' import type { Event } from './event.ts'
import type {EventPointer, ProfilePointer} from './nip19.ts' import type { EventPointer, ProfilePointer } from './nip19.ts'
export type NIP10Result = { export type NIP10Result = {
/** /**
@@ -28,7 +28,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
reply: undefined, reply: undefined,
root: undefined, root: undefined,
mentions: [], mentions: [],
profiles: [] profiles: [],
} }
const eTags: string[][] = [] const eTags: string[][] = []
@@ -41,7 +41,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
if (tag[0] === 'p' && tag[1]) { if (tag[0] === 'p' && tag[1]) {
result.profiles.push({ result.profiles.push({
pubkey: tag[1], pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [] relays: tag[2] ? [tag[2]] : [],
}) })
} }
} }
@@ -49,16 +49,11 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) { for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex] const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [ const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
string,
string,
undefined | string,
undefined | string
]
const eventPointer: EventPointer = { const eventPointer: EventPointer = {
id: eTagEventId, id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [] relays: eTagRelayUrl ? [eTagRelayUrl] : [],
} }
const isFirstETag = eTagIndex === 0 const isFirstETag = eTagIndex === 0

View File

@@ -1,4 +1,4 @@
import {getPow} from './nip13.ts' import { getPow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => { test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358' const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'

View File

@@ -1,42 +1,16 @@
import {hexToBytes} from '@noble/hashes/utils'
/** Get POW difficulty from a Nostr hex ID. */ /** Get POW difficulty from a Nostr hex ID. */
export function getPow(id: string): number { export function getPow(hex: string): number {
return getLeadingZeroBits(hexToBytes(id)) let count = 0
}
/** for (let i = 0; i < hex.length; i++) {
* Get number of leading 0 bits. Adapted from nostream. const nibble = parseInt(hex[i], 16)
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts if (nibble === 0) {
*/ count += 4
function getLeadingZeroBits(hash: Uint8Array): number { } else {
let total: number, i: number, bits: number count += Math.clz32(nibble) - 28
for (i = 0, total = 0; i < hash.length; i++) {
bits = msb(hash[i])
total += bits
if (bits !== 8) {
break break
} }
} }
return total
} return count
/**
* Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function msb(b: number) {
let n = 0
if (b === 0) {
return 8
}
// eslint-disable-next-line no-cond-assign
while (b >>= 1) {
n++
}
return 7 - n
} }

View File

@@ -1,13 +1,12 @@
import {finishEvent, Kind} from './event.ts' import { finishEvent, Kind } from './event.ts'
import {getPublicKey} from './keys.ts' import { getPublicKey } from './keys.ts'
import {finishRepostEvent, getRepostedEventPointer, getRepostedEvent} from './nip18.ts' import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import {buildEvent} from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
const relayUrl = 'https://relay.example.com' const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => { describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
@@ -16,30 +15,25 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
kind: Kind.Text, kind: Kind.Text,
tags: [ tags: [
['e', 'replied event id'], ['e', 'replied event id'],
['p', 'replied event pubkey'] ['p', 'replied event pubkey'],
], ],
content: 'Replied to a post', content: 'Replied to a post',
created_at: 1617932115 created_at: 1617932115,
}, },
privateKey privateKey,
) )
it('should create a signed event from a minimal template', () => { it('should create a signed event from a minimal template', () => {
const template = { const template = {
created_at: 1617932115 created_at: 1617932115,
} }
const event = finishRepostEvent( const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost) expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([ expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl], ['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey] ['p', repostedEvent.pubkey],
]) ])
expect(event.content).toEqual(JSON.stringify(repostedEvent)) expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at) expect(event.created_at).toEqual(template.created_at)
@@ -62,21 +56,16 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
const template = { const template = {
tags: [['nonstandard', 'tag']], tags: [['nonstandard', 'tag']],
content: '' as const, content: '' as const,
created_at: 1617932115 created_at: 1617932115,
} }
const event = finishRepostEvent( const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost) expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([ expect(event.tags).toEqual([
['nonstandard', 'tag'], ['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl], ['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey] ['p', repostedEvent.pubkey],
]) ])
expect(event.content).toEqual('') expect(event.content).toEqual('')
expect(event.created_at).toEqual(template.created_at) expect(event.created_at).toEqual(template.created_at)

View File

@@ -1,5 +1,5 @@
import {Event, finishEvent, Kind, verifySignature} from './event.ts' import { Event, finishEvent, Kind, verifySignature } from './event.ts'
import {EventPointer} from './nip19.ts' import { EventPointer } from './nip19.ts'
export type RepostEventTemplate = { export type RepostEventTemplate = {
/** /**
@@ -13,7 +13,7 @@ export type RepostEventTemplate = {
* Any other content will be ignored and replaced with the stringified JSON of the reposted event. * Any other content will be ignored and replaced with the stringified JSON of the reposted event.
* @default Stringified JSON of the reposted event * @default Stringified JSON of the reposted event
*/ */
content?: ''; content?: ''
created_at: number created_at: number
} }
@@ -24,16 +24,15 @@ export function finishRepostEvent(
relayUrl: string, relayUrl: string,
privateKey: string, privateKey: string,
): Event<Kind.Repost> { ): Event<Kind.Repost> {
return finishEvent({ return finishEvent(
kind: Kind.Repost, {
tags: [ kind: Kind.Repost,
...(t.tags ?? []), tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
[ 'e', reposted.id, relayUrl ], content: t.content === '' ? '' : JSON.stringify(reposted),
[ 'p', reposted.pubkey ], created_at: t.created_at,
], },
content: t.content === '' ? '' : JSON.stringify(reposted), privateKey,
created_at: t.created_at, )
}, privateKey)
} }
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer { export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
@@ -61,16 +60,19 @@ export function getRepostedEventPointer(event: Event<number>): undefined | Event
return { return {
id: lastETag[1], id: lastETag[1],
relays: [ lastETag[2], lastPTag?.[2] ].filter((x): x is string => typeof x === 'string'), relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
author: lastPTag?.[1], author: lastPTag?.[1],
} }
} }
export type GetRepostedEventOptions = { export type GetRepostedEventOptions = {
skipVerification?: boolean, skipVerification?: boolean
}; }
export function getRepostedEvent(event: Event<number>, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event<number> { export function getRepostedEvent(
event: Event<number>,
{ skipVerification }: GetRepostedEventOptions = {},
): undefined | Event<number> {
const pointer = getRepostedEventPointer(event) const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') { if (pointer === undefined || event.content === '') {

View File

@@ -1,4 +1,4 @@
import {generatePrivateKey, getPublicKey} from './keys.ts' import { generatePrivateKey, getPublicKey } from './keys.ts'
import { import {
decode, decode,
naddrEncode, naddrEncode,
@@ -14,7 +14,7 @@ test('encode and decode nsec', () => {
let sk = generatePrivateKey() let sk = generatePrivateKey()
let nsec = nsecEncode(sk) let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/) expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = decode(nsec) let { type, data } = decode(nsec)
expect(type).toEqual('nsec') expect(type).toEqual('nsec')
expect(data).toEqual(sk) expect(data).toEqual(sk)
}) })
@@ -23,20 +23,17 @@ test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey()) let pk = getPublicKey(generatePrivateKey())
let npub = npubEncode(pk) let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/) expect(npub).toMatch(/npub1\w+/)
let {type, data} = decode(npub) let { type, data } = decode(npub)
expect(type).toEqual('npub') expect(type).toEqual('npub')
expect(data).toEqual(pk) expect(data).toEqual(pk)
}) })
test('encode and decode nprofile', () => { test('encode and decode nprofile', () => {
let pk = getPublicKey(generatePrivateKey()) let pk = getPublicKey(generatePrivateKey())
let relays = [ let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
'wss://relay.nostr.example.mydomain.example.com', let nprofile = nprofileEncode({ pubkey: pk, relays })
'wss://nostr.banana.com'
]
let nprofile = nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/) expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = decode(nprofile) let { type, data } = decode(nprofile)
expect(type).toEqual('nprofile') expect(type).toEqual('nprofile')
const pointer = data as ProfilePointer const pointer = data as ProfilePointer
expect(pointer.pubkey).toEqual(pk) expect(pointer.pubkey).toEqual(pk)
@@ -48,31 +45,24 @@ test('decode nprofile without relays', () => {
expect( expect(
decode( decode(
nprofileEncode({ nprofileEncode({
pubkey: pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322', relays: [],
relays: [] }),
}) ).data,
).data ).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
).toHaveProperty(
'pubkey',
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
)
}) })
test('encode and decode naddr', () => { test('encode and decode naddr', () => {
let pk = getPublicKey(generatePrivateKey()) let pk = getPublicKey(generatePrivateKey())
let relays = [ let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let naddr = naddrEncode({ let naddr = naddrEncode({
pubkey: pk, pubkey: pk,
relays, relays,
kind: 30023, kind: 30023,
identifier: 'banana' identifier: 'banana',
}) })
expect(naddr).toMatch(/naddr1\w+/) expect(naddr).toMatch(/naddr1\w+/)
let {type, data} = decode(naddr) let { type, data } = decode(naddr)
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(pk) expect(pointer.pubkey).toEqual(pk)
@@ -83,31 +73,25 @@ test('encode and decode naddr', () => {
}) })
test('decode naddr from habla.news', () => { test('decode naddr from habla.news', () => {
let {type, data} = decode( let { type, data } = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5' 'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
) )
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual( expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
)
expect(pointer.kind).toEqual(30023) expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references') expect(pointer.identifier).toEqual('references')
}) })
test('decode naddr from go-nostr with different TLV ordering', () => { test('decode naddr from go-nostr with different TLV ordering', () => {
let {type, data} = decode( let { type, data } = decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx' 'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
) )
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual( expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
)
expect(pointer.relays).toContain(
'wss://relay.nostr.example.mydomain.example.com'
)
expect(pointer.relays).toContain('wss://nostr.banana.com') expect(pointer.relays).toContain('wss://nostr.banana.com')
expect(pointer.kind).toEqual(30023) expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana') expect(pointer.identifier).toEqual('banana')
@@ -117,7 +101,7 @@ test('encode and decode nrelay', () => {
let url = 'wss://relay.nostr.example' let url = 'wss://relay.nostr.example'
let nrelay = nrelayEncode(url) let nrelay = nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/) expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = decode(nrelay) let { type, data } = decode(nrelay)
expect(type).toEqual('nrelay') expect(type).toEqual('nrelay')
expect(data).toEqual(url) expect(data).toEqual(url)
}) })

View File

@@ -1,7 +1,7 @@
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils' import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
import {bech32} from '@scure/base' import { bech32 } from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts' import { utf8Decoder, utf8Encoder } from './utils.ts'
const Bech32MaxSize = 5000 const Bech32MaxSize = 5000
@@ -9,8 +9,7 @@ const Bech32MaxSize = 5000
* Bech32 regex. * Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32 * @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/ */
export const BECH32_REGEX = export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
export type ProfilePointer = { export type ProfilePointer = {
pubkey: string // hex pubkey: string // hex
@@ -52,7 +51,7 @@ export type DecodeResult = {
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix> 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
export function decode(nip19: string): DecodeResult { export function decode(nip19: string): DecodeResult {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize) let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words)) let data = new Uint8Array(bech32.fromWords(words))
switch (prefix) { switch (prefix) {
@@ -65,24 +64,23 @@ export function decode(nip19: string): DecodeResult {
type: 'nprofile', type: 'nprofile',
data: { data: {
pubkey: bytesToHex(tlv[0][0]), pubkey: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [] relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
} },
} }
} }
case 'nevent': { case 'nevent': {
let tlv = parseTLV(data) let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent') if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes') if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32) if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
throw new Error('TLV 2 should be 32 bytes')
return { return {
type: 'nevent', type: 'nevent',
data: { data: {
id: bytesToHex(tlv[0][0]), id: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [], relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
} },
} }
} }
@@ -100,8 +98,8 @@ export function decode(nip19: string): DecodeResult {
identifier: utf8Decoder.decode(tlv[0][0]), identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]), pubkey: bytesToHex(tlv[2][0]),
kind: parseInt(bytesToHex(tlv[3][0]), 16), kind: parseInt(bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [] relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
} },
} }
} }
@@ -111,21 +109,21 @@ export function decode(nip19: string): DecodeResult {
return { return {
type: 'nrelay', type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0]) data: utf8Decoder.decode(tlv[0][0]),
} }
} }
case 'nsec': case 'nsec':
case 'npub': case 'npub':
case 'note': case 'note':
return {type: prefix, data: bytesToHex(data)} return { type: prefix, data: bytesToHex(data) }
default: default:
throw new Error(`unknown prefix ${prefix}`) throw new Error(`unknown prefix ${prefix}`)
} }
} }
type TLV = {[t: number]: Uint8Array[]} type TLV = { [t: number]: Uint8Array[] }
function parseTLV(data: Uint8Array): TLV { function parseTLV(data: Uint8Array): TLV {
let result: TLV = {} let result: TLV = {}
@@ -168,7 +166,7 @@ function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Pre
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` { export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(profile.pubkey)], 0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)) 1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
}) })
return encodeBech32('nprofile', data) return encodeBech32('nprofile', data)
} }
@@ -177,7 +175,7 @@ export function neventEncode(event: EventPointer): `nevent1${string}` {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(event.id)], 0: [hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)), 1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : [] 2: event.author ? [hexToBytes(event.author)] : [],
}) })
return encodeBech32('nevent', data) return encodeBech32('nevent', data)
} }
@@ -190,14 +188,14 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
0: [utf8Encoder.encode(addr.identifier)], 0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)), 1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(addr.pubkey)], 2: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)] 3: [new Uint8Array(kind)],
}) })
return encodeBech32('naddr', data) return encodeBech32('naddr', data)
} }
export function nrelayEncode(url: string): `nrelay1${string}` { export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({ let data = encodeTLV({
0: [utf8Encoder.encode(url)] 0: [utf8Encoder.encode(url)],
}) })
return encodeBech32('nrelay', data) return encodeBech32('nrelay', data)
} }

View File

@@ -1,41 +1,23 @@
import {test as testRegex, parse} from './nip21.ts' import { test as testRegex, parse } from './nip21.ts'
test('test()', () => { test('test()', () => {
expect( expect(testRegex('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true)
testRegex( expect(testRegex('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true)
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6' expect(testRegex(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
)
).toBe(true)
expect(
testRegex(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
testRegex(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('nostr:')).toBe(false) expect(testRegex('nostr:')).toBe(false)
expect( expect(testRegex('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
testRegex(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('gggggg')).toBe(false) expect(testRegex('gggggg')).toBe(false)
}) })
test('parse', () => { test('parse', () => {
const result = parse( const result = parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({ expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: { decoded: {
type: 'note', type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b' data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
} },
}) })
}) })

View File

@@ -1,14 +1,11 @@
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts' import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
/** Nostr URI regex, eg `nostr:npub1...` */ /** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`) export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */ /** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` { export function test(value: unknown): value is `nostr:${string}` {
return ( return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
typeof value === 'string' &&
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
)
} }
/** Parsed Nostr URI data. */ /** Parsed Nostr URI data. */
@@ -28,6 +25,6 @@ export function parse(uri: string): NostrURI {
return { return {
uri: match[0] as `nostr:${string}`, uri: match[0] as `nostr:${string}`,
value: match[1], value: match[1],
decoded: decode(match[1]) decoded: decode(match[1]),
} }
} }

View File

@@ -1,10 +1,9 @@
import {finishEvent, Kind} from './event.ts' import { finishEvent, Kind } from './event.ts'
import {getPublicKey} from './keys.ts' import { getPublicKey } from './keys.ts'
import {finishReactionEvent, getReactedEventPointer} from './nip25.ts' import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => { describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey = const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
@@ -13,17 +12,17 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
kind: Kind.Text, kind: Kind.Text,
tags: [ tags: [
['e', 'replied event id'], ['e', 'replied event id'],
['p', 'replied event pubkey'] ['p', 'replied event pubkey'],
], ],
content: 'Replied to a post', content: 'Replied to a post',
created_at: 1617932115 created_at: 1617932115,
}, },
privateKey privateKey,
) )
it('should create a signed event from a minimal template', () => { it('should create a signed event from a minimal template', () => {
const template = { const template = {
created_at: 1617932115 created_at: 1617932115,
} }
const event = finishReactionEvent(template, reactedEvent, privateKey) const event = finishReactionEvent(template, reactedEvent, privateKey)
@@ -33,7 +32,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
['e', 'replied event id'], ['e', 'replied event id'],
['p', 'replied event pubkey'], ['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'], ['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'] ['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
]) ])
expect(event.content).toEqual('+') expect(event.content).toEqual('+')
expect(event.created_at).toEqual(template.created_at) expect(event.created_at).toEqual(template.created_at)
@@ -51,7 +50,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
const template = { const template = {
tags: [['nonstandard', 'tag']], tags: [['nonstandard', 'tag']],
content: '👍', content: '👍',
created_at: 1617932115 created_at: 1617932115,
} }
const event = finishReactionEvent(template, reactedEvent, privateKey) const event = finishReactionEvent(template, reactedEvent, privateKey)
@@ -62,7 +61,7 @@ describe('finishReactionEvent + getReactedEventPointer', () => {
['e', 'replied event id'], ['e', 'replied event id'],
['p', 'replied event pubkey'], ['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'], ['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'] ['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
]) ])
expect(event.content).toEqual('👍') expect(event.content).toEqual('👍')
expect(event.created_at).toEqual(template.created_at) expect(event.created_at).toEqual(template.created_at)

View File

@@ -1,6 +1,6 @@
import {Event, finishEvent, Kind} from './event.ts' import { Event, finishEvent, Kind } from './event.ts'
import type {EventPointer} from './nip19.ts' import type { EventPointer } from './nip19.ts'
export type ReactionEventTemplate = { export type ReactionEventTemplate = {
/** /**
@@ -21,21 +21,17 @@ export function finishReactionEvent(
reacted: Event<number>, reacted: Event<number>,
privateKey: string, privateKey: string,
): Event<Kind.Reaction> { ): Event<Kind.Reaction> {
const inheritedTags = reacted.tags.filter( const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
(tag) => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'),
)
return finishEvent({ return finishEvent(
...t, {
kind: Kind.Reaction, ...t,
tags: [ kind: Kind.Reaction,
...(t.tags ?? []), tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
...inheritedTags, content: t.content ?? '+',
['e', reacted.id], },
['p', reacted.pubkey], privateKey,
], )
content: t.content ?? '+',
}, privateKey)
} }
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer { export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
@@ -63,7 +59,7 @@ export function getReactedEventPointer(event: Event<number>): undefined | EventP
return { return {
id: lastETag[1], id: lastETag[1],
relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined), relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
author: lastPTag[1], author: lastPTag[1],
} }
} }

View File

@@ -1,13 +1,12 @@
import {getPublicKey, generatePrivateKey} from './keys.ts' import { getPublicKey, generatePrivateKey } from './keys.ts'
import {getDelegator, createDelegation} from './nip26.ts' import { getDelegator, createDelegation } from './nip26.ts'
import {buildEvent} from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
test('parse good delegation from NIP', async () => { test('parse good delegation from NIP', async () => {
expect( expect(
getDelegator({ getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109, created_at: 1660896109,
kind: 1, kind: 1,
tags: [ tags: [
@@ -15,12 +14,12 @@ test('parse good delegation from NIP', async () => {
'delegation', 'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e', '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200', 'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
] ],
], ],
content: 'Hello world', content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}) }),
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e') ).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
}) })
@@ -28,8 +27,7 @@ test('parse bad delegations', async () => {
expect( expect(
getDelegator({ getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109, created_at: 1660896109,
kind: 1, kind: 1,
tags: [ tags: [
@@ -37,19 +35,18 @@ test('parse bad delegations', async () => {
'delegation', 'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f', '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
'kind=1&created_at>1640995200', 'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
] ],
], ],
content: 'Hello world', content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}) }),
).toEqual(null) ).toEqual(null)
expect( expect(
getDelegator({ getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109, created_at: 1660896109,
kind: 1, kind: 1,
tags: [ tags: [
@@ -57,19 +54,18 @@ test('parse bad delegations', async () => {
'delegation', 'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e', '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1740995200', 'kind=1&created_at>1740995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
] ],
], ],
content: 'Hello world', content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}) }),
).toEqual(null) ).toEqual(null)
expect( expect(
getDelegator({ getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey: '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109, created_at: 1660896109,
kind: 1, kind: 1,
tags: [ tags: [
@@ -77,12 +73,12 @@ test('parse bad delegations', async () => {
'delegation', 'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e', '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200', 'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
] ],
], ],
content: 'Hello world', content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
}) }),
).toEqual(null) ).toEqual(null)
}) })
@@ -91,7 +87,7 @@ test('create and verify delegation', async () => {
let pk1 = getPublicKey(sk1) let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey() let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2) let pk2 = getPublicKey(sk2)
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1}) let delegation = createDelegation(sk1, { pubkey: pk2, kind: 1 })
expect(delegation).toHaveProperty('from', pk1) expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2) expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1') expect(delegation).toHaveProperty('cond', 'kind=1')

View File

@@ -1,11 +1,11 @@
import {schnorr} from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import {bytesToHex} from '@noble/hashes/utils' import { bytesToHex } from '@noble/hashes/utils'
import {sha256} from '@noble/hashes/sha256' import { sha256 } from '@noble/hashes/sha256'
import {utf8Encoder} from './utils.ts' import { utf8Encoder } from './utils.ts'
import {getPublicKey} from './keys.ts' import { getPublicKey } from './keys.ts'
import type {Event} from './event.ts' import type { Event } from './event.ts'
export type Parameters = { export type Parameters = {
pubkey: string // the key to whom the delegation will be given pubkey: string // the key to whom the delegation will be given
@@ -21,32 +21,24 @@ export type Delegation = {
sig: string sig: string
} }
export function createDelegation( export function createDelegation(privateKey: string, parameters: Parameters): Delegation {
privateKey: string,
parameters: Parameters
): Delegation {
let conditions = [] let conditions = []
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`) if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
if (parameters.until) conditions.push(`created_at<${parameters.until}`) if (parameters.until) conditions.push(`created_at<${parameters.until}`)
if (parameters.since) conditions.push(`created_at>${parameters.since}`) if (parameters.since) conditions.push(`created_at>${parameters.since}`)
let cond = conditions.join('&') let cond = conditions.join('&')
if (cond === '') if (cond === '') throw new Error('refusing to create a delegation without any conditions')
throw new Error('refusing to create a delegation without any conditions')
let sighash = sha256( let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`))
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
)
let sig = bytesToHex( let sig = bytesToHex(schnorr.sign(sighash, privateKey))
schnorr.sign(sighash, privateKey)
)
return { return {
from: getPublicKey(privateKey), from: getPublicKey(privateKey),
to: parameters.pubkey, to: parameters.pubkey,
cond, cond,
sig sig,
} }
} }
@@ -65,27 +57,14 @@ export function getDelegator(event: Event<number>): string | null {
let [key, operator, value] = conditions[i].split(/\b/) let [key, operator, value] = conditions[i].split(/\b/)
// the supported conditions are just 'kind' and 'created_at' for now // the supported conditions are just 'kind' and 'created_at' for now
if (key === 'kind' && operator === '=' && event.kind === parseInt(value)) if (key === 'kind' && operator === '=' && event.kind === parseInt(value)) continue
continue else if (key === 'created_at' && operator === '<' && event.created_at < parseInt(value)) continue
else if ( else if (key === 'created_at' && operator === '>' && event.created_at > parseInt(value)) continue
key === 'created_at' &&
operator === '<' &&
event.created_at < parseInt(value)
)
continue
else if (
key === 'created_at' &&
operator === '>' &&
event.created_at > parseInt(value)
)
continue
else return null // invalid condition else return null // invalid condition
} }
// check signature // check signature
let sighash = sha256( let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`))
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
)
if (!schnorr.verify(sig, sighash, pubkey)) return null if (!schnorr.verify(sig, sighash, pubkey)) return null
return pubkey return pubkey

View File

@@ -1,8 +1,8 @@
import {matchAll, replaceAll} from './nip27.ts' import { matchAll, replaceAll } from './nip27.ts'
test('matchAll', () => { test('matchAll', () => {
const result = matchAll( const result = matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
) )
expect([...result]).toEqual([ expect([...result]).toEqual([
@@ -11,40 +11,40 @@ test('matchAll', () => {
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: { decoded: {
type: 'npub', type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6' data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
}, },
start: 6, start: 6,
end: 75 end: 75,
}, },
{ {
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: { decoded: {
type: 'note', type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b' data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
}, },
start: 78, start: 78,
end: 147 end: 147,
} },
]) ])
}) })
test('matchAll with an invalid nip19', () => { test('matchAll with an invalid nip19', () => {
const result = matchAll( const result = matchAll(
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' 'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
) )
expect([...result]).toEqual([ expect([...result]).toEqual([
{ {
decoded: { decoded: {
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
type: 'note' type: 'note',
}, },
end: 193, end: 193,
start: 124, start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
} },
]) ])
}) })
@@ -52,7 +52,7 @@ test('replaceAll', () => {
const content = const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' 'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = replaceAll(content, ({decoded, value}) => { const result = replaceAll(content, ({ decoded, value }) => {
switch (decoded.type) { switch (decoded.type) {
case 'npub': case 'npub':
return '@alex' return '@alex'

View File

@@ -1,5 +1,5 @@
import {decode} from './nip19.ts' import { decode } from './nip19.ts'
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts' import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */ /** Regex to find NIP-21 URIs inside event content. */
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
@@ -13,7 +13,7 @@ export interface NostrURIMatch extends NostrURI {
} }
/** Find and decode all NIP-21 URIs. */ /** Find and decode all NIP-21 URIs. */
export function * matchAll(content: string): Iterable<NostrURIMatch> { export function* matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex()) const matches = content.matchAll(regex())
for (const match of matches) { for (const match of matches) {
@@ -25,7 +25,7 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
value, value,
decoded: decode(value), decoded: decode(value),
start: match.index!, start: match.index!,
end: match.index! + uri.length end: match.index! + uri.length,
} }
} catch (_e) { } catch (_e) {
// do nothing // do nothing
@@ -52,15 +52,12 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
* }) * })
* ``` * ```
*/ */
export function replaceAll( export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
content: string,
replacer: (match: NostrURI) => string
): string {
return content.replaceAll(regex(), (uri, value: string) => { return content.replaceAll(regex(), (uri, value: string) => {
return replacer({ return replacer({
uri: uri as `nostr:${string}`, uri: uri as `nostr:${string}`,
value, value,
decoded: decode(value) decoded: decode(value),
}) })
}) })
} }

118
nip28.test.ts Normal file
View File

@@ -0,0 +1,118 @@
import { Kind } from './event.ts'
import { getPublicKey } from './keys.ts'
import {
channelCreateEvent,
channelMetadataEvent,
channelMessageEvent,
channelHideMessageEvent,
channelMuteUserEvent,
ChannelMetadata,
ChannelMessageEventTemplate,
} from './nip28.ts'
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
describe('NIP-28 Functions', () => {
const channelMetadata: ChannelMetadata = {
name: 'Test Channel',
about: 'This is a test channel',
picture: 'https://example.com/picture.jpg',
}
it('channelCreateEvent should create an event with given template', () => {
const template = {
content: channelMetadata,
created_at: 1617932115,
}
const event = channelCreateEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelCreation)
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
})
it('channelMetadataEvent should create a signed event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
content: channelMetadata,
created_at: 1617932115,
}
const event = channelMetadataEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMetadata)
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
it('channelMessageEvent should create a signed message event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115,
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags[0]).toEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
it('channelMessageEvent should create a signed message reply event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
reply_to_channel_message_event_id: 'channel message event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115,
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags).toContainEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
expect(event.tags).toContainEqual(['e', template.reply_to_channel_message_event_id, template.relay_url, 'reply'])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
it('channelHideMessageEvent should create a signed event with given template', () => {
const template = {
channel_message_event_id: 'channel message event id',
content: { reason: 'Inappropriate content' },
created_at: 1617932115,
}
const event = channelHideMessageEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
it('channelMuteUserEvent should create a signed event with given template', () => {
const template = {
content: { reason: 'Spamming' },
created_at: 1617932115,
pubkey_to_mute: 'pubkey to mute',
}
const event = channelMuteUserEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
})

160
nip28.ts Normal file
View File

@@ -0,0 +1,160 @@
import { Event, finishEvent, Kind } from './event.ts'
export interface ChannelMetadata {
name: string
about: string
picture: string
}
export interface ChannelCreateEventTemplate {
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMetadataEventTemplate {
channel_create_event_id: string
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMessageEventTemplate {
channel_create_event_id: string
reply_to_channel_message_event_id?: string
relay_url: string
content: string
created_at: number
tags?: string[][]
}
export interface ChannelHideMessageEventTemplate {
channel_message_event_id: string
content: string | { reason: string }
created_at: number
tags?: string[][]
}
export interface ChannelMuteUserEventTemplate {
content: string | { reason: string }
created_at: number
pubkey_to_mute: string
tags?: string[][]
}
export const channelCreateEvent = (
t: ChannelCreateEventTemplate,
privateKey: string,
): Event<Kind.ChannelCreation> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelCreation,
tags: [...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMetadataEvent = (
t: ChannelMetadataEventTemplate,
privateKey: string,
): Event<Kind.ChannelMetadata> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: string): Event<Kind.ChannelMessage> => {
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
if (t.reply_to_channel_message_event_id) {
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
}
return finishEvent(
{
kind: Kind.ChannelMessage,
tags: [...tags, ...(t.tags ?? [])],
content: t.content,
created_at: t.created_at,
},
privateKey,
)
}
/* "e" tag should be the kind 42 event to hide */
export const channelHideMessageEvent = (
t: ChannelHideMessageEventTemplate,
privateKey: string,
): Event<Kind.ChannelHideMessage> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelHideMessage,
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMuteUserEvent = (
t: ChannelMuteUserEventTemplate,
privateKey: string,
): Event<Kind.ChannelMuteUser> | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finishEvent(
{
kind: Kind.ChannelMuteUser,
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}

View File

@@ -1,6 +1,6 @@
import fetch from 'node-fetch' import fetch from 'node-fetch'
import {useFetchImplementation, validateGithub} from './nip39.ts' import { useFetchImplementation, validateGithub } from './nip39.ts'
test('validate github claim', async () => { test('validate github claim', async () => {
useFetchImplementation(fetch) useFetchImplementation(fetch)
@@ -8,7 +8,7 @@ test('validate github claim', async () => {
let result = await validateGithub( let result = await validateGithub(
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z', 'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'vitorpamplona', 'vitorpamplona',
'cf19e2d1d7f8dac6348ad37b35ec8421' 'cf19e2d1d7f8dac6348ad37b35ec8421',
) )
expect(result).toBe(true) expect(result).toBe(true)
}) })

View File

@@ -8,19 +8,10 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export async function validateGithub( export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
pubkey: string,
username: string,
proof: string
): Promise<boolean> {
try { try {
let res = await ( let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
await _fetch(`https://gist.github.com/${username}/${proof}/raw`) return res === `Verifying that I control the following Nostr public key: ${pubkey}`
).text()
return (
res ===
`Verifying that I control the following Nostr public key: ${pubkey}`
)
} catch (_) { } catch (_) {
return false return false
} }

View File

@@ -1,23 +1,23 @@
import 'websocket-polyfill' import 'websocket-polyfill'
import {finishEvent} from './event.ts' import { finishEvent } from './event.ts'
import {generatePrivateKey} from './keys.ts' import { generatePrivateKey } from './keys.ts'
import {authenticate} from './nip42.ts' import { authenticate } from './nip42.ts'
import {relayInit} from './relay.ts' import { relayInit } from './relay.ts'
test('auth flow', () => { test('auth flow', () => {
const relay = relayInit('wss://nostr.kollider.xyz') const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect() relay.connect()
const sk = generatePrivateKey() const sk = generatePrivateKey()
return new Promise<void>((resolve) => { return new Promise<void>(resolve => {
relay.on('auth', async challenge => { relay.on('auth', async challenge => {
await expect( await expect(
authenticate({ authenticate({
challenge, challenge,
relay, relay,
sign: (e) => finishEvent(e, sk) sign: e => finishEvent(e, sk),
}) }),
).rejects.toBeTruthy() ).rejects.toBeTruthy()
relay.close() relay.close()
resolve() resolve()

View File

@@ -1,5 +1,5 @@
import {Kind, type EventTemplate, type Event} from './event.ts' import { Kind, type EventTemplate, type Event } from './event.ts'
import {Relay} from './relay.ts' import { Relay } from './relay.ts'
/** /**
* Authenticate via NIP-42 flow. * Authenticate via NIP-42 flow.
@@ -13,7 +13,7 @@ import {Relay} from './relay.ts'
export const authenticate = async ({ export const authenticate = async ({
challenge, challenge,
relay, relay,
sign sign,
}: { }: {
challenge: string challenge: string
relay: Relay relay: Relay
@@ -24,19 +24,9 @@ export const authenticate = async ({
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
['relay', relay.url], ['relay', relay.url],
['challenge', challenge] ['challenge', challenge],
], ],
content: '' content: '',
} }
const pub = relay.auth(await sign(e)) return relay.auth(await sign(e))
return new Promise((resolve, reject) => {
pub.on('ok', function ok() {
pub.off('ok', ok)
resolve()
})
pub.on('failed', function fail(reason: string) {
pub.off('failed', fail)
reject(reason)
})
})
} }

21
nip44.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import crypto from 'node:crypto'
import { hexToBytes } from '@noble/hashes/utils'
import { encrypt, decrypt, getSharedSecret } from './nip44.ts'
import { getPublicKey, generatePrivateKey } from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let sharedKey1 = getSharedSecret(sk1, pk2)
let sharedKey2 = getSharedSecret(sk2, pk1)
expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
})

40
nip44.ts Normal file
View File

@@ -0,0 +1,40 @@
import { base64 } from '@scure/base'
import { randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
import { xchacha20 } from '@noble/ciphers/chacha'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
export function encrypt(key: Uint8Array, text: string, v = 1) {
if (v !== 1) {
throw new Error('NIP44: unknown encryption version')
}
const nonce = randomBytes(24)
const plaintext = utf8Encoder.encode(text)
const ciphertext = xchacha20(key, nonce, plaintext)
const payload = new Uint8Array(25 + ciphertext.length)
payload.set([v], 0)
payload.set(nonce, 1)
payload.set(ciphertext, 25)
return base64.encode(payload)
}
export function decrypt(key: Uint8Array, payload: string) {
let data = base64.decode(payload)
if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
}
const nonce = data.slice(1, 25)
const ciphertext = data.slice(25)
const plaintext = xchacha20(key, nonce, ciphertext)
return utf8Decoder.decode(plaintext)
}

View File

@@ -1,17 +1,11 @@
import {finishEvent} from './event.ts' import { finishEvent } from './event.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts' import { getPublicKey, generatePrivateKey } from './keys.ts'
import { import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
getZapEndpoint, import { buildEvent } from './test-helpers.ts'
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import {buildEvent} from './test-helpers.ts'
describe('getZapEndpoint', () => { describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => { test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({kind: 0, content: '{}'}) const metadata = buildEvent({ kind: 0, content: '{}' })
const result = await getZapEndpoint(metadata) const result = await getZapEndpoint(metadata)
expect(result).toBeNull() expect(result).toBeNull()
@@ -21,28 +15,22 @@ describe('getZapEndpoint', () => {
const fetchImplementation = jest.fn(() => Promise.reject(new Error())) const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation) useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'}) const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata) const result = await getZapEndpoint(metadata)
expect(result).toBeNull() expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith( expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
'https://domain/.well-known/lnurlp/name'
)
}) })
test('returns null if the response does not allow Nostr payments', async () => { test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = jest.fn(() => const fetchImplementation = jest.fn(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
Promise.resolve({json: () => ({allowsNostr: false})})
)
useFetchImplementation(fetchImplementation) useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'}) const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata) const result = await getZapEndpoint(metadata)
expect(result).toBeNull() expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith( expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
'https://domain/.well-known/lnurlp/name'
)
}) })
test('returns the callback URL if the response allows Nostr payments', async () => { test('returns the callback URL if the response allows Nostr payments', async () => {
@@ -51,19 +39,17 @@ describe('getZapEndpoint', () => {
json: () => ({ json: () => ({
allowsNostr: true, allowsNostr: true,
nostrPubkey: 'pubkey', nostrPubkey: 'pubkey',
callback: 'callback' callback: 'callback',
}) }),
}) }),
) )
useFetchImplementation(fetchImplementation) useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'}) const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata) const result = await getZapEndpoint(metadata)
expect(result).toBe('callback') expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith( expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
'https://domain/.well-known/lnurlp/name'
)
}) })
}) })
@@ -75,8 +61,8 @@ describe('makeZapRequest', () => {
profile: 'profile', profile: 'profile',
event: null, event: null,
relays: [], relays: [],
comment: '' comment: '',
}) }),
).toThrow() ).toThrow()
}) })
@@ -87,8 +73,8 @@ describe('makeZapRequest', () => {
event: null, event: null,
amount: 100, amount: 100,
relays: [], relays: [],
comment: '' comment: '',
}) }),
).toThrow() ).toThrow()
}) })
@@ -98,7 +84,7 @@ describe('makeZapRequest', () => {
event: 'event', event: 'event',
amount: 100, amount: 100,
relays: ['relay1', 'relay2'], relays: ['relay1', 'relay2'],
comment: 'comment' comment: 'comment',
}) })
expect(result.kind).toBe(9734) expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0) expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
@@ -107,8 +93,8 @@ describe('makeZapRequest', () => {
expect.arrayContaining([ expect.arrayContaining([
['p', 'profile'], ['p', 'profile'],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
]) ]),
) )
expect(result.tags).toContainEqual(['e', 'event']) expect(result.tags).toContainEqual(['e', 'event'])
}) })
@@ -116,9 +102,7 @@ describe('makeZapRequest', () => {
describe('validateZapRequest', () => { describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => { test('returns an error message for invalid JSON', () => {
expect(validateZapRequest('invalid JSON')).toBe( expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
'Invalid zap request JSON.'
)
}) })
test('returns an error message if the Zap request is not a valid Nostr event', () => { test('returns an error message if the Zap request is not a valid Nostr event', () => {
@@ -129,13 +113,11 @@ describe('validateZapRequest', () => {
tags: [ tags: [
['p', 'profile'], ['p', 'profile'],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
} }
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
'Zap request is not a valid Nostr event.'
)
}) })
test('returns an error message if the signature on the Zap request is invalid', () => { test('returns an error message if the signature on the Zap request is invalid', () => {
@@ -150,13 +132,11 @@ describe('validateZapRequest', () => {
tags: [ tags: [
['p', publicKey], ['p', publicKey],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
} }
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
'Invalid signature on zap request.'
)
}) })
test('returns an error message if the Zap request does not have a "p" tag', () => { test('returns an error message if the Zap request does not have a "p" tag', () => {
@@ -169,15 +149,13 @@ describe('validateZapRequest', () => {
content: 'content', content: 'content',
tags: [ tags: [
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
}, },
privateKey privateKey,
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
"Zap request doesn't have a 'p' tag."
)
}) })
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => { test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
@@ -191,15 +169,13 @@ describe('validateZapRequest', () => {
tags: [ tags: [
['p', 'invalid hex'], ['p', 'invalid hex'],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
}, },
privateKey privateKey,
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
"Zap request 'p' tag is not valid hex."
)
}) })
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => { test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
@@ -215,15 +191,13 @@ describe('validateZapRequest', () => {
['p', publicKey], ['p', publicKey],
['e', 'invalid hex'], ['e', 'invalid hex'],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
}, },
privateKey privateKey,
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
"Zap request 'e' tag is not valid hex."
)
}) })
test('returns an error message if the Zap request does not have a relays tag', () => { test('returns an error message if the Zap request does not have a relays tag', () => {
@@ -237,15 +211,13 @@ describe('validateZapRequest', () => {
content: 'content', content: 'content',
tags: [ tags: [
['p', publicKey], ['p', publicKey],
['amount', '100'] ['amount', '100'],
] ],
}, },
privateKey privateKey,
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
"Zap request doesn't have a 'relays' tag."
)
}) })
test('returns null for a valid Zap request', () => { test('returns null for a valid Zap request', () => {
@@ -260,10 +232,10 @@ describe('validateZapRequest', () => {
tags: [ tags: [
['p', publicKey], ['p', publicKey],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
}, },
privateKey privateKey,
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull() expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
@@ -284,17 +256,17 @@ describe('makeZapReceipt', () => {
tags: [ tags: [
['p', publicKey], ['p', publicKey],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
}, },
privateKey privateKey,
) ),
) )
const preimage = 'preimage' const preimage = 'preimage'
const bolt11 = 'bolt11' const bolt11 = 'bolt11'
const paidAt = new Date() const paidAt = new Date()
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt}) const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
expect(result.kind).toBe(9735) expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0) expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
@@ -318,16 +290,16 @@ describe('makeZapReceipt', () => {
tags: [ tags: [
['p', publicKey], ['p', publicKey],
['amount', '100'], ['amount', '100'],
['relays', 'relay1', 'relay2'] ['relays', 'relay1', 'relay2'],
] ],
}, },
privateKey privateKey,
) ),
) )
const bolt11 = 'bolt11' const bolt11 = 'bolt11'
const paidAt = new Date() const paidAt = new Date()
const result = makeZapReceipt({zapRequest, bolt11, paidAt}) const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
expect(result.kind).toBe(9735) expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0) expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)

View File

@@ -1,13 +1,7 @@
import {bech32} from '@scure/base' import { bech32 } from '@scure/base'
import { import { Kind, validateEvent, verifySignature, type Event, type EventTemplate } from './event.ts'
Kind, import { utf8Decoder } from './utils.ts'
validateEvent,
verifySignature,
type Event,
type EventTemplate,
} from './event.ts'
import {utf8Decoder} from './utils.ts'
var _fetch: any var _fetch: any
@@ -19,14 +13,12 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export async function getZapEndpoint( export async function getZapEndpoint(metadata: Event<Kind.Metadata>): Promise<null | string> {
metadata: Event<Kind.Metadata>
): Promise<null | string> {
try { try {
let lnurl: string = '' let lnurl: string = ''
let {lud06, lud16} = JSON.parse(metadata.content) let { lud06, lud16 } = JSON.parse(metadata.content)
if (lud06) { if (lud06) {
let {words} = bech32.decode(lud06, 1000) let { words } = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words) let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data) lnurl = utf8Decoder.decode(data)
} else if (lud16) { } else if (lud16) {
@@ -54,7 +46,7 @@ export function makeZapRequest({
event, event,
amount, amount,
relays, relays,
comment = '' comment = '',
}: { }: {
profile: string profile: string
event: string | null event: string | null
@@ -72,8 +64,8 @@ export function makeZapRequest({
tags: [ tags: [
['p', profile], ['p', profile],
['amount', amount.toString()], ['amount', amount.toString()],
['relays', ...relays] ['relays', ...relays],
] ],
} }
if (event) { if (event) {
@@ -92,19 +84,16 @@ export function validateZapRequest(zapRequestString: string): string | null {
return 'Invalid zap request JSON.' return 'Invalid zap request JSON.'
} }
if (!validateEvent(zapRequest)) if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
return 'Zap request is not a valid Nostr event.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.' if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v) let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag." if (!p) return "Zap request doesn't have a 'p' tag."
if (!p[1].match(/^[a-f0-9]{64}$/)) if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
return "Zap request 'p' tag is not valid hex."
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v) let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
if (e && !e[1].match(/^[a-f0-9]{64}$/)) if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
return "Zap request 'e' tag is not valid hex."
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v) let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
if (!relays) return "Zap request doesn't have a 'relays' tag." if (!relays) return "Zap request doesn't have a 'relays' tag."
@@ -116,7 +105,7 @@ export function makeZapReceipt({
zapRequest, zapRequest,
preimage, preimage,
bolt11, bolt11,
paidAt paidAt,
}: { }: {
zapRequest: string zapRequest: string
preimage?: string preimage?: string
@@ -124,19 +113,13 @@ export function makeZapReceipt({
paidAt: Date paidAt: Date
}): EventTemplate<Kind.Zap> { }): EventTemplate<Kind.Zap> {
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest) let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter( let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
([t]) => t === 'e' || t === 'p' || t === 'a'
)
let zap: EventTemplate<Kind.Zap> = { let zap: EventTemplate<Kind.Zap> = {
kind: 9735, kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000), created_at: Math.round(paidAt.getTime() / 1000),
content: '', content: '',
tags: [ tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
...tagsFromZapRequest,
['bolt11', bolt11],
['description', zapRequest]
]
} }
if (preimage) { if (preimage) {

View File

@@ -1,20 +1,14 @@
import {base64} from '@scure/base' import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
import {getToken, validateToken} from './nip98.ts' import { Event, Kind, finishEvent } from './event.ts'
import {Event, Kind, finishEvent} from './event.ts' import { generatePrivateKey, getPublicKey } from './keys.ts'
import {utf8Decoder} from './utils.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
const sk = generatePrivateKey() const sk = generatePrivateKey()
describe('getToken', () => { describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => { test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e => let result = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
finishEvent(e, sk)
)
const decodedResult: Event = JSON.parse( const decodedResult: Event = await unpackEventFromToken(result)
utf8Decoder.decode(base64.decode(result))
)
expect(decodedResult.created_at).toBeGreaterThan(0) expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('') expect(decodedResult.content).toBe('')
@@ -22,18 +16,14 @@ describe('getToken', () => {
expect(decodedResult.pubkey).toBe(getPublicKey(sk)) expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([ expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'], ['u', 'http://test.com'],
['method', 'get'] ['method', 'get'],
]) ])
}) })
test('getToken POST returns token without authorization scheme', async () => { test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e => let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk))
finishEvent(e, sk)
)
const decodedResult: Event = JSON.parse( const decodedResult: Event = await unpackEventFromToken(result)
utf8Decoder.decode(base64.decode(result))
)
expect(decodedResult.created_at).toBeGreaterThan(0) expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('') expect(decodedResult.content).toBe('')
@@ -41,25 +31,18 @@ describe('getToken', () => {
expect(decodedResult.pubkey).toBe(getPublicKey(sk)) expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([ expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'], ['u', 'http://test.com'],
['method', 'post'] ['method', 'post'],
]) ])
}) })
test('getToken GET returns token WITH authorization scheme', async () => { test('getToken GET returns token WITH authorization scheme', async () => {
const authorizationScheme = 'Nostr ' const authorizationScheme = 'Nostr '
let result = await getToken( let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk), true)
'http://test.com',
'post',
e => finishEvent(e, sk),
true
)
expect(result.startsWith(authorizationScheme)).toBe(true) expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = JSON.parse( const decodedResult: Event = await unpackEventFromToken(result)
utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, '')))
)
expect(decodedResult.created_at).toBeGreaterThan(0) expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('') expect(decodedResult.content).toBe('')
@@ -67,15 +50,10 @@ describe('getToken', () => {
expect(decodedResult.pubkey).toBe(getPublicKey(sk)) expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([ expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'], ['u', 'http://test.com'],
['method', 'post'] ['method', 'post'],
]) ])
}) })
test('getToken unknown method throws an error', async () => {
const result = getToken('http://test.com', 'fake', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken missing loginUrl throws an error', async () => { test('getToken missing loginUrl throws an error', async () => {
const result = getToken('', 'get', e => finishEvent(e, sk)) const result = getToken('', 'get', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error) await expect(result).rejects.toThrow(Error)
@@ -89,21 +67,14 @@ describe('getToken', () => {
describe('validateToken', () => { describe('validateToken', () => {
test('validateToken returns true for valid token without authorization scheme', async () => { test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
finishEvent(e, sk)
)
const result = await validateToken(validToken, 'http://test.com', 'get') const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true) expect(result).toBe(true)
}) })
test('validateToken returns true for valid token with authorization scheme', async () => { test('validateToken returns true for valid token with authorization scheme', async () => {
const validToken = await getToken( const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const result = await validateToken(validToken, 'http://test.com', 'get') const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true) expect(result).toBe(true)
@@ -120,20 +91,40 @@ describe('validateToken', () => {
}) })
test('validateToken throws an error for a wrong url', async () => { test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
finishEvent(e, sk)
)
const result = validateToken(validToken, 'http://wrong-test.com', 'get') const result = validateToken(validToken, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error) await expect(result).rejects.toThrow(Error)
}) })
test('validateToken throws an error for a wrong method', async () => { test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
finishEvent(e, sk)
)
const result = validateToken(validToken, 'http://test.com', 'post') const result = validateToken(validToken, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error) await expect(result).rejects.toThrow(Error)
}) })
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
}) })

View File

@@ -1,17 +1,6 @@
import {base64} from '@scure/base' import { base64 } from '@scure/base'
import { import { Event, EventTemplate, Kind, getBlankEvent, verifySignature } from './event'
Event, import { utf8Decoder, utf8Encoder } from './utils'
EventTemplate,
Kind,
getBlankEvent,
verifySignature
} from './event'
import {utf8Decoder, utf8Encoder} from './utils'
enum HttpMethod {
Get = 'get',
Post = 'post'
}
const _authorizationScheme = 'Nostr ' const _authorizationScheme = 'Nostr '
@@ -20,51 +9,48 @@ const _authorizationScheme = 'Nostr '
* *
* @example * @example
* const sign = window.nostr.signEvent * const sign = window.nostr.signEvent
* await getToken('https://example.com/login', 'post', sign, true) * await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
*/ */
export async function getToken( export async function getToken(
loginUrl: string, loginUrl: string,
httpMethod: HttpMethod | string, httpMethod: string,
sign: <K extends number = number>( sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>,
e: EventTemplate<K> includeAuthorizationScheme: boolean = false,
) => Promise<Event<K>> | Event<K>,
includeAuthorizationScheme: boolean = false
): Promise<string> { ): Promise<string> {
if (!loginUrl || !httpMethod) if (!loginUrl || !httpMethod) throw new Error('Missing loginUrl or httpMethod')
throw new Error('Missing loginUrl or httpMethod')
if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post)
throw new Error('Unknown httpMethod')
const event = getBlankEvent(Kind.HttpAuth) const event = getBlankEvent(Kind.HttpAuth)
event.tags = [ event.tags = [
['u', loginUrl], ['u', loginUrl],
['method', httpMethod] ['method', httpMethod],
] ]
event.created_at = Math.round(new Date().getTime() / 1000) event.created_at = Math.round(new Date().getTime() / 1000)
const signedEvent = await sign(event) const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
? _authorizationScheme return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
: ''
return (
authorizationScheme +
base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
)
} }
/** /**
* Validate token for NIP-98 flow. * Validate token for NIP-98 flow.
* *
* @example * @example
* await validateToken('Nostr base64token', 'https://example.com/login', 'post') * await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/ */
export async function validateToken( export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
token: string, const event = await unpackEventFromToken(token).catch(error => {
url: string, throw error
method: string })
): Promise<boolean> { const valid = await validateEvent(event, url, method).catch(error => {
throw error
})
return valid
}
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) { if (!token) {
throw new Error('Missing token') throw new Error('Missing token')
} }
@@ -76,6 +62,11 @@ export async function validateToken(
} }
const event = JSON.parse(eventB64) as Event const event = JSON.parse(eventB64) as Event
return event
}
export async function validateEvent(event: Event, url: string, method: string): Promise<boolean> {
if (!event) { if (!event) {
throw new Error('Invalid nostr event') throw new Error('Invalid nostr event')
} }
@@ -101,10 +92,7 @@ export async function validateToken(
} }
const methodTag = event.tags.find(t => t[0] === 'method') const methodTag = event.tags.find(t => t[0] === 'method')
if ( if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
methodTag?.length !== 1 &&
methodTag?.[1].toLowerCase() !== method.toLowerCase()
) {
throw new Error('Invalid nostr event, method tag invalid') throw new Error('Invalid nostr event, method tag invalid')
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "nostr-tools", "name": "nostr-tools",
"version": "1.13.0", "version": "1.15.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -19,11 +19,20 @@
}, },
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/curves": "1.0.0", "@noble/ciphers": "^0.2.0",
"@noble/hashes": "1.3.0", "@noble/curves": "1.1.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1", "@scure/base": "1.1.1",
"@scure/bip32": "1.3.0", "@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.0" "@scure/bip39": "1.2.1"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}, },
"keywords": [ "keywords": [
"decentralization", "decentralization",
@@ -41,18 +50,19 @@
"@types/jest": "^29.5.1", "@types/jest": "^29.5.1",
"@types/node": "^18.13.0", "@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3", "@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^5.51.0", "@typescript-eslint/parser": "^6.5.0",
"esbuild": "0.16.9", "esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1", "esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.40.0", "eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.1", "eslint-plugin-jest": "^27.2.3",
"esm-loader-typescript": "^1.0.3", "esm-loader-typescript": "^1.0.3",
"events": "^3.3.0", "events": "^3.3.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"node-fetch": "^2.6.9", "node-fetch": "^2.6.9",
"prettier": "^2.8.4", "prettier": "^3.0.3",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"tsd": "^0.22.0", "tsd": "^0.22.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",

View File

@@ -1,8 +1,8 @@
import 'websocket-polyfill' import 'websocket-polyfill'
import {finishEvent, type Event} from './event.ts' import { finishEvent, type Event } from './event.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts' import { generatePrivateKey, getPublicKey } from './keys.ts'
import {SimplePool} from './pool.ts' import { SimplePool } from './pool.ts'
let pool = new SimplePool() let pool = new SimplePool()
@@ -11,23 +11,18 @@ let relays = [
'wss://relay.nostr.bg/', 'wss://relay.nostr.bg/',
'wss://nostr.fmt.wiz.biz/', 'wss://nostr.fmt.wiz.biz/',
'wss://relay.nostr.band/', 'wss://relay.nostr.band/',
'wss://nos.lol/' 'wss://nos.lol/',
] ]
afterAll(() => { afterAll(() => {
pool.close([ pool.close([...relays, 'wss://nostr.wine', 'wss://offchain.pub', 'wss://eden.nostr.land'])
...relays,
'wss://nostr.wine',
'wss://offchain.pub',
'wss://eden.nostr.land'
])
}) })
test('removing duplicates when querying', async () => { test('removing duplicates when querying', async () => {
let priv = generatePrivateKey() let priv = generatePrivateKey()
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{authors: [pub]}]) let sub = pool.sub(relays, [{ authors: [pub] }])
let received: Event[] = [] let received: Event[] = []
sub.on('event', event => { sub.on('event', event => {
@@ -37,12 +32,15 @@ test('removing duplicates when querying', async () => {
received.push(event) received.push(event)
}) })
let event = finishEvent({ let event = finishEvent(
created_at: Math.round(Date.now() / 1000), {
content: 'test', created_at: Math.round(Date.now() / 1000),
kind: 22345, content: 'test',
tags: [] kind: 22345,
}, priv) tags: [],
},
priv,
)
pool.publish(relays, event) pool.publish(relays, event)
@@ -55,8 +53,8 @@ test('same with double querying', async () => {
let priv = generatePrivateKey() let priv = generatePrivateKey()
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
let sub1 = pool.sub(relays, [{authors: [pub]}]) let sub1 = pool.sub(relays, [{ authors: [pub] }])
let sub2 = pool.sub(relays, [{authors: [pub]}]) let sub2 = pool.sub(relays, [{ authors: [pub] }])
let received: Event[] = [] let received: Event[] = []
@@ -68,12 +66,15 @@ test('same with double querying', async () => {
received.push(event) received.push(event)
}) })
let event = finishEvent({ let event = finishEvent(
created_at: Math.round(Date.now() / 1000), {
content: 'test2', created_at: Math.round(Date.now() / 1000),
kind: 22346, content: 'test2',
tags: [] kind: 22346,
}, priv) tags: [],
},
priv,
)
pool.publish(relays, event) pool.publish(relays, event)
@@ -84,13 +85,10 @@ test('same with double querying', async () => {
test('get()', async () => { test('get()', async () => {
let event = await pool.get(relays, { let event = await pool.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'] ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
}) })
expect(event).toHaveProperty( expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
}) })
test('list()', async () => { test('list()', async () => {
@@ -98,13 +96,11 @@ test('list()', async () => {
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'], [...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
[ [
{ {
authors: [ authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
],
kinds: [1], kinds: [1],
limit: 2 limit: 2,
} },
] ],
) )
// the actual received number will be greater than 2, but there will be no duplicates // the actual received number will be greater than 2, but there will be no duplicates
@@ -112,27 +108,21 @@ test('list()', async () => {
events events
.map(evt => evt.id) .map(evt => evt.id)
// @ts-ignore ??? // @ts-ignore ???
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []) .reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []).length,
.length
) )
let relaysForAllEvents = events let relaysForAllEvents = events.map(event => pool.seenOn(event.id)).reduce((acc, n) => acc.concat(n), [])
.map(event => pool.seenOn(event.id))
.reduce((acc, n) => acc.concat(n), [])
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length) expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
}) })
test('seenOnEnabled: false', async () => { test('seenOnEnabled: false', async () => {
const poolWithoutSeenOn = new SimplePool({seenOnEnabled: false}) const poolWithoutSeenOn = new SimplePool({ seenOnEnabled: false })
const event = await poolWithoutSeenOn.get(relays, { const event = await poolWithoutSeenOn.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'] ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
}) })
expect(event).toHaveProperty( expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id) const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)

211
pool.ts
View File

@@ -1,27 +1,39 @@
import { import { relayInit, eventsGenerator, type Relay, type Sub, type SubscriptionOptions } from './relay.ts'
relayInit, import { normalizeURL } from './utils.ts'
type Pub,
type Relay, import type { Event } from './event.ts'
type Sub, import { matchFilters, type Filter } from './filter.ts'
type SubscriptionOptions,
} from './relay.ts' type BatchedRequest = {
import {normalizeURL} from './utils.ts' filters: Filter<any>[]
relays: string[]
resolve: (events: Event<any>[]) => void
events: Event<any>[]
}
import type {Event} from './event.ts'
import type {Filter} from './filter.ts'
export class SimplePool { export class SimplePool {
private _conn: {[url: string]: Relay} private _conn: { [url: string]: Relay }
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay private _seenOn: { [id: string]: Set<string> } = {} // a map of all events we've seen in each relay
private batchedByKey: { [batchKey: string]: BatchedRequest[] } = {}
private eoseSubTimeout: number private eoseSubTimeout: number
private getTimeout: number private getTimeout: number
private seenOnEnabled: boolean = true private seenOnEnabled: boolean = true
private batchInterval: number = 100
constructor(options: {eoseSubTimeout?: number; getTimeout?: number; seenOnEnabled?: boolean} = {}) { constructor(
options: {
eoseSubTimeout?: number
getTimeout?: number
seenOnEnabled?: boolean
batchInterval?: number
} = {},
) {
this._conn = {} this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400 this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400 this.getTimeout = options.getTimeout || 3400
this.seenOnEnabled = options.seenOnEnabled !== false this.seenOnEnabled = options.seenOnEnabled !== false
this.batchInterval = options.batchInterval || 100
} }
close(relays: string[]): void { close(relays: string[]): void {
@@ -37,7 +49,7 @@ export class SimplePool {
if (!this._conn[nm]) { if (!this._conn[nm]) {
this._conn[nm] = relayInit(nm, { this._conn[nm] = relayInit(nm, {
getTimeout: this.getTimeout * 0.9, getTimeout: this.getTimeout * 0.9,
listTimeout: this.getTimeout * 0.9 listTimeout: this.getTimeout * 0.9,
}) })
} }
@@ -48,7 +60,7 @@ export class SimplePool {
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> { sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
let _knownIds: Set<string> = new Set() let _knownIds: Set<string> = new Set()
let modifiedOpts = {...(opts || {})} let modifiedOpts = { ...(opts || {}) }
modifiedOpts.alreadyHaveEvent = (id, url) => { modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) { if (opts?.alreadyHaveEvent?.(id, url)) {
return true return true
@@ -67,44 +79,49 @@ export class SimplePool {
let eosesMissing = relays.length let eosesMissing = relays.length
let eoseSent = false let eoseSent = false
let eoseTimeout = setTimeout(() => { let eoseTimeout = setTimeout(
eoseSent = true () => {
for (let cb of eoseListeners.values()) cb() eoseSent = true
}, this.eoseSubTimeout) for (let cb of eoseListeners.values()) cb()
},
opts?.eoseSubTimeout || this.eoseSubTimeout,
)
relays.forEach(async relay => { relays
let r .filter((r, i, a) => a.indexOf(r) === i)
try { .forEach(async relay => {
r = await this.ensureRelay(relay) let r
} catch (err) { try {
handleEose() r = await this.ensureRelay(relay)
return } catch (err) {
} handleEose()
if (!r) return return
let s = r.sub(filters, modifiedOpts)
s.on('event', (event) => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
} }
} if (!r) return
}) let s = r.sub(filters, modifiedOpts)
s.on('event', event => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
let greaterSub: Sub = { function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
}
}
})
let greaterSub: Sub<K> = {
sub(filters, opts) { sub(filters, opts) {
subs.forEach(sub => sub.sub(filters, opts)) subs.forEach(sub => sub.sub(filters, opts))
return greaterSub return greaterSub as any
}, },
unsub() { unsub() {
subs.forEach(sub => sub.unsub()) subs.forEach(sub => sub.unsub())
@@ -119,9 +136,11 @@ export class SimplePool {
off(type, cb) { off(type, cb) {
if (type === 'event') { if (type === 'event') {
eventListeners.delete(cb) eventListeners.delete(cb)
} else if (type === 'eose') } else if (type === 'eose') eoseListeners.delete(cb as () => void | Promise<void>)
eoseListeners.delete(cb as () => void | Promise<void>) },
} get events() {
return eventsGenerator(greaterSub)
},
} }
return greaterSub return greaterSub
@@ -130,7 +149,7 @@ export class SimplePool {
get<K extends number = number>( get<K extends number = number>(
relays: string[], relays: string[],
filter: Filter<K>, filter: Filter<K>,
opts?: SubscriptionOptions opts?: SubscriptionOptions,
): Promise<Event<K> | null> { ): Promise<Event<K> | null> {
return new Promise(resolve => { return new Promise(resolve => {
let sub = this.sub(relays, [filter], opts) let sub = this.sub(relays, [filter], opts)
@@ -138,7 +157,7 @@ export class SimplePool {
sub.unsub() sub.unsub()
resolve(null) resolve(null)
}, this.getTimeout) }, this.getTimeout)
sub.on('event', (event) => { sub.on('event', event => {
resolve(event) resolve(event)
clearTimeout(timeout) clearTimeout(timeout)
sub.unsub() sub.unsub()
@@ -149,13 +168,13 @@ export class SimplePool {
list<K extends number = number>( list<K extends number = number>(
relays: string[], relays: string[],
filters: Filter<K>[], filters: Filter<K>[],
opts?: SubscriptionOptions opts?: SubscriptionOptions,
): Promise<Event<K>[]> { ): Promise<Event<K>[]> {
return new Promise(resolve => { return new Promise(resolve => {
let events: Event<K>[] = [] let events: Event<K>[] = []
let sub = this.sub(relays, filters, opts) let sub = this.sub(relays, filters, opts)
sub.on('event', (event) => { sub.on('event', event => {
events.push(event) events.push(event)
}) })
@@ -167,39 +186,61 @@ export class SimplePool {
}) })
} }
publish(relays: string[], event: Event<number>): Pub { batchedList<K extends number = number>(
const pubPromises: Promise<Pub>[] = relays.map(async relay => { batchKey: string,
let r relays: string[],
try { filters: Filter<K>[],
r = await this.ensureRelay(relay) ): Promise<Event<K>[]> {
return r.publish(event) return new Promise(resolve => {
} catch (_) { if (!this.batchedByKey[batchKey]) {
return {on() {}, off() {}} this.batchedByKey[batchKey] = [
{
filters,
relays,
resolve,
events: [],
},
]
setTimeout(() => {
Object.keys(this.batchedByKey).forEach(async batchKey => {
const batchedRequests = this.batchedByKey[batchKey]
const filters = [] as Filter[]
const relays = [] as string[]
batchedRequests.forEach(br => {
filters.push(...br.filters)
relays.push(...br.relays)
})
const sub = this.sub(relays, filters)
sub.on('event', event => {
batchedRequests.forEach(br => matchFilters(br.filters, event) && br.events.push(event))
})
sub.on('eose', () => {
sub.unsub()
batchedRequests.forEach(br => br.resolve(br.events))
})
delete this.batchedByKey[batchKey]
})
}, this.batchInterval)
} else {
this.batchedByKey[batchKey].push({
filters,
relays,
resolve,
events: [],
})
} }
}) })
}
const callbackMap = new Map() publish(relays: string[], event: Event<number>): Promise<void>[] {
return relays.map(async relay => {
return { let r = await this.ensureRelay(relay)
on(type, cb) { return r.publish(event)
relays.forEach(async (relay, i) => { })
let pub = await pubPromises[i]
let callback = () => cb(relay)
callbackMap.set(cb, callback)
pub.on(type, callback)
})
},
off(type, cb) {
relays.forEach(async (_, i) => {
let callback = callbackMap.get(cb)
if (callback) {
let pub = await pubPromises[i]
pub.off(type, callback)
}
})
}
}
} }
seenOn(id: string): string[] { seenOn(id: string): string[] {

View File

@@ -1,25 +1,12 @@
import {parseReferences} from './references.ts' import { parseReferences } from './references.ts'
import {buildEvent} from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
test('parse mentions', () => { test('parse mentions', () => {
let evt = buildEvent({ let evt = buildEvent({
tags: [ tags: [
[ ['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
'p', ['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', ['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
'wss://nostr.com'
],
[
'e',
'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33',
'wss://other.com',
'reply'
],
[
'e',
'31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
''
]
], ],
content: content:
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]', 'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
@@ -29,33 +16,31 @@ test('parse mentions', () => {
{ {
text: '#[0]', text: '#[0]',
profile: { profile: {
pubkey: pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', relays: ['wss://nostr.com'],
relays: ['wss://nostr.com'] },
}
}, },
{ {
text: '#[2]', text: '#[2]',
event: { event: {
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
relays: [] relays: [],
} },
}, },
{ {
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg', text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
profile: { profile: {
pubkey: pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393', relays: [],
relays: [] },
}
}, },
{ {
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4', text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
event: { event: {
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393', id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: [], relays: [],
author: undefined author: undefined,
} },
} },
]) ])
}) })

View File

@@ -1,11 +1,6 @@
import { import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
decode,
type AddressPointer,
type ProfilePointer,
type EventPointer,
} from './nip19.ts'
import type {Event} from './event.ts' import type { Event } from './event.ts'
type Reference = { type Reference = {
text: string text: string
@@ -14,8 +9,7 @@ type Reference = {
address?: AddressPointer address?: AddressPointer
} }
const mentionRegex = const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
export function parseReferences(evt: Event): Reference[] { export function parseReferences(evt: Event): Reference[] {
let references: Reference[] = [] let references: Reference[] = []
@@ -23,40 +17,40 @@ export function parseReferences(evt: Event): Reference[] {
if (ref[2]) { if (ref[2]) {
// it's a NIP-27 mention // it's a NIP-27 mention
try { try {
let {type, data} = decode(ref[1]) let { type, data } = decode(ref[1])
switch (type) { switch (type) {
case 'npub': { case 'npub': {
references.push({ references.push({
text: ref[0], text: ref[0],
profile: {pubkey: data as string, relays: []} profile: { pubkey: data as string, relays: [] },
}) })
break break
} }
case 'nprofile': { case 'nprofile': {
references.push({ references.push({
text: ref[0], text: ref[0],
profile: data as ProfilePointer profile: data as ProfilePointer,
}) })
break break
} }
case 'note': { case 'note': {
references.push({ references.push({
text: ref[0], text: ref[0],
event: {id: data as string, relays: []} event: { id: data as string, relays: [] },
}) })
break break
} }
case 'nevent': { case 'nevent': {
references.push({ references.push({
text: ref[0], text: ref[0],
event: data as EventPointer event: data as EventPointer,
}) })
break break
} }
case 'naddr': { case 'naddr': {
references.push({ references.push({
text: ref[0], text: ref[0],
address: data as AddressPointer address: data as AddressPointer,
}) })
break break
} }
@@ -74,14 +68,14 @@ export function parseReferences(evt: Event): Reference[] {
case 'p': { case 'p': {
references.push({ references.push({
text: ref[0], text: ref[0],
profile: {pubkey: tag[1], relays: tag[2] ? [tag[2]] : []} profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
}) })
break break
} }
case 'e': { case 'e': {
references.push({ references.push({
text: ref[0], text: ref[0],
event: {id: tag[1], relays: tag[2] ? [tag[2]] : []} event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
}) })
break break
} }
@@ -94,8 +88,8 @@ export function parseReferences(evt: Event): Reference[] {
identifier, identifier,
pubkey, pubkey,
kind: parseInt(kind, 10), kind: parseInt(kind, 10),
relays: tag[2] ? [tag[2]] : [] relays: tag[2] ? [tag[2]] : [],
} },
}) })
} catch (err) { } catch (err) {
/***/ /***/

View File

@@ -1,8 +1,8 @@
import 'websocket-polyfill' import 'websocket-polyfill'
import {finishEvent} from './event.ts' import { finishEvent } from './event.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts' import { generatePrivateKey, getPublicKey } from './keys.ts'
import {relayInit} from './relay.ts' import { relayInit } from './relay.ts'
let relay = relayInit('wss://relay.damus.io/') let relay = relayInit('wss://relay.damus.io/')
@@ -23,7 +23,7 @@ test('connectivity', () => {
relay.on('error', () => { relay.on('error', () => {
resolve(false) resolve(false)
}) })
}) }),
).resolves.toBe(true) ).resolves.toBe(true)
}) })
@@ -33,14 +33,11 @@ test('querying', async () => {
let sub = relay.sub([ let sub = relay.sub([
{ {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'] ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
} },
]) ])
sub.on('event', event => { sub.on('event', event => {
expect(event).toHaveProperty( expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true) resolve1(true)
}) })
sub.on('eose', () => { sub.on('eose', () => {
@@ -53,33 +50,41 @@ test('querying', async () => {
}), }),
new Promise<boolean>(resolve => { new Promise<boolean>(resolve => {
resolve2 = resolve resolve2 = resolve
}) }),
]) ])
expect(t1).toEqual(true) expect(t1).toEqual(true)
expect(t2).toEqual(true) expect(t2).toEqual(true)
}, 10000)
test('async iterator', async () => {
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
])
for await (const event of sub.events) {
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
break
}
}) })
test('get()', async () => { test('get()', async () => {
let event = await relay.get({ let event = await relay.get({
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'] ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
}) })
expect(event).toHaveProperty( expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
}) })
test('list()', async () => { test('list()', async () => {
let events = await relay.list([ let events = await relay.list([
{ {
authors: [ authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
],
kinds: [1], kinds: [1],
limit: 2 limit: 2,
} },
]) ])
expect(events.length).toEqual(2) expect(events.length).toEqual(2)
@@ -94,8 +99,8 @@ test('listening (twice) and publishing', async () => {
let sub = relay.sub([ let sub = relay.sub([
{ {
kinds: [27572], kinds: [27572],
authors: [pk] authors: [pk],
} },
]) ])
sub.on('event', event => { sub.on('event', event => {
@@ -111,12 +116,15 @@ test('listening (twice) and publishing', async () => {
resolve2(true) resolve2(true)
}) })
let event = finishEvent({ let event = finishEvent(
kind: 27572, {
created_at: Math.floor(Date.now() / 1000), kind: 27572,
tags: [], created_at: Math.floor(Date.now() / 1000),
content: 'nostr-tools test suite' tags: [],
}, sk) content: 'nostr-tools test suite',
},
sk,
)
relay.publish(event) relay.publish(event)
return expect( return expect(
@@ -126,7 +134,7 @@ test('listening (twice) and publishing', async () => {
}), }),
new Promise(resolve => { new Promise(resolve => {
resolve2 = resolve resolve2 = resolve
}) }),
]) ]),
).resolves.toEqual([true, true]) ).resolves.toEqual([true, true])
}) })

181
relay.ts
View File

@@ -1,8 +1,8 @@
/* global WebSocket */ /* global WebSocket */
import {verifySignature, validateEvent, type Event} from './event.ts' import { verifySignature, validateEvent, type Event } from './event.ts'
import {matchFilters, type Filter} from './filter.ts' import { matchFilters, type Filter } from './filter.ts'
import {getHex64, getSubscriptionId} from './fakejson.ts' import { getHex64, getSubscriptionId } from './fakejson.ts'
import { MessageQueue } from './utils.ts' import { MessageQueue } from './utils.ts'
type RelayEvent = { type RelayEvent = {
@@ -15,7 +15,7 @@ type RelayEvent = {
export type CountPayload = { export type CountPayload = {
count: number count: number
} }
type SubEvent<K extends number> = { export type SubEvent<K extends number> = {
event: (event: Event<K>) => void | Promise<void> event: (event: Event<K>) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void> count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void> eose: () => void | Promise<void>
@@ -28,36 +28,18 @@ export type Relay = {
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K> sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]> list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null> get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
count: ( count: (filters: Filter[], opts?: SubscriptionOptions) => Promise<CountPayload | null>
filters: Filter[], publish: (event: Event<number>) => Promise<void>
opts?: SubscriptionOptions auth: (event: Event<number>) => Promise<void>
) => Promise<CountPayload | null> off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
publish: (event: Event<number>) => Pub on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(event: T, listener: U) => void
auth: (event: Event<number>) => Pub
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
}
export type Pub = {
on: (type: 'ok' | 'failed', cb: any) => void
off: (type: 'ok' | 'failed', cb: any) => void
} }
export type Sub<K extends number = number> = { export type Sub<K extends number = number> = {
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K> sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
unsub: () => void unsub: () => void
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>( on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
event: T, off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
listener: U events: AsyncGenerator<Event<K>, void, unknown>
) => void
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
event: T,
listener: U
) => void
} }
export type SubscriptionOptions = { export type SubscriptionOptions = {
@@ -65,14 +47,15 @@ export type SubscriptionOptions = {
verb?: 'REQ' | 'COUNT' verb?: 'REQ' | 'COUNT'
skipVerification?: boolean skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean) alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
eoseSubTimeout?: number
} }
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({ const newListeners = (): { [TK in keyof RelayEvent]: RelayEvent[TK][] } => ({
connect: [], connect: [],
disconnect: [], disconnect: [],
error: [], error: [],
notice: [], notice: [],
auth: [] auth: [],
}) })
export function relayInit( export function relayInit(
@@ -81,21 +64,20 @@ export function relayInit(
getTimeout?: number getTimeout?: number
listTimeout?: number listTimeout?: number
countTimeout?: number countTimeout?: number
} = {} } = {},
): Relay { ): Relay {
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options let { listTimeout = 3000, getTimeout = 3000, countTimeout = 3000 } = options
var ws: WebSocket var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {} var openSubs: { [id: string]: { filters: Filter[] } & SubscriptionOptions } = {}
var listeners = newListeners() var listeners = newListeners()
var subListeners: { var subListeners: {
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]} [subid: string]: { [TK in keyof SubEvent<any>]: SubEvent<any>[TK][] }
} = {} } = {}
var pubListeners: { var pubListeners: {
[eventid: string]: { [eventid: string]: {
ok: Array<() => void> resolve: (_: unknown) => void
seen: Array<() => void> reject: (err: Error) => void
failed: Array<(reason: string) => void>
} }
} = {} } = {}
@@ -146,11 +128,7 @@ export function relayInit(
let subid = getSubscriptionId(json) let subid = getSubscriptionId(json)
if (subid) { if (subid) {
let so = openSubs[subid] let so = openSubs[subid]
if ( if (so && so.alreadyHaveEvent && so.alreadyHaveEvent(getHex64(json, 'id'), url)) {
so &&
so.alreadyHaveEvent &&
so.alreadyHaveEvent(getHex64(json, 'id'), url)
) {
return return
} }
} }
@@ -196,10 +174,9 @@ export function relayInit(
let ok: boolean = data[2] let ok: boolean = data[2]
let reason: string = data[3] || '' let reason: string = data[3] || ''
if (id in pubListeners) { if (id in pubListeners) {
if (ok) pubListeners[id].ok.forEach(cb => cb()) let { resolve, reject } = pubListeners[id]
else pubListeners[id].failed.forEach(cb => cb(reason)) if (ok) resolve(null)
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here else reject(new Error(reason))
pubListeners[id].failed = []
} }
return return
} }
@@ -252,8 +229,8 @@ export function relayInit(
verb = 'REQ', verb = 'REQ',
skipVerification = false, skipVerification = false,
alreadyHaveEvent = null, alreadyHaveEvent = null,
id = Math.random().toString().slice(2) id = Math.random().toString().slice(2),
}: SubscriptionOptions = {} }: SubscriptionOptions = {},
): Sub<K> => { ): Sub<K> => {
let subid = id let subid = id
@@ -261,16 +238,16 @@ export function relayInit(
id: subid, id: subid,
filters, filters,
skipVerification, skipVerification,
alreadyHaveEvent alreadyHaveEvent,
} }
trySend([verb, subid, ...filters]) trySend([verb, subid, ...filters])
return { let subscription: Sub<K> = {
sub: (newFilters, newOpts = {}) => sub: (newFilters, newOpts = {}) =>
sub(newFilters || filters, { sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || skipVerification, skipVerification: newOpts.skipVerification || skipVerification,
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent, alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
id: subid id: subid,
}), }),
unsub: () => { unsub: () => {
delete openSubs[subid] delete openSubs[subid]
@@ -281,7 +258,7 @@ export function relayInit(
subListeners[subid] = subListeners[subid] || { subListeners[subid] = subListeners[subid] || {
event: [], event: [],
count: [], count: [],
eose: [] eose: [],
} }
subListeners[subid][type].push(cb) subListeners[subid][type].push(cb)
}, },
@@ -289,50 +266,39 @@ export function relayInit(
let listeners = subListeners[subid] let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb) let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1) if (idx >= 0) listeners[type].splice(idx, 1)
} },
get events() {
return eventsGenerator(subscription)
},
} }
return subscription
} }
function _publishEvent(event: Event<number>, type: string) { function _publishEvent(event: Event<number>, type: string) {
if (!event.id) throw new Error(`event ${event} has no id`) return new Promise((resolve, reject) => {
let id = event.id if (!event.id) {
reject(new Error(`event ${event} has no id`))
trySend([type, event]) return
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
} }
}
let id = event.id
trySend([type, event])
pubListeners[id] = { resolve, reject }
})
} }
return { return {
url, url,
sub, sub,
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>( on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
type: T,
cb: U
): void => {
listeners[type].push(cb) listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) { if (type === 'connect' && ws?.readyState === 1) {
// i would love to know why we need this // i would love to know why we need this
;(cb as () => void)() ;(cb as () => void)()
} }
}, },
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>( off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
type: T,
cb: U
): void => {
let index = listeners[type].indexOf(cb) let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1) if (index !== -1) listeners[type].splice(index, 1)
}, },
@@ -349,7 +315,7 @@ export function relayInit(
clearTimeout(timeout) clearTimeout(timeout)
resolve(events) resolve(events)
}) })
s.on('event', (event) => { s.on('event', event => {
events.push(event) events.push(event)
}) })
}), }),
@@ -360,7 +326,7 @@ export function relayInit(
s.unsub() s.unsub()
resolve(null) resolve(null)
}, getTimeout) }, getTimeout)
s.on('event', (event) => { s.on('event', event => {
s.unsub() s.unsub()
clearTimeout(timeout) clearTimeout(timeout)
resolve(event) resolve(event)
@@ -368,7 +334,7 @@ export function relayInit(
}), }),
count: (filters: Filter[]): Promise<CountPayload | null> => count: (filters: Filter[]): Promise<CountPayload | null> =>
new Promise(resolve => { new Promise(resolve => {
let s = sub(filters, {...sub, verb: 'COUNT'}) let s = sub(filters, { ...sub, verb: 'COUNT' })
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
s.unsub() s.unsub()
resolve(null) resolve(null)
@@ -379,23 +345,54 @@ export function relayInit(
resolve(event) resolve(event)
}) })
}), }),
publish(event): Pub { async publish(event): Promise<void> {
return _publishEvent(event, 'EVENT') await _publishEvent(event, 'EVENT')
}, },
auth(event): Pub { async auth(event): Promise<void> {
return _publishEvent(event, 'AUTH') await _publishEvent(event, 'AUTH')
}, },
connect, connect,
close(): void { close(): void {
listeners = newListeners() listeners = newListeners()
subListeners = {} subListeners = {}
pubListeners = {} pubListeners = {}
if (ws.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
ws?.close() ws.close()
} }
}, },
get status() { get status() {
return ws?.readyState ?? 3 return ws?.readyState ?? 3
} },
}
}
export async function* eventsGenerator<K extends number>(sub: Sub<K>): AsyncGenerator<Event<K>, void, unknown> {
let nextResolve: ((event: Event<K>) => void) | undefined
const eventQueue: Event<K>[] = []
const pushToQueue = (event: Event<K>) => {
if (nextResolve) {
nextResolve(event)
nextResolve = undefined
} else {
eventQueue.push(event)
}
}
sub.on('event', pushToQueue)
try {
while (true) {
if (eventQueue.length > 0) {
yield eventQueue.shift()!
} else {
const event = await new Promise<Event<K>>(resolve => {
nextResolve = resolve
})
yield event
}
}
} finally {
sub.off('event', pushToQueue)
} }
} }

View File

@@ -1,4 +1,4 @@
import type {Event} from './event.ts' import type { Event } from './event.ts'
type EventParams<K extends number> = Partial<Event<K>> type EventParams<K extends number> = Partial<Event<K>>
@@ -12,6 +12,6 @@ export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<
content: '', content: '',
tags: [], tags: [],
sig: '', sig: '',
...params ...params,
} }
} }

View File

@@ -1,101 +1,109 @@
import {buildEvent} from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
import { import { MessageQueue, insertEventIntoAscendingList, insertEventIntoDescendingList } from './utils.ts'
MessageQueue,
insertEventIntoAscendingList,
insertEventIntoDescendingList,
} from './utils.ts'
import type {Event} from './event.ts' import type { Event } from './event.ts'
describe('inserting into a desc sorted list of events', () => { describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => { test('insert into an empty list', async () => {
const list0: Event[] = [] const list0: Event[] = []
expect( expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
insertEventIntoDescendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
}) })
test('insert in the beginning of a list', async () => { test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 20}), buildEvent({created_at: 10})] const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
const list1 = insertEventIntoDescendingList(list0, buildEvent({ const list1 = insertEventIntoDescendingList(
id: 'abc', list0,
created_at: 30 buildEvent({
})) id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(3) expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc') expect(list1[0].id).toBe('abc')
}) })
test('insert in the beginning of a list with same created_at', async () => { test('insert in the beginning of a list with same created_at', async () => {
const list0 = [ const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
buildEvent({created_at: 30}), const list1 = insertEventIntoDescendingList(
buildEvent({created_at: 20}), list0,
buildEvent({created_at: 10}), buildEvent({
] id: 'abc',
const list1 = insertEventIntoDescendingList(list0, buildEvent({ created_at: 30,
id: 'abc', }),
created_at: 30 )
}))
expect(list1).toHaveLength(4) expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc') expect(list1[0].id).toBe('abc')
}) })
test('insert in the middle of a list', async () => { test('insert in the middle of a list', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 30}), buildEvent({ created_at: 30 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 10}), buildEvent({ created_at: 10 }),
buildEvent({created_at: 1}), buildEvent({ created_at: 1 }),
] ]
const list1 = insertEventIntoDescendingList(list0, buildEvent({ const list1 = insertEventIntoDescendingList(
id: 'abc', list0,
created_at: 15 buildEvent({
})) id: 'abc',
created_at: 15,
}),
)
expect(list1).toHaveLength(5) expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc') expect(list1[2].id).toBe('abc')
}) })
test('insert in the end of a list', async () => { test('insert in the end of a list', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 10}), buildEvent({ created_at: 10 }),
] ]
const list1 = insertEventIntoDescendingList(list0, buildEvent({ const list1 = insertEventIntoDescendingList(
id: 'abc', list0,
created_at: 5 buildEvent({
})) id: 'abc',
created_at: 5,
}),
)
expect(list1).toHaveLength(6) expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc') expect(list1.slice(-1)[0].id).toBe('abc')
}) })
test('insert in the last-to-end of a list with same created_at', async () => { test('insert in the last-to-end of a list with same created_at', async () => {
const list0: Event[] = [ const list0: Event[] = [
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 10}), buildEvent({ created_at: 10 }),
] ]
const list1 = insertEventIntoDescendingList(list0, buildEvent({ const list1 = insertEventIntoDescendingList(
id: 'abc', list0,
created_at: 10 buildEvent({
})) id: 'abc',
created_at: 10,
}),
)
expect(list1).toHaveLength(6) expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc') expect(list1.slice(-2)[0].id).toBe('abc')
}) })
test('do not insert duplicates', async () => { test('do not insert duplicates', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 10, id: 'abc'}), buildEvent({ created_at: 10, id: 'abc' }),
] ]
const list1 = insertEventIntoDescendingList(list0, buildEvent({ const list1 = insertEventIntoDescendingList(
id: 'abc', list0,
created_at: 10 buildEvent({
})) id: 'abc',
created_at: 10,
}),
)
expect(list1).toHaveLength(3) expect(list1).toHaveLength(3)
}) })
}) })
@@ -103,92 +111,104 @@ describe('inserting into a desc sorted list of events', () => {
describe('inserting into a asc sorted list of events', () => { describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => { test('insert into an empty list', async () => {
const list0: Event[] = [] const list0: Event[] = []
expect( expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
insertEventIntoAscendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
}) })
test('insert in the beginning of a list', async () => { test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 10}), buildEvent({created_at: 20})] const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
const list1 = insertEventIntoAscendingList(list0, buildEvent({ const list1 = insertEventIntoAscendingList(
id: 'abc', list0,
created_at: 1 buildEvent({
})) id: 'abc',
created_at: 1,
}),
)
expect(list1).toHaveLength(3) expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc') expect(list1[0].id).toBe('abc')
}) })
test('insert in the beginning of a list with same created_at', async () => { test('insert in the beginning of a list with same created_at', async () => {
const list0 = [ const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
buildEvent({created_at: 10}), const list1 = insertEventIntoAscendingList(
buildEvent({created_at: 20}), list0,
buildEvent({created_at: 30}), buildEvent({
] id: 'abc',
const list1 = insertEventIntoAscendingList(list0, buildEvent({ created_at: 10,
id: 'abc', }),
created_at: 10 )
}))
expect(list1).toHaveLength(4) expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc') expect(list1[0].id).toBe('abc')
}) })
test('insert in the middle of a list', async () => { test('insert in the middle of a list', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 10}), buildEvent({ created_at: 10 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 30}), buildEvent({ created_at: 30 }),
buildEvent({created_at: 40}), buildEvent({ created_at: 40 }),
] ]
const list1 = insertEventIntoAscendingList(list0, buildEvent({ const list1 = insertEventIntoAscendingList(
id: 'abc', list0,
created_at: 25 buildEvent({
})) id: 'abc',
created_at: 25,
}),
)
expect(list1).toHaveLength(5) expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc') expect(list1[2].id).toBe('abc')
}) })
test('insert in the end of a list', async () => { test('insert in the end of a list', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 40}), buildEvent({ created_at: 40 }),
] ]
const list1 = insertEventIntoAscendingList(list0, buildEvent({ const list1 = insertEventIntoAscendingList(
id: 'abc', list0,
created_at: 50 buildEvent({
})) id: 'abc',
created_at: 50,
}),
)
expect(list1).toHaveLength(6) expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc') expect(list1.slice(-1)[0].id).toBe('abc')
}) })
test('insert in the last-to-end of a list with same created_at', async () => { test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 30}), buildEvent({ created_at: 30 }),
] ]
const list1 = insertEventIntoAscendingList(list0, buildEvent({ const list1 = insertEventIntoAscendingList(
id: 'abc', list0,
created_at: 30 buildEvent({
})) id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(6) expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc') expect(list1.slice(-2)[0].id).toBe('abc')
}) })
test('do not insert duplicates', async () => { test('do not insert duplicates', async () => {
const list0 = [ const list0 = [
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 20}), buildEvent({ created_at: 20 }),
buildEvent({created_at: 30, id: 'abc'}), buildEvent({ created_at: 30, id: 'abc' }),
] ]
const list1 = insertEventIntoAscendingList(list0, buildEvent({ const list1 = insertEventIntoAscendingList(
id: 'abc', list0,
created_at: 30 buildEvent({
})) id: 'abc',
created_at: 30,
}),
)
expect(list1).toHaveLength(3) expect(list1).toHaveLength(3)
}) })
}) })

View File

@@ -1,4 +1,4 @@
import type {Event} from './event.ts' import type { Event } from './event.ts'
export const utf8Decoder = new TextDecoder('utf-8') export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder() export const utf8Encoder = new TextEncoder()
@@ -7,11 +7,7 @@ export function normalizeURL(url: string): string {
let p = new URL(url) let p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/') p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1) if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if ( if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
(p.port === '80' && p.protocol === 'ws:') ||
(p.port === '443' && p.protocol === 'wss:')
)
p.port = ''
p.searchParams.sort() p.searchParams.sort()
p.hash = '' p.hash = ''
return p.toString() return p.toString()
@@ -20,10 +16,7 @@ export function normalizeURL(url: string): string {
// //
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array // fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
// //
export function insertEventIntoDescendingList( export function insertEventIntoDescendingList(sortedArray: Event<number>[], event: Event<number>) {
sortedArray: Event<number>[],
event: Event<number>
) {
let start = 0 let start = 0
let end = sortedArray.length - 1 let end = sortedArray.length - 1
let midPoint let midPoint
@@ -55,20 +48,13 @@ export function insertEventIntoDescendingList(
// insert when num is NOT already in (no duplicates) // insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) { if (sortedArray[position]?.id !== event.id) {
return [ return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
...sortedArray.slice(0, position),
event,
...sortedArray.slice(position)
]
} }
return sortedArray return sortedArray
} }
export function insertEventIntoAscendingList( export function insertEventIntoAscendingList(sortedArray: Event<number>[], event: Event<number>) {
sortedArray: Event<number>[],
event: Event<number>
) {
let start = 0 let start = 0
let end = sortedArray.length - 1 let end = sortedArray.length - 1
let midPoint let midPoint
@@ -100,11 +86,7 @@ export function insertEventIntoAscendingList(
// insert when num is NOT already in (no duplicates) // insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) { if (sortedArray[position]?.id !== event.id) {
return [ return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
...sortedArray.slice(0, position),
event,
...sortedArray.slice(position)
]
} }
return sortedArray return sortedArray

1372
yarn.lock

File diff suppressed because it is too large Load Diff