Compare commits

..

1 Commits

Author SHA1 Message Date
fiatjaf
31ab318387 end the rollup madness, just ship the source. 2021-12-11 09:00:25 -03:00
69 changed files with 542 additions and 9827 deletions

View File

@@ -1,9 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -1,9 +1,4 @@
{ {
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"],
"parserOptions": { "parserOptions": {
"ecmaVersion": 9, "ecmaVersion": 9,
"ecmaFeatures": { "ecmaFeatures": {
@@ -18,11 +13,14 @@
"node": true "node": true
}, },
"plugins": [
"babel"
],
"globals": { "globals": {
"document": false, "document": false,
"navigator": false, "navigator": false,
"window": false, "window": false,
"crypto": false,
"location": false, "location": false,
"URL": false, "URL": false,
"URLSearchParams": false, "URLSearchParams": false,
@@ -34,23 +32,23 @@
"rules": { "rules": {
"accessor-pairs": 2, "accessor-pairs": 2,
"arrow-spacing": [2, {"before": true, "after": true}], "arrow-spacing": [2, { "before": true, "after": true }],
"block-spacing": [2, "always"], "block-spacing": [2, "always"],
"brace-style": [2, "1tbs", {"allowSingleLine": true}], "brace-style": [2, "1tbs", { "allowSingleLine": true }],
"comma-dangle": 0, "comma-dangle": 0,
"comma-spacing": [2, {"before": false, "after": true}], "comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"], "comma-style": [2, "last"],
"constructor-super": 2, "constructor-super": 2,
"curly": [0, "multi-line"], "curly": [0, "multi-line"],
"dot-location": [2, "property"], "dot-location": [2, "property"],
"eol-last": 2, "eol-last": 2,
"eqeqeq": [2, "allow-null"], "eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, {"before": true, "after": true}], "generator-star-spacing": [2, { "before": true, "after": true }],
"handle-callback-err": [2, "^(err|error)$"], "handle-callback-err": [2, "^(err|error)$" ],
"indent": 0, "indent": 0,
"jsx-quotes": [2, "prefer-double"], "jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, {"beforeColon": false, "afterColon": true}], "key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [2, {"before": true, "after": true}], "keyword-spacing": [2, { "before": true, "after": true }],
"new-cap": 0, "new-cap": 0,
"new-parens": 0, "new-parens": 0,
"no-array-constructor": 2, "no-array-constructor": 2,
@@ -82,12 +80,12 @@
"no-irregular-whitespace": 2, "no-irregular-whitespace": 2,
"no-iterator": 2, "no-iterator": 2,
"no-label-var": 2, "no-label-var": 2,
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}], "no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
"no-lone-blocks": 2, "no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2, "no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2, "no-multi-spaces": 2,
"no-multi-str": 2, "no-multi-str": 2,
"no-multiple-empty-lines": [2, {"max": 2}], "no-multiple-empty-lines": [2, { "max": 2 }],
"no-native-reassign": 2, "no-native-reassign": 2,
"no-negated-in-lhs": 2, "no-negated-in-lhs": 2,
"no-new": 0, "no-new": 0,
@@ -101,6 +99,7 @@
"no-octal-escape": 2, "no-octal-escape": 2,
"no-path-concat": 0, "no-path-concat": 0,
"no-proto": 2, "no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2, "no-regex-spaces": 2,
"no-return-assign": 0, "no-return-assign": 0,
"no-self-assign": 2, "no-self-assign": 2,
@@ -115,34 +114,23 @@
"no-undef": 2, "no-undef": 2,
"no-undef-init": 2, "no-undef-init": 2,
"no-unexpected-multiline": 2, "no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, {"defaultAssignment": false}], "no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2, "no-unreachable": 2,
"no-unused-vars": [ "no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
2,
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
],
"no-useless-call": 2, "no-useless-call": 2,
"no-useless-constructor": 2, "no-useless-constructor": 2,
"no-with": 2, "no-with": 2,
"one-var": [0, {"initialized": "never"}], "one-var": [0, { "initialized": "never" }],
"operator-linebreak": [ "operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
2,
"after",
{"overrides": {"?": "before", ":": "before"}}
],
"padded-blocks": [2, "never"], "padded-blocks": [2, "never"],
"quotes": [ "quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
2,
"single",
{"avoidEscape": true, "allowTemplateLiterals": true}
],
"semi": [2, "never"], "semi": [2, "never"],
"semi-spacing": [2, {"before": false, "after": true}], "semi-spacing": [2, { "before": false, "after": true }],
"space-before-blocks": [2, "always"], "space-before-blocks": [2, "always"],
"space-before-function-paren": 0, "space-before-function-paren": 0,
"space-in-parens": [2, "never"], "space-in-parens": [2, "never"],
"space-infix-ops": 2, "space-infix-ops": 2,
"space-unary-ops": [2, {"words": true, "nonwords": false}], "space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": 0, "spaced-comment": 0,
"template-curly-spacing": [2, "never"], "template-curly-spacing": [2, "never"],
"use-isnan": 2, "use-isnan": 2,
@@ -150,13 +138,5 @@
"wrap-iife": [2, "any"], "wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"], "yield-star-spacing": [2, "both"],
"yoda": [0] "yoda": [0]
}, }
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest/globals": true },
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
} }

View File

@@ -1,23 +0,0 @@
name: publish npm package
on:
push:
tags: [v*]
jobs:
publish-npm:
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 build
- run: just test
- run: just emit-types
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
greater-version-only: true

View File

@@ -1,18 +0,0 @@
name: test every commit
on:
push:
branches:
- master
pull_request:
jobs:
test:
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 test

3
.gitignore vendored
View File

@@ -2,6 +2,3 @@ node_modules
dist dist
yarn.lock yarn.lock
package-lock.json package-lock.json
.envrc
lib
test.html

24
LICENSE
View File

@@ -1,24 +0,0 @@
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>

350
README.md
View File

