mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b0817a40 | ||
|
|
c73268c4e2 | ||
|
|
6874f58c0a | ||
|
|
e899cc32b7 | ||
|
|
de72172583 | ||
|
|
073dcaafd6 | ||
|
|
8e932f0c5a | ||
|
|
f9a048679f | ||
|
|
6db8b94275 | ||
|
|
13bc2ad5a8 | ||
|
|
55f032d0a4 | ||
|
|
c890e29290 | ||
|
|
c18f050468 | ||
|
|
401b9c7864 | ||
|
|
c175f6c804 | ||
|
|
41265a19f5 | ||
|
|
d88761907a | ||
|
|
8325d4351e | ||
|
|
62bf592d72 | ||
|
|
54f3bedf38 | ||
|
|
34e0ad8c41 | ||
|
|
e9eac28bab | ||
|
|
85035d61f2 | ||
|
|
cf46560619 | ||
|
|
e7aa23cb1d | ||
|
|
5977d68ec2 | ||
|
|
48767d382d | ||
|
|
718032022c | ||
|
|
2a70bb18ff | ||
|
|
9effe807d1 | ||
|
|
899c2bd0dc | ||
|
|
918d514a25 | ||
|
|
48cb9046c4 | ||
|
|
864dd28b26 | ||
|
|
fa085367c9 | ||
|
|
350951b88e | ||
|
|
c6133f7160 | ||
|
|
470512bbeb | ||
|
|
c3acb82464 | ||
|
|
fc23d05764 | ||
|
|
8296ce897c | ||
|
|
3ca78c0e13 | ||
|
|
837a05e54d | ||
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 | ||
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 | ||
|
|
c1848d78a0 | ||
|
|
81776ba811 | ||
|
|
915d6d729b | ||
|
|
1a23f5ee01 | ||
|
|
fec40490a2 | ||
|
|
bb3e41bb89 | ||
|
|
27b971eef3 | ||
|
|
0041008b22 | ||
|
|
ae5bf4c72c | ||
|
|
75fc836cf6 | ||
|
|
70b025b8da | ||
|
|
c9bc702d90 | ||
|
|
7652318185 | ||
|
|
d81a2444b3 | ||
|
|
7507943253 | ||
|
|
b9a7f814aa | ||
|
|
0e364701da | ||
|
|
a55fb8465f | ||
|
|
472a01af6a | ||
|
|
bb5acfc197 | ||
|
|
1c6f39e4ae | ||
|
|
5b15237b95 | ||
|
|
4184609a00 | ||
|
|
97287cad74 | ||
|
|
fa21f71ab5 | ||
|
|
08885ab8da | ||
|
|
9f896479d0 | ||
|
|
82caa2aad9 | ||
|
|
67a8ee23ce | ||
|
|
18e8227123 | ||
|
|
64caef9cda | ||
|
|
6a07d2d9d3 | ||
|
|
341ccc5ac5 | ||
|
|
d2a9af2586 | ||
|
|
5d92be05bb | ||
|
|
03cc18d53b | ||
|
|
ac7598b5e3 | ||
|
|
424449c773 | ||
|
|
ab6abe6815 | ||
|
|
30fd6b6215 | ||
|
|
8a53b3b8b3 | ||
|
|
d0bd599ce8 | ||
|
|
1cbb62e6b9 | ||
|
|
977316915b | ||
|
|
dd8f555094 | ||
|
|
87f5ea4291 | ||
|
|
595ae21baf | ||
|
|
9fa554ca8e | ||
|
|
1647601727 | ||
|
|
b66ca1787a | ||
|
|
278cdda9c2 | ||
|
|
552530fa3f | ||
|
|
13e9b4aa3e | ||
|
|
9a3e05ce5f | ||
|
|
55ff796b9f | ||
|
|
3ef2ad5bc4 | ||
|
|
45c07a5f45 | ||
|
|
6a037d1658 | ||
|
|
dcf101c6c2 | ||
|
|
eb97dbd9ef | ||
|
|
92988051c6 | ||
|
|
bf7e00d32a | ||
|
|
9241089997 | ||
|
|
32c47e9bd8 | ||
|
|
6e58fe371c | ||
|
|
26e35d50e0 | ||
|
|
ef3184a6e0 | ||
|
|
56fe3dd5dd | ||
|
|
f1bb5030c8 | ||
|
|
ac212cb5c8 | ||
|
|
204ae0eff1 | ||
|
|
f17ab41d72 | ||
|
|
f6f5ee8223 | ||
|
|
a05506468d | ||
|
|
674ff66b6f | ||
|
|
731705047a | ||
|
|
94b382a49f | ||
|
|
199411a971 | ||
|
|
a1dc6f41b9 | ||
|
|
5b59b93d86 | ||
|
|
12acd7bdca | ||
|
|
3bdb68020d | ||
|
|
b0a58e2ca4 | ||
|
|
b063be76ae |
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["prettier"],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
@@ -18,8 +19,6 @@
|
||||
"node": true
|
||||
},
|
||||
|
||||
"plugins": ["babel"],
|
||||
|
||||
"globals": {
|
||||
"document": false,
|
||||
"navigator": false,
|
||||
@@ -36,23 +35,22 @@
|
||||
|
||||
"rules": {
|
||||
"accessor-pairs": 2,
|
||||
"arrow-spacing": [2, {"before": true, "after": true}],
|
||||
"arrow-spacing": [2, { "before": true, "after": true }],
|
||||
"block-spacing": [2, "always"],
|
||||
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
|
||||
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
|
||||
"comma-dangle": 0,
|
||||
"comma-spacing": [2, {"before": false, "after": true}],
|
||||
"comma-spacing": [2, { "before": false, "after": true }],
|
||||
"comma-style": [2, "last"],
|
||||
"constructor-super": 2,
|
||||
"curly": [0, "multi-line"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"generator-star-spacing": [2, {"before": true, "after": true}],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
"key-spacing": [2, {"beforeColon": false, "afterColon": true}],
|
||||
"keyword-spacing": [2, {"before": true, "after": true}],
|
||||
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
||||
"keyword-spacing": [2, { "before": true, "after": true }],
|
||||
"new-cap": 0,
|
||||
"new-parens": 0,
|
||||
"no-array-constructor": 2,
|
||||
@@ -84,12 +82,12 @@
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-iterator": 2,
|
||||
"no-label-var": 2,
|
||||
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}],
|
||||
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
|
||||
"no-lone-blocks": 2,
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-multi-spaces": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-multiple-empty-lines": [2, {"max": 2}],
|
||||
"no-multiple-empty-lines": [2, { "max": 2 }],
|
||||
"no-native-reassign": 2,
|
||||
"no-negated-in-lhs": 2,
|
||||
"no-new": 0,
|
||||
@@ -103,7 +101,6 @@
|
||||
"no-octal-escape": 2,
|
||||
"no-path-concat": 0,
|
||||
"no-proto": 2,
|
||||
"no-redeclare": 2,
|
||||
"no-regex-spaces": 2,
|
||||
"no-return-assign": 0,
|
||||
"no-self-assign": 2,
|
||||
@@ -118,34 +115,23 @@
|
||||
"no-undef": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unneeded-ternary": [2, {"defaultAssignment": false}],
|
||||
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||
"no-unreachable": 2,
|
||||
"no-unused-vars": [
|
||||
2,
|
||||
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
|
||||
],
|
||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-with": 2,
|
||||
"one-var": [0, {"initialized": "never"}],
|
||||
"operator-linebreak": [
|
||||
2,
|
||||
"after",
|
||||
{"overrides": {"?": "before", ":": "before"}}
|
||||
],
|
||||
"one-var": [0, { "initialized": "never" }],
|
||||
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
|
||||
"padded-blocks": [2, "never"],
|
||||
"quotes": [
|
||||
2,
|
||||
"single",
|
||||
{"avoidEscape": true, "allowTemplateLiterals": true}
|
||||
],
|
||||
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
|
||||
"semi": [2, "never"],
|
||||
"semi-spacing": [2, {"before": false, "after": true}],
|
||||
"semi-spacing": [2, { "before": false, "after": true }],
|
||||
"space-before-blocks": [2, "always"],
|
||||
"space-before-function-paren": 0,
|
||||
"space-in-parens": [2, "never"],
|
||||
"space-infix-ops": 2,
|
||||
"space-unary-ops": [2, {"words": true, "nonwords": false}],
|
||||
"space-unary-ops": [2, { "words": true, "nonwords": false }],
|
||||
"spaced-comment": 0,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
"use-isnan": 2,
|
||||
@@ -153,5 +139,13 @@
|
||||
"wrap-iife": [2, "any"],
|
||||
"yield-star-spacing": [2, "both"],
|
||||
"yoda": [0]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.ts"],
|
||||
"env": { "jest/globals": true },
|
||||
"plugins": ["jest"],
|
||||
"extends": ["plugin:jest/recommended"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@@ -15,5 +15,14 @@ jobs:
|
||||
node-version: 18
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: just build
|
||||
- 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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
semi: false
|
||||
arrowParens: avoid
|
||||
bracketSpacing: true
|
||||
insertPragma: false
|
||||
printWidth: 80
|
||||
printWidth: 120
|
||||
proseWrap: preserve
|
||||
semi: false
|
||||
singleQuote: true
|
||||
trailingComma: none
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
jsxBracketSameLine: false
|
||||
bracketSpacing: false
|
||||
|
||||
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
124
README.md
124
README.md
@@ -4,18 +4,22 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||
|
||||
Only depends on _@scure_ and _@noble_ packages.
|
||||
|
||||
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install nostr-tools # or yarn add nostr-tools
|
||||
```
|
||||
|
||||
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||
|
||||
## Usage
|
||||
|
||||
### Generating a private key and a public key
|
||||
|
||||
```js
|
||||
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
|
||||
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generatePrivateKey() // `sk` is a hex string
|
||||
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
@@ -24,24 +28,18 @@ let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
### Creating, signing and verifying events
|
||||
|
||||
```js
|
||||
import {
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
signEvent,
|
||||
getEventHash,
|
||||
getPublicKey
|
||||
} from 'nostr-tools'
|
||||
import { validateEvent, verifySignature, getSignature, getEventHash, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let event = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello',
|
||||
pubkey: getPublicKey(privateKey)
|
||||
pubkey: getPublicKey(privateKey),
|
||||
}
|
||||
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, privateKey)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
|
||||
let ok = validateEvent(event)
|
||||
let veryOk = verifySignature(event)
|
||||
@@ -50,13 +48,7 @@ let veryOk = verifySignature(event)
|
||||
### Interacting with a relay
|
||||
|
||||
```js
|
||||
import {
|
||||
relayInit,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
signEvent
|
||||
} from 'nostr-tools'
|
||||
import { relayInit, finishEvent, generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const relay = relayInit('wss://relay.example.com')
|
||||
relay.on('connect', () => {
|
||||
@@ -71,8 +63,8 @@ await relay.connect()
|
||||
// let's query for an event that exists
|
||||
let sub = relay.sub([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
}
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
])
|
||||
sub.on('event', event => {
|
||||
console.log('we got the event we wanted:', event)
|
||||
@@ -88,8 +80,8 @@ let pk = getPublicKey(sk)
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk]
|
||||
}
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
@@ -98,25 +90,18 @@ sub.on('event', event => {
|
||||
|
||||
let event = {
|
||||
kind: 1,
|
||||
pubkey: pk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'hello world'
|
||||
content: 'hello world',
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, sk)
|
||||
|
||||
let pub = relay.publish(event)
|
||||
pub.on('ok', () => {
|
||||
console.log(`${relay.url} has accepted our event`)
|
||||
})
|
||||
pub.on('failed', reason => {
|
||||
console.log(`failed to publish to ${relay.url}: ${reason}`)
|
||||
})
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finishEvent(event, sk)
|
||||
await relay.publish(signedEvent)
|
||||
|
||||
let events = await relay.list([{kinds: [0, 1]}])
|
||||
let events = await relay.list([{ kinds: [0, 1] }])
|
||||
let event = await relay.get({
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
relay.close()
|
||||
@@ -131,7 +116,7 @@ import 'websocket-polyfill'
|
||||
### Interacting with multiple relays
|
||||
|
||||
```js
|
||||
import {SimplePool} from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
@@ -141,11 +126,9 @@ let sub = pool.sub(
|
||||
[...relays, 'wss://relay.example3.com'],
|
||||
[
|
||||
{
|
||||
authors: [
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
]
|
||||
}
|
||||
]
|
||||
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
sub.on('event', event => {
|
||||
@@ -154,31 +137,35 @@ sub.on('event', event => {
|
||||
})
|
||||
|
||||
let pubs = pool.publish(relays, newEvent)
|
||||
pubs.on('ok', () => {
|
||||
// this may be called multiple times, once for every relay that accepts the event
|
||||
// ...
|
||||
})
|
||||
await Promise.all(pubs)
|
||||
|
||||
let events = await pool.list(relays, [{kinds: [0, 1]}])
|
||||
let events = await pool.list(relays, [{ kinds: [0, 1] }])
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
let relaysForEvent = pool.seenOn(
|
||||
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
let batchedEvents = await pool.batchedList('notes', relays, [{ kinds: [1] }])
|
||||
// `batchedList` will wait for other function calls with the same `batchKey`
|
||||
// (e.g. 'notes', 'authors', etc) within a fixed amount of time (default: `100ms`) before sending
|
||||
// next ws request, and batch all requests with similar `batchKey`s together in a single request.
|
||||
|
||||
let relaysForEvent = pool.seenOn('44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
// relaysForEvent will be an array of URLs from relays a given event was seen on
|
||||
|
||||
pool.close()
|
||||
```
|
||||
|
||||
read more details about `batchedList` on this pr: [https://github.com/nbd-wtf/nostr-tools/pull/279](https://github.com/nbd-wtf/nostr-tools/pull/279#issue-1859315757)
|
||||
|
||||
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||
|
||||
```js
|
||||
import {parseReferences} from 'nostr-tools'
|
||||
import { parseReferences } from 'nostr-tools'
|
||||
|
||||
let references = parseReferences(event)
|
||||
let simpleAugmentedContent = event.content
|
||||
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
|
||||
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||
: event
|
||||
@@ -193,7 +180,7 @@ for (let i = 0; i < references.length; i++) {
|
||||
### Querying profile data from a NIP-05 address
|
||||
|
||||
```js
|
||||
import {nip05} from 'nostr-tools'
|
||||
import { nip05 } from 'nostr-tools'
|
||||
|
||||
let profile = await nip05.queryProfile('jb55.com')
|
||||
console.log(profile.pubkey)
|
||||
@@ -202,7 +189,7 @@ console.log(profile.relays)
|
||||
// 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
|
||||
nip05.useFetchImplementation(require('node-fetch'))
|
||||
@@ -211,27 +198,24 @@ nip05.useFetchImplementation(require('node-fetch'))
|
||||
### Encoding and decoding NIP-19 codes
|
||||
|
||||
```js
|
||||
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
|
||||
import { nip19, generatePrivateKey, getPublicKey } from 'nostr-tools'
|
||||
|
||||
let sk = generatePrivateKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
let {type, data} = nip19.decode(nsec)
|
||||
let { type, data } = nip19.decode(nsec)
|
||||
assert(type === 'nsec')
|
||||
assert(data === sk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let npub = nip19.npubEncode(pk)
|
||||
let {type, data} = nip19.decode(npub)
|
||||
let { type, data } = nip19.decode(npub)
|
||||
assert(type === 'npub')
|
||||
assert(data === pk)
|
||||
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = [
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
||||
let {type, data} = nip19.decode(nprofile)
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||
let { type, data } = nip19.decode(nprofile)
|
||||
assert(type === 'nprofile')
|
||||
assert(data.pubkey === pk)
|
||||
assert(data.relays.length === 2)
|
||||
@@ -240,7 +224,7 @@ assert(data.relays.length === 2)
|
||||
### Encrypting and decrypting direct messages
|
||||
|
||||
```js
|
||||
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
||||
import { nip04, getPublicKey, generatePrivateKey } from 'nostr-tools'
|
||||
|
||||
// sender
|
||||
let sk1 = generatePrivateKey()
|
||||
@@ -259,14 +243,14 @@ let event = {
|
||||
pubkey: pk1,
|
||||
tags: [['p', pk2]],
|
||||
content: ciphertext,
|
||||
...otherProperties
|
||||
...otherProperties,
|
||||
}
|
||||
|
||||
sendEvent(event)
|
||||
|
||||
// on the receiver side
|
||||
sub.on('event', event => {
|
||||
let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
||||
sub.on('event', async event => {
|
||||
let sender = event.pubkey
|
||||
pk1 === sender
|
||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
||||
})
|
||||
@@ -275,7 +259,7 @@ sub.on('event', event => {
|
||||
### Performing and checking for delegation
|
||||
|
||||
```js
|
||||
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
|
||||
import { nip26, getPublicKey, generatePrivateKey } from 'nostr-tools'
|
||||
|
||||
// delegator
|
||||
let sk1 = generatePrivateKey()
|
||||
@@ -290,7 +274,7 @@ let delegation = nip26.createDelegation(sk1, {
|
||||
pubkey: pk2,
|
||||
kind: 1,
|
||||
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
|
||||
@@ -299,7 +283,7 @@ let event = {
|
||||
kind: 1,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
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
|
||||
@@ -325,4 +309,4 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
|
||||
|
||||
## License
|
||||
|
||||
Public domain.
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
12
build.js
12
build.js
@@ -6,7 +6,7 @@ const esbuild = require('esbuild')
|
||||
let common = {
|
||||
entryPoints: ['index.ts'],
|
||||
bundle: true,
|
||||
sourcemap: 'external'
|
||||
sourcemap: 'external',
|
||||
}
|
||||
|
||||
esbuild
|
||||
@@ -14,10 +14,10 @@ esbuild
|
||||
...common,
|
||||
outfile: 'lib/esm/nostr.mjs',
|
||||
format: 'esm',
|
||||
packages: 'external'
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({type: 'module'})
|
||||
const packageJson = JSON.stringify({ type: 'module' })
|
||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('esm build success.')
|
||||
@@ -28,7 +28,7 @@ esbuild
|
||||
...common,
|
||||
outfile: 'lib/nostr.cjs.js',
|
||||
format: 'cjs',
|
||||
packages: 'external'
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => console.log('cjs build success.'))
|
||||
|
||||
@@ -41,7 +41,7 @@ esbuild
|
||||
define: {
|
||||
window: 'self',
|
||||
global: 'self',
|
||||
process: '{"env": {}}'
|
||||
}
|
||||
process: '{"env": {}}',
|
||||
},
|
||||
})
|
||||
.then(() => console.log('standalone build success.'))
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
const {
|
||||
import {
|
||||
getBlankEvent,
|
||||
finishEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
signEvent,
|
||||
getPublicKey,
|
||||
Kind
|
||||
} = require('./lib/nostr.cjs')
|
||||
getSignature,
|
||||
Kind,
|
||||
verifiedSymbol,
|
||||
} from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
|
||||
describe('Event', () => {
|
||||
describe('getBlankEvent', () => {
|
||||
@@ -17,22 +18,30 @@ describe('Event', () => {
|
||||
kind: 255,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
created_at: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a blank event object with defined kind', () => {
|
||||
expect(getBlankEvent(Kind.Text)).toEqual({
|
||||
kind: 1,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishEvent', () => {
|
||||
it('should create a signed event from a template', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const template = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishEvent(template, privateKey)
|
||||
@@ -49,8 +58,7 @@ describe('Event', () => {
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
it('should serialize a valid event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
@@ -58,7 +66,7 @@ describe('Event', () => {
|
||||
created_at: 1617932115,
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!'
|
||||
content: 'Hello, world!',
|
||||
}
|
||||
|
||||
const serializedEvent = serializeEvent(unsignedEvent)
|
||||
@@ -70,24 +78,24 @@ describe('Event', () => {
|
||||
unsignedEvent.created_at,
|
||||
unsignedEvent.kind,
|
||||
unsignedEvent.tags,
|
||||
unsignedEvent.content
|
||||
])
|
||||
unsignedEvent.content,
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error for an invalid event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey // missing content
|
||||
pubkey: publicKey, // missing content
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
@@ -95,8 +103,7 @@ describe('Event', () => {
|
||||
|
||||
describe('getEventHash', () => {
|
||||
it('should return the correct event hash', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
@@ -104,7 +111,7 @@ describe('Event', () => {
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const eventHash = getEventHash(unsignedEvent)
|
||||
@@ -116,8 +123,7 @@ describe('Event', () => {
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
@@ -125,7 +131,7 @@ describe('Event', () => {
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(unsignedEvent)
|
||||
@@ -145,7 +151,7 @@ describe('Event', () => {
|
||||
const invalidEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
created_at: 1617932115 // missing content and pubkey
|
||||
created_at: 1617932115, // missing content and pubkey
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
@@ -162,15 +168,14 @@ describe('Event', () => {
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid properties', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: '1617932115', // should be a number
|
||||
pubkey: publicKey
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
@@ -184,7 +189,7 @@ describe('Event', () => {
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: 'invalid_pubkey'
|
||||
pubkey: 'invalid_pubkey',
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
@@ -193,8 +198,7 @@ describe('Event', () => {
|
||||
})
|
||||
|
||||
it('should return false for an object with invalid tags', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const invalidEvent = {
|
||||
@@ -202,7 +206,7 @@ describe('Event', () => {
|
||||
tags: {}, // should be an array
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const isValid = validateEvent(invalidEvent)
|
||||
@@ -213,17 +217,16 @@ describe('Event', () => {
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should return true for a valid event signature', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const event = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
@@ -232,21 +235,20 @@ describe('Event', () => {
|
||||
})
|
||||
|
||||
it('should return false for an invalid event signature', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const event = finishEvent(
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
// tamper with the signature
|
||||
event.sig = event.sig.replace(/0/g, '1')
|
||||
event.sig = event.sig.replace(/^.{3}/g, '666')
|
||||
|
||||
const isValid = verifySignature(event)
|
||||
|
||||
@@ -254,37 +256,55 @@ describe('Event', () => {
|
||||
})
|
||||
|
||||
it('should return false when verifying an event with a different private key', () => {
|
||||
const privateKey1 =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey1 = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const privateKey2 =
|
||||
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
|
||||
const privateKey2 = '5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
|
||||
const publicKey2 = getPublicKey(privateKey2)
|
||||
|
||||
const event = finishEvent(
|
||||
const { [verifiedSymbol]: _, ...event } = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey1
|
||||
privateKey1,
|
||||
)
|
||||
|
||||
// verify with different private key
|
||||
const isValid = verifySignature({
|
||||
...event,
|
||||
pubkey: publicKey2
|
||||
pubkey: publicKey2,
|
||||
})
|
||||
|
||||
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('signEvent', () => {
|
||||
it('should sign an event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
describe('getSignature', () => {
|
||||
it('should produce the correct signature for an event object', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const unsignedEvent = {
|
||||
@@ -292,15 +312,16 @@ describe('Event', () => {
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const sig = signEvent(unsignedEvent, privateKey)
|
||||
const sig = getSignature(unsignedEvent, privateKey)
|
||||
|
||||
// verify the signature
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig
|
||||
id: getEventHash(unsignedEvent),
|
||||
sig,
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
@@ -309,27 +330,26 @@ describe('Event', () => {
|
||||
})
|
||||
|
||||
it('should not sign an event with different private key', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const wrongPrivateKey =
|
||||
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
|
||||
const wrongPrivateKey = 'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: Kind.Text,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 1617932115,
|
||||
pubkey: publicKey
|
||||
pubkey: publicKey,
|
||||
}
|
||||
|
||||
const sig = signEvent(unsignedEvent, wrongPrivateKey)
|
||||
const sig = getSignature(unsignedEvent, wrongPrivateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig
|
||||
sig,
|
||||
})
|
||||
|
||||
expect(typeof sig).toEqual('string')
|
||||
117
event.ts
117
event.ts
@@ -1,9 +1,14 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
import {utf8Encoder} from './utils'
|
||||
import {getPublicKey} from './keys'
|
||||
import { getPublicKey } from './keys.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 */
|
||||
export enum Kind {
|
||||
Metadata = 0,
|
||||
@@ -12,74 +17,85 @@ export enum Kind {
|
||||
Contacts = 3,
|
||||
EncryptedDirectMessage = 4,
|
||||
EventDeletion = 5,
|
||||
Repost = 6,
|
||||
Reaction = 7,
|
||||
BadgeAward = 8,
|
||||
ChannelCreation = 40,
|
||||
ChannelMetadata = 41,
|
||||
ChannelMessage = 42,
|
||||
ChannelHideMessage = 43,
|
||||
ChannelMuteUser = 44,
|
||||
Blank = 255,
|
||||
Report = 1984,
|
||||
ZapRequest = 9734,
|
||||
Zap = 9735,
|
||||
RelayList = 10002,
|
||||
ClientAuth = 22242,
|
||||
Article = 30023
|
||||
NwcRequest = 23194,
|
||||
HttpAuth = 27235,
|
||||
ProfileBadge = 30008,
|
||||
BadgeDefinition = 30009,
|
||||
Article = 30023,
|
||||
FileMetadata = 1063,
|
||||
}
|
||||
|
||||
export type EventTemplate = {
|
||||
kind: Kind
|
||||
export interface Event<K extends number = number> {
|
||||
kind: K
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type UnsignedEvent = EventTemplate & {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export type Event = UnsignedEvent & {
|
||||
id: string
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
export function getBlankEvent(): EventTemplate {
|
||||
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<K extends number>(kind: K): EventTemplate<K>
|
||||
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
|
||||
return {
|
||||
kind: 255,
|
||||
kind,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
created_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function finishEvent(t: EventTemplate, privateKey: string): Event {
|
||||
let event = t as Event
|
||||
export function finishEvent<K extends number = number>(t: EventTemplate<K>, privateKey: string): VerifiedEvent<K> {
|
||||
const event = t as VerifiedEvent<K>
|
||||
event.pubkey = getPublicKey(privateKey)
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, privateKey)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
event[verifiedSymbol] = true
|
||||
return event
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent): string {
|
||||
if (!validateEvent(evt))
|
||||
throw new Error("can't serialize event with wrong or missing properties")
|
||||
export function serializeEvent(evt: UnsignedEvent<number>): string {
|
||||
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||
|
||||
return JSON.stringify([
|
||||
0,
|
||||
evt.pubkey,
|
||||
evt.created_at,
|
||||
evt.kind,
|
||||
evt.tags,
|
||||
evt.content
|
||||
])
|
||||
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||
}
|
||||
|
||||
export function getEventHash(event: UnsignedEvent): string {
|
||||
export function getEventHash(event: UnsignedEvent<number>): string {
|
||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||
return secp256k1.utils.bytesToHex(eventHash)
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
export function validateEvent(event: UnsignedEvent): boolean {
|
||||
if (typeof event !== 'object') return false
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
@@ -98,16 +114,31 @@ export function validateEvent(event: UnsignedEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function verifySignature(event: Event & {sig: string}): boolean {
|
||||
return secp256k1.schnorr.verifySync(
|
||||
event.sig,
|
||||
getEventHash(event),
|
||||
event.pubkey
|
||||
)
|
||||
/** 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 {
|
||||
return (event[verifiedSymbol] = schnorr.verify(event.sig, hash, event.pubkey))
|
||||
} catch (err) {
|
||||
return (event[verifiedSymbol] = false)
|
||||
}
|
||||
}
|
||||
|
||||
export function signEvent(event: UnsignedEvent, key: string): string {
|
||||
return secp256k1.utils.bytesToHex(
|
||||
secp256k1.schnorr.signSync(getEventHash(event), key)
|
||||
/** @deprecated Use `getSignature` instead. */
|
||||
export function signEvent(event: UnsignedEvent<number>, key: string): string {
|
||||
console.warn(
|
||||
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.',
|
||||
)
|
||||
return getSignature(event, key)
|
||||
}
|
||||
|
||||
/** Calculate the signature for an event. */
|
||||
export function getSignature(event: UnsignedEvent<number>, key: string): string {
|
||||
return bytesToHex(schnorr.sign(getEventHash(event), key))
|
||||
}
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {fj} = require('./lib/nostr.cjs')
|
||||
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||
|
||||
test('match id', () => {
|
||||
expect(
|
||||
fj.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}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||
)
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
fj.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":[]}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||
)
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||
),
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
test('match kind', () => {
|
||||
expect(
|
||||
fj.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}]`,
|
||||
1
|
||||
)
|
||||
1,
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
fj.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":[]}]`,
|
||||
12720
|
||||
)
|
||||
12720,
|
||||
),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test('match subscription id', () => {
|
||||
expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||
expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||
expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||
expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
||||
'kasjbdjkav'
|
||||
)
|
||||
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
|
||||
expect(
|
||||
fj.getSubscriptionId(
|
||||
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
|
||||
)
|
||||
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
|
||||
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||
})
|
||||
176
filter.test.js
176
filter.test.js
@@ -1,176 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
it('should return true when all filter conditions are met', () => {
|
||||
const filter = {
|
||||
ids: ['123', '456'],
|
||||
kinds: [1, 2, 3],
|
||||
authors: ['abc'],
|
||||
since: 100,
|
||||
until: 200,
|
||||
'#tag': ['value']
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']]
|
||||
}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event id is not in the filter', () => {
|
||||
const filter = {ids: ['123', '456']}
|
||||
|
||||
const event = {id: '789'}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event kind is not in the filter', () => {
|
||||
const filter = {kinds: [1, 2, 3]}
|
||||
|
||||
const event = {kind: 4}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event author is not in the filter', () => {
|
||||
const filter = {authors: ['abc', 'def']}
|
||||
|
||||
const event = {pubkey: 'ghi'}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag is not present in the event', () => {
|
||||
const filter = {'#tag': ['value1', 'value2']}
|
||||
|
||||
const event = {tags: [['not_tag', 'value1']]}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag value is not present in the event', () => {
|
||||
const filter = {'#tag': ['value1', 'value2']}
|
||||
|
||||
const event = {tags: [['tag', 'value3']]}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = {'#tag1': ['foo']}
|
||||
|
||||
const event = {
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [
|
||||
['tag1', 'foo'],
|
||||
['tag2', 'bar']
|
||||
]
|
||||
}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is before the filter since value', () => {
|
||||
const filter = {since: 100}
|
||||
|
||||
const event = {created_at: 50}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event is after the filter until value', () => {
|
||||
const filter = {until: 100}
|
||||
|
||||
const event = {created_at: 150}
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFilters', () => {
|
||||
it('should return true when at least one filter matches the event', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], kinds: [1], authors: ['abc']},
|
||||
{ids: ['456'], kinds: [2], authors: ['def']},
|
||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
||||
]
|
||||
|
||||
const event = {id: '789', kind: 3, pubkey: 'ghi'}
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when event matches one or more filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], limit: 1},
|
||||
{kinds: [1], limit: 2},
|
||||
{authors: ['abc'], limit: 3}
|
||||
]
|
||||
|
||||
const event = {id: '123', kind: 1, pubkey: 'abc', created_at: 150}
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when no filters match the event', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], kinds: [1], authors: ['abc']},
|
||||
{ids: ['456'], kinds: [2], authors: ['def']},
|
||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
||||
]
|
||||
|
||||
const event = {id: '100', kind: 4, pubkey: 'jkl'}
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when event matches none of the filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ids: ['123'], limit: 1},
|
||||
{kinds: [1], limit: 2},
|
||||
{authors: ['abc'], limit: 3}
|
||||
]
|
||||
const event = {id: '456', kind: 2, pubkey: 'def', created_at: 200}
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
243
filter.test.ts
Normal file
243
filter.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
it('should return true when all filter conditions are met', () => {
|
||||
const filter = {
|
||||
ids: ['123', '456'],
|
||||
kinds: [1, 2, 3],
|
||||
authors: ['abc'],
|
||||
since: 100,
|
||||
until: 200,
|
||||
'#tag': ['value'],
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event id is not in the filter', () => {
|
||||
const filter = { ids: ['123', '456'] }
|
||||
|
||||
const event = buildEvent({ id: '789' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the event id starts with a prefix', () => {
|
||||
const filter = { ids: ['22', '00'] }
|
||||
|
||||
const event = buildEvent({ id: '001' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event kind is not in the filter', () => {
|
||||
const filter = { kinds: [1, 2, 3] }
|
||||
|
||||
const event = buildEvent({ kind: 4 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when the event author is not in the filter', () => {
|
||||
const filter = { authors: ['abc', 'def'] }
|
||||
|
||||
const event = buildEvent({ pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({ tags: [['not_tag', 'value1']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when a tag value is not present in the event', () => {
|
||||
const filter = { '#tag': ['value1', 'value2'] }
|
||||
|
||||
const event = buildEvent({ tags: [['tag', 'value3']] })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = { '#tag1': ['foo'] }
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [
|
||||
['tag1', 'foo'],
|
||||
['tag2', 'bar'],
|
||||
],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is before the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 50 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||
const filter = { since: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event is after the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 150 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||
const filter = { until: 100 }
|
||||
|
||||
const event = buildEvent({ created_at: 100 })
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchFilters', () => {
|
||||
it('should return true when at least one filter matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when at least one prefix matches the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['1'], kinds: [1], authors: ['a'] },
|
||||
{ ids: ['4'], kinds: [2], authors: ['d'] },
|
||||
{ ids: ['9'], kinds: [3], authors: ['g'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when event matches one or more filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when no filters match the event', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||
]
|
||||
|
||||
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return false when event matches none of the filters and some have limit set', () => {
|
||||
const filters = [
|
||||
{ ids: ['123'], limit: 1 },
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ authors: ['abc'], limit: 3 },
|
||||
]
|
||||
const event = buildEvent({
|
||||
id: '456',
|
||||
kind: 2,
|
||||
pubkey: 'def',
|
||||
created_at: 200,
|
||||
})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeFilters', () => {
|
||||
it('should merge filters', () => {
|
||||
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||
ids: ['a', 'b', 'c'],
|
||||
limit: 3,
|
||||
authors: ['x'],
|
||||
})
|
||||
|
||||
expect(
|
||||
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
})
|
||||
67
filter.ts
67
filter.ts
@@ -1,51 +1,72 @@
|
||||
import {Event} from './event'
|
||||
import { Event } from './event.ts'
|
||||
|
||||
export type Filter = {
|
||||
export type Filter<K extends number = number> = {
|
||||
ids?: string[]
|
||||
kinds?: number[]
|
||||
kinds?: K[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[]
|
||||
[key: `#${string}`]: string[] | undefined
|
||||
}
|
||||
|
||||
export function matchFilter(
|
||||
filter: Filter,
|
||||
event: Event & {id: string}
|
||||
): boolean {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
|
||||
export function matchFilter(filter: Filter<number>, event: Event<number>): boolean {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
|
||||
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (let f in filter) {
|
||||
if (f[0] === '#') {
|
||||
let tagName = f.slice(1)
|
||||
let values = filter[`#${tagName}`]
|
||||
if (
|
||||
values &&
|
||||
!event.tags.find(
|
||||
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.since && event.created_at < filter.since) return false
|
||||
if (filter.until && event.created_at >= filter.until) return false
|
||||
if (filter.until && event.created_at > filter.until) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchFilters(
|
||||
filters: Filter[],
|
||||
event: Event & {id: string}
|
||||
): boolean {
|
||||
export function matchFilters(filters: Filter<number>[], event: Event<number>): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
|
||||
let result: Filter<number> = {}
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
let filter = filters[i]
|
||||
Object.entries(filter).forEach(([property, values]) => {
|
||||
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
|
||||
// @ts-ignore
|
||||
result[property] = result[property] || []
|
||||
// @ts-ignore
|
||||
for (let v = 0; v < values.length; v++) {
|
||||
// @ts-ignore
|
||||
let value = values[v]
|
||||
// @ts-ignore
|
||||
if (!result[property].includes(value)) result[property].push(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
|
||||
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
|
||||
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
51
index.ts
51
index.ts
@@ -1,27 +1,28 @@
|
||||
export * from './keys'
|
||||
export * from './relay'
|
||||
export * from './event'
|
||||
export * from './filter'
|
||||
export * from './pool'
|
||||
export * from './references'
|
||||
export * from './keys.ts'
|
||||
export * from './relay.ts'
|
||||
export * from './event.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04'
|
||||
export * as nip05 from './nip05'
|
||||
export * as nip06 from './nip06'
|
||||
export * as nip10 from './nip10'
|
||||
export * as nip19 from './nip19'
|
||||
export * as nip26 from './nip26'
|
||||
export * as nip39 from './nip39'
|
||||
export * as nip57 from './nip57'
|
||||
export * as nip04 from './nip04.ts'
|
||||
export * as nip05 from './nip05.ts'
|
||||
export * as nip06 from './nip06.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
export * as nip25 from './nip25.ts'
|
||||
export * as nip26 from './nip26.ts'
|
||||
export * as nip27 from './nip27.ts'
|
||||
export * as nip28 from './nip28.ts'
|
||||
export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
export * as fj from './fakejson'
|
||||
export * as utils from './utils'
|
||||
|
||||
// monkey patch secp256k1
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {hmac} from '@noble/hashes/hmac'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
|
||||
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
|
||||
secp256k1.utils.sha256Sync = (...msgs) =>
|
||||
sha256(secp256k1.utils.concatBytes(...msgs))
|
||||
export * as fj from './fakejson.ts'
|
||||
export * as utils from './utils.ts'
|
||||
|
||||
5
jest.config.js
Normal file
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
}
|
||||
11
justfile
11
justfile
@@ -7,10 +7,10 @@ build:
|
||||
rm -rf lib
|
||||
node build.js
|
||||
|
||||
test: build
|
||||
test:
|
||||
jest
|
||||
|
||||
test-only file: build
|
||||
test-only file:
|
||||
jest {{file}}
|
||||
|
||||
emit-types:
|
||||
@@ -20,4 +20,9 @@ publish: build emit-types
|
||||
npm publish
|
||||
|
||||
format:
|
||||
prettier --plugin-search-dir . --write .
|
||||
eslint --ext .ts --fix *.ts
|
||||
prettier --write *.ts
|
||||
|
||||
lint:
|
||||
eslint --ext .ts *.ts
|
||||
prettier --check *.ts
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/* eslint-env jest */
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
|
||||
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
||||
|
||||
test('test private key generation', () => {
|
||||
test('private key generation', () => {
|
||||
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('test public key generation', () => {
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('test public key from private key deterministic', () => {
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
7
keys.ts
7
keys.ts
@@ -1,9 +1,10 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
|
||||
return bytesToHex(schnorr.utils.randomPrivateKey())
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey: string): string {
|
||||
return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
|
||||
return bytesToHex(schnorr.getPublicKey(privateKey))
|
||||
}
|
||||
|
||||
20
kinds.test.ts
Normal file
20
kinds.test.ts
Normal 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
40
kinds.ts
Normal 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,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
globalThis.crypto = require('crypto')
|
||||
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
||||
|
||||
test('encrypt and decrypt message', async () => {
|
||||
let sk1 = generatePrivateKey()
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let pk2 = getPublicKey(sk2)
|
||||
|
||||
expect(
|
||||
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
|
||||
).toEqual('hello')
|
||||
})
|
||||
17
nip04.test.ts
Normal file
17
nip04.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { encrypt, decrypt } from './nip04.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)
|
||||
|
||||
expect(await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))).toEqual('hello')
|
||||
})
|
||||
54
nip04.ts
54
nip04.ts
@@ -1,61 +1,39 @@
|
||||
import {randomBytes} from '@noble/hashes/utils'
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {base64} from '@scure/base'
|
||||
import { randomBytes } from '@noble/hashes/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import {utf8Decoder, utf8Encoder} from './utils'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
export async function encrypt(
|
||||
privkey: string,
|
||||
pubkey: string,
|
||||
text: string
|
||||
): Promise<string> {
|
||||
// @ts-ignore
|
||||
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||
// @ts-ignore
|
||||
crypto.subtle = crypto.webcrypto.subtle
|
||||
}
|
||||
|
||||
export async function encrypt(privkey: string, pubkey: string, text: string): Promise<string> {
|
||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16))
|
||||
let plaintext = utf8Encoder.encode(text)
|
||||
let cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
normalizedKey,
|
||||
{name: 'AES-CBC'},
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
let ciphertext = await crypto.subtle.encrypt(
|
||||
{name: 'AES-CBC', iv},
|
||||
cryptoKey,
|
||||
plaintext
|
||||
)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
privkey: string,
|
||||
pubkey: string,
|
||||
data: string
|
||||
): Promise<string> {
|
||||
export async function decrypt(privkey: string, pubkey: string, data: string): Promise<string> {
|
||||
let [ctb64, ivb64] = data.split('?iv=')
|
||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||
let normalizedKey = getNormalizedX(key)
|
||||
|
||||
let cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
normalizedKey,
|
||||
{name: 'AES-CBC'},
|
||||
false,
|
||||
['decrypt']
|
||||
)
|
||||
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
let ciphertext = base64.decode(ctb64)
|
||||
let iv = base64.decode(ivb64)
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt(
|
||||
{name: 'AES-CBC', iv},
|
||||
cryptoKey,
|
||||
ciphertext
|
||||
)
|
||||
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const fetch = require('node-fetch')
|
||||
const {nip05} = require('./lib/nostr.cjs')
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
nip05.useFetchImplementation(fetch)
|
||||
|
||||
let p1 = await nip05.queryProfile('jb55.com')
|
||||
expect(p1.pubkey).toEqual(
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
expect(p1.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p2 = await nip05.queryProfile('jb55@jb55.com')
|
||||
expect(p2.pubkey).toEqual(
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
expect(p2.relays).toEqual(['wss://relay.damus.io'])
|
||||
})
|
||||
26
nip05.test.ts
Normal file
26
nip05.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let p1 = await queryProfile('jb55.com')
|
||||
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p2 = await queryProfile('jb55@jb55.com')
|
||||
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p3 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(p3!.relays).toEqual([
|
||||
'wss://relay.nostr.bg',
|
||||
'wss://nos.lol',
|
||||
'wss://nostr-verified.wellorder.net',
|
||||
'wss://nostr.zebedee.cloud',
|
||||
'wss://eden.nostr.land',
|
||||
'wss://nostr.milou.lol',
|
||||
])
|
||||
})
|
||||
87
nip05.ts
87
nip05.ts
@@ -1,4 +1,13 @@
|
||||
import {ProfilePointer} from './nip19'
|
||||
import { ProfilePointer } from './nip19.ts'
|
||||
|
||||
/**
|
||||
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||
*
|
||||
* - 0: full match
|
||||
* - 1: name (optional)
|
||||
* - 2: domain
|
||||
*/
|
||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -10,14 +19,9 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function searchDomain(
|
||||
domain: string,
|
||||
query = ''
|
||||
): Promise<{[name: string]: string}> {
|
||||
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||
try {
|
||||
let res = await (
|
||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
|
||||
).json()
|
||||
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
||||
|
||||
return res.names
|
||||
} catch (_) {
|
||||
@@ -25,36 +29,53 @@ export async function searchDomain(
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryProfile(
|
||||
fullname: string
|
||||
): Promise<ProfilePointer | null> {
|
||||
let [name, domain] = fullname.split('@')
|
||||
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||
const match = fullname.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
if (!domain) {
|
||||
// if there is no @, it is because it is just a domain, so assume the name is "_"
|
||||
domain = name
|
||||
name = '_'
|
||||
}
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
|
||||
if (!domain.includes('.')) return null
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await (
|
||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
).json()
|
||||
} catch (err) {
|
||||
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
const { names, relays } = parseNIP05Result(await res.json())
|
||||
|
||||
const pubkey = names[name]
|
||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!res?.names?.[name]) return null
|
||||
|
||||
let pubkey = res.names[name] as string
|
||||
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relays
|
||||
/** nostr.json result. */
|
||||
export interface NIP05Result {
|
||||
names: {
|
||||
[name: string]: string
|
||||
}
|
||||
relays?: {
|
||||
[pubkey: string]: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the nostr.json and throw if it's not valid. */
|
||||
function parseNIP05Result(json: any): NIP05Result {
|
||||
const result: NIP05Result = {
|
||||
names: {},
|
||||
}
|
||||
|
||||
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||
result.names[name] = pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if (json.relays) {
|
||||
result.relays = {}
|
||||
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
const {nip06} = require('./lib/nostr.cjs')
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual(
|
||||
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
|
||||
)
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual(
|
||||
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
|
||||
)
|
||||
})
|
||||
14
nip06.test.ts
Normal file
14
nip06.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||
})
|
||||
|
||||
test('generate private key from a mnemonic and passphrase', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const passphrase = '123'
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||
})
|
||||
19
nip06.ts
19
nip06.ts
@@ -1,20 +1,13 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {wordlist} from '@scure/bip39/wordlists/english.js'
|
||||
import {
|
||||
generateMnemonic,
|
||||
mnemonicToSeedSync,
|
||||
validateMnemonic
|
||||
} from '@scure/bip39'
|
||||
import {HDKey} from '@scure/bip32'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { wordlist } from '@scure/bip39/wordlists/english'
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||
import { HDKey } from '@scure/bip32'
|
||||
|
||||
export function privateKeyFromSeedWords(
|
||||
mnemonic: string,
|
||||
passphrase?: string
|
||||
): string {
|
||||
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return secp256k1.utils.bytesToHex(privateKey)
|
||||
return bytesToHex(privateKey)
|
||||
}
|
||||
|
||||
export function generateSeedWords(): string {
|
||||
|
||||
353
nip10.test.js
353
nip10.test.js
@@ -1,353 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {nip10} = require('./lib/nostr.cjs')
|
||||
|
||||
describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||
relays: []
|
||||
}
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: []
|
||||
}
|
||||
],
|
||||
reply: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: []
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: []
|
||||
}
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: []
|
||||
}
|
||||
],
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: []
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: []
|
||||
}
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: []
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'e',
|
||||
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: []
|
||||
}
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.todo('recommended + a lot of events')
|
||||
test.todo('recommended + 3 events')
|
||||
test.todo('recommended + 2 events')
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'p',
|
||||
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
'wss://relay.mostr.pub'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
'wss://relay.mostr.pub',
|
||||
'reply'
|
||||
],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey:
|
||||
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
{
|
||||
pubkey:
|
||||
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
}
|
||||
],
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub']
|
||||
},
|
||||
root: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
232
nip10.test.ts
Normal file
232
nip10.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { parse } from './nip10.ts'
|
||||
|
||||
describe('parse NIP10-referenced events', () => {
|
||||
test('legacy + a lot of events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 3 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 2 events', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
{
|
||||
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
relays: [],
|
||||
},
|
||||
root: {
|
||||
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
|
||||
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||
relays: [],
|
||||
},
|
||||
],
|
||||
reply: undefined,
|
||||
root: {
|
||||
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
|
||||
relays: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.todo('recommended + a lot of events')
|
||||
test.todo('recommended + 3 events')
|
||||
test.todo('recommended + 2 events')
|
||||
|
||||
test('recommended + 1 event', () => {
|
||||
let event = {
|
||||
tags: [
|
||||
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
|
||||
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
|
||||
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
|
||||
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
|
||||
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
|
||||
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
|
||||
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
|
||||
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
{
|
||||
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
],
|
||||
reply: {
|
||||
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||
relays: ['wss://relay.mostr.pub'],
|
||||
},
|
||||
root: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
17
nip10.ts
17
nip10.ts
@@ -1,5 +1,5 @@
|
||||
import type {Event} from './event'
|
||||
import type {EventPointer, ProfilePointer} from './nip19'
|
||||
import type { Event } from './event.ts'
|
||||
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
reply: undefined,
|
||||
root: undefined,
|
||||
mentions: [],
|
||||
profiles: []
|
||||
profiles: [],
|
||||
}
|
||||
|
||||
const eTags: string[][] = []
|
||||
@@ -41,7 +41,7 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
result.profiles.push({
|
||||
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++) {
|
||||
const eTag = eTags[eTagIndex]
|
||||
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [
|
||||
string,
|
||||
string,
|
||||
undefined | string,
|
||||
undefined | string
|
||||
]
|
||||
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
|
||||
|
||||
const eventPointer: EventPointer = {
|
||||
id: eTagEventId,
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : []
|
||||
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
|
||||
}
|
||||
|
||||
const isFirstETag = eTagIndex === 0
|
||||
|
||||
24
nip13.test.ts
Normal file
24
nip13.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getPow, minePow } from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||
const difficulty = getPow(id)
|
||||
expect(difficulty).toEqual(21)
|
||||
})
|
||||
|
||||
test('mines POW for an event', async () => {
|
||||
const difficulty = 10
|
||||
|
||||
const event = minePow(
|
||||
{
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Hello, world!',
|
||||
created_at: 0,
|
||||
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
difficulty,
|
||||
)
|
||||
|
||||
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
|
||||
})
|
||||
52
nip13.ts
Normal file
52
nip13.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type UnsignedEvent, type Event, getEventHash } from './event.ts'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(hex: string): number {
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
const nibble = parseInt(hex[i], 16)
|
||||
if (nibble === 0) {
|
||||
count += 4
|
||||
} else {
|
||||
count += Math.clz32(nibble) - 28
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Mine an event with the desired POW. This function mutates the event.
|
||||
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
|
||||
*
|
||||
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
|
||||
*/
|
||||
export function minePow<K extends number>(unsigned: UnsignedEvent<K>, difficulty: number): Omit<Event<K>, 'sig'> {
|
||||
let count = 0
|
||||
|
||||
const event = unsigned as Omit<Event<K>, 'sig'>
|
||||
const tag = ['nonce', count.toString(), difficulty.toString()]
|
||||
|
||||
event.tags.push(tag)
|
||||
|
||||
while (true) {
|
||||
const now = Math.floor(new Date().getTime() / 1000)
|
||||
|
||||
if (now !== event.created_at) {
|
||||
count = 0
|
||||
event.created_at = now
|
||||
}
|
||||
|
||||
tag[1] = (++count).toString()
|
||||
|
||||
event.id = getEventHash(event)
|
||||
|
||||
if (getPow(event.id) >= difficulty) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
101
nip18.test.ts
Normal file
101
nip18.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { finishEvent, Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
const relayUrl = 'https://relay.example.com'
|
||||
|
||||
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const repostedEvent = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
it('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
|
||||
it('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '' as const,
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Repost)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
])
|
||||
expect(event.content).toEqual('')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
it('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
kind: Kind.Repost,
|
||||
tags: [['e', 'reposted event id', relayUrl]],
|
||||
})
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual('reposted event id')
|
||||
expect(repostedEventPointer!.author).toEqual(undefined)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
99
nip18.ts
Normal file
99
nip18.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Event, finishEvent, Kind, verifySignature } from './event.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
* Pass only non-nip18 tags if you have to.
|
||||
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
|
||||
*/
|
||||
tags?: string[][]
|
||||
|
||||
/**
|
||||
* Pass an empty string to NOT include 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
|
||||
*/
|
||||
content?: ''
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishRepostEvent(
|
||||
t: RepostEventTemplate,
|
||||
reposted: Event<number>,
|
||||
relayUrl: string,
|
||||
privateKey: string,
|
||||
): Event<Kind.Repost> {
|
||||
return finishEvent(
|
||||
{
|
||||
kind: Kind.Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||
if (event.kind !== Kind.Repost) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let lastETag: undefined | string[]
|
||||
let lastPTag: undefined | string[]
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length >= 2) {
|
||||
if (tag[0] === 'e' && lastETag === undefined) {
|
||||
lastETag = tag
|
||||
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||
lastPTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastETag === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
|
||||
author: lastPTag?.[1],
|
||||
}
|
||||
}
|
||||
|
||||
export type GetRepostedEventOptions = {
|
||||
skipVerification?: boolean
|
||||
}
|
||||
|
||||
export function getRepostedEvent(
|
||||
event: Event<number>,
|
||||
{ skipVerification }: GetRepostedEventOptions = {},
|
||||
): undefined | Event<number> {
|
||||
const pointer = getRepostedEventPointer(event)
|
||||
|
||||
if (pointer === undefined || event.content === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let repostedEvent: undefined | Event<number>
|
||||
|
||||
try {
|
||||
repostedEvent = JSON.parse(event.content) as Event<number>
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (repostedEvent.id !== pointer.id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!skipVerification && !verifySignature(repostedEvent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return repostedEvent
|
||||
}
|
||||
102
nip19.test.js
102
nip19.test.js
@@ -1,102 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
expect(nsec).toMatch(/nsec1\w+/)
|
||||
let {type, data} = nip19.decode(nsec)
|
||||
expect(type).toEqual('nsec')
|
||||
expect(data).toEqual(sk)
|
||||
})
|
||||
|
||||
test('encode and decode npub', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let npub = nip19.npubEncode(pk)
|
||||
expect(npub).toMatch(/npub1\w+/)
|
||||
let {type, data} = nip19.decode(npub)
|
||||
expect(type).toEqual('npub')
|
||||
expect(data).toEqual(pk)
|
||||
})
|
||||
|
||||
test('encode and decode nprofile', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = [
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let {type, data} = nip19.decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
expect(data.pubkey).toEqual(pk)
|
||||
expect(data.relays).toContain(relays[0])
|
||||
expect(data.relays).toContain(relays[1])
|
||||
})
|
||||
|
||||
test('decode nprofile without relays', () => {
|
||||
expect(
|
||||
nip19.decode(
|
||||
nip19.nprofileEncode({
|
||||
pubkey:
|
||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: []
|
||||
})
|
||||
).data
|
||||
).toHaveProperty(
|
||||
'pubkey',
|
||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
|
||||
)
|
||||
})
|
||||
|
||||
test('encode and decode naddr', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = [
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let naddr = nip19.naddrEncode({
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
identifier: 'banana'
|
||||
})
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let {type, data} = nip19.decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
expect(data.pubkey).toEqual(pk)
|
||||
expect(data.relays).toContain(relays[0])
|
||||
expect(data.relays).toContain(relays[1])
|
||||
expect(data.kind).toEqual(30023)
|
||||
expect(data.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let {type, data} = nip19.decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
expect(data.pubkey).toEqual(
|
||||
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
|
||||
)
|
||||
expect(data.kind).toEqual(30023)
|
||||
expect(data.identifier).toEqual('references')
|
||||
})
|
||||
|
||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
let {type, data} = nip19.decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
expect(data.pubkey).toEqual(
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
)
|
||||
expect(data.relays).toContain(
|
||||
'wss://relay.nostr.example.mydomain.example.com'
|
||||
)
|
||||
expect(data.relays).toContain('wss://nostr.banana.com')
|
||||
expect(data.kind).toEqual(30023)
|
||||
expect(data.identifier).toEqual('banana')
|
||||
})
|
||||
143
nip19.test.ts
Normal file
143
nip19.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
neventEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
EventPointer,
|
||||
} from './nip19.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let nsec = nsecEncode(sk)
|
||||
expect(nsec).toMatch(/nsec1\w+/)
|
||||
let { type, data } = decode(nsec)
|
||||
expect(type).toEqual('nsec')
|
||||
expect(data).toEqual(sk)
|
||||
})
|
||||
|
||||
test('encode and decode npub', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let npub = npubEncode(pk)
|
||||
expect(npub).toMatch(/npub1\w+/)
|
||||
let { type, data } = decode(npub)
|
||||
expect(type).toEqual('npub')
|
||||
expect(data).toEqual(pk)
|
||||
})
|
||||
|
||||
test('encode and decode nprofile', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let nprofile = nprofileEncode({ pubkey: pk, relays })
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let { type, data } = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
const pointer = data as ProfilePointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
})
|
||||
|
||||
test('decode nprofile without relays', () => {
|
||||
expect(
|
||||
decode(
|
||||
nprofileEncode({
|
||||
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: [],
|
||||
}),
|
||||
).data,
|
||||
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
|
||||
})
|
||||
|
||||
test('encode and decode naddr', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
identifier: 'banana',
|
||||
})
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nevent', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
})
|
||||
|
||||
test('encode and decode nevent with kind 0', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 0,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(0)
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
})
|
||||
|
||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nrelay', () => {
|
||||
let url = 'wss://relay.nostr.example'
|
||||
let nrelay = nrelayEncode(url)
|
||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||
let { type, data } = decode(nrelay)
|
||||
expect(type).toEqual('nrelay')
|
||||
expect(data).toEqual(url)
|
||||
})
|
||||
155
nip19.ts
155
nip19.ts
@@ -1,10 +1,29 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {bech32} from '@scure/base'
|
||||
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import {utf8Decoder, utf8Encoder} from './utils'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||
*/
|
||||
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||
|
||||
function integerToUint8Array(number: number) {
|
||||
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
|
||||
const uint8Array = new Uint8Array(4)
|
||||
|
||||
// Use bitwise operations to extract the bytes.
|
||||
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
|
||||
uint8Array[1] = (number >> 16) & 0xff
|
||||
uint8Array[2] = (number >> 8) & 0xff
|
||||
uint8Array[3] = number & 0xff // Least significant byte (LSB)
|
||||
|
||||
return uint8Array
|
||||
}
|
||||
|
||||
export type ProfilePointer = {
|
||||
pubkey: string // hex
|
||||
relays?: string[]
|
||||
@@ -14,6 +33,7 @@ export type EventPointer = {
|
||||
id: string // hex
|
||||
relays?: string[]
|
||||
author?: string
|
||||
kind?: number
|
||||
}
|
||||
|
||||
export type AddressPointer = {
|
||||
@@ -23,11 +43,29 @@ export type AddressPointer = {
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export function decode(nip19: string): {
|
||||
type: string
|
||||
data: ProfilePointer | EventPointer | AddressPointer | string
|
||||
} {
|
||||
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
||||
type Prefixes = {
|
||||
nprofile: ProfilePointer
|
||||
nrelay: string
|
||||
nevent: EventPointer
|
||||
naddr: AddressPointer
|
||||
nsec: string
|
||||
npub: string
|
||||
note: string
|
||||
}
|
||||
|
||||
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||
type: Prefix
|
||||
data: Prefixes[Prefix]
|
||||
}
|
||||
|
||||
export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
switch (prefix) {
|
||||
@@ -39,27 +77,26 @@ export function decode(nip19: string): {
|
||||
return {
|
||||
type: 'nprofile',
|
||||
data: {
|
||||
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||
}
|
||||
pubkey: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'nevent': {
|
||||
let tlv = parseTLV(data)
|
||||
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[2] && tlv[2][0].length !== 32)
|
||||
throw new Error('TLV 2 should be 32 bytes')
|
||||
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||
|
||||
return {
|
||||
type: 'nevent',
|
||||
data: {
|
||||
id: secp256k1.utils.bytesToHex(tlv[0][0]),
|
||||
id: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
author: tlv[2]?.[0]
|
||||
? secp256k1.utils.bytesToHex(tlv[2][0])
|
||||
: undefined
|
||||
}
|
||||
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,24 +112,34 @@ export function decode(nip19: string): {
|
||||
type: 'naddr',
|
||||
data: {
|
||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||
pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||
pubkey: bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'nrelay': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
||||
|
||||
return {
|
||||
type: 'nrelay',
|
||||
data: utf8Decoder.decode(tlv[0][0]),
|
||||
}
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
case 'npub':
|
||||
case 'note':
|
||||
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
|
||||
return { type: prefix, data: bytesToHex(data) }
|
||||
|
||||
default:
|
||||
throw new Error(`unknown prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
|
||||
type TLV = {[t: number]: Uint8Array[]}
|
||||
type TLV = { [t: number]: Uint8Array[] }
|
||||
|
||||
function parseTLV(data: Uint8Array): TLV {
|
||||
let result: TLV = {}
|
||||
@@ -100,64 +147,80 @@ function parseTLV(data: Uint8Array): TLV {
|
||||
while (rest.length > 0) {
|
||||
let t = rest[0]
|
||||
let l = rest[1]
|
||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
||||
let v = rest.slice(2, 2 + l)
|
||||
rest = rest.slice(2 + l)
|
||||
if (v.length < l) continue
|
||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||
result[t] = result[t] || []
|
||||
result[t].push(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function nsecEncode(hex: string): string {
|
||||
export function nsecEncode(hex: string): `nsec1${string}` {
|
||||
return encodeBytes('nsec', hex)
|
||||
}
|
||||
|
||||
export function npubEncode(hex: string): string {
|
||||
export function npubEncode(hex: string): `npub1${string}` {
|
||||
return encodeBytes('npub', hex)
|
||||
}
|
||||
|
||||
export function noteEncode(hex: string): string {
|
||||
export function noteEncode(hex: string): `note1${string}` {
|
||||
return encodeBytes('note', hex)
|
||||
}
|
||||
|
||||
function encodeBytes(prefix: string, hex: string): string {
|
||||
let data = secp256k1.utils.hexToBytes(hex)
|
||||
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode(prefix, words, Bech32MaxSize)
|
||||
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): string {
|
||||
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` {
|
||||
let data = hexToBytes(hex)
|
||||
return encodeBech32(prefix, data)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode('nprofile', words, Bech32MaxSize)
|
||||
return encodeBech32('nprofile', data)
|
||||
}
|
||||
|
||||
export function neventEncode(event: EventPointer): string {
|
||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||
let kindArray
|
||||
if (event.kind != undefined) {
|
||||
kindArray = integerToUint8Array(event.kind)
|
||||
}
|
||||
|
||||
let data = encodeTLV({
|
||||
0: [secp256k1.utils.hexToBytes(event.id)],
|
||||
0: [hexToBytes(event.id)],
|
||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
|
||||
2: event.author ? [hexToBytes(event.author)] : [],
|
||||
3: kindArray ? [new Uint8Array(kindArray)] : [],
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode('nevent', words, Bech32MaxSize)
|
||||
|
||||
return encodeBech32('nevent', data)
|
||||
}
|
||||
|
||||
export function naddrEncode(addr: AddressPointer): string {
|
||||
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||
let kind = new ArrayBuffer(4)
|
||||
new DataView(kind).setUint32(0, addr.kind, false)
|
||||
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(addr.identifier)],
|
||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: [secp256k1.utils.hexToBytes(addr.pubkey)],
|
||||
3: [new Uint8Array(kind)]
|
||||
2: [hexToBytes(addr.pubkey)],
|
||||
3: [new Uint8Array(kind)],
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode('naddr', words, Bech32MaxSize)
|
||||
return encodeBech32('naddr', data)
|
||||
}
|
||||
|
||||
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(url)],
|
||||
})
|
||||
return encodeBech32('nrelay', data)
|
||||
}
|
||||
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
@@ -173,5 +236,5 @@ function encodeTLV(tlv: TLV): Uint8Array {
|
||||
})
|
||||
})
|
||||
|
||||
return secp256k1.utils.concatBytes(...entries)
|
||||
return concatBytes(...entries)
|
||||
}
|
||||
|
||||
23
nip21.test.ts
Normal file
23
nip21.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test as testRegex, parse } from './nip21.ts'
|
||||
|
||||
test('test()', () => {
|
||||
expect(testRegex('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true)
|
||||
expect(testRegex('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true)
|
||||
expect(testRegex(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||
expect(testRegex('nostr:')).toBe(false)
|
||||
expect(testRegex('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||
expect(testRegex('gggggg')).toBe(false)
|
||||
})
|
||||
|
||||
test('parse', () => {
|
||||
const result = parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||
|
||||
expect(result).toEqual({
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
})
|
||||
})
|
||||
30
nip21.ts
Normal file
30
nip21.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||
}
|
||||
|
||||
/** Parsed Nostr URI data. */
|
||||
export interface NostrURI {
|
||||
/** Full URI including the `nostr:` protocol. */
|
||||
uri: `nostr:${string}`
|
||||
/** The bech32-encoded data (eg `npub1...`). */
|
||||
value: string
|
||||
/** Decoded bech32 string, according to NIP-19. */
|
||||
decoded: DecodeResult
|
||||
}
|
||||
|
||||
/** Parse and decode a Nostr URI. */
|
||||
export function parse(uri: string): NostrURI {
|
||||
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
|
||||
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
||||
return {
|
||||
uri: match[0] as `nostr:${string}`,
|
||||
value: match[1],
|
||||
decoded: decode(match[1]),
|
||||
}
|
||||
}
|
||||
77
nip25.test.ts
Normal file
77
nip25.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { finishEvent, Kind } from './event.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
|
||||
|
||||
describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||
const privateKey = 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const reactedEvent = finishEvent(
|
||||
{
|
||||
kind: Kind.Text,
|
||||
tags: [
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
|
||||
it('should create a signed event from a minimal template', () => {
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('+')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const reactedEventPointer = getReactedEventPointer(event)
|
||||
|
||||
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
|
||||
it('should create a signed event from a filled template', () => {
|
||||
const template = {
|
||||
tags: [['nonstandard', 'tag']],
|
||||
content: '👍',
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(Kind.Reaction)
|
||||
expect(event.tags).toEqual([
|
||||
['nonstandard', 'tag'],
|
||||
['e', 'replied event id'],
|
||||
['p', 'replied event pubkey'],
|
||||
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||
])
|
||||
expect(event.content).toEqual('👍')
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
expect(typeof event.id).toEqual('string')
|
||||
expect(typeof event.sig).toEqual('string')
|
||||
|
||||
const reactedEventPointer = getReactedEventPointer(event)
|
||||
|
||||
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||
})
|
||||
})
|
||||
65
nip25.ts
Normal file
65
nip25.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Event, finishEvent, Kind } from './event.ts'
|
||||
|
||||
import type { EventPointer } from './nip19.ts'
|
||||
|
||||
export type ReactionEventTemplate = {
|
||||
/**
|
||||
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
|
||||
*/
|
||||
tags?: string[][]
|
||||
|
||||
/**
|
||||
* @default '+'
|
||||
*/
|
||||
content?: string
|
||||
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export function finishReactionEvent(
|
||||
t: ReactionEventTemplate,
|
||||
reacted: Event<number>,
|
||||
privateKey: string,
|
||||
): Event<Kind.Reaction> {
|
||||
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
|
||||
|
||||
return finishEvent(
|
||||
{
|
||||
...t,
|
||||
kind: Kind.Reaction,
|
||||
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
|
||||
content: t.content ?? '+',
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||
if (event.kind !== Kind.Reaction) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let lastETag: undefined | string[]
|
||||
let lastPTag: undefined | string[]
|
||||
|
||||
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||
const tag = event.tags[i]
|
||||
if (tag.length >= 2) {
|
||||
if (tag[0] === 'e' && lastETag === undefined) {
|
||||
lastETag = tag
|
||||
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||
lastPTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastETag === undefined || lastPTag === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: lastETag[1],
|
||||
relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
|
||||
author: lastPTag[1],
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
import { getDelegator, createDelegation } from './nip26.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
test('parse good delegation from NIP', async () => {
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
@@ -15,21 +14,20 @@ test('parse good delegation from NIP', async () => {
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
|
||||
})
|
||||
|
||||
test('parse bad delegations', async () => {
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
@@ -37,19 +35,18 @@ test('parse bad delegations', async () => {
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
@@ -57,19 +54,18 @@ test('parse bad delegations', async () => {
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1740995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
||||
pubkey: '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
||||
created_at: 1660896109,
|
||||
kind: 1,
|
||||
tags: [
|
||||
@@ -77,12 +73,12 @@ test('parse bad delegations', async () => {
|
||||
'delegation',
|
||||
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
|
||||
'kind=1&created_at>1640995200',
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
|
||||
]
|
||||
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1',
|
||||
],
|
||||
],
|
||||
content: 'Hello world',
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
|
||||
})
|
||||
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6',
|
||||
}),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
@@ -91,15 +87,15 @@ test('create and verify delegation', async () => {
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
|
||||
let delegation = createDelegation(sk1, { pubkey: pk2, kind: 1 })
|
||||
expect(delegation).toHaveProperty('from', pk1)
|
||||
expect(delegation).toHaveProperty('to', pk2)
|
||||
expect(delegation).toHaveProperty('cond', 'kind=1')
|
||||
|
||||
let event = {
|
||||
let event = buildEvent({
|
||||
kind: 1,
|
||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
||||
pubkey: pk2
|
||||
}
|
||||
expect(nip26.getDelegator(event)).toEqual(pk1)
|
||||
pubkey: pk2,
|
||||
})
|
||||
expect(getDelegator(event)).toEqual(pk1)
|
||||
})
|
||||
61
nip26.ts
61
nip26.ts
@@ -1,15 +1,17 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
import {Event} from './event'
|
||||
import {utf8Encoder} from './utils'
|
||||
import {getPublicKey} from './keys'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
import { getPublicKey } from './keys.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
export type Parameters = {
|
||||
pubkey: string // the key to whom the delegation will be given
|
||||
kind: number | undefined
|
||||
until: number | undefined // delegation will only be valid until this date
|
||||
since: number | undefined // delegation will be valid from this date on
|
||||
kind?: number
|
||||
until?: number // delegation will only be valid until this date
|
||||
since?: number // delegation will be valid from this date on
|
||||
}
|
||||
|
||||
export type Delegation = {
|
||||
@@ -19,36 +21,28 @@ export type Delegation = {
|
||||
sig: string
|
||||
}
|
||||
|
||||
export function createDelegation(
|
||||
privateKey: string,
|
||||
parameters: Parameters
|
||||
): Delegation {
|
||||
export function createDelegation(privateKey: string, parameters: Parameters): Delegation {
|
||||
let conditions = []
|
||||
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
|
||||
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
|
||||
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
|
||||
let cond = conditions.join('&')
|
||||
|
||||
if (cond === '')
|
||||
throw new Error('refusing to create a delegation without any conditions')
|
||||
if (cond === '') throw new Error('refusing to create a delegation without any conditions')
|
||||
|
||||
let sighash = sha256(
|
||||
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
|
||||
)
|
||||
let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`))
|
||||
|
||||
let sig = secp256k1.utils.bytesToHex(
|
||||
secp256k1.schnorr.signSync(sighash, privateKey)
|
||||
)
|
||||
let sig = bytesToHex(schnorr.sign(sighash, privateKey))
|
||||
|
||||
return {
|
||||
from: getPublicKey(privateKey),
|
||||
to: parameters.pubkey,
|
||||
cond,
|
||||
sig
|
||||
sig,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDelegator(event: Event): string | null {
|
||||
export function getDelegator(event: Event<number>): string | null {
|
||||
// find delegation tag
|
||||
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
||||
if (!tag) return null
|
||||
@@ -63,28 +57,15 @@ export function getDelegator(event: Event): string | null {
|
||||
let [key, operator, value] = conditions[i].split(/\b/)
|
||||
|
||||
// the supported conditions are just 'kind' and 'created_at' for now
|
||||
if (key === 'kind' && operator === '=' && event.kind === parseInt(value))
|
||||
continue
|
||||
else if (
|
||||
key === 'created_at' &&
|
||||
operator === '<' &&
|
||||
event.created_at < parseInt(value)
|
||||
)
|
||||
continue
|
||||
else if (
|
||||
key === 'created_at' &&
|
||||
operator === '>' &&
|
||||
event.created_at > parseInt(value)
|
||||
)
|
||||
continue
|
||||
if (key === 'kind' && operator === '=' && event.kind === parseInt(value)) continue
|
||||
else if (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
|
||||
}
|
||||
|
||||
// check signature
|
||||
let sighash = sha256(
|
||||
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
|
||||
)
|
||||
if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
|
||||
let sighash = sha256(utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`))
|
||||
if (!schnorr.verify(sig, sighash, pubkey)) return null
|
||||
|
||||
return pubkey
|
||||
}
|
||||
|
||||
67
nip27.test.ts
Normal file
67
nip27.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
})
|
||||
63
nip27.ts
Normal file
63
nip27.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
/** Index where the URI begins in the event content. */
|
||||
start: number
|
||||
/** Index where the URI ends in the event content. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [uri, value] = match
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length,
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of Nostr URIs in the text.
|
||||
*
|
||||
* WARNING: using this on an HTML string is potentially unsafe!
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||
* switch(decoded.type) {
|
||||
* case 'npub':
|
||||
* return renderMention(decoded)
|
||||
* case 'note':
|
||||
* return renderNote(decoded)
|
||||
* default:
|
||||
* return value
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
|
||||
return content.replaceAll(regex(), (uri, value: string) => {
|
||||
return replacer({
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
})
|
||||
})
|
||||
}
|
||||
118
nip28.test.ts
Normal file
118
nip28.test.ts
Normal 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
160
nip28.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const fetch = require('node-fetch')
|
||||
const {nip39} = require('./lib/nostr.cjs.js')
|
||||
|
||||
test('validate github claim', async () => {
|
||||
nip39.useFetchImplementation(fetch)
|
||||
|
||||
let result = await nip39.validateGithub(
|
||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||
'vitorpamplona',
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421'
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
14
nip39.test.ts
Normal file
14
nip39.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { useFetchImplementation, validateGithub } from './nip39.ts'
|
||||
|
||||
test('validate github claim', async () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let result = await validateGithub(
|
||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||
'vitorpamplona',
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421',
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
15
nip39.ts
15
nip39.ts
@@ -8,19 +8,10 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function validateGithub(
|
||||
pubkey: string,
|
||||
username: string,
|
||||
proof: string
|
||||
): Promise<boolean> {
|
||||
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
|
||||
try {
|
||||
let res = await (
|
||||
await _fetch(`https://gist.github.com/${username}/${proof}/raw`)
|
||||
).text()
|
||||
return (
|
||||
res ===
|
||||
`Verifying that I control the following Nostr public key: ${pubkey}`
|
||||
)
|
||||
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
|
||||
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
|
||||
26
nip42.test.ts
Normal file
26
nip42.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import { finishEvent } from './event.ts'
|
||||
import { generatePrivateKey } from './keys.ts'
|
||||
import { authenticate } from './nip42.ts'
|
||||
import { relayInit } from './relay.ts'
|
||||
|
||||
test('auth flow', () => {
|
||||
const relay = relayInit('wss://nostr.kollider.xyz')
|
||||
relay.connect()
|
||||
const sk = generatePrivateKey()
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
relay.on('auth', async challenge => {
|
||||
await expect(
|
||||
authenticate({
|
||||
challenge,
|
||||
relay,
|
||||
sign: e => finishEvent(e, sk),
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
relay.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
32
nip42.ts
Normal file
32
nip42.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Kind, type EventTemplate, type Event } from './event.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
/**
|
||||
* Authenticate via NIP-42 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* relay.on('auth', challenge =>
|
||||
* authenticate({ relay, sign, challenge })
|
||||
* )
|
||||
*/
|
||||
export const authenticate = async ({
|
||||
challenge,
|
||||
relay,
|
||||
sign,
|
||||
}: {
|
||||
challenge: string
|
||||
relay: Relay
|
||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
|
||||
}): Promise<void> => {
|
||||
const e: EventTemplate = {
|
||||
kind: Kind.ClientAuth,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relay.url],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
return relay.auth(await sign(e))
|
||||
}
|
||||
21
nip44.test.ts
Normal file
21
nip44.test.ts
Normal 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
40
nip44.ts
Normal 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)
|
||||
}
|
||||
71
nip47.test.ts
Normal file
71
nip47.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||
import { Kind } from './event'
|
||||
import { decrypt } from './nip04.ts'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-undef
|
||||
globalThis.crypto = crypto
|
||||
|
||||
describe('parseConnectionString', () => {
|
||||
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
const { pubkey, relay, secret } = parseConnectionString(connectionString)
|
||||
|
||||
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
|
||||
expect(relay).toBe('wss://relay.damus.io')
|
||||
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||
})
|
||||
|
||||
test('throws an error if no pubkey in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
|
||||
test('throws an error if no relay in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
|
||||
test('throws an error if no secret in connection string', async () => {
|
||||
const connectionString =
|
||||
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
|
||||
|
||||
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeNwcRequestEvent', () => {
|
||||
test('returns a valid NWC request event', async () => {
|
||||
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
|
||||
const secret = '71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||
const invoice =
|
||||
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
|
||||
const timeBefore = Date.now() / 1000
|
||||
const result = await makeNwcRequestEvent({
|
||||
pubkey,
|
||||
secret,
|
||||
invoice,
|
||||
})
|
||||
const timeAfter = Date.now() / 1000
|
||||
expect(result.kind).toBe(Kind.NwcRequest)
|
||||
expect(result.created_at).toBeGreaterThan(timeBefore)
|
||||
expect(result.created_at).toBeLessThan(timeAfter)
|
||||
expect(await decrypt(secret, pubkey, result.content)).toEqual(
|
||||
JSON.stringify({
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result.tags).toEqual([['p', pubkey]])
|
||||
expect(result.id).toEqual(expect.any(String))
|
||||
expect(result.sig).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
42
nip47.ts
Normal file
42
nip47.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { finishEvent } from './event.ts'
|
||||
import { encrypt } from './nip04.ts'
|
||||
import { Kind } from './event'
|
||||
|
||||
export function parseConnectionString(connectionString: string) {
|
||||
const { pathname, searchParams } = new URL(connectionString)
|
||||
const pubkey = pathname
|
||||
const relay = searchParams.get('relay')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
if (!pubkey || !relay || !secret) {
|
||||
throw new Error('invalid connection string')
|
||||
}
|
||||
|
||||
return { pubkey, relay, secret }
|
||||
}
|
||||
|
||||
export async function makeNwcRequestEvent({
|
||||
pubkey,
|
||||
secret,
|
||||
invoice,
|
||||
}: {
|
||||
pubkey: string
|
||||
secret: string
|
||||
invoice: string
|
||||
}) {
|
||||
const content = {
|
||||
method: 'pay_invoice',
|
||||
params: {
|
||||
invoice,
|
||||
},
|
||||
}
|
||||
const encryptedContent = await encrypt(secret, pubkey, JSON.stringify(content))
|
||||
const eventTemplate = {
|
||||
kind: Kind.NwcRequest,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: encryptedContent,
|
||||
tags: [['p', pubkey]],
|
||||
}
|
||||
|
||||
return finishEvent(eventTemplate, secret)
|
||||
}
|
||||
@@ -1,45 +1,36 @@
|
||||
const {bech32} = require('@scure/base')
|
||||
const {
|
||||
nip57,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
finishEvent
|
||||
} = require('./lib/nostr.cjs')
|
||||
import { finishEvent } from './event.ts'
|
||||
import { getPublicKey, generatePrivateKey } from './keys.ts'
|
||||
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('getZapEndpoint', () => {
|
||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||
const metadata = {content: '{}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null if fetch fails', async () => {
|
||||
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
||||
nip57.useFetchImplementation(fetchImplementation)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||
'https://domain/.well-known/lnurlp/name'
|
||||
)
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns null if the response does not allow Nostr payments', async () => {
|
||||
const fetchImplementation = jest.fn(() =>
|
||||
Promise.resolve({json: () => ({allowsNostr: false})})
|
||||
)
|
||||
nip57.useFetchImplementation(fetchImplementation)
|
||||
const fetchImplementation = jest.fn(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||
'https://domain/.well-known/lnurlp/name'
|
||||
)
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
|
||||
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||
@@ -48,52 +39,52 @@ describe('getZapEndpoint', () => {
|
||||
json: () => ({
|
||||
allowsNostr: true,
|
||||
nostrPubkey: 'pubkey',
|
||||
callback: 'callback'
|
||||
})
|
||||
})
|
||||
callback: 'callback',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
nip57.useFetchImplementation(fetchImplementation)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBe('callback')
|
||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||
'https://domain/.well-known/lnurlp/name'
|
||||
)
|
||||
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeZapRequest', () => {
|
||||
test('throws an error if amount is not given', () => {
|
||||
expect(() =>
|
||||
nip57.makeZapRequest({
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: null,
|
||||
relays: [],
|
||||
comment: ''
|
||||
})
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('throws an error if profile is not given', () => {
|
||||
expect(() =>
|
||||
nip57.makeZapRequest({
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
event: null,
|
||||
amount: 100,
|
||||
relays: [],
|
||||
comment: ''
|
||||
})
|
||||
comment: '',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test('returns a valid Zap request', () => {
|
||||
const result = nip57.makeZapRequest({
|
||||
const result = makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: 'event',
|
||||
amount: 100,
|
||||
relays: ['relay1', 'relay2'],
|
||||
comment: 'comment'
|
||||
comment: 'comment',
|
||||
})
|
||||
expect(result.kind).toBe(9734)
|
||||
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||
@@ -102,8 +93,8 @@ describe('makeZapRequest', () => {
|
||||
expect.arrayContaining([
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
])
|
||||
['relays', 'relay1', 'relay2'],
|
||||
]),
|
||||
)
|
||||
expect(result.tags).toContainEqual(['e', 'event'])
|
||||
})
|
||||
@@ -111,9 +102,7 @@ describe('makeZapRequest', () => {
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
expect(nip57.validateZapRequest('invalid JSON')).toBe(
|
||||
'Invalid zap request JSON.'
|
||||
)
|
||||
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
||||
@@ -124,13 +113,11 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', 'profile'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
'Zap request is not a valid Nostr event.'
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
|
||||
})
|
||||
|
||||
test('returns an error message if the signature on the Zap request is invalid', () => {
|
||||
@@ -145,13 +132,11 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
}
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
'Invalid signature on zap request.'
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
||||
@@ -164,15 +149,13 @@ describe('validateZapRequest', () => {
|
||||
content: 'content',
|
||||
tags: [
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request doesn't have a 'p' tag."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("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', () => {
|
||||
@@ -186,15 +169,13 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request 'p' tag is not valid hex."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("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', () => {
|
||||
@@ -210,15 +191,13 @@ describe('validateZapRequest', () => {
|
||||
['p', publicKey],
|
||||
['e', 'invalid hex'],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request 'e' tag is not valid hex."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
|
||||
})
|
||||
|
||||
test('returns an error message if the Zap request does not have a relays tag', () => {
|
||||
@@ -232,15 +211,13 @@ describe('validateZapRequest', () => {
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100']
|
||||
]
|
||||
['amount', '100'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request doesn't have a 'relays' tag."
|
||||
)
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
|
||||
})
|
||||
|
||||
test('returns null for a valid Zap request', () => {
|
||||
@@ -255,13 +232,13 @@ describe('validateZapRequest', () => {
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
privateKey,
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,17 +256,17 @@ describe('makeZapReceipt', () => {
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const preimage = 'preimage'
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
||||
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
@@ -313,16 +290,16 @@ describe('makeZapReceipt', () => {
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2']
|
||||
]
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
},
|
||||
privateKey
|
||||
)
|
||||
privateKey,
|
||||
),
|
||||
)
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
|
||||
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
52
nip57.ts
52
nip57.ts
@@ -1,7 +1,7 @@
|
||||
import {bech32} from '@scure/base'
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
import {Event, EventTemplate, validateEvent, verifySignature} from './event'
|
||||
import {utf8Decoder} from './utils'
|
||||
import { Kind, validateEvent, verifySignature, type Event, type EventTemplate } from './event.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -13,12 +13,12 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
export async function getZapEndpoint(metadata: Event<Kind.Metadata>): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let {lud06, lud16} = JSON.parse(metadata.content)
|
||||
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||
if (lud06) {
|
||||
let {words} = bech32.decode(lud06, 1000)
|
||||
let { words } = bech32.decode(lud06, 1000)
|
||||
let data = bech32.fromWords(words)
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
@@ -46,26 +46,26 @@ export function makeZapRequest({
|
||||
event,
|
||||
amount,
|
||||
relays,
|
||||
comment = ''
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
}): EventTemplate {
|
||||
}): EventTemplate<Kind.ZapRequest> {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
|
||||
let zr = {
|
||||
let zr: EventTemplate<Kind.ZapRequest> = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
tags: [
|
||||
['p', profile],
|
||||
['amount', amount.toString()],
|
||||
['relays', ...relays]
|
||||
]
|
||||
['relays', ...relays],
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
@@ -84,18 +84,16 @@ export function validateZapRequest(zapRequestString: string): string | null {
|
||||
return 'Invalid zap request JSON.'
|
||||
}
|
||||
|
||||
if (!validateEvent(zapRequest))
|
||||
return 'Zap request is not a valid Nostr event.'
|
||||
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||
|
||||
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
||||
|
||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||
if (!p[1].match(/^[a-f0-9]{64}$/))
|
||||
return "Zap request 'p' tag is not valid hex."
|
||||
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||
|
||||
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||
if (e && !e[1].match(/^[a-f0-9]{64}$/))
|
||||
return "Zap request 'e' tag is not valid hex."
|
||||
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||
|
||||
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||
@@ -107,27 +105,21 @@ export function makeZapReceipt({
|
||||
zapRequest,
|
||||
preimage,
|
||||
bolt11,
|
||||
paidAt
|
||||
paidAt,
|
||||
}: {
|
||||
zapRequest: string
|
||||
preimage: string | null
|
||||
preimage?: string
|
||||
bolt11: string
|
||||
paidAt: Date
|
||||
}): EventTemplate {
|
||||
let zr: Event = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(
|
||||
([t]) => t === 'e' || t === 'p' || t === 'a'
|
||||
)
|
||||
}): EventTemplate<Kind.Zap> {
|
||||
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||
|
||||
let zap = {
|
||||
let zap: EventTemplate<Kind.Zap> = {
|
||||
kind: 9735,
|
||||
created_at: Math.round(paidAt.getTime() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
...tagsFromZapRequest,
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest]
|
||||
]
|
||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
||||
}
|
||||
|
||||
if (preimage) {
|
||||
|
||||
130
nip98.test.ts
Normal file
130
nip98.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
|
||||
import { Event, Kind, finishEvent } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
|
||||
const sk = generatePrivateKey()
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk))
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
|
||||
let result = await getToken('http://test.com', 'post', e => finishEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken missing loginUrl throws an error', async () => {
|
||||
const result = getToken('', 'get', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('getToken missing httpMethod throws an error', async () => {
|
||||
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk), true)
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
await expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finishEvent(e, sk))
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
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)
|
||||
})
|
||||
})
|
||||
100
nip98.ts
Normal file
100
nip98.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { base64 } from '@scure/base'
|
||||
import { Event, EventTemplate, Kind, getBlankEvent, verifySignature } from './event'
|
||||
import { utf8Decoder, utf8Encoder } from './utils'
|
||||
|
||||
const _authorizationScheme = 'Nostr '
|
||||
|
||||
/**
|
||||
* Generate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
|
||||
*/
|
||||
export async function getToken(
|
||||
loginUrl: string,
|
||||
httpMethod: string,
|
||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>,
|
||||
includeAuthorizationScheme: boolean = false,
|
||||
): Promise<string> {
|
||||
if (!loginUrl || !httpMethod) throw new Error('Missing loginUrl or httpMethod')
|
||||
|
||||
const event = getBlankEvent(Kind.HttpAuth)
|
||||
|
||||
event.tags = [
|
||||
['u', loginUrl],
|
||||
['method', httpMethod],
|
||||
]
|
||||
event.created_at = Math.round(new Date().getTime() / 1000)
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
|
||||
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token for NIP-98 flow.
|
||||
*
|
||||
* @example
|
||||
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||
*/
|
||||
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
|
||||
throw new Error('Invalid token')
|
||||
}
|
||||
|
||||
const event = JSON.parse(eventB64) as Event
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
}
|
||||
if (!verifySignature(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== Kind.HttpAuth) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
}
|
||||
|
||||
// Event must be less than 60 seconds old
|
||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||
throw new Error('Invalid nostr event, expired')
|
||||
}
|
||||
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||
throw new Error('Invalid nostr event, url tag invalid')
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
49
package.json
49
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nostr-tools",
|
||||
"version": "1.8.2",
|
||||
"version": "1.16.0",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -14,16 +14,25 @@
|
||||
"module": "lib/esm/nostr.mjs",
|
||||
"exports": {
|
||||
"import": "./lib/esm/nostr.mjs",
|
||||
"require": "./lib/nostr.cjs.js"
|
||||
"require": "./lib/nostr.cjs.js",
|
||||
"types": "./lib/index.d.ts"
|
||||
},
|
||||
"license": "Public domain",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.0.0",
|
||||
"@noble/secp256k1": "^1.7.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^1.1.5",
|
||||
"@scure/bip39": "^1.1.1",
|
||||
"prettier": "^2.8.4"
|
||||
"@noble/ciphers": "^0.2.0",
|
||||
"@noble/curves": "1.1.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"decentralization",
|
||||
@@ -32,21 +41,31 @@
|
||||
"client",
|
||||
"nostr"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^29.4.2",
|
||||
"jest": "^29.5.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"ts-jest": "^29.0.5",
|
||||
"prettier": "^3.0.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.0.4",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
133
pool.test.js
133
pool.test.js
@@ -1,133 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
require('websocket-polyfill')
|
||||
const {
|
||||
SimplePool,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
signEvent
|
||||
} = require('./lib/nostr.cjs')
|
||||
|
||||
let pool = new SimplePool()
|
||||
|
||||
let relays = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.bg/',
|
||||
'wss://nostr.fmt.wiz.biz/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/'
|
||||
]
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([
|
||||
...relays,
|
||||
'wss://nostr.wine',
|
||||
'wss://offchain.pub',
|
||||
'wss://eden.nostr.land'
|
||||
])
|
||||
})
|
||||
|
||||
test('removing duplicates when querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub = pool.sub(relays, [{authors: [pub]}])
|
||||
let received = []
|
||||
|
||||
sub.on('event', event => {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = {
|
||||
pubkey: pub,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
kind: 22345,
|
||||
tags: []
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, priv)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('same with double querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub1 = pool.sub(relays, [{authors: [pub]}])
|
||||
let sub2 = pool.sub(relays, [{authors: [pub]}])
|
||||
|
||||
let received = []
|
||||
|
||||
sub1.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
sub2.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = {
|
||||
pubkey: pub,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 22346,
|
||||
tags: []
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, priv)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
)
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await pool.list(
|
||||
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||
[
|
||||
{
|
||||
authors: [
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
],
|
||||
kinds: [1],
|
||||
limit: 2
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toEqual(
|
||||
events
|
||||
.map(evt => evt.id)
|
||||
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
|
||||
.length
|
||||
)
|
||||
|
||||
let relaysForAllEvents = events
|
||||
.map(event => pool.seenOn(event.id))
|
||||
.reduce((acc, n) => acc.concat(n), [])
|
||||
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
|
||||
})
|
||||
130
pool.test.ts
Normal file
130
pool.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'websocket-polyfill'
|
||||
|
||||
import { finishEvent, type Event } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
|
||||
let relays = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.bg/',
|
||||
'wss://nostr.fmt.wiz.biz/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/',
|
||||
]
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://nostr.wine', 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
})
|
||||
|
||||
test('removing duplicates when querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub = pool.sub(relays, [{ authors: [pub] }])
|
||||
let received: Event[] = []
|
||||
|
||||
sub.on('event', event => {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
kind: 22345,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('same with double querying', async () => {
|
||||
let priv = generatePrivateKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub1 = pool.sub(relays, [{ authors: [pub] }])
|
||||
let sub2 = pool.sub(relays, [{ authors: [pub] }])
|
||||
|
||||
let received: Event[] = []
|
||||
|
||||
sub1.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
sub2.on('event', event => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = finishEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 22346,
|
||||
tags: [],
|
||||
},
|
||||
priv,
|
||||
)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await pool.list(
|
||||
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||
[
|
||||
{
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toEqual(
|
||||
events
|
||||
.map(evt => evt.id)
|
||||
// @ts-ignore ???
|
||||
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []).length,
|
||||
)
|
||||
|
||||
let relaysForAllEvents = events.map(event => pool.seenOn(event.id)).reduce((acc, n) => acc.concat(n), [])
|
||||
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
|
||||
})
|
||||
|
||||
test('seenOnEnabled: false', async () => {
|
||||
const poolWithoutSeenOn = new SimplePool({ seenOnEnabled: false })
|
||||
|
||||
const event = await poolWithoutSeenOn.get(relays, {
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
|
||||
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
|
||||
|
||||
expect(relaysForEvent).toHaveLength(0)
|
||||
})
|
||||
170
pool.ts
170
pool.ts
@@ -1,20 +1,39 @@
|
||||
import {Relay, relayInit} from './relay'
|
||||
import {normalizeURL} from './utils'
|
||||
import {Filter} from './filter'
|
||||
import {Event} from './event'
|
||||
import {SubscriptionOptions, Sub, Pub} from './relay'
|
||||
import { eventsGenerator, relayInit, type Relay, type Sub, type SubscriptionOptions } from './relay.ts'
|
||||
import { normalizeURL } from './utils.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
import { matchFilters, mergeFilters, type Filter } from './filter.ts'
|
||||
|
||||
type BatchedRequest = {
|
||||
filters: Filter<any>[]
|
||||
relays: string[]
|
||||
resolve: (events: Event<any>[]) => void
|
||||
events: Event<any>[]
|
||||
}
|
||||
|
||||
export class SimplePool {
|
||||
private _conn: {[url: string]: Relay}
|
||||
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
|
||||
private _conn: { [url: string]: 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 getTimeout: number
|
||||
private seenOnEnabled: boolean = true
|
||||
private batchInterval: number = 100
|
||||
|
||||
constructor(options: {eoseSubTimeout?: number; getTimeout?: number} = {}) {
|
||||
constructor(
|
||||
options: {
|
||||
eoseSubTimeout?: number
|
||||
getTimeout?: number
|
||||
seenOnEnabled?: boolean
|
||||
batchInterval?: number
|
||||
} = {},
|
||||
) {
|
||||
this._conn = {}
|
||||
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
||||
this.getTimeout = options.getTimeout || 3400
|
||||
this.seenOnEnabled = options.seenOnEnabled !== false
|
||||
this.batchInterval = options.batchInterval || 100
|
||||
}
|
||||
|
||||
close(relays: string[]): void {
|
||||
@@ -30,7 +49,7 @@ export class SimplePool {
|
||||
if (!this._conn[nm]) {
|
||||
this._conn[nm] = relayInit(nm, {
|
||||
getTimeout: this.getTimeout * 0.9,
|
||||
listTimeout: this.getTimeout * 0.9
|
||||
listTimeout: this.getTimeout * 0.9,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,31 +58,38 @@ export class SimplePool {
|
||||
return relay
|
||||
}
|
||||
|
||||
sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
|
||||
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
|
||||
let _knownIds: Set<string> = new Set()
|
||||
let modifiedOpts = {...(opts || {})}
|
||||
let modifiedOpts = { ...(opts || {}) }
|
||||
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
||||
if (opts?.alreadyHaveEvent?.(id, url)) {
|
||||
return true
|
||||
}
|
||||
if (this.seenOnEnabled) {
|
||||
let set = this._seenOn[id] || new Set()
|
||||
set.add(url)
|
||||
this._seenOn[id] = set
|
||||
}
|
||||
return _knownIds.has(id)
|
||||
}
|
||||
|
||||
let subs: Sub[] = []
|
||||
let eventListeners: Set<(event: Event) => void> = new Set()
|
||||
let eventListeners: Set<any> = new Set()
|
||||
let eoseListeners: Set<() => void> = new Set()
|
||||
let eosesMissing = relays.length
|
||||
|
||||
let eoseSent = false
|
||||
let eoseTimeout = setTimeout(() => {
|
||||
let eoseTimeout = setTimeout(
|
||||
() => {
|
||||
eoseSent = true
|
||||
for (let cb of eoseListeners.values()) cb()
|
||||
}, this.eoseSubTimeout)
|
||||
},
|
||||
opts?.eoseSubTimeout || this.eoseSubTimeout,
|
||||
)
|
||||
|
||||
relays.forEach(async relay => {
|
||||
relays
|
||||
.filter((r, i, a) => a.indexOf(r) === i)
|
||||
.forEach(async relay => {
|
||||
let r
|
||||
try {
|
||||
r = await this.ensureRelay(relay)
|
||||
@@ -73,7 +99,7 @@ export class SimplePool {
|
||||
}
|
||||
if (!r) return
|
||||
let s = r.sub(filters, modifiedOpts)
|
||||
s.on('event', (event: Event) => {
|
||||
s.on('event', event => {
|
||||
_knownIds.add(event.id as string)
|
||||
for (let cb of eventListeners.values()) cb(event)
|
||||
})
|
||||
@@ -92,10 +118,10 @@ export class SimplePool {
|
||||
}
|
||||
})
|
||||
|
||||
let greaterSub: Sub = {
|
||||
let greaterSub: Sub<K> = {
|
||||
sub(filters, opts) {
|
||||
subs.forEach(sub => sub.sub(filters, opts))
|
||||
return greaterSub
|
||||
return greaterSub as any
|
||||
},
|
||||
unsub() {
|
||||
subs.forEach(sub => sub.unsub())
|
||||
@@ -110,26 +136,28 @@ export class SimplePool {
|
||||
off(type, cb) {
|
||||
if (type === 'event') {
|
||||
eventListeners.delete(cb)
|
||||
} else if (type === 'eose')
|
||||
eoseListeners.delete(cb as () => void | Promise<void>)
|
||||
}
|
||||
} else if (type === 'eose') eoseListeners.delete(cb as () => void | Promise<void>)
|
||||
},
|
||||
get events() {
|
||||
return eventsGenerator(greaterSub)
|
||||
},
|
||||
}
|
||||
|
||||
return greaterSub
|
||||
}
|
||||
|
||||
get(
|
||||
get<K extends number = number>(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
opts?: SubscriptionOptions
|
||||
): Promise<Event | null> {
|
||||
filter: Filter<K>,
|
||||
opts?: SubscriptionOptions,
|
||||
): Promise<Event<K> | null> {
|
||||
return new Promise(resolve => {
|
||||
let sub = this.sub(relays, [filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
sub.unsub()
|
||||
resolve(null)
|
||||
}, this.getTimeout)
|
||||
sub.on('event', (event: Event) => {
|
||||
sub.on('event', event => {
|
||||
resolve(event)
|
||||
clearTimeout(timeout)
|
||||
sub.unsub()
|
||||
@@ -137,16 +165,16 @@ export class SimplePool {
|
||||
})
|
||||
}
|
||||
|
||||
list(
|
||||
list<K extends number = number>(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
opts?: SubscriptionOptions
|
||||
): Promise<Event[]> {
|
||||
filters: Filter<K>[],
|
||||
opts?: SubscriptionOptions,
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
let events: Event[] = []
|
||||
let events: Event<K>[] = []
|
||||
let sub = this.sub(relays, filters, opts)
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
sub.on('event', event => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
@@ -158,39 +186,61 @@ export class SimplePool {
|
||||
})
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event): Pub {
|
||||
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
|
||||
let r
|
||||
try {
|
||||
r = await this.ensureRelay(relay)
|
||||
return r.publish(event)
|
||||
} catch (_) {
|
||||
return {on() {}, off() {}}
|
||||
}
|
||||
})
|
||||
|
||||
const callbackMap = new Map()
|
||||
|
||||
return {
|
||||
on(type, cb) {
|
||||
relays.forEach(async (relay, i) => {
|
||||
let pub = await pubPromises[i]
|
||||
let callback = () => cb(relay)
|
||||
callbackMap.set(cb, callback)
|
||||
pub.on(type, callback)
|
||||
})
|
||||
batchedList<K extends number = number>(
|
||||
batchKey: string,
|
||||
relays: string[],
|
||||
filters: Filter<K>[],
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
if (!this.batchedByKey[batchKey]) {
|
||||
this.batchedByKey[batchKey] = [
|
||||
{
|
||||
filters,
|
||||
relays,
|
||||
resolve,
|
||||
events: [],
|
||||
},
|
||||
]
|
||||
|
||||
off(type, cb) {
|
||||
relays.forEach(async (_, i) => {
|
||||
let callback = callbackMap.get(cb)
|
||||
if (callback) {
|
||||
let pub = await pubPromises[i]
|
||||
pub.off(type, callback)
|
||||
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, [mergeFilters(...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: [],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event<number>): Promise<void>[] {
|
||||
return relays.map(async relay => {
|
||||
let r = await this.ensureRelay(relay)
|
||||
return r.publish(event)
|
||||
})
|
||||
}
|
||||
|
||||
seenOn(id: string): string[] {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {parseReferences} = require('./lib/nostr.cjs')
|
||||
|
||||
test('parse mentions', () => {
|
||||
let evt = {
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
'wss://nostr.com'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33',
|
||||
'wss://other.com',
|
||||
'reply'
|
||||
],
|
||||
[
|
||||
'e',
|
||||
'31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||
''
|
||||
]
|
||||
],
|
||||
content:
|
||||
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]'
|
||||
}
|
||||
|
||||
expect(parseReferences(evt)).toEqual([
|
||||
{
|
||||
text: '#[0]',
|
||||
profile: {
|
||||
pubkey:
|
||||
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
relays: ['wss://nostr.com']
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '#[2]',
|
||||
event: {
|
||||
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||
relays: []
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
|
||||
profile: {
|
||||
pubkey:
|
||||
'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: []
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
|
||||
event: {
|
||||
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
author: undefined
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
46
references.test.ts
Normal file
46
references.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { parseReferences } from './references.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
test('parse mentions', () => {
|
||||
let evt = buildEvent({
|
||||
tags: [
|
||||
['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
|
||||
['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
|
||||
['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
|
||||
],
|
||||
content:
|
||||
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
|
||||
})
|
||||
|
||||
expect(parseReferences(evt)).toEqual([
|
||||
{
|
||||
text: '#[0]',
|
||||
profile: {
|
||||
pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||
relays: ['wss://nostr.com'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '#[2]',
|
||||
event: {
|
||||
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||
relays: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
|
||||
profile: {
|
||||
pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
|
||||
event: {
|
||||
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||
relays: [],
|
||||
author: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Event} from './event'
|
||||
import {decode, AddressPointer, ProfilePointer, EventPointer} from './nip19'
|
||||
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
type Reference = {
|
||||
text: string
|
||||
@@ -8,8 +9,7 @@ type Reference = {
|
||||
address?: AddressPointer
|
||||
}
|
||||
|
||||
const mentionRegex =
|
||||
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||
|
||||
export function parseReferences(evt: Event): Reference[] {
|
||||
let references: Reference[] = []
|
||||
@@ -17,40 +17,40 @@ export function parseReferences(evt: Event): Reference[] {
|
||||
if (ref[2]) {
|
||||
// it's a NIP-27 mention
|
||||
try {
|
||||
let {type, data} = decode(ref[1])
|
||||
let { type, data } = decode(ref[1])
|
||||
switch (type) {
|
||||
case 'npub': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: {pubkey: data as string, relays: []}
|
||||
profile: { pubkey: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nprofile': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: data as ProfilePointer
|
||||
profile: data as ProfilePointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'note': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: {id: data as string, relays: []}
|
||||
event: { id: data as string, relays: [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'nevent': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: data as EventPointer
|
||||
event: data as EventPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'naddr': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: data as AddressPointer
|
||||
address: data as AddressPointer,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -68,28 +68,28 @@ export function parseReferences(evt: Event): Reference[] {
|
||||
case 'p': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
profile: {pubkey: tag[1], relays: tag[2] ? [tag[2]] : []}
|
||||
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'e': {
|
||||
references.push({
|
||||
text: ref[0],
|
||||
event: {id: tag[1], relays: tag[2] ? [tag[2]] : []}
|
||||
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'a': {
|
||||
try {
|
||||
let [kind, pubkey, identifier] = ref[1].split(':')
|
||||
let [kind, pubkey, identifier] = tag[1].split(':')
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: {
|
||||
identifier,
|
||||
pubkey,
|
||||
kind: parseInt(kind, 10),
|
||||
relays: tag[2] ? [tag[2]] : []
|
||||
}
|
||||
relays: tag[2] ? [tag[2]] : [],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
/***/
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
import 'websocket-polyfill'
|
||||
|
||||
require('websocket-polyfill')
|
||||
const {
|
||||
relayInit,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
signEvent
|
||||
} = require('./lib/nostr.cjs')
|
||||
import { finishEvent } from './event.ts'
|
||||
import { generatePrivateKey, getPublicKey } from './keys.ts'
|
||||
import { relayInit } from './relay.ts'
|
||||
|
||||
let relay = relayInit('wss://relay.damus.io/')
|
||||
|
||||
@@ -28,24 +23,21 @@ test('connectivity', () => {
|
||||
relay.on('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
var resolve1
|
||||
var resolve2
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
}
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
},
|
||||
])
|
||||
sub.on('event', event => {
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
)
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
resolve1(true)
|
||||
})
|
||||
sub.on('eose', () => {
|
||||
@@ -53,38 +45,46 @@ test('querying', async () => {
|
||||
})
|
||||
|
||||
let [t1, t2] = await Promise.all([
|
||||
new Promise(resolve => {
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve2 = resolve
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
expect(t1).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 () => {
|
||||
let event = await relay.get({
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
|
||||
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||
})
|
||||
|
||||
expect(event).toHaveProperty(
|
||||
'id',
|
||||
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
|
||||
)
|
||||
expect(event).toHaveProperty('id', 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027')
|
||||
})
|
||||
|
||||
test('list()', async () => {
|
||||
let events = await relay.list([
|
||||
{
|
||||
authors: [
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
],
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
kinds: [1],
|
||||
limit: 2
|
||||
}
|
||||
limit: 2,
|
||||
},
|
||||
])
|
||||
|
||||
expect(events.length).toEqual(2)
|
||||
@@ -93,14 +93,14 @@ test('list()', async () => {
|
||||
test('listening (twice) and publishing', async () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
var resolve1
|
||||
var resolve2
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
kinds: [27572],
|
||||
authors: [pk]
|
||||
}
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
@@ -116,15 +116,15 @@ test('listening (twice) and publishing', async () => {
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let event = {
|
||||
let event = finishEvent(
|
||||
{
|
||||
kind: 27572,
|
||||
pubkey: pk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite'
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, sk)
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
relay.publish(event)
|
||||
return expect(
|
||||
@@ -134,7 +134,7 @@ test('listening (twice) and publishing', async () => {
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
resolve2 = resolve
|
||||
})
|
||||
])
|
||||
}),
|
||||
]),
|
||||
).resolves.toEqual([true, true])
|
||||
})
|
||||
268
relay.ts
268
relay.ts
@@ -1,17 +1,23 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import {Event, verifySignature, validateEvent} from './event'
|
||||
import {Filter, matchFilters} from './filter'
|
||||
import {getHex64, getSubscriptionId} from './fakejson'
|
||||
import { verifySignature, validateEvent, type Event } from './event.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { MessageQueue } from './utils.ts'
|
||||
|
||||
type RelayEvent = {
|
||||
connect: () => void | Promise<void>
|
||||
disconnect: () => void | Promise<void>
|
||||
error: () => void | Promise<void>
|
||||
notice: (msg: string) => void | Promise<void>
|
||||
auth: (challenge: string) => void | Promise<void>
|
||||
}
|
||||
type SubEvent = {
|
||||
event: (event: Event) => void | Promise<void>
|
||||
export type CountPayload = {
|
||||
count: number
|
||||
}
|
||||
export type SubEvent<K extends number> = {
|
||||
event: (event: Event<K>) => void | Promise<void>
|
||||
count: (payload: CountPayload) => void | Promise<void>
|
||||
eose: () => void | Promise<void>
|
||||
}
|
||||
export type Relay = {
|
||||
@@ -19,67 +25,59 @@ export type Relay = {
|
||||
status: number
|
||||
connect: () => Promise<void>
|
||||
close: () => void
|
||||
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
|
||||
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
|
||||
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
|
||||
publish: (event: Event) => 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
|
||||
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>[]>
|
||||
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
|
||||
count: (filters: Filter[], opts?: SubscriptionOptions) => Promise<CountPayload | null>
|
||||
publish: (event: Event<number>) => Promise<void>
|
||||
auth: (event: Event<number>) => Promise<void>
|
||||
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 = {
|
||||
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
|
||||
export type Sub<K extends number = number> = {
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
|
||||
unsub: () => void
|
||||
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
|
||||
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(event: T, listener: U) => void
|
||||
events: AsyncGenerator<Event<K>, void, unknown>
|
||||
}
|
||||
|
||||
export type SubscriptionOptions = {
|
||||
id?: string
|
||||
verb?: 'REQ' | 'COUNT'
|
||||
skipVerification?: boolean
|
||||
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
|
||||
eoseSubTimeout?: number
|
||||
}
|
||||
|
||||
const newListeners = (): { [TK in keyof RelayEvent]: RelayEvent[TK][] } => ({
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: [],
|
||||
auth: [],
|
||||
})
|
||||
|
||||
export function relayInit(
|
||||
url: string,
|
||||
options: {
|
||||
getTimeout?: number
|
||||
listTimeout?: number
|
||||
} = {}
|
||||
countTimeout?: number
|
||||
} = {},
|
||||
): Relay {
|
||||
let {listTimeout = 3000, getTimeout = 3000} = options
|
||||
let { listTimeout = 3000, getTimeout = 3000, countTimeout = 3000 } = options
|
||||
|
||||
var ws: WebSocket
|
||||
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
||||
var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: []
|
||||
}
|
||||
var openSubs: { [id: string]: { filters: Filter[] } & SubscriptionOptions } = {}
|
||||
var listeners = newListeners()
|
||||
var subListeners: {
|
||||
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
|
||||
[subid: string]: { [TK in keyof SubEvent<any>]: SubEvent<any>[TK][] }
|
||||
} = {}
|
||||
var pubListeners: {
|
||||
[eventid: string]: {
|
||||
ok: Array<() => void>
|
||||
seen: Array<() => void>
|
||||
failed: Array<(reason: string) => void>
|
||||
resolve: (_: unknown) => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
} = {}
|
||||
|
||||
@@ -107,34 +105,30 @@ export function relayInit(
|
||||
listeners.disconnect.forEach(cb => cb())
|
||||
}
|
||||
|
||||
let incomingMessageQueue: string[] = []
|
||||
let incomingMessageQueue: MessageQueue = new MessageQueue()
|
||||
let handleNextInterval: any
|
||||
|
||||
ws.onmessage = e => {
|
||||
incomingMessageQueue.push(e.data)
|
||||
incomingMessageQueue.enqueue(e.data)
|
||||
if (!handleNextInterval) {
|
||||
handleNextInterval = setInterval(handleNext, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (incomingMessageQueue.length === 0) {
|
||||
if (incomingMessageQueue.size === 0) {
|
||||
clearInterval(handleNextInterval)
|
||||
handleNextInterval = null
|
||||
return
|
||||
}
|
||||
|
||||
var json = incomingMessageQueue.shift()
|
||||
var json = incomingMessageQueue.dequeue()
|
||||
if (!json) return
|
||||
|
||||
let subid = getSubscriptionId(json)
|
||||
if (subid) {
|
||||
let so = openSubs[subid]
|
||||
if (
|
||||
so &&
|
||||
so.alreadyHaveEvent &&
|
||||
so.alreadyHaveEvent(getHex64(json, 'id'), url)
|
||||
) {
|
||||
if (so && so.alreadyHaveEvent && so.alreadyHaveEvent(getHex64(json, 'id'), url)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -146,7 +140,7 @@ export function relayInit(
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT':
|
||||
case 'EVENT': {
|
||||
let id = data[1]
|
||||
let event = data[2]
|
||||
if (
|
||||
@@ -159,6 +153,14 @@ export function relayInit(
|
||||
;(subListeners[id]?.event || []).forEach(cb => cb(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT':
|
||||
let id = data[1]
|
||||
let payload = data[2]
|
||||
if (openSubs[id]) {
|
||||
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
|
||||
}
|
||||
return
|
||||
case 'EOSE': {
|
||||
let id = data[1]
|
||||
if (id in subListeners) {
|
||||
@@ -172,10 +174,9 @@ export function relayInit(
|
||||
let ok: boolean = data[2]
|
||||
let reason: string = data[3] || ''
|
||||
if (id in pubListeners) {
|
||||
if (ok) pubListeners[id].ok.forEach(cb => cb())
|
||||
else pubListeners[id].failed.forEach(cb => cb(reason))
|
||||
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here
|
||||
pubListeners[id].failed = []
|
||||
let { resolve, reject } = pubListeners[id]
|
||||
if (ok) resolve(null)
|
||||
else reject(new Error(reason))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -183,6 +184,11 @@ export function relayInit(
|
||||
let notice = data[1]
|
||||
listeners.notice.forEach(cb => cb(notice))
|
||||
return
|
||||
case 'AUTH': {
|
||||
let challenge = data[1]
|
||||
listeners.auth?.forEach(cb => cb(challenge))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
@@ -217,81 +223,89 @@ export function relayInit(
|
||||
}
|
||||
}
|
||||
|
||||
const sub = (
|
||||
filters: Filter[],
|
||||
const sub = <K extends number = number>(
|
||||
filters: Filter<K>[],
|
||||
{
|
||||
verb = 'REQ',
|
||||
skipVerification = false,
|
||||
alreadyHaveEvent = null,
|
||||
id = Math.random().toString().slice(2)
|
||||
}: SubscriptionOptions = {}
|
||||
): Sub => {
|
||||
id = Math.random().toString().slice(2),
|
||||
}: SubscriptionOptions = {},
|
||||
): Sub<K> => {
|
||||
let subid = id
|
||||
|
||||
openSubs[subid] = {
|
||||
id: subid,
|
||||
filters,
|
||||
skipVerification,
|
||||
alreadyHaveEvent
|
||||
alreadyHaveEvent,
|
||||
}
|
||||
trySend(['REQ', subid, ...filters])
|
||||
trySend([verb, subid, ...filters])
|
||||
|
||||
return {
|
||||
let subscription: Sub<K> = {
|
||||
sub: (newFilters, newOpts = {}) =>
|
||||
sub(newFilters || filters, {
|
||||
skipVerification: newOpts.skipVerification || skipVerification,
|
||||
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
|
||||
id: subid
|
||||
id: subid,
|
||||
}),
|
||||
unsub: () => {
|
||||
delete openSubs[subid]
|
||||
delete subListeners[subid]
|
||||
trySend(['CLOSE', subid])
|
||||
},
|
||||
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
on: (type, cb) => {
|
||||
subListeners[subid] = subListeners[subid] || {
|
||||
event: [],
|
||||
eose: []
|
||||
count: [],
|
||||
eose: [],
|
||||
}
|
||||
subListeners[subid][type].push(cb)
|
||||
},
|
||||
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
off: (type, cb): void => {
|
||||
let listeners = subListeners[subid]
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
},
|
||||
get events() {
|
||||
return eventsGenerator(subscription)
|
||||
},
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
function _publishEvent(event: Event<number>, type: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!event.id) {
|
||||
reject(new Error(`event ${event} has no id`))
|
||||
return
|
||||
}
|
||||
|
||||
let id = event.id
|
||||
trySend([type, event])
|
||||
pubListeners[id] = { resolve, reject }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
|
||||
listeners[type].push(cb)
|
||||
if (type === 'connect' && ws?.readyState === 1) {
|
||||
// i would love to know why we need this
|
||||
;(cb as () => void)()
|
||||
}
|
||||
},
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(type: T, cb: U): void => {
|
||||
let index = listeners[type].indexOf(cb)
|
||||
if (index !== -1) listeners[type].splice(index, 1)
|
||||
},
|
||||
list: (filters: Filter[], opts?: SubscriptionOptions): Promise<Event[]> =>
|
||||
list: (filters, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, opts)
|
||||
let events: Event[] = []
|
||||
let events: Event<any>[] = []
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(events)
|
||||
@@ -301,56 +315,84 @@ export function relayInit(
|
||||
clearTimeout(timeout)
|
||||
resolve(events)
|
||||
})
|
||||
s.on('event', (event: Event) => {
|
||||
s.on('event', event => {
|
||||
events.push(event)
|
||||
})
|
||||
}),
|
||||
get: (filter: Filter, opts?: SubscriptionOptions): Promise<Event | null> =>
|
||||
get: (filter, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub([filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, getTimeout)
|
||||
s.on('event', (event: Event) => {
|
||||
s.on('event', event => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
publish(event: Event): Pub {
|
||||
if (!event.id) throw new Error(`event ${event} has no id`)
|
||||
let id = event.id
|
||||
|
||||
trySend(['EVENT', event])
|
||||
|
||||
return {
|
||||
on: (type: 'ok' | 'failed', cb: any) => {
|
||||
pubListeners[id] = pubListeners[id] || {
|
||||
ok: [],
|
||||
failed: []
|
||||
}
|
||||
pubListeners[id][type].push(cb)
|
||||
count: (filters: Filter[]): Promise<CountPayload | null> =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, { ...sub, verb: 'COUNT' })
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, countTimeout)
|
||||
s.on('count', (event: CountPayload) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
async publish(event): Promise<void> {
|
||||
await _publishEvent(event, 'EVENT')
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
async auth(event): Promise<void> {
|
||||
await _publishEvent(event, 'AUTH')
|
||||
},
|
||||
connect,
|
||||
close(): void {
|
||||
listeners = {connect: [], disconnect: [], error: [], notice: []}
|
||||
listeners = newListeners()
|
||||
subListeners = {}
|
||||
pubListeners = {}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws?.close()
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
get status() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
17
test-helpers.ts
Normal file
17
test-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
type EventParams<K extends number> = Partial<Event<K>>
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
|
||||
return {
|
||||
id: '',
|
||||
kind: 1 as K,
|
||||
pubkey: '',
|
||||
created_at: 0,
|
||||
content: '',
|
||||
tags: [],
|
||||
sig: '',
|
||||
...params,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": "."
|
||||
"rootDir": ".",
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
|
||||
183
utils.test.js
183
utils.test.js
@@ -1,183 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {utils} = require('./lib/nostr.cjs')
|
||||
|
||||
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
|
||||
|
||||
describe('inserting into a desc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0 = []
|
||||
expect(
|
||||
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [{created_at: 20}, {created_at: 10}]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 30},
|
||||
{created_at: 20},
|
||||
{created_at: 10},
|
||||
{created_at: 1}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 15
|
||||
})
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 10}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 5
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 10}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 10, id: 'abc'}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inserting into a asc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0 = []
|
||||
expect(
|
||||
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [{created_at: 10}, {created_at: 20}]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 1
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
})
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 10},
|
||||
{created_at: 20},
|
||||
{created_at: 30},
|
||||
{created_at: 40}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 25
|
||||
})
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 40}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 50
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 30}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 30, id: 'abc'}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
259
utils.test.ts
Normal file
259
utils.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
import { MessageQueue, insertEventIntoAscendingList, insertEventIntoDescendingList } from './utils.ts'
|
||||
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
describe('inserting into a desc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 1 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 15,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 5,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0: Event[] = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10 }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 10, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inserting into a asc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0: Event[] = []
|
||||
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 1,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 10,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 10 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 25,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 40 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 50,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30 }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 20 }),
|
||||
buildEvent({ created_at: 30, id: 'abc' }),
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(
|
||||
list0,
|
||||
buildEvent({
|
||||
id: 'abc',
|
||||
created_at: 30,
|
||||
}),
|
||||
)
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enque a message into MessageQueue', () => {
|
||||
test('enque into an empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
})
|
||||
test('enque into a non-empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
expect(queue.first!.value).toBe('node1')
|
||||
expect(queue.last!.value).toBe('node2')
|
||||
expect(queue.size).toBe(3)
|
||||
})
|
||||
test('dequeue from an empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe(null)
|
||||
expect(queue.size).toBe(0)
|
||||
})
|
||||
test('dequeue from a non-empty queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
queue.enqueue('node2')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
})
|
||||
test('dequeue more than in queue', () => {
|
||||
const queue = new MessageQueue()
|
||||
queue.enqueue('node1')
|
||||
queue.enqueue('node3')
|
||||
const item1 = queue.dequeue()
|
||||
expect(item1).toBe('node1')
|
||||
const item2 = queue.dequeue()
|
||||
expect(item2).toBe('node3')
|
||||
expect(queue.size).toBe(0)
|
||||
const item3 = queue.dequeue()
|
||||
expect(item3).toBe(null)
|
||||
})
|
||||
})
|
||||
106
utils.ts
106
utils.ts
@@ -1,4 +1,4 @@
|
||||
import {Event} from './event'
|
||||
import type { Event } from './event.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
@@ -7,11 +7,7 @@ export function normalizeURL(url: string): string {
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
if (
|
||||
(p.port === '80' && p.protocol === 'ws:') ||
|
||||
(p.port === '443' && p.protocol === 'wss:')
|
||||
)
|
||||
p.port = ''
|
||||
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||
p.searchParams.sort()
|
||||
p.hash = ''
|
||||
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
|
||||
//
|
||||
export function insertEventIntoDescendingList(
|
||||
sortedArray: Event[],
|
||||
event: Event
|
||||
) {
|
||||
export function insertEventIntoDescendingList(sortedArray: Event<number>[], event: Event<number>) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
@@ -55,20 +48,13 @@ export function insertEventIntoDescendingList(
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [
|
||||
...sortedArray.slice(0, position),
|
||||
event,
|
||||
...sortedArray.slice(position)
|
||||
]
|
||||
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(
|
||||
sortedArray: Event[],
|
||||
event: Event
|
||||
) {
|
||||
export function insertEventIntoAscendingList(sortedArray: Event<number>[], event: Event<number>) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
let midPoint
|
||||
@@ -100,12 +86,84 @@ export function insertEventIntoAscendingList(
|
||||
|
||||
// insert when num is NOT already in (no duplicates)
|
||||
if (sortedArray[position]?.id !== event.id) {
|
||||
return [
|
||||
...sortedArray.slice(0, position),
|
||||
event,
|
||||
...sortedArray.slice(position)
|
||||
]
|
||||
return [...sortedArray.slice(0, position), event, ...sortedArray.slice(position)]
|
||||
}
|
||||
|
||||
return sortedArray
|
||||
}
|
||||
|
||||
export class MessageNode {
|
||||
private _value: string
|
||||
private _next: MessageNode | null
|
||||
|
||||
public get value(): string {
|
||||
return this._value
|
||||
}
|
||||
public set value(message: string) {
|
||||
this._value = message
|
||||
}
|
||||
public get next(): MessageNode | null {
|
||||
return this._next
|
||||
}
|
||||
public set next(node: MessageNode | null) {
|
||||
this._next = node
|
||||
}
|
||||
|
||||
constructor(message: string) {
|
||||
this._value = message
|
||||
this._next = null
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageQueue {
|
||||
private _first: MessageNode | null
|
||||
private _last: MessageNode | null
|
||||
|
||||
public get first(): MessageNode | null {
|
||||
return this._first
|
||||
}
|
||||
public set first(messageNode: MessageNode | null) {
|
||||
this._first = messageNode
|
||||
}
|
||||
public get last(): MessageNode | null {
|
||||
return this._last
|
||||
}
|
||||
public set last(messageNode: MessageNode | null) {
|
||||
this._last = messageNode
|
||||
}
|
||||
private _size: number
|
||||
public get size(): number {
|
||||
return this._size
|
||||
}
|
||||
public set size(v: number) {
|
||||
this._size = v
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._first = null
|
||||
this._last = null
|
||||
this._size = 0
|
||||
}
|
||||
enqueue(message: string): boolean {
|
||||
const newNode = new MessageNode(message)
|
||||
if (this._size === 0 || !this._last) {
|
||||
this._first = newNode
|
||||
this._last = newNode
|
||||
} else {
|
||||
this._last.next = newNode
|
||||
this._last = newNode
|
||||
}
|
||||
this._size++
|
||||
return true
|
||||
}
|
||||
dequeue(): string | null {
|
||||
if (this._size === 0 || !this._first) return null
|
||||
|
||||
let prev = this._first
|
||||
this._first = prev.next
|
||||
prev.next = null
|
||||
|
||||
this._size--
|
||||
return prev.value
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user