@@ -2,329 +2,69 @@
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients. 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
```
## Usage ## Usage
### Generating a private key and a public key
```js ```js
import {generatePrivateKey, getPublicKey} from 'nostr-tools' import {relayPool} from 'nostr-tools'
let sk = generatePrivateKey() // `sk` is a hex string const pool = relayPool()
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events pool.setPrivateKey('<hex>') // optional
```js pool.addRelay('ws://some.relay.com', {read: true, write: true})
import { pool.addRelay('ws://other.relay.cool', {read: true, write: true})
validateEvent,
verifySignature,
getSignature,
getEventHash,
getPublicKey
} from 'nostr-tools'
let event = { // example callback function for a subscription
kind: 1, function onEvent(event, relay) => {
created_at: Math.floor(Date.now() / 1000), console.log(`got an event from ${relay.url} which is already validated.`, event)
tags: [],
content: 'hello',
pubkey: getPublicKey(privateKey)
} }
event.id = getEventHash(event) // subscribing to a single user
event.sig = getSignature(event, privateKey) // author is the user's public key
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
let ok = validateEvent(event) // or bulk follow
let veryOk = verifySignature(event) pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
```
### Interacting with a relay // reuse a subscription channel
const mySubscription = pool.sub({cb: ..., filter: ....})
mySubscription.sub({filter: ....})
mySubscription.sub({cb: ...})
mySubscription.unsub()
```js // get specific event
import { const specificChannel = pool.sub({
relayInit, cb: (event, relay) => {
generatePrivateKey, console.log('got specific event from relay', event, relay)
getPublicKey, specificChannel.unsub()
getEventHash, },
getSignature filter: {id: '<hex>'}
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
}) })
await relay.connect() // or get a specific event plus all the events that reference it in the 'e' tag
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
// let's query for an event that exists // get all events
let sub = relay.sub([ pool.sub({cb: (event, relay) => {...}, filter: {}})
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'] // get recent events
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
// publishing events(inside an async function):
const ev = await pool.publish(eventObject, (status, url) => {
if (status === 0) {
console.log(`publish request sent to ${url}`)
} }
]) if (status === 1) {
sub.on('event', event => { console.log(`event published by ${url}`, ev)
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
{
kinds: [1],
authors: [pk]
} }
])
sub.on('event', event => {
console.log('got event:', event)
}) })
// it will be signed automatically with the key supplied above
// or pass an already signed event to bypass this
let event = { // subscribing to a new relay
kind: 1, pool.addRelay('<url>')
pubkey: pk, // will automatically subscribe to the all the events called with .sub above
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world'
}
event.id = getEventHash(event)
event.sig = getSignature(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}`)
})
let events = await relay.list([{kinds: [0, 1]}])
let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
relay.close()
``` ```
To use this on Node.js you first must install `websocket-polyfill` and import it: For other utils please read the source (for now).
```js
import 'websocket-polyfill'
```
### Interacting with multiple relays
```js
import {SimplePool} from 'nostr-tools'
const pool = new SimplePool()
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let sub = pool.sub(
[...relays, 'wss://relay.example3.com'],
[
{
authors: [
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
]
}
]
)
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
let pubs = pool.publish(relays, newEvent)
pubs.on('ok', () => {
// this may be called multiple times, once for every relay that accepts the event
// ...
})
let events = await pool.list(relays, [{kinds: [0, 1]}])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
let relaysForEvent = pool.seenOn(
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
// relaysForEvent will be an array of URLs from relays a given event was seen on
```
### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js
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 augmentedReference = profile
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
: event
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
: address
? `<a href="${text}">[link]</a>`
: text
simpleAugmentedContent.replaceAll(text, augmentedReference)
}
```
### Querying profile data from a NIP-05 address
```js
import {nip05} from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
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:
```js
nip05.useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
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)
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)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Encrypting and decrypting direct messages
```js
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = await nip04.encrypt(sk1, pk2, message)
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties
}
sendEvent(event)
// on the receiver side
sub.on('event', event => {
let sender = event.pubkey
pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
})
```
### Performing and checking for delegation
```js
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
// delegator
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// delegatee
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// generate delegation
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 */
})
// the delegatee uses the delegation when building an event
let event = {
pubkey: pk2,
kind: 1,
created_at: Math.round(Date.now() / 1000),
content: 'hello from a delegated key',
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]]
}
// finally any receiver of this event can check for the presence of a valid delegation tag
let delegator = nip26.getDelegator(event)
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### Using from the browser (if you don't want to use a bundler)
```html
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generatePrivateKey('...') // and so on
</script>
```
## Plumbing
1. Install [`just`](https://just.systems/)
2. `just -l`
## License
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env node
const fs = require('fs')
const esbuild = require('esbuild')
let common = {
entryPoints: ['index.ts'],
bundle: true,
sourcemap: 'external'
}
esbuild
.build({
...common,
outfile: 'lib/esm/nostr.mjs',
format: 'esm',
packages: 'external'
})
.then(() => {
const packageJson = JSON.stringify({type: 'module'})
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
esbuild
.build({
...common,
outfile: 'lib/nostr.cjs.js',
format: 'cjs',
packages: 'external'
})
.then(() => console.log('cjs build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',
define: {
window: 'self',
global: 'self',
process: '{"env": {}}'
}
})
.then(() => console.log('standalone build success.'))

43
event.js Normal file
View File

@@ -0,0 +1,43 @@
import {Buffer} from 'buffer'
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from './utils'
export function getBlankEvent() {
return {
kind: 255,
pubkey: null,
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt) {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags || [],
evt.content
])
}
export async function getEventHash(event) {
let eventHash = await sha256(Buffer.from(serializeEvent(event)))
return Buffer.from(eventHash).toString('hex')
}
export async function verifySignature(event) {
return await secp256k1.schnorr.verify(
event.sig,
await getEventHash(event),
event.pubkey
)
}
export async function signEvent(event, key) {
let eventHash = await getEventHash(event)
return await secp256k1.schnorr.sign(eventHash, key)
}

View File

@@ -1,352 +0,0 @@
import {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
getSignature,
Kind,
} from './event.ts'
import {getPublicKey} from './keys.ts'
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent()).toEqual({
kind: 255,
content: '',
tags: [],
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 publicKey = getPublicKey(privateKey)
const template = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
}
const event = finishEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
it('should serialize a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: Kind.Text,
tags: [],
content: 'Hello, world!'
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content
])
)
})
it('should throw an error for an invalid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115,
pubkey: publicKey // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
it('should return the correct event hash', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
it('should return true for a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
it('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
it('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115 // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid properties', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey'
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid tags', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifySignature', () => {
it('should return true for a valid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
const isValid = verifySignature(event)
expect(isValid).toEqual(true)
})
it('should return false for an invalid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
// tamper with the signature
event.sig = event.sig.replace(/0/g, '1')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
it('should return false when verifying an event with a different private key', () => {
const privateKey1 =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 =
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2)
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey1
)
// verify with different private key
const isValid = verifySignature({
...event,
pubkey: publicKey2
})
expect(isValid).toEqual(false)
})
})
describe('getSignature', () => {
it('should produce the correct signature for an event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = getSignature(unsignedEvent, privateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(true)
})
it('should not sign an event with different private key', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const wrongPrivateKey =
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = getSignature(unsignedEvent, wrongPrivateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

138
event.ts
View File

@@ -1,138 +0,0 @@
import {schnorr} from '@noble/curves/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {bytesToHex} from '@noble/hashes/utils'
import {getPublicKey} from './keys.ts'
import {utf8Encoder} from './utils.ts'
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
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,
HttpAuth = 27235,
ProfileBadge = 30008,
BadgeDefinition = 30009,
Article = 30023
}
export type EventTemplate<K extends number = Kind> = {
kind: K
tags: string[][]
content: string
created_at: number
}
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & {
pubkey: string
}
export type Event<K extends number = Kind> = UnsignedEvent<K> & {
id: string
sig: string
}
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,
content: '',
tags: [],
created_at: 0
}
}
export function finishEvent<K extends number = Kind>(
t: EventTemplate<K>,
privateKey: string
): Event<K> {
let event = t as Event<K>
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
return event
}
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
])
}
export function getEventHash(event: UnsignedEvent<number>): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
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
if (typeof event.pubkey !== 'string') return false
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event: Event<number>): boolean {
try {
return schnorr.verify(event.sig, getEventHash(event), event.pubkey)
} catch (err) {
return false
}
}
/** @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))
}

View File

@@ -1,47 +0,0 @@
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts'
test('match id', () => {
expect(
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'
)
).toBeTruthy()
expect(
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'
)
).toBeFalsy()
})
test('match kind', () => {
expect(
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
)
).toBeTruthy()
expect(
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
)
).toBeTruthy()
})
test('match subscription id', () => {
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
'kasjbdjkav'
)
expect(
getSubscriptionId(
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
)
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
})

View File

@@ -1,41 +0,0 @@
export function getHex64(json: string, field: string): string {
let len = field.length + 3
let idx = json.indexOf(`"${field}":`) + len
let s = json.slice(idx).indexOf(`"`) + idx + 1
return json.slice(s, s + 64)
}
export function getInt(json: string, field: string): number {
let len = field.length
let idx = json.indexOf(`"${field}":`) + len + 3
let sliced = json.slice(idx)
let end = Math.min(sliced.indexOf(','), sliced.indexOf('}'))
return parseInt(sliced.slice(0, end), 10)
}
export function getSubscriptionId(json: string): string | null {
let idx = json.slice(0, 22).indexOf(`"EVENT"`)
if (idx === -1) return null
let pstart = json.slice(idx + 7 + 1).indexOf(`"`)
if (pstart === -1) return null
let start = idx + 7 + 1 + pstart
let pend = json.slice(start + 1, 80).indexOf(`"`)
if (pend === -1) return null
let end = start + 1 + pend
return json.slice(start + 1, end)
}
export function matchEventId(json: string, id: string): boolean {
return id === getHex64(json, 'id')
}
export function matchEventPubkey(json: string, pubkey: string): boolean {
return pubkey === getHex64(json, 'pubkey')
}
export function matchEventKind(json: string, kind: number): boolean {
return kind === getInt(json, 'kind')
}

View File

@@ -1,248 +0,0 @@
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})
})
})
})

View File

@@ -1,92 +0,0 @@
import {Event, type Kind} from './event.ts'
export type Filter<K extends number = Kind> = {
ids?: string[]
kinds?: K[]
authors?: string[]
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}
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 (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at > filter.until) return false
return true
}
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
}

25
index.js Normal file
View File

@@ -0,0 +1,25 @@
import {relayConnect} from './relay'
import {relayPool} from './pool'
import {
getBlankEvent,
signEvent,
verifySignature,
serializeEvent,
getEventHash
} from './event'
import {makeRandom32, sha256, getPublicKey} from './utils'
export {
relayConnect,
relayPool,
signEvent,
verifySignature,
serializeEvent,
getEventHash,
makeRandom32,
sha256,
getPublicKey,
getBlankEvent
}
export * from './nip04'
export * from './nip05'

View File

@@ -1,25 +0,0 @@
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.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 nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'

View File

@@ -1,5 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@@ -1,23 +0,0 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
install-dependencies:
yarn --ignore-engines
build:
rm -rf lib
node build.js
test:
jest
test-only file:
jest {{file}}
emit-types:
tsc # see tsconfig.json
publish: build emit-types
npm publish
format:
prettier --plugin-search-dir . --write .

View File

@@ -1,18 +0,0 @@
import {generatePrivateKey, getPublicKey} from './keys.ts'
test('private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})

10
keys.ts
View File

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

37
nip04.js Normal file
View File

@@ -0,0 +1,37 @@
import {Buffer} from 'buffer'
import * as secp256k1 from '@noble/secp256k1'
export function encrypt(privkey, pubkey, text) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
let iv = crypto.randomFillSync(new Uint8Array(16))
var cipher = crypto.createCipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
iv
)
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
return [encryptedMessage, Buffer.from(iv.buffer).toString('base64')]
}
export function decrypt(privkey, pubkey, ciphertext, iv) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getOnlyXFromFullSharedSecret(key)
var decipher = crypto.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64')
)
let decryptedMessage = decipher.update(ciphertext, 'base64')
decryptedMessage += decipher.final('utf8')
return decryptedMessage
}
function getOnlyXFromFullSharedSecret(fullSharedSecretCoordinates) {
return fullSharedSecretCoordinates.substr(2, 64)
}

View File

@@ -1,19 +0,0 @@
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')
})

View File

@@ -1,72 +0,0 @@
import {randomBytes} from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1'
import {base64} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts'
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt(
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 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> {
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['decrypt']
)
let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64)
let plaintext = await crypto.subtle.decrypt(
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext)
return text
}
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}

52
nip05.js Normal file
View File

@@ -0,0 +1,52 @@
import {Buffer} from 'buffer'
import dnsPacket from 'dns-packet'
const dohProviders = [
'cloudflare-dns.com',
'fi.doh.dns.snopyta.org',
'basic.bravedns.com',
'hydra.plan9-ns1.com',
'doh.pl.ahadns.net',
'dns.flatuslifir.is',
'doh.dns.sb',
'doh.li'
]
let counter = 0
export async function keyFromDomain(domain) {
let host = dohProviders[counter % dohProviders.length]
let buf = dnsPacket.encode({
type: 'query',
id: Math.floor(Math.random() * 65534),
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
type: 'TXT',
name: `_nostrkey.${domain}`
}
]
})
let fetching = fetch(`https://${host}/dns-query`, {
method: 'POST',
headers: {
'Content-Type': 'application/dns-message',
'Content-Length': Buffer.byteLength(buf)
},
body: buf
})
counter++
try {
let response = Buffer.from(await (await fetching).arrayBuffer())
let {answers} = dnsPacket.decode(response)
if (answers.length === 0) return null
return Buffer.from(answers[0].data[0]).toString()
} catch (err) {
console.log(`error querying DNS for ${domain} on ${host}`, err)
return null
}
}

View File

@@ -1,32 +0,0 @@
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',
])
})

View File

@@ -1,86 +0,0 @@
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
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(
domain: string,
query = ''
): Promise<{[name: string]: string}> {
try {
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
).json()
return res.names
} catch (_) {
return {}
}
}
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
const match = fullname.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
try {
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
const { names, relays } = parseNIP05Result(await res.json())
const pubkey = names[name]
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
} catch (_e) {
return null
}
}
/** nostr.json result. */
export interface NIP05Result {
names: {
[name: string]: string
}
relays?: {
[pubkey: string]: string[]
}
}
/** Parse the nostr.json and throw if it's not valid. */
function parseNIP05Result(json: any): NIP05Result {
const result: NIP05Result = {
names: {},
}
for (const [name, pubkey] of Object.entries(json.names)) {
if (typeof name === 'string' && typeof pubkey === 'string') {
result.names[name] = pubkey
}
}
if (json.relays) {
result.relays = {}
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === 'string' && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
}
}
}
return result
}

View File

@@ -1,18 +0,0 @@
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'
)
})

View File

@@ -1,26 +0,0 @@
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 {
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 bytesToHex(privateKey)
}
export function generateSeedWords(): string {
return generateMnemonic(wordlist)
}
export function validateWords(words: string): boolean {
return validateMnemonic(words, wordlist)
}

View File

@@ -1,351 +0,0 @@
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
})
})
})

View File

@@ -1,96 +0,0 @@
import type {Event} from './event.ts'
import type {EventPointer, ProfilePointer} from './nip19.ts'
export type NIP10Result = {
/**
* Pointer to the root of the thread.
*/
root: EventPointer | undefined
/**
* Pointer to a "parent" event that parsed event replies to (responded to).
*/
reply: EventPointer | undefined
/**
* Pointers to events which may or may not be in the reply chain.
*/
mentions: EventPointer[]
/**
* List of pubkeys that are involved in the thread in no particular order.
*/
profiles: ProfilePointer[]
}
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined,
root: undefined,
mentions: [],
profiles: []
}
const eTags: string[][] = []
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) {
eTags.push(tag)
}
if (tag[0] === 'p' && tag[1]) {
result.profiles.push({
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : []
})
}
}
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [
string,
string,
undefined | string,
undefined | string
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : []
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
}
return result
}

View File

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

View File

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

View File

@@ -1,112 +0,0 @@
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])
})
})

View File

@@ -1,97 +0,0 @@
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
}

View File

@@ -1,123 +0,0 @@
import {generatePrivateKey, getPublicKey} from './keys.ts'
import {
decode,
naddrEncode,
nprofileEncode,
npubEncode,
nrelayEncode,
nsecEncode,
type AddressPointer,
type ProfilePointer,
} 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('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)
})

219
nip19.ts
View File

@@ -1,219 +0,0 @@
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils'
import {bech32} from '@scure/base'
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,}/
export type ProfilePointer = {
pubkey: string // hex
relays?: string[]
}
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
}
export type AddressPointer = {
identifier: string
pubkey: string
kind: number
relays?: string[]
}
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) {
case 'nprofile': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
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')
return {
type: 'nevent',
data: {
id: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined
}
}
}
case 'naddr': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
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: bytesToHex(data)}
default:
throw new Error(`unknown prefix ${prefix}`)
}
}
type TLV = {[t: number]: Uint8Array[]}
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
let rest = data
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) 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): `nsec1${string}` {
return encodeBytes('nsec', hex)
}
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hex)
}
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hex)
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${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: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
})
return encodeBech32('nprofile', data)
}
export function neventEncode(event: EventPointer): `nevent1${string}` {
let data = encodeTLV({
0: [hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : []
})
return encodeBech32('nevent', data)
}
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: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)]
})
return encodeBech32('naddr', data)
}
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
return encodeBech32('nrelay', data)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv).forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
return concatBytes(...entries)
}

View File

@@ -1,41 +0,0 @@
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'
}
})
})

View File

@@ -1,33 +0,0 @@
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])
}
}

View File

@@ -1,78 +0,0 @@
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)
})
})

View File

@@ -1,69 +0,0 @@
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],
}
}

View File

@@ -1,105 +0,0 @@
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(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
})
test('parse bad delegations', async () => {
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1740995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
})
test('create and verify delegation', async () => {
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
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 = buildEvent({
kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2,
})
expect(getDelegator(event)).toEqual(pk1)
})

View File

@@ -1,92 +0,0 @@
import {schnorr} from '@noble/curves/secp256k1'
import {bytesToHex} from '@noble/hashes/utils'
import {sha256} from '@noble/hashes/sha256'
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
until?: number // delegation will only be valid until this date
since?: number // delegation will be valid from this date on
}
export type Delegation = {
from: string // the pubkey who signed the delegation
to: string // the pubkey that is allowed to use the delegation
cond: string // the string of conditions as they should be included in the event tag
sig: string
}
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')
let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
)
let sig = bytesToHex(
schnorr.sign(sighash, privateKey)
)
return {
from: getPublicKey(privateKey),
to: parameters.pubkey,
cond,
sig
}
}
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
let pubkey = tag[1]
let cond = tag[2]
let sig = tag[3]
// check conditions
let conditions = cond.split('&')
for (let i = 0; i < conditions.length; i++) {
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
else return null // invalid condition
}
// check signature
let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
)
if (!schnorr.verify(sig, sighash, pubkey)) return null
return pubkey
}

View File

@@ -1,67 +0,0 @@
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')
})

View File

@@ -1,66 +0,0 @@
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)
})
})
}

View File

@@ -1,14 +0,0 @@
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)
})

View File

@@ -1,27 +0,0 @@
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
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}`
)
} catch (_) {
return false
}
}

View File

@@ -1,26 +0,0 @@
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()
})
})
})

View File

@@ -1,42 +0,0 @@
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: ''
}
const pub = relay.auth(await sign(e))
return new Promise((resolve, reject) => {
pub.on('ok', function ok() {
pub.off('ok', ok)
resolve()
})
pub.on('failed', function fail(reason: string) {
pub.off('failed', fail)
reject(reason)
})
})
}

View File

@@ -1,340 +0,0 @@
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 = 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()))
useFetchImplementation(fetchImplementation)
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'
)
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
Promise.resolve({json: () => ({allowsNostr: false})})
)
useFetchImplementation(fetchImplementation)
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'
)
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback'
})
})
)
useFetchImplementation(fetchImplementation)
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'
)
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: ''
})
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: ''
})
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment'
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2']
])
)
expect(result.tags).toContainEqual(['e', 'event'])
})
})
describe('validateZapRequest', () => {
test('returns an error message for invalid 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', () => {
const zapRequest = {
kind: 1234,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
}
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', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
pubkey: publicKey,
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
}
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', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
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', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
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', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['e', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
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', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'relays' tag."
)
})
test('returns null for a valid Zap request', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
})
})
describe('makeZapReceipt', () => {
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
)
const preimage = 'preimage'
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).toContainEqual(['preimage', preimage])
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
)
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = makeZapReceipt({zapRequest, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).not.toContain('preimage')
})
})

147
nip57.ts
View File

@@ -1,147 +0,0 @@
import {bech32} from '@scure/base'
import {
Kind,
validateEvent,
verifySignature,
type Event,
type EventTemplate,
} from './event.ts'
import {utf8Decoder} from './utils.ts'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function getZapEndpoint(
metadata: Event<Kind.Metadata>
): Promise<null | string> {
try {
let lnurl: string = ''
let {lud06, lud16} = JSON.parse(metadata.content)
if (lud06) {
let {words} = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
} else {
return null
}
let res = await _fetch(lnurl)
let body = await res.json()
if (body.allowsNostr && body.nostrPubkey) {
return body.callback
}
} catch (err) {
/*-*/
}
return null
}
export function makeZapRequest({
profile,
event,
amount,
relays,
comment = ''
}: {
profile: string
event: string | null
amount: number
comment: string
relays: string[]
}): EventTemplate<Kind.ZapRequest> {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate<Kind.ZapRequest> = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
tags: [
['p', profile],
['amount', amount.toString()],
['relays', ...relays]
]
}
if (event) {
zr.tags.push(['e', event])
}
return zr
}
export function validateZapRequest(zapRequestString: string): string | null {
let zapRequest: Event
try {
zapRequest = JSON.parse(zapRequestString)
} catch (err) {
return 'Invalid zap request JSON.'
}
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."
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."
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
if (!relays) return "Zap request doesn't have a 'relays' tag."
return null
}
export function makeZapReceipt({
zapRequest,
preimage,
bolt11,
paidAt
}: {
zapRequest: string
preimage?: string
bolt11: string
paidAt: Date
}): 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: EventTemplate<Kind.Zap> = {
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [
...tagsFromZapRequest,
['bolt11', bolt11],
['description', zapRequest]
]
}
if (preimage) {
zap.tags.push(['preimage', preimage])
}
return zap
}

View File

@@ -1,139 +0,0 @@
import {base64} from '@scure/base'
import {getToken, validateToken} from './nip98.ts'
import {Event, Kind, finishEvent} from './event.ts'
import {utf8Decoder} from './utils.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 = JSON.parse(
utf8Decoder.decode(base64.decode(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 = JSON.parse(
utf8Decoder.decode(base64.decode(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 = JSON.parse(
utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, '')))
)
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 unknown method throws an error', async () => {
const result = getToken('http://test.com', 'fake', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken missing loginUrl throws an error', async () => {
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)
})
})

112
nip98.ts
View File

@@ -1,112 +0,0 @@
import {base64} from '@scure/base'
import {
Event,
EventTemplate,
Kind,
getBlankEvent,
verifySignature
} from './event'
import {utf8Decoder, utf8Encoder} from './utils'
enum HttpMethod {
Get = 'get',
Post = 'post'
}
const _authorizationScheme = 'Nostr '
/**
* Generate token for NIP-98 flow.
*
* @example
* const sign = window.nostr.signEvent
* await getToken('https://example.com/login', 'post', sign, true)
*/
export async function getToken(
loginUrl: string,
httpMethod: 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')
if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post)
throw new Error('Unknown 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 validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(
token: string,
url: string,
method: string
): Promise<boolean> {
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
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
}

View File

@@ -1,61 +1,27 @@
{ {
"name": "nostr-tools", "name": "nostr-tools",
"version": "1.13.1", "version": "0.6.5",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/nbd-wtf/nostr-tools.git" "url": "https://github.com/fiatjaf/nostr-tools.git"
}, },
"files": [
"./lib/**/*"
],
"types": "./lib/index.d.ts",
"main": "lib/nostr.cjs.js",
"module": "lib/esm/nostr.mjs",
"exports": {
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js",
"types": "./lib/index.d.ts"
},
"license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/curves": "1.1.0", "@noble/secp256k1": "^1.3.0",
"@noble/hashes": "1.3.1", "buffer": "^6.0.3",
"@scure/base": "1.1.1", "dns-packet": "^5.2.4",
"@scure/bip32": "1.3.1", "websocket-polyfill": "^0.0.3"
"@scure/bip39": "1.2.1"
}, },
"keywords": [ "keywords": [
"decentralization", "decentralization",
"twitter",
"p2p",
"mastodon",
"ssb",
"social", "social",
"unstoppable",
"censorship",
"censorship-resistance", "censorship-resistance",
"client", "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",
"@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.40.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"jest": "^29.5.0",
"node-fetch": "^2.6.9",
"prettier": "^2.8.4",
"ts-jest": "^29.1.0",
"tsd": "^0.22.0",
"typescript": "^5.0.4",
"websocket-polyfill": "^0.0.3"
}
} }

122
pool.js Normal file
View File

@@ -0,0 +1,122 @@
import {getEventHash, signEvent} from './event'
import {relayConnect, normalizeRelayURL} from './relay'
export function relayPool(globalPrivateKey) {
const relays = {}
const globalSub = []
const noticeCallbacks = []
function propagateNotice(notice, relayURL) {
for (let i = 0; i < noticeCallbacks.length; i++) {
let {relay} = relays[relayURL]
noticeCallbacks[i](notice, relay)
}
}
const activeSubscriptions = {}
const sub = ({cb, filter}, id = Math.random().toString().slice(2)) => {
const subControllers = Object.fromEntries(
Object.values(relays)
.filter(({policy}) => policy.read)
.map(({relay}) => [
relay.url,
relay.sub({filter, cb: event => cb(event, relay.url)})
])
)
const activeCallback = cb
const activeFilters = filter
activeSubscriptions[id] = {
sub: ({cb = activeCallback, filter = activeFilters}) =>
Object.entries(subControllers).map(([relayURL, sub]) => [
relayURL,
sub.sub({cb, filter}, id)
]),
addRelay: relay => {
subControllers[relay.url] = relay.sub({cb, filter})
},
removeRelay: relayURL => {
if (relayURL in subControllers) {
subControllers[relayURL].unsub()
if (Object.keys(subControllers).length === 0) unsub()
}
},
unsub: () => {
Object.values(subControllers).forEach(sub => sub.unsub())
delete activeSubscriptions[id]
}
}
return activeSubscriptions[id]
}
return {
sub,
relays,
setPrivateKey(privateKey) {
globalPrivateKey = privateKey
},
async addRelay(url, policy = {read: true, write: true}) {
let relayURL = normalizeRelayURL(url)
if (relayURL in relays) return
let relay = await relayConnect(url, notice => {
propagateNotice(notice, relayURL)
})
relays[relayURL] = {relay, policy}
Object.values(activeSubscriptions).forEach(subscription =>
subscription.addRelay(relay)
)
return relay
},
removeRelay(url) {
let relayURL = normalizeRelayURL(url)
let {relay} = relays[relayURL]
if (!relay) return
Object.values(activeSubscriptions).forEach(subscription =>
subscription.removeRelay(relay)
)
relay.close()
delete relays[relayURL]
},
onNotice(cb) {
noticeCallbacks.push(cb)
},
offNotice(cb) {
let index = noticeCallbacks.indexOf(cb)
if (index !== -1) noticeCallbacks.splice(index, 1)
},
async publish(event, statusCallback = (status, relayURL) => {}) {
if (!event.sig) {
event.tags = event.tags || []
if (globalPrivateKey) {
event.id = await getEventHash(event)
event.sig = await signEvent(event, globalPrivateKey)
} else {
throw new Error(
"can't publish unsigned event. either sign this event beforehand or pass a private key while initializing this relay pool so it can be signed automatically."
)
}
}
Object.values(relays)
.filter(({policy}) => policy.write)
.map(async ({relay}) => {
try {
await relay.publish(event, status =>
statusCallback(status, relay.url)
)
} catch (err) {
statusCallback(-1, relay.url)
}
})
return event
}
}
}

View File

@@ -1,140 +0,0 @@
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)
})

208
pool.ts
View File

@@ -1,208 +0,0 @@
import {
relayInit,
type Pub,
type Relay,
type Sub,
type SubscriptionOptions,
} from './relay.ts'
import {normalizeURL} from './utils.ts'
import type {Event} from './event.ts'
import type {Filter} from './filter.ts'
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 eoseSubTimeout: number
private getTimeout: number
private seenOnEnabled: boolean = true
constructor(options: {eoseSubTimeout?: number; getTimeout?: number; seenOnEnabled?: boolean} = {}) {
this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
this.seenOnEnabled = options.seenOnEnabled !== false
}
close(relays: string[]): void {
relays.forEach(url => {
let relay = this._conn[normalizeURL(url)]
if (relay) relay.close()
})
}
async ensureRelay(url: string): Promise<Relay> {
const nm = normalizeURL(url)
if (!this._conn[nm]) {
this._conn[nm] = relayInit(nm, {
getTimeout: this.getTimeout * 0.9,
listTimeout: this.getTimeout * 0.9
})
}
const relay = this._conn[nm]
await relay.connect()
return relay
}
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
let _knownIds: Set<string> = new Set()
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<any> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length
let eoseSent = false
let eoseTimeout = setTimeout(() => {
eoseSent = true
for (let cb of eoseListeners.values()) cb()
}, this.eoseSubTimeout)
relays.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
if (!r) return
let s = r.sub(filters, modifiedOpts)
s.on('event', (event) => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) cb()
}
}
})
let greaterSub: Sub = {
sub(filters, opts) {
subs.forEach(sub => sub.sub(filters, opts))
return greaterSub
},
unsub() {
subs.forEach(sub => sub.unsub())
},
on(type, cb) {
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') {
eoseListeners.add(cb as () => void | Promise<void>)
}
},
off(type, cb) {
if (type === 'event') {
eventListeners.delete(cb)
} else if (type === 'eose')
eoseListeners.delete(cb as () => void | Promise<void>)
}
}
return greaterSub
}
get<K extends number = number>(
relays: string[],
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) => {
resolve(event)
clearTimeout(timeout)
sub.unsub()
})
})
}
list<K extends number = number>(
relays: string[],
filters: Filter<K>[],
opts?: SubscriptionOptions
): Promise<Event<K>[]> {
return new Promise(resolve => {
let events: Event<K>[] = []
let sub = this.sub(relays, filters, opts)
sub.on('event', (event) => {
events.push(event)
})
// we can rely on an eose being emitted here because pool.sub() will fake one
sub.on('eose', () => {
sub.unsub()
resolve(events)
})
})
}
publish(relays: string[], event: Event<number>): 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)
})
},
off(type, cb) {
relays.forEach(async (_, i) => {
let callback = callbackMap.get(cb)
if (callback) {
let pub = await pubPromises[i]
pub.off(type, callback)
}
})
}
}
}
seenOn(id: string): string[] {
return Array.from(this._seenOn[id]?.values?.() || [])
}
}

View File

@@ -1,61 +0,0 @@
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
}
}
])
})

View File

@@ -1,110 +0,0 @@
import {
decode,
type AddressPointer,
type ProfilePointer,
type EventPointer,
} from './nip19.ts'
import type {Event} from './event.ts'
type Reference = {
text: string
profile?: ProfilePointer
event?: EventPointer
address?: AddressPointer
}
const mentionRegex =
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
export function parseReferences(evt: Event): Reference[] {
let references: Reference[] = []
for (let ref of evt.content.matchAll(mentionRegex)) {
if (ref[2]) {
// it's a NIP-27 mention
try {
let {type, data} = decode(ref[1])
switch (type) {
case 'npub': {
references.push({
text: ref[0],
profile: {pubkey: data as string, relays: []}
})
break
}
case 'nprofile': {
references.push({
text: ref[0],
profile: data as ProfilePointer
})
break
}
case 'note': {
references.push({
text: ref[0],
event: {id: data as string, relays: []}
})
break
}
case 'nevent': {
references.push({
text: ref[0],
event: data as EventPointer
})
break
}
case 'naddr': {
references.push({
text: ref[0],
address: data as AddressPointer
})
break
}
}
} catch (err) {
/***/
}
} else if (ref[3]) {
// it's a NIP-10 mention
let idx = parseInt(ref[3], 10)
let tag = evt.tags[idx]
if (!tag) continue
switch (tag[0]) {
case 'p': {
references.push({
text: ref[0],
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]] : []}
})
break
}
case 'a': {
try {
let [kind, pubkey, identifier] = tag[1].split(':')
references.push({
text: ref[0],
address: {
identifier,
pubkey,
kind: parseInt(kind, 10),
relays: tag[2] ? [tag[2]] : []
}
})
} catch (err) {
/***/
}
break
}
}
}
}
return references
}

160
relay.js Normal file
View File

@@ -0,0 +1,160 @@
import 'websocket-polyfill'
import {verifySignature} from './event'
export function normalizeRelayURL(url) {
let [host, ...qs] = url.split('?')
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
if (host.slice(0, 2) !== 'ws') host = 'wss://' + host
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
return [host, ...qs].join('?')
}
export function relayConnect(url, onNotice) {
url = normalizeRelayURL(url)
var ws, resolveOpen, untilOpen
var openSubs = {}
let attemptNumber = 1
let nextAttemptSeconds = 1
function resetOpenState() {
untilOpen = new Promise(resolve => {
resolveOpen = resolve
})
}
var channels = {}
function connect() {
ws = new WebSocket(url)
ws.onopen = () => {
console.log('connected to', url)
resolveOpen()
// restablish old subscriptions
for (let channel in openSubs) {
let filters = openSubs[channel]
let cb = channels[channel]
sub({cb, filter: filters}, channel)
}
}
ws.onerror = () => {
console.log('error connecting to relay', url)
}
ws.onclose = () => {
resetOpenState()
attemptNumber++
nextAttemptSeconds += attemptNumber
console.log(
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
)
setTimeout(async () => {
try {
connect()
} catch (err) {}
}, nextAttemptSeconds * 1000)
}
ws.onmessage = async e => {
var data
try {
data = JSON.parse(e.data)
} catch (err) {
data = e.data
}
if (data.length > 1) {
if (data[0] === 'NOTICE') {
if (data.length < 2) return
console.log('message from relay ' + url + ': ' + data[1])
onNotice(data[1])
return
}
if (data[0] === 'EVENT') {
if (data.length < 3) return
let channel = data[1]
let event = data[2]
if (await verifySignature(event)) {
if (channels[channel]) {
channels[channel](event)
}
} else {
console.warn('got event with invalid signature from ' + url, event)
}
return
}
}
}
}
resetOpenState()
try {
connect()
} catch (err) {}
async function trySend(params) {
let msg = JSON.stringify(params)
await untilOpen
ws.send(msg)
}
const sub = ({cb, filter}, channel = Math.random().toString().slice(2)) => {
var filters = []
if (Array.isArray(filter)) {
filters = filter
} else {
filters.push(filter)
}
trySend(['REQ', channel, ...filters])
channels[channel] = cb
openSubs[channel] = filters
const activeCallback = cb
const activeFilters = filters
return {
sub: ({cb = activeCallback, filter = activeFilters}) =>
sub({cb, filter}, channel),
unsub: () => {
delete openSubs[channel]
delete channels[channel]
trySend(['CLOSE', channel])
}
}
}
return {
url,
sub,
async publish(event, statusCallback = status => {}) {
try {
await trySend(['EVENT', event])
statusCallback(0)
let {unsub} = relay.sub({
cb: () => {
statusCallback(1)
},
filter: {id: event.id}
})
setTimeout(unsub, 5000)
} catch (err) {
statusCallback(-1)
}
},
close() {
ws.close()
},
get status() {
return ws.readyState
}
}
}

View File

@@ -1,132 +0,0 @@
import 'websocket-polyfill'
import {finishEvent} from './event.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
import {relayInit} from './relay.ts'
let relay = relayInit('wss://relay.damus.io/')
beforeAll(() => {
relay.connect()
})
afterAll(() => {
relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
})
).resolves.toBe(true)
})
test('querying', async () => {
var resolve1: (value: boolean) => void
var resolve2: (value: boolean) => void
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
let [t1, t2] = await Promise.all([
new Promise<boolean>(resolve => {
resolve1 = resolve
}),
new Promise<boolean>(resolve => {
resolve2 = resolve
})
])
expect(t1).toEqual(true)
expect(t2).toEqual(true)
})
test('get()', async () => {
let event = await relay.get({
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
})
test('list()', async () => {
let events = await relay.list([
{
authors: [
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
],
kinds: [1],
limit: 2
}
])
expect(events.length).toEqual(2)
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
var resolve1: (value: boolean) => void
var resolve2: (value: boolean) => void
let sub = relay.sub([
{
kinds: [27572],
authors: [pk]
}
])
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1(true)
})
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve2(true)
})
let event = finishEvent({
kind: 27572,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite'
}, sk)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})

401
relay.ts
View File

@@ -1,401 +0,0 @@
/* global WebSocket */
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>
}
export type CountPayload = {
count: number
}
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 = {
url: string
status: number
connect: () => Promise<void>
close: () => 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>) => Pub
auth: (event: Event<number>) => Pub
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
}
export type Pub = {
on: (type: 'ok' | 'failed', cb: any) => void
off: (type: 'ok' | 'failed', cb: any) => void
}
export type Sub<K extends number = number> = {
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
unsub: () => 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
}
export type SubscriptionOptions = {
id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
}
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, countTimeout = 3000} = options
var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners = newListeners()
var subListeners: {
[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>
}
} = {}
var connectionPromise: Promise<void> | undefined
async function connectRelay(): Promise<void> {
if (connectionPromise) return connectionPromise
connectionPromise = new Promise((resolve, reject) => {
try {
ws = new WebSocket(url)
} catch (err) {
reject(err)
}
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
resolve()
}
ws.onerror = () => {
connectionPromise = undefined
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
connectionPromise = undefined
listeners.disconnect.forEach(cb => cb())
}
let incomingMessageQueue: MessageQueue = new MessageQueue()
let handleNextInterval: any
ws.onmessage = e => {
incomingMessageQueue.enqueue(e.data)
if (!handleNextInterval) {
handleNextInterval = setInterval(handleNext, 0)
}
}
function handleNext() {
if (incomingMessageQueue.size === 0) {
clearInterval(handleNextInterval)
handleNextInterval = null
return
}
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)
) {
return
}
}
try {
let data = JSON.parse(json)
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT': {
let id = data[1]
let event = data[2]
if (
validateEvent(event) &&
openSubs[id] &&
(openSubs[id].skipVerification || verifySignature(event)) &&
matchFilters(openSubs[id].filters, event)
) {
openSubs[id]
;(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) {
subListeners[id].eose.forEach(cb => cb())
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
}
return
}
case 'OK': {
let id: string = data[1]
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 = []
}
return
}
case 'NOTICE':
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
}
}
})
return connectionPromise
}
function connected() {
return ws?.readyState === 1
}
async function connect(): Promise<void> {
if (connected()) return // ws already open
await connectRelay()
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
if (!connected()) {
await new Promise(resolve => setTimeout(resolve, 1000))
if (!connected()) {
return
}
}
try {
ws.send(msg)
} catch (err) {
console.log(err)
}
}
const sub = <K extends number = number>(
filters: Filter<K>[],
{
verb = 'REQ',
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2)
}: SubscriptionOptions = {}
): Sub<K> => {
let subid = id
openSubs[subid] = {
id: subid,
filters,
skipVerification,
alreadyHaveEvent
}
trySend([verb, subid, ...filters])
return {
sub: (newFilters, newOpts = {}) =>
sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || skipVerification,
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
id: subid
}),
unsub: () => {
delete openSubs[subid]
delete subListeners[subid]
trySend(['CLOSE', subid])
},
on: (type, cb) => {
subListeners[subid] = subListeners[subid] || {
event: [],
count: [],
eose: []
}
subListeners[subid][type].push(cb)
},
off: (type, cb): void => {
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
function _publishEvent(event: Event<number>, type: string) {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend([type, event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
return {
url,
sub,
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 => {
let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1)
},
list: (filters, opts?: SubscriptionOptions) =>
new Promise(resolve => {
let s = sub(filters, opts)
let events: Event<any>[] = []
let timeout = setTimeout(() => {
s.unsub()
resolve(events)
}, listTimeout)
s.on('eose', () => {
s.unsub()
clearTimeout(timeout)
resolve(events)
})
s.on('event', (event) => {
events.push(event)
})
}),
get: (filter, opts?: SubscriptionOptions) =>
new Promise(resolve => {
let s = sub([filter], opts)
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, getTimeout)
s.on('event', (event) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
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)
})
}),
publish(event): Pub {
return _publishEvent(event, 'EVENT')
},
auth(event): Pub {
return _publishEvent(event, 'AUTH')
},
connect,
close(): void {
listeners = newListeners()
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {
ws?.close()
}
},
get status() {
return ws?.readyState ?? 3
}
}
}

16
rollup.config.js Normal file
View File

@@ -0,0 +1,16 @@
import pkg from './package.json'
export default {
input: 'index.js',
output: [
{
name: 'nostrtools',
file: pkg.browser,
format: 'umd'
},
{
file: pkg.module,
format: 'es'
}
]
}

View File

@@ -1,17 +0,0 @@
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
}
}

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"declaration": true,
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,
"outDir": "lib",
"rootDir": ".",
"allowImportingTsExtensions": true
}
}

6
utils.js Normal file
View File

@@ -0,0 +1,6 @@
import * as secp256k1 from '@noble/secp256k1'
export const makeRandom32 = () => secp256k1.utils.randomPrivateKey()
export const sha256 = m => secp256k1.utils.sha256(Uint8Array.from(m))
export const getPublicKey = privateKey =>
secp256k1.schnorr.getPublicKey(privateKey)

View File

@@ -1,239 +0,0 @@
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)
})
})

187
utils.ts
View File

@@ -1,187 +0,0 @@
import type {Event} from './event.ts'
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()
export function normalizeURL(url: string): string {
let p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if (
(p.port === '80' && p.protocol === 'ws:') ||
(p.port === '443' && p.protocol === 'wss:')
)
p.port = ''
p.searchParams.sort()
p.hash = ''
return p.toString()
}
//
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
//
export function insertEventIntoDescendingList(
sortedArray: Event<number>[],
event: Event<number>
) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
if (end < 0) {
position = 0
} else if (event.created_at < sortedArray[end].created_at) {
position = end + 1
} else if (event.created_at >= sortedArray[start].created_at) {
position = start
} else
while (true) {
if (end <= start + 1) {
position = end
break
}
midPoint = Math.floor(start + (end - start) / 2)
if (sortedArray[midPoint].created_at > event.created_at) {
start = midPoint
} else if (sortedArray[midPoint].created_at < event.created_at) {
end = midPoint
} else {
// aMidPoint === num
position = midPoint
break
}
}
// 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
}
export function insertEventIntoAscendingList(
sortedArray: Event<number>[],
event: Event<number>
) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
if (end < 0) {
position = 0
} else if (event.created_at > sortedArray[end].created_at) {
position = end + 1
} else if (event.created_at <= sortedArray[start].created_at) {
position = start
} else
while (true) {
if (end <= start + 1) {
position = end
break
}
midPoint = Math.floor(start + (end - start) / 2)
if (sortedArray[midPoint].created_at < event.created_at) {
start = midPoint
} else if (sortedArray[midPoint].created_at > event.created_at) {
end = midPoint
} else {
// aMidPoint === num
position = midPoint
break
}
}
// 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
}
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
}
}

4100
yarn.lock

File diff suppressed because it is too large Load Diff