mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-11 17:48:50 +00:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 | ||
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 | ||
|
|
c1848d78a0 | ||
|
|
81776ba811 | ||
|
|
915d6d729b | ||
|
|
1a23f5ee01 | ||
|
|
fec40490a2 | ||
|
|
bb3e41bb89 | ||
|
|
27b971eef3 | ||
|
|
0041008b22 | ||
|
|
ae5bf4c72c | ||
|
|
75fc836cf6 | ||
|
|
70b025b8da | ||
|
|
c9bc702d90 | ||
|
|
7652318185 | ||
|
|
d81a2444b3 | ||
|
|
7507943253 | ||
|
|
b9a7f814aa | ||
|
|
0e364701da | ||
|
|
a55fb8465f | ||
|
|
472a01af6a | ||
|
|
bb5acfc197 | ||
|
|
1c6f39e4ae | ||
|
|
5b15237b95 | ||
|
|
4184609a00 | ||
|
|
97287cad74 | ||
|
|
fa21f71ab5 | ||
|
|
08885ab8da | ||
|
|
9f896479d0 | ||
|
|
82caa2aad9 | ||
|
|
67a8ee23ce | ||
|
|
18e8227123 | ||
|
|
64caef9cda | ||
|
|
6a07d2d9d3 | ||
|
|
341ccc5ac5 | ||
|
|
d2a9af2586 | ||
|
|
5d92be05bb | ||
|
|
03cc18d53b | ||
|
|
ac7598b5e3 | ||
|
|
424449c773 | ||
|
|
ab6abe6815 | ||
|
|
30fd6b6215 | ||
|
|
8a53b3b8b3 | ||
|
|
d0bd599ce8 | ||
|
|
1cbb62e6b9 | ||
|
|
977316915b | ||
|
|
dd8f555094 | ||
|
|
87f5ea4291 | ||
|
|
595ae21baf | ||
|
|
9fa554ca8e | ||
|
|
1647601727 | ||
|
|
b66ca1787a | ||
|
|
278cdda9c2 | ||
|
|
552530fa3f | ||
|
|
13e9b4aa3e | ||
|
|
9a3e05ce5f |
@@ -2,7 +2,7 @@
|
|||||||
"root": true,
|
"root": true,
|
||||||
|
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": ["@typescript-eslint"],
|
"plugins": ["@typescript-eslint", "babel"],
|
||||||
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 9,
|
"ecmaVersion": 9,
|
||||||
@@ -18,8 +18,6 @@
|
|||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"plugins": ["babel"],
|
|
||||||
|
|
||||||
"globals": {
|
"globals": {
|
||||||
"document": false,
|
"document": false,
|
||||||
"navigator": false,
|
"navigator": false,
|
||||||
@@ -103,7 +101,6 @@
|
|||||||
"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,
|
||||||
@@ -153,5 +150,13 @@
|
|||||||
"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"]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -15,5 +15,4 @@ jobs:
|
|||||||
node-version: 18
|
node-version: 18
|
||||||
- uses: extractions/setup-just@v1
|
- uses: extractions/setup-just@v1
|
||||||
- run: just install-dependencies
|
- run: just install-dependencies
|
||||||
- run: just build
|
|
||||||
- run: just test
|
- run: just test
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -4,6 +4,8 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
|||||||
|
|
||||||
Only depends on _@scure_ and _@noble_ packages.
|
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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -27,7 +29,7 @@ let pk = getPublicKey(sk) // `pk` is a hex string
|
|||||||
import {
|
import {
|
||||||
validateEvent,
|
validateEvent,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
signEvent,
|
getSignature,
|
||||||
getEventHash,
|
getEventHash,
|
||||||
getPublicKey
|
getPublicKey
|
||||||
} from 'nostr-tools'
|
} from 'nostr-tools'
|
||||||
@@ -41,7 +43,7 @@ let event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.id = getEventHash(event)
|
event.id = getEventHash(event)
|
||||||
event.sig = signEvent(event, privateKey)
|
event.sig = getSignature(event, privateKey)
|
||||||
|
|
||||||
let ok = validateEvent(event)
|
let ok = validateEvent(event)
|
||||||
let veryOk = verifySignature(event)
|
let veryOk = verifySignature(event)
|
||||||
@@ -55,7 +57,7 @@ import {
|
|||||||
generatePrivateKey,
|
generatePrivateKey,
|
||||||
getPublicKey,
|
getPublicKey,
|
||||||
getEventHash,
|
getEventHash,
|
||||||
signEvent
|
getSignature
|
||||||
} from 'nostr-tools'
|
} from 'nostr-tools'
|
||||||
|
|
||||||
const relay = relayInit('wss://relay.example.com')
|
const relay = relayInit('wss://relay.example.com')
|
||||||
@@ -104,15 +106,9 @@ let event = {
|
|||||||
content: 'hello world'
|
content: 'hello world'
|
||||||
}
|
}
|
||||||
event.id = getEventHash(event)
|
event.id = getEventHash(event)
|
||||||
event.sig = signEvent(event, sk)
|
event.sig = getSignature(event, sk)
|
||||||
|
|
||||||
let pub = relay.publish(event)
|
await 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 events = await relay.list([{kinds: [0, 1]}])
|
||||||
let event = await relay.get({
|
let event = await relay.get({
|
||||||
@@ -266,7 +262,7 @@ sendEvent(event)
|
|||||||
|
|
||||||
// on the receiver side
|
// on the receiver side
|
||||||
sub.on('event', event => {
|
sub.on('event', event => {
|
||||||
let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
let sender = event.pubkey
|
||||||
pk1 === sender
|
pk1 === sender
|
||||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
const {
|
import {
|
||||||
getBlankEvent,
|
getBlankEvent,
|
||||||
finishEvent,
|
finishEvent,
|
||||||
serializeEvent,
|
serializeEvent,
|
||||||
getEventHash,
|
getEventHash,
|
||||||
validateEvent,
|
validateEvent,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
signEvent,
|
getSignature,
|
||||||
getPublicKey,
|
Kind,
|
||||||
Kind
|
} from './event.ts'
|
||||||
} = require('./lib/nostr.cjs')
|
import {getPublicKey} from './keys.ts'
|
||||||
|
|
||||||
describe('Event', () => {
|
describe('Event', () => {
|
||||||
describe('getBlankEvent', () => {
|
describe('getBlankEvent', () => {
|
||||||
@@ -20,6 +20,15 @@ describe('Event', () => {
|
|||||||
created_at: 0
|
created_at: 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should return a blank event object with defined kind', () => {
|
||||||
|
expect(getBlankEvent(Kind.Text)).toEqual({
|
||||||
|
kind: 1,
|
||||||
|
content: '',
|
||||||
|
tags: [],
|
||||||
|
created_at: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('finishEvent', () => {
|
describe('finishEvent', () => {
|
||||||
@@ -88,6 +97,7 @@ describe('Event', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
|
// @ts-expect-error
|
||||||
serializeEvent(invalidEvent)
|
serializeEvent(invalidEvent)
|
||||||
}).toThrow("can't serialize event with wrong or missing properties")
|
}).toThrow("can't serialize event with wrong or missing properties")
|
||||||
})
|
})
|
||||||
@@ -281,8 +291,8 @@ describe('Event', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('signEvent', () => {
|
describe('getSignature', () => {
|
||||||
it('should sign an event object', () => {
|
it('should produce the correct signature for an event object', () => {
|
||||||
const privateKey =
|
const privateKey =
|
||||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||||
const publicKey = getPublicKey(privateKey)
|
const publicKey = getPublicKey(privateKey)
|
||||||
@@ -295,9 +305,10 @@ describe('Event', () => {
|
|||||||
pubkey: publicKey
|
pubkey: publicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const sig = signEvent(unsignedEvent, privateKey)
|
const sig = getSignature(unsignedEvent, privateKey)
|
||||||
|
|
||||||
// verify the signature
|
// verify the signature
|
||||||
|
// @ts-expect-error
|
||||||
const isValid = verifySignature({
|
const isValid = verifySignature({
|
||||||
...unsignedEvent,
|
...unsignedEvent,
|
||||||
sig
|
sig
|
||||||
@@ -324,9 +335,10 @@ describe('Event', () => {
|
|||||||
pubkey: publicKey
|
pubkey: publicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const sig = signEvent(unsignedEvent, wrongPrivateKey)
|
const sig = getSignature(unsignedEvent, wrongPrivateKey)
|
||||||
|
|
||||||
// verify the signature
|
// verify the signature
|
||||||
|
// @ts-expect-error
|
||||||
const isValid = verifySignature({
|
const isValid = verifySignature({
|
||||||
...unsignedEvent,
|
...unsignedEvent,
|
||||||
sig
|
sig
|
||||||
80
event.ts
80
event.ts
@@ -1,9 +1,11 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {schnorr} from '@noble/curves/secp256k1'
|
||||||
import {sha256} from '@noble/hashes/sha256'
|
import {sha256} from '@noble/hashes/sha256'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils'
|
||||||
|
|
||||||
import {utf8Encoder} from './utils'
|
import {getPublicKey} from './keys.ts'
|
||||||
import {getPublicKey} from './keys'
|
import {utf8Encoder} from './utils.ts'
|
||||||
|
|
||||||
|
/** @deprecated Use numbers instead. */
|
||||||
/* eslint-disable no-unused-vars */
|
/* eslint-disable no-unused-vars */
|
||||||
export enum Kind {
|
export enum Kind {
|
||||||
Metadata = 0,
|
Metadata = 0,
|
||||||
@@ -12,6 +14,7 @@ export enum Kind {
|
|||||||
Contacts = 3,
|
Contacts = 3,
|
||||||
EncryptedDirectMessage = 4,
|
EncryptedDirectMessage = 4,
|
||||||
EventDeletion = 5,
|
EventDeletion = 5,
|
||||||
|
Repost = 6,
|
||||||
Reaction = 7,
|
Reaction = 7,
|
||||||
BadgeAward = 8,
|
BadgeAward = 8,
|
||||||
ChannelCreation = 40,
|
ChannelCreation = 40,
|
||||||
@@ -19,50 +22,58 @@ export enum Kind {
|
|||||||
ChannelMessage = 42,
|
ChannelMessage = 42,
|
||||||
ChannelHideMessage = 43,
|
ChannelHideMessage = 43,
|
||||||
ChannelMuteUser = 44,
|
ChannelMuteUser = 44,
|
||||||
|
Blank = 255,
|
||||||
Report = 1984,
|
Report = 1984,
|
||||||
ZapRequest = 9734,
|
ZapRequest = 9734,
|
||||||
Zap = 9735,
|
Zap = 9735,
|
||||||
RelayList = 10002,
|
RelayList = 10002,
|
||||||
ClientAuth = 22242,
|
ClientAuth = 22242,
|
||||||
BadgeDefinition = 30008,
|
HttpAuth = 27235,
|
||||||
ProfileBadge = 30009,
|
ProfileBadge = 30008,
|
||||||
Article = 30023
|
BadgeDefinition = 30009,
|
||||||
|
Article = 30023,
|
||||||
|
FileMetadata = 1063
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventTemplate = {
|
export type EventTemplate<K extends number = number> = {
|
||||||
kind: Kind
|
kind: K
|
||||||
tags: string[][]
|
tags: string[][]
|
||||||
content: string
|
content: string
|
||||||
created_at: number
|
created_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UnsignedEvent = EventTemplate & {
|
export type UnsignedEvent<K extends number = number> = EventTemplate<K> & {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = UnsignedEvent & {
|
export type Event<K extends number = number> = UnsignedEvent<K> & {
|
||||||
id: string
|
id: string
|
||||||
sig: string
|
sig: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlankEvent(): EventTemplate {
|
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 {
|
return {
|
||||||
kind: 255,
|
kind,
|
||||||
content: '',
|
content: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
created_at: 0
|
created_at: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finishEvent(t: EventTemplate, privateKey: string): Event {
|
export function finishEvent<K extends number = number>(
|
||||||
let event = t as Event
|
t: EventTemplate<K>,
|
||||||
|
privateKey: string
|
||||||
|
): Event<K> {
|
||||||
|
let event = t as Event<K>
|
||||||
event.pubkey = getPublicKey(privateKey)
|
event.pubkey = getPublicKey(privateKey)
|
||||||
event.id = getEventHash(event)
|
event.id = getEventHash(event)
|
||||||
event.sig = signEvent(event, privateKey)
|
event.sig = getSignature(event, privateKey)
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeEvent(evt: UnsignedEvent): string {
|
export function serializeEvent(evt: UnsignedEvent<number>): string {
|
||||||
if (!validateEvent(evt))
|
if (!validateEvent(evt))
|
||||||
throw new Error("can't serialize event with wrong or missing properties")
|
throw new Error("can't serialize event with wrong or missing properties")
|
||||||
|
|
||||||
@@ -76,14 +87,15 @@ export function serializeEvent(evt: UnsignedEvent): string {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventHash(event: UnsignedEvent): string {
|
export function getEventHash(event: UnsignedEvent<number>): string {
|
||||||
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||||
return secp256k1.utils.bytesToHex(eventHash)
|
return bytesToHex(eventHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
|
||||||
|
obj instanceof Object
|
||||||
|
|
||||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
|
||||||
if (!isRecord(event)) return false
|
if (!isRecord(event)) return false
|
||||||
if (typeof event.kind !== 'number') return false
|
if (typeof event.kind !== 'number') return false
|
||||||
if (typeof event.content !== 'string') return false
|
if (typeof event.content !== 'string') return false
|
||||||
@@ -103,16 +115,26 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifySignature(event: Event): boolean {
|
export function verifySignature(event: Event<number>): boolean {
|
||||||
return secp256k1.schnorr.verifySync(
|
try {
|
||||||
event.sig,
|
return schnorr.verify(event.sig, getEventHash(event), event.pubkey)
|
||||||
getEventHash(event),
|
} catch (err) {
|
||||||
event.pubkey
|
return false
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signEvent(event: UnsignedEvent, key: string): string {
|
/** @deprecated Use `getSignature` instead. */
|
||||||
return secp256k1.utils.bytesToHex(
|
export function signEvent(event: UnsignedEvent<number>, key: string): string {
|
||||||
secp256k1.schnorr.signSync(getEventHash(event), key)
|
console.warn(
|
||||||
|
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.'
|
||||||
)
|
)
|
||||||
|
return getSignature(event, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate the signature for an event. */
|
||||||
|
export function getSignature(
|
||||||
|
event: UnsignedEvent<number>,
|
||||||
|
key: string
|
||||||
|
): string {
|
||||||
|
return bytesToHex(schnorr.sign(getEventHash(event), key))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
/* eslint-env jest */
|
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts'
|
||||||
|
|
||||||
const {fj} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('match id', () => {
|
test('match id', () => {
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventId(
|
matchEventId(
|
||||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||||
)
|
)
|
||||||
).toBeTruthy()
|
).toBeTruthy()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventId(
|
matchEventId(
|
||||||
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||||
)
|
)
|
||||||
@@ -20,14 +18,14 @@ test('match id', () => {
|
|||||||
|
|
||||||
test('match kind', () => {
|
test('match kind', () => {
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventKind(
|
matchEventKind(
|
||||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
1
|
1
|
||||||
)
|
)
|
||||||
).toBeTruthy()
|
).toBeTruthy()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fj.matchEventKind(
|
matchEventKind(
|
||||||
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||||
12720
|
12720
|
||||||
)
|
)
|
||||||
@@ -35,14 +33,14 @@ test('match kind', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('match subscription id', () => {
|
test('match subscription id', () => {
|
||||||
expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||||
expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||||
expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||||
expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
||||||
'kasjbdjkav'
|
'kasjbdjkav'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
fj.getSubscriptionId(
|
getSubscriptionId(
|
||||||
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
|
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
|
||||||
)
|
)
|
||||||
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
/* eslint-env jest */
|
import {matchFilter, matchFilters, mergeFilters} from './filter.ts'
|
||||||
|
import {buildEvent} from './test-helpers.ts'
|
||||||
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
|
|
||||||
|
|
||||||
describe('Filter', () => {
|
describe('Filter', () => {
|
||||||
describe('matchFilter', () => {
|
describe('matchFilter', () => {
|
||||||
@@ -14,13 +13,13 @@ describe('Filter', () => {
|
|||||||
'#tag': ['value']
|
'#tag': ['value']
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = {
|
const event = buildEvent({
|
||||||
id: '123',
|
id: '123',
|
||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: 'abc',
|
pubkey: 'abc',
|
||||||
created_at: 150,
|
created_at: 150,
|
||||||
tags: [['tag', 'value']]
|
tags: [['tag', 'value']]
|
||||||
}
|
})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ describe('Filter', () => {
|
|||||||
it('should return false when the event id is not in the filter', () => {
|
it('should return false when the event id is not in the filter', () => {
|
||||||
const filter = {ids: ['123', '456']}
|
const filter = {ids: ['123', '456']}
|
||||||
|
|
||||||
const event = {id: '789'}
|
const event = buildEvent({id: '789'})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ describe('Filter', () => {
|
|||||||
it('should return true when the event id starts with a prefix', () => {
|
it('should return true when the event id starts with a prefix', () => {
|
||||||
const filter = {ids: ['22', '00']}
|
const filter = {ids: ['22', '00']}
|
||||||
|
|
||||||
const event = {id: '001'}
|
const event = buildEvent({id: '001'})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ describe('Filter', () => {
|
|||||||
it('should return false when the event kind is not in the filter', () => {
|
it('should return false when the event kind is not in the filter', () => {
|
||||||
const filter = {kinds: [1, 2, 3]}
|
const filter = {kinds: [1, 2, 3]}
|
||||||
|
|
||||||
const event = {kind: 4}
|
const event = buildEvent({kind: 4})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ describe('Filter', () => {
|
|||||||
it('should return false when the event author is not in the filter', () => {
|
it('should return false when the event author is not in the filter', () => {
|
||||||
const filter = {authors: ['abc', 'def']}
|
const filter = {authors: ['abc', 'def']}
|
||||||
|
|
||||||
const event = {pubkey: 'ghi'}
|
const event = buildEvent({pubkey: 'ghi'})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ describe('Filter', () => {
|
|||||||
it('should return false when a tag is not present in the event', () => {
|
it('should return false when a tag is not present in the event', () => {
|
||||||
const filter = {'#tag': ['value1', 'value2']}
|
const filter = {'#tag': ['value1', 'value2']}
|
||||||
|
|
||||||
const event = {tags: [['not_tag', 'value1']]}
|
const event = buildEvent({tags: [['not_tag', 'value1']]})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ describe('Filter', () => {
|
|||||||
it('should return false when a tag value is not present in the event', () => {
|
it('should return false when a tag value is not present in the event', () => {
|
||||||
const filter = {'#tag': ['value1', 'value2']}
|
const filter = {'#tag': ['value1', 'value2']}
|
||||||
|
|
||||||
const event = {tags: [['tag', 'value3']]}
|
const event = buildEvent({tags: [['tag', 'value3']]})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -90,7 +89,7 @@ describe('Filter', () => {
|
|||||||
it('should return true when filter has tags that is present in the event', () => {
|
it('should return true when filter has tags that is present in the event', () => {
|
||||||
const filter = {'#tag1': ['foo']}
|
const filter = {'#tag1': ['foo']}
|
||||||
|
|
||||||
const event = {
|
const event = buildEvent({
|
||||||
id: '123',
|
id: '123',
|
||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: 'abc',
|
pubkey: 'abc',
|
||||||
@@ -99,7 +98,7 @@ describe('Filter', () => {
|
|||||||
['tag1', 'foo'],
|
['tag1', 'foo'],
|
||||||
['tag2', 'bar']
|
['tag2', 'bar']
|
||||||
]
|
]
|
||||||
}
|
})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
@@ -109,22 +108,42 @@ describe('Filter', () => {
|
|||||||
it('should return false when the event is before the filter since value', () => {
|
it('should return false when the event is before the filter since value', () => {
|
||||||
const filter = {since: 100}
|
const filter = {since: 100}
|
||||||
|
|
||||||
const event = {created_at: 50}
|
const event = buildEvent({created_at: 50})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
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', () => {
|
it('should return false when the event is after the filter until value', () => {
|
||||||
const filter = {until: 100}
|
const filter = {until: 100}
|
||||||
|
|
||||||
const event = {created_at: 150}
|
const event = buildEvent({created_at: 150})
|
||||||
|
|
||||||
const result = matchFilter(filter, event)
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
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', () => {
|
describe('matchFilters', () => {
|
||||||
@@ -135,7 +154,7 @@ describe('Filter', () => {
|
|||||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
||||||
]
|
]
|
||||||
|
|
||||||
const event = {id: '789', kind: 3, pubkey: 'ghi'}
|
const event = buildEvent({id: '789', kind: 3, pubkey: 'ghi'})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
@@ -149,7 +168,7 @@ describe('Filter', () => {
|
|||||||
{ids: ['9'], kinds: [3], authors: ['g']}
|
{ids: ['9'], kinds: [3], authors: ['g']}
|
||||||
]
|
]
|
||||||
|
|
||||||
const event = {id: '987', kind: 3, pubkey: 'ghi'}
|
const event = buildEvent({id: '987', kind: 3, pubkey: 'ghi'})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
@@ -163,7 +182,12 @@ describe('Filter', () => {
|
|||||||
{authors: ['abc'], limit: 3}
|
{authors: ['abc'], limit: 3}
|
||||||
]
|
]
|
||||||
|
|
||||||
const event = {id: '123', kind: 1, pubkey: 'abc', created_at: 150}
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150
|
||||||
|
})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
@@ -177,7 +201,7 @@ describe('Filter', () => {
|
|||||||
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
{ids: ['789'], kinds: [3], authors: ['ghi']}
|
||||||
]
|
]
|
||||||
|
|
||||||
const event = {id: '100', kind: 4, pubkey: 'jkl'}
|
const event = buildEvent({id: '100', kind: 4, pubkey: 'jkl'})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
@@ -190,11 +214,35 @@ describe('Filter', () => {
|
|||||||
{kinds: [1], limit: 2},
|
{kinds: [1], limit: 2},
|
||||||
{authors: ['abc'], limit: 3}
|
{authors: ['abc'], limit: 3}
|
||||||
]
|
]
|
||||||
const event = {id: '456', kind: 2, pubkey: 'def', created_at: 200}
|
const event = buildEvent({
|
||||||
|
id: '456',
|
||||||
|
kind: 2,
|
||||||
|
pubkey: 'def',
|
||||||
|
created_at: 200
|
||||||
|
})
|
||||||
|
|
||||||
const result = matchFilters(filters, event)
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
expect(result).toEqual(false)
|
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})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
54
filter.ts
54
filter.ts
@@ -1,19 +1,19 @@
|
|||||||
import {Event} from './event'
|
import {Event, type Kind} from './event.ts'
|
||||||
|
|
||||||
export type Filter = {
|
export type Filter<K extends number = number> = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
kinds?: number[]
|
kinds?: K[]
|
||||||
authors?: string[]
|
authors?: string[]
|
||||||
since?: number
|
since?: number
|
||||||
until?: number
|
until?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
search?: string
|
search?: string
|
||||||
[key: `#${string}`]: string[]
|
[key: `#${string}`]: string[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchFilter(
|
export function matchFilter(
|
||||||
filter: Filter,
|
filter: Filter<number>,
|
||||||
event: Event
|
event: Event<number>
|
||||||
): boolean {
|
): boolean {
|
||||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||||
@@ -34,7 +34,7 @@ export function matchFilter(
|
|||||||
if (
|
if (
|
||||||
values &&
|
values &&
|
||||||
!event.tags.find(
|
!event.tags.find(
|
||||||
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
|
([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
@@ -42,17 +42,51 @@ export function matchFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.since && event.created_at < filter.since) return false
|
if (filter.since && event.created_at < filter.since) return false
|
||||||
if (filter.until && event.created_at >= filter.until) return false
|
if (filter.until && event.created_at > filter.until) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchFilters(
|
export function matchFilters(
|
||||||
filters: Filter[],
|
filters: Filter<number>[],
|
||||||
event: Event
|
event: Event<number>
|
||||||
): boolean {
|
): boolean {
|
||||||
for (let i = 0; i < filters.length; i++) {
|
for (let i = 0; i < filters.length; i++) {
|
||||||
if (matchFilter(filters[i], event)) return true
|
if (matchFilter(filters[i], event)) return true
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
54
index.ts
54
index.ts
@@ -1,31 +1,27 @@
|
|||||||
export * from './keys'
|
export * from './keys.ts'
|
||||||
export * from './relay'
|
export * from './relay.ts'
|
||||||
export * from './event'
|
export * from './event.ts'
|
||||||
export * from './filter'
|
export * from './filter.ts'
|
||||||
export * from './pool'
|
export * from './pool.ts'
|
||||||
export * from './references'
|
export * from './references.ts'
|
||||||
|
|
||||||
export * as nip04 from './nip04'
|
export * as nip04 from './nip04.ts'
|
||||||
export * as nip05 from './nip05'
|
export * as nip05 from './nip05.ts'
|
||||||
export * as nip06 from './nip06'
|
export * as nip06 from './nip06.ts'
|
||||||
export * as nip10 from './nip10'
|
export * as nip10 from './nip10.ts'
|
||||||
export * as nip13 from './nip13'
|
export * as nip13 from './nip13.ts'
|
||||||
export * as nip19 from './nip19'
|
export * as nip18 from './nip18.ts'
|
||||||
export * as nip21 from './nip21'
|
export * as nip19 from './nip19.ts'
|
||||||
export * as nip26 from './nip26'
|
export * as nip21 from './nip21.ts'
|
||||||
export * as nip27 from './nip27'
|
export * as nip25 from './nip25.ts'
|
||||||
export * as nip39 from './nip39'
|
export * as nip26 from './nip26.ts'
|
||||||
export * as nip42 from './nip42'
|
export * as nip27 from './nip27.ts'
|
||||||
export * as nip57 from './nip57'
|
export * as nip28 from './nip28.ts'
|
||||||
|
export * as nip39 from './nip39.ts'
|
||||||
|
export * as nip42 from './nip42.ts'
|
||||||
|
export * as nip44 from './nip44.ts'
|
||||||
|
export * as nip57 from './nip57.ts'
|
||||||
|
export * as nip98 from './nip98.ts'
|
||||||
|
|
||||||
export * as fj from './fakejson'
|
export * as fj from './fakejson.ts'
|
||||||
export * as utils from './utils'
|
export * as utils from './utils.ts'
|
||||||
|
|
||||||
// monkey patch secp256k1
|
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
import {hmac} from '@noble/hashes/hmac'
|
|
||||||
import {sha256} from '@noble/hashes/sha256'
|
|
||||||
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
|
|
||||||
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
|
|
||||||
secp256k1.utils.sha256Sync = (...msgs) =>
|
|
||||||
sha256(secp256k1.utils.concatBytes(...msgs))
|
|
||||||
|
|||||||
5
jest.config.js
Normal file
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
}
|
||||||
4
justfile
4
justfile
@@ -7,10 +7,10 @@ build:
|
|||||||
rm -rf lib
|
rm -rf lib
|
||||||
node build.js
|
node build.js
|
||||||
|
|
||||||
test: build
|
test:
|
||||||
jest
|
jest
|
||||||
|
|
||||||
test-only file: build
|
test-only file:
|
||||||
jest {{file}}
|
jest {{file}}
|
||||||
|
|
||||||
emit-types:
|
emit-types:
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
/* eslint-env jest */
|
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||||
|
|
||||||
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
test('private key generation', () => {
|
||||||
|
|
||||||
test('test private key generation', () => {
|
|
||||||
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test public key generation', () => {
|
test('public key generation', () => {
|
||||||
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test public key from private key deterministic', () => {
|
test('public key from private key deterministic', () => {
|
||||||
let sk = generatePrivateKey()
|
let sk = generatePrivateKey()
|
||||||
let pk = getPublicKey(sk)
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
7
keys.ts
7
keys.ts
@@ -1,9 +1,10 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {schnorr} from '@noble/curves/secp256k1'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils'
|
||||||
|
|
||||||
export function generatePrivateKey(): string {
|
export function generatePrivateKey(): string {
|
||||||
return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
|
return bytesToHex(schnorr.utils.randomPrivateKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPublicKey(privateKey: string): string {
|
export function getPublicKey(privateKey: string): string {
|
||||||
return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
|
return bytesToHex(schnorr.getPublicKey(privateKey))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
globalThis.crypto = require('crypto')
|
|
||||||
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('encrypt and decrypt message', async () => {
|
|
||||||
let sk1 = generatePrivateKey()
|
|
||||||
let sk2 = generatePrivateKey()
|
|
||||||
let pk1 = getPublicKey(sk1)
|
|
||||||
let pk2 = getPublicKey(sk2)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
|
|
||||||
).toEqual('hello')
|
|
||||||
})
|
|
||||||
19
nip04.test.ts
Normal file
19
nip04.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
10
nip04.ts
10
nip04.ts
@@ -1,8 +1,14 @@
|
|||||||
import {randomBytes} from '@noble/hashes/utils'
|
import {randomBytes} from '@noble/hashes/utils'
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {secp256k1} from '@noble/curves/secp256k1'
|
||||||
import {base64} from '@scure/base'
|
import {base64} from '@scure/base'
|
||||||
|
|
||||||
import {utf8Decoder, utf8Encoder} from './utils'
|
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(
|
export async function encrypt(
|
||||||
privkey: string,
|
privkey: string,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const fetch = require('node-fetch')
|
|
||||||
const {nip05} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('fetch nip05 profiles', async () => {
|
|
||||||
nip05.useFetchImplementation(fetch)
|
|
||||||
|
|
||||||
let p1 = await nip05.queryProfile('jb55.com')
|
|
||||||
expect(p1.pubkey).toEqual(
|
|
||||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
|
||||||
)
|
|
||||||
expect(p1.relays).toEqual(['wss://relay.damus.io'])
|
|
||||||
|
|
||||||
let p2 = await nip05.queryProfile('jb55@jb55.com')
|
|
||||||
expect(p2.pubkey).toEqual(
|
|
||||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
|
||||||
)
|
|
||||||
expect(p2.relays).toEqual(['wss://relay.damus.io'])
|
|
||||||
|
|
||||||
let p3 = await nip05.queryProfile('channel.ninja@channel.ninja')
|
|
||||||
expect(p3.pubkey).toEqual(
|
|
||||||
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
32
nip05.test.ts
Normal file
32
nip05.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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',
|
||||||
|
])
|
||||||
|
})
|
||||||
78
nip05.ts
78
nip05.ts
@@ -1,4 +1,13 @@
|
|||||||
import {ProfilePointer} from './nip19'
|
import {ProfilePointer} from './nip19.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||||
|
*
|
||||||
|
* - 0: full match
|
||||||
|
* - 1: name (optional)
|
||||||
|
* - 2: domain
|
||||||
|
*/
|
||||||
|
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
||||||
|
|
||||||
var _fetch: any
|
var _fetch: any
|
||||||
|
|
||||||
@@ -25,36 +34,53 @@ export async function searchDomain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryProfile(
|
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||||
fullname: string
|
const match = fullname.match(NIP05_REGEX)
|
||||||
): Promise<ProfilePointer | null> {
|
if (!match) return null
|
||||||
let [name, domain] = fullname.split('@')
|
|
||||||
|
|
||||||
if (!domain) {
|
const [_, name = '_', domain] = match
|
||||||
// if there is no @, it is because it is just a domain, so assume the name is "_"
|
|
||||||
domain = name
|
|
||||||
name = '_'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name.match(/^[A-Za-z0-9-_.]+$/)) return null
|
|
||||||
if (!domain.includes('.')) return null
|
|
||||||
|
|
||||||
let res
|
|
||||||
try {
|
try {
|
||||||
res = await (
|
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
const { names, relays } = parseNIP05Result(await res.json())
|
||||||
).json()
|
|
||||||
} catch (err) {
|
const pubkey = names[name]
|
||||||
|
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||||
|
} catch (_e) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res?.names?.[name]) return null
|
/** nostr.json result. */
|
||||||
|
export interface NIP05Result {
|
||||||
let pubkey = res.names[name] as string
|
names: {
|
||||||
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
|
[name: string]: string
|
||||||
|
}
|
||||||
return {
|
relays?: {
|
||||||
pubkey,
|
[pubkey: string]: string[]
|
||||||
relays
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the nostr.json and throw if it's not valid. */
|
||||||
|
function parseNIP05Result(json: any): NIP05Result {
|
||||||
|
const result: NIP05Result = {
|
||||||
|
names: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||||
|
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||||
|
result.names[name] = pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.relays) {
|
||||||
|
result.relays = {}
|
||||||
|
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||||
|
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||||
|
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/* eslint-env jest */
|
import {privateKeyFromSeedWords} from './nip06.ts'
|
||||||
const {nip06} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('generate private key from a mnemonic', async () => {
|
test('generate private key from a mnemonic', async () => {
|
||||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
|
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||||
expect(privateKey).toEqual(
|
expect(privateKey).toEqual(
|
||||||
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
|
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
|
||||||
)
|
)
|
||||||
@@ -12,7 +11,7 @@ test('generate private key from a mnemonic', async () => {
|
|||||||
test('generate private key from a mnemonic and passphrase', async () => {
|
test('generate private key from a mnemonic and passphrase', async () => {
|
||||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
const passphrase = '123'
|
const passphrase = '123'
|
||||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
|
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||||
expect(privateKey).toEqual(
|
expect(privateKey).toEqual(
|
||||||
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
|
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
|
||||||
)
|
)
|
||||||
6
nip06.ts
6
nip06.ts
@@ -1,5 +1,5 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {bytesToHex} from '@noble/hashes/utils'
|
||||||
import {wordlist} from '@scure/bip39/wordlists/english.js'
|
import {wordlist} from '@scure/bip39/wordlists/english'
|
||||||
import {
|
import {
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
mnemonicToSeedSync,
|
mnemonicToSeedSync,
|
||||||
@@ -14,7 +14,7 @@ export function privateKeyFromSeedWords(
|
|||||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
||||||
if (!privateKey) throw new Error('could not derive private key')
|
if (!privateKey) throw new Error('could not derive private key')
|
||||||
return secp256k1.utils.bytesToHex(privateKey)
|
return bytesToHex(privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSeedWords(): string {
|
export function generateSeedWords(): string {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/* eslint-env jest */
|
import {parse} from './nip10.ts'
|
||||||
|
|
||||||
const {nip10} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
describe('parse NIP10-referenced events', () => {
|
describe('parse NIP10-referenced events', () => {
|
||||||
test('legacy + a lot of events', () => {
|
test('legacy + a lot of events', () => {
|
||||||
@@ -49,7 +47,7 @@ describe('parse NIP10-referenced events', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
expect(parse(event)).toEqual({
|
||||||
mentions: [
|
mentions: [
|
||||||
{
|
{
|
||||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
@@ -130,7 +128,7 @@ describe('parse NIP10-referenced events', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
expect(parse(event)).toEqual({
|
||||||
mentions: [
|
mentions: [
|
||||||
{
|
{
|
||||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
@@ -191,7 +189,7 @@ describe('parse NIP10-referenced events', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
expect(parse(event)).toEqual({
|
||||||
mentions: [],
|
mentions: [],
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
@@ -235,7 +233,7 @@ describe('parse NIP10-referenced events', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
expect(parse(event)).toEqual({
|
||||||
mentions: [],
|
mentions: [],
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
@@ -304,7 +302,7 @@ describe('parse NIP10-referenced events', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip10.parse(event)).toEqual({
|
expect(parse(event)).toEqual({
|
||||||
mentions: [],
|
mentions: [],
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
4
nip10.ts
4
nip10.ts
@@ -1,5 +1,5 @@
|
|||||||
import type {Event} from './event'
|
import type {Event} from './event.ts'
|
||||||
import type {EventPointer, ProfilePointer} from './nip19'
|
import type {EventPointer, ProfilePointer} from './nip19.ts'
|
||||||
|
|
||||||
export type NIP10Result = {
|
export type NIP10Result = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/* eslint-env jest */
|
import {getPow} from './nip13.ts'
|
||||||
const {nip13} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('identifies proof-of-work difficulty', async () => {
|
test('identifies proof-of-work difficulty', async () => {
|
||||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||||
const difficulty = nip13.getPow(id)
|
const difficulty = getPow(id)
|
||||||
expect(difficulty).toEqual(21)
|
expect(difficulty).toEqual(21)
|
||||||
})
|
})
|
||||||
4
nip13.ts
4
nip13.ts
@@ -1,8 +1,8 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {hexToBytes} from '@noble/hashes/utils'
|
||||||
|
|
||||||
/** Get POW difficulty from a Nostr hex ID. */
|
/** Get POW difficulty from a Nostr hex ID. */
|
||||||
export function getPow(id: string): number {
|
export function getPow(id: string): number {
|
||||||
return getLeadingZeroBits(secp256k1.utils.hexToBytes(id))
|
return getLeadingZeroBits(hexToBytes(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
112
nip18.test.ts
Normal file
112
nip18.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
97
nip18.ts
Normal file
97
nip18.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
/* eslint-env jest */
|
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||||
|
import {
|
||||||
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
decode,
|
||||||
|
naddrEncode,
|
||||||
|
nprofileEncode,
|
||||||
|
npubEncode,
|
||||||
|
nrelayEncode,
|
||||||
|
nsecEncode,
|
||||||
|
type AddressPointer,
|
||||||
|
type ProfilePointer,
|
||||||
|
} from './nip19.ts'
|
||||||
|
|
||||||
test('encode and decode nsec', () => {
|
test('encode and decode nsec', () => {
|
||||||
let sk = generatePrivateKey()
|
let sk = generatePrivateKey()
|
||||||
let nsec = nip19.nsecEncode(sk)
|
let nsec = nsecEncode(sk)
|
||||||
expect(nsec).toMatch(/nsec1\w+/)
|
expect(nsec).toMatch(/nsec1\w+/)
|
||||||
let {type, data} = nip19.decode(nsec)
|
let {type, data} = decode(nsec)
|
||||||
expect(type).toEqual('nsec')
|
expect(type).toEqual('nsec')
|
||||||
expect(data).toEqual(sk)
|
expect(data).toEqual(sk)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('encode and decode npub', () => {
|
test('encode and decode npub', () => {
|
||||||
let pk = getPublicKey(generatePrivateKey())
|
let pk = getPublicKey(generatePrivateKey())
|
||||||
let npub = nip19.npubEncode(pk)
|
let npub = npubEncode(pk)
|
||||||
expect(npub).toMatch(/npub1\w+/)
|
expect(npub).toMatch(/npub1\w+/)
|
||||||
let {type, data} = nip19.decode(npub)
|
let {type, data} = decode(npub)
|
||||||
expect(type).toEqual('npub')
|
expect(type).toEqual('npub')
|
||||||
expect(data).toEqual(pk)
|
expect(data).toEqual(pk)
|
||||||
})
|
})
|
||||||
@@ -26,19 +34,20 @@ test('encode and decode nprofile', () => {
|
|||||||
'wss://relay.nostr.example.mydomain.example.com',
|
'wss://relay.nostr.example.mydomain.example.com',
|
||||||
'wss://nostr.banana.com'
|
'wss://nostr.banana.com'
|
||||||
]
|
]
|
||||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
let nprofile = nprofileEncode({pubkey: pk, relays})
|
||||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||||
let {type, data} = nip19.decode(nprofile)
|
let {type, data} = decode(nprofile)
|
||||||
expect(type).toEqual('nprofile')
|
expect(type).toEqual('nprofile')
|
||||||
expect(data.pubkey).toEqual(pk)
|
const pointer = data as ProfilePointer
|
||||||
expect(data.relays).toContain(relays[0])
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
expect(data.relays).toContain(relays[1])
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.relays).toContain(relays[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('decode nprofile without relays', () => {
|
test('decode nprofile without relays', () => {
|
||||||
expect(
|
expect(
|
||||||
nip19.decode(
|
decode(
|
||||||
nip19.nprofileEncode({
|
nprofileEncode({
|
||||||
pubkey:
|
pubkey:
|
||||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||||
relays: []
|
relays: []
|
||||||
@@ -56,56 +65,59 @@ test('encode and decode naddr', () => {
|
|||||||
'wss://relay.nostr.example.mydomain.example.com',
|
'wss://relay.nostr.example.mydomain.example.com',
|
||||||
'wss://nostr.banana.com'
|
'wss://nostr.banana.com'
|
||||||
]
|
]
|
||||||
let naddr = nip19.naddrEncode({
|
let naddr = naddrEncode({
|
||||||
pubkey: pk,
|
pubkey: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
identifier: 'banana'
|
identifier: 'banana'
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/naddr1\w+/)
|
expect(naddr).toMatch(/naddr1\w+/)
|
||||||
let {type, data} = nip19.decode(naddr)
|
let {type, data} = decode(naddr)
|
||||||
expect(type).toEqual('naddr')
|
expect(type).toEqual('naddr')
|
||||||
expect(data.pubkey).toEqual(pk)
|
const pointer = data as AddressPointer
|
||||||
expect(data.relays).toContain(relays[0])
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
expect(data.relays).toContain(relays[1])
|
expect(pointer.relays).toContain(relays[0])
|
||||||
expect(data.kind).toEqual(30023)
|
expect(pointer.relays).toContain(relays[1])
|
||||||
expect(data.identifier).toEqual('banana')
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('banana')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('decode naddr from habla.news', () => {
|
test('decode naddr from habla.news', () => {
|
||||||
let {type, data} = nip19.decode(
|
let {type, data} = decode(
|
||||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
|
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
|
||||||
)
|
)
|
||||||
expect(type).toEqual('naddr')
|
expect(type).toEqual('naddr')
|
||||||
expect(data.pubkey).toEqual(
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual(
|
||||||
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
|
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
|
||||||
)
|
)
|
||||||
expect(data.kind).toEqual(30023)
|
expect(pointer.kind).toEqual(30023)
|
||||||
expect(data.identifier).toEqual('references')
|
expect(pointer.identifier).toEqual('references')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||||
let {type, data} = nip19.decode(
|
let {type, data} = decode(
|
||||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
|
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(type).toEqual('naddr')
|
expect(type).toEqual('naddr')
|
||||||
expect(data.pubkey).toEqual(
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual(
|
||||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||||
)
|
)
|
||||||
expect(data.relays).toContain(
|
expect(pointer.relays).toContain(
|
||||||
'wss://relay.nostr.example.mydomain.example.com'
|
'wss://relay.nostr.example.mydomain.example.com'
|
||||||
)
|
)
|
||||||
expect(data.relays).toContain('wss://nostr.banana.com')
|
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||||
expect(data.kind).toEqual(30023)
|
expect(pointer.kind).toEqual(30023)
|
||||||
expect(data.identifier).toEqual('banana')
|
expect(pointer.identifier).toEqual('banana')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('encode and decode nrelay', () => {
|
test('encode and decode nrelay', () => {
|
||||||
let url = "wss://relay.nostr.example"
|
let url = 'wss://relay.nostr.example'
|
||||||
let nrelay = nip19.nrelayEncode(url)
|
let nrelay = nrelayEncode(url)
|
||||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||||
let {type, data} = nip19.decode(nrelay)
|
let {type, data} = decode(nrelay)
|
||||||
expect(type).toEqual('nrelay')
|
expect(type).toEqual('nrelay')
|
||||||
expect(data).toEqual(url)
|
expect(data).toEqual(url)
|
||||||
})
|
})
|
||||||
102
nip19.ts
102
nip19.ts
@@ -1,10 +1,17 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils'
|
||||||
import {bech32} from '@scure/base'
|
import {bech32} from '@scure/base'
|
||||||
|
|
||||||
import {utf8Decoder, utf8Encoder} from './utils'
|
import {utf8Decoder, utf8Encoder} from './utils.ts'
|
||||||
|
|
||||||
const Bech32MaxSize = 5000
|
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 = {
|
export type ProfilePointer = {
|
||||||
pubkey: string // hex
|
pubkey: string // hex
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
@@ -23,15 +30,27 @@ export type AddressPointer = {
|
|||||||
relays?: string[]
|
relays?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DecodeResult =
|
type Prefixes = {
|
||||||
| {type: 'nprofile'; data: ProfilePointer}
|
nprofile: ProfilePointer
|
||||||
| {type: 'nrelay'; data: string}
|
nrelay: string
|
||||||
| {type: 'nevent'; data: EventPointer}
|
nevent: EventPointer
|
||||||
| {type: 'naddr'; data: AddressPointer}
|
naddr: AddressPointer
|
||||||
| {type: 'nsec'; data: string}
|
nsec: string
|
||||||
| {type: 'npub'; data: string}
|
npub: string
|
||||||
| {type: 'note'; data: 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 {
|
export function decode(nip19: string): DecodeResult {
|
||||||
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
||||||
let data = new Uint8Array(bech32.fromWords(words))
|
let data = new Uint8Array(bech32.fromWords(words))
|
||||||
@@ -45,7 +64,7 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
return {
|
return {
|
||||||
type: 'nprofile',
|
type: 'nprofile',
|
||||||
data: {
|
data: {
|
||||||
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
|
pubkey: bytesToHex(tlv[0][0]),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,11 +79,9 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
return {
|
return {
|
||||||
type: 'nevent',
|
type: 'nevent',
|
||||||
data: {
|
data: {
|
||||||
id: secp256k1.utils.bytesToHex(tlv[0][0]),
|
id: bytesToHex(tlv[0][0]),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
author: tlv[2]?.[0]
|
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined
|
||||||
? secp256k1.utils.bytesToHex(tlv[2][0])
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,8 +98,8 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
type: 'naddr',
|
type: 'naddr',
|
||||||
data: {
|
data: {
|
||||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||||
pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
|
pubkey: bytesToHex(tlv[2][0]),
|
||||||
kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
|
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +118,7 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
case 'nsec':
|
case 'nsec':
|
||||||
case 'npub':
|
case 'npub':
|
||||||
case 'note':
|
case 'note':
|
||||||
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
|
return {type: prefix, data: bytesToHex(data)}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`unknown prefix ${prefix}`)
|
throw new Error(`unknown prefix ${prefix}`)
|
||||||
@@ -116,72 +133,73 @@ function parseTLV(data: Uint8Array): TLV {
|
|||||||
while (rest.length > 0) {
|
while (rest.length > 0) {
|
||||||
let t = rest[0]
|
let t = rest[0]
|
||||||
let l = rest[1]
|
let l = rest[1]
|
||||||
|
if (!l) throw new Error(`malformed TLV ${t}`)
|
||||||
let v = rest.slice(2, 2 + l)
|
let v = rest.slice(2, 2 + l)
|
||||||
rest = rest.slice(2 + l)
|
rest = rest.slice(2 + l)
|
||||||
if (v.length < l) continue
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
result[t] = result[t] || []
|
result[t] = result[t] || []
|
||||||
result[t].push(v)
|
result[t].push(v)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nsecEncode(hex: string): string {
|
export function nsecEncode(hex: string): `nsec1${string}` {
|
||||||
return encodeBytes('nsec', hex)
|
return encodeBytes('nsec', hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function npubEncode(hex: string): string {
|
export function npubEncode(hex: string): `npub1${string}` {
|
||||||
return encodeBytes('npub', hex)
|
return encodeBytes('npub', hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noteEncode(hex: string): string {
|
export function noteEncode(hex: string): `note1${string}` {
|
||||||
return encodeBytes('note', hex)
|
return encodeBytes('note', hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBytes(prefix: string, hex: string): string {
|
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||||
let data = secp256k1.utils.hexToBytes(hex)
|
|
||||||
let words = bech32.toWords(data)
|
let words = bech32.toWords(data)
|
||||||
return bech32.encode(prefix, words, Bech32MaxSize)
|
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nprofileEncode(profile: ProfilePointer): string {
|
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` {
|
||||||
|
let data = hexToBytes(hex)
|
||||||
|
return encodeBech32(prefix, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
|
0: [hexToBytes(profile.pubkey)],
|
||||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nprofile', data)
|
||||||
return bech32.encode('nprofile', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function neventEncode(event: EventPointer): string {
|
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [secp256k1.utils.hexToBytes(event.id)],
|
0: [hexToBytes(event.id)],
|
||||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
|
2: event.author ? [hexToBytes(event.author)] : []
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nevent', data)
|
||||||
return bech32.encode('nevent', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function naddrEncode(addr: AddressPointer): string {
|
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||||
let kind = new ArrayBuffer(4)
|
let kind = new ArrayBuffer(4)
|
||||||
new DataView(kind).setUint32(0, addr.kind, false)
|
new DataView(kind).setUint32(0, addr.kind, false)
|
||||||
|
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [utf8Encoder.encode(addr.identifier)],
|
0: [utf8Encoder.encode(addr.identifier)],
|
||||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
2: [secp256k1.utils.hexToBytes(addr.pubkey)],
|
2: [hexToBytes(addr.pubkey)],
|
||||||
3: [new Uint8Array(kind)]
|
3: [new Uint8Array(kind)]
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('naddr', data)
|
||||||
return bech32.encode('naddr', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nrelayEncode(url: string): string {
|
export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [utf8Encoder.encode(url)]
|
0: [utf8Encoder.encode(url)]
|
||||||
})
|
})
|
||||||
let words = bech32.toWords(data)
|
return encodeBech32('nrelay', data)
|
||||||
return bech32.encode('nrelay', words, Bech32MaxSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
@@ -197,5 +215,5 @@ function encodeTLV(tlv: TLV): Uint8Array {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return secp256k1.utils.concatBytes(...entries)
|
return concatBytes(...entries)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
/* eslint-env jest */
|
import {test as testRegex, parse} from './nip21.ts'
|
||||||
const {nip21} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('test', () => {
|
test('test()', () => {
|
||||||
expect(
|
expect(
|
||||||
nip21.test(
|
testRegex(
|
||||||
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||||
)
|
)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
nip21.test(
|
testRegex(
|
||||||
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
)
|
)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
nip21.test(
|
testRegex(
|
||||||
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||||
)
|
)
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
expect(nip21.test('nostr:')).toBe(false)
|
expect(testRegex('nostr:')).toBe(false)
|
||||||
expect(
|
expect(
|
||||||
nip21.test(
|
testRegex(
|
||||||
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||||
)
|
)
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
expect(nip21.test('gggggg')).toBe(false)
|
expect(testRegex('gggggg')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('parse', () => {
|
test('parse', () => {
|
||||||
const result = nip21.parse(
|
const result = parse(
|
||||||
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
)
|
)
|
||||||
|
|
||||||
16
nip21.ts
16
nip21.ts
@@ -1,12 +1,4 @@
|
|||||||
import * as nip19 from './nip19'
|
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts'
|
||||||
import * as nip21 from './nip21'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,}/
|
|
||||||
|
|
||||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||||
@@ -26,16 +18,16 @@ export interface NostrURI {
|
|||||||
/** The bech32-encoded data (eg `npub1...`). */
|
/** The bech32-encoded data (eg `npub1...`). */
|
||||||
value: string
|
value: string
|
||||||
/** Decoded bech32 string, according to NIP-19. */
|
/** Decoded bech32 string, according to NIP-19. */
|
||||||
decoded: nip19.DecodeResult
|
decoded: DecodeResult
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse and decode a Nostr URI. */
|
/** Parse and decode a Nostr URI. */
|
||||||
export function parse(uri: string): NostrURI {
|
export function parse(uri: string): NostrURI {
|
||||||
const match = uri.match(new RegExp(`^${nip21.NOSTR_URI_REGEX.source}$`))
|
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
|
||||||
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
||||||
return {
|
return {
|
||||||
uri: match[0] as `nostr:${string}`,
|
uri: match[0] as `nostr:${string}`,
|
||||||
value: match[1],
|
value: match[1],
|
||||||
decoded: nip19.decode(match[1])
|
decoded: decode(match[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
nip25.test.ts
Normal file
78
nip25.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
69
nip25.ts
Normal file
69
nip25.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {Event, finishEvent, Kind} from './event.ts'
|
||||||
|
|
||||||
|
import type {EventPointer} from './nip19.ts'
|
||||||
|
|
||||||
|
export type ReactionEventTemplate = {
|
||||||
|
/**
|
||||||
|
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
|
||||||
|
*/
|
||||||
|
tags?: string[][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default '+'
|
||||||
|
*/
|
||||||
|
content?: string
|
||||||
|
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishReactionEvent(
|
||||||
|
t: ReactionEventTemplate,
|
||||||
|
reacted: Event<number>,
|
||||||
|
privateKey: string,
|
||||||
|
): Event<Kind.Reaction> {
|
||||||
|
const inheritedTags = reacted.tags.filter(
|
||||||
|
(tag) => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return finishEvent({
|
||||||
|
...t,
|
||||||
|
kind: Kind.Reaction,
|
||||||
|
tags: [
|
||||||
|
...(t.tags ?? []),
|
||||||
|
...inheritedTags,
|
||||||
|
['e', reacted.id],
|
||||||
|
['p', reacted.pubkey],
|
||||||
|
],
|
||||||
|
content: t.content ?? '+',
|
||||||
|
}, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
|
||||||
|
if (event.kind !== Kind.Reaction) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastETag: undefined | string[]
|
||||||
|
let lastPTag: undefined | string[]
|
||||||
|
|
||||||
|
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
if (tag.length >= 2) {
|
||||||
|
if (tag[0] === 'e' && lastETag === undefined) {
|
||||||
|
lastETag = tag
|
||||||
|
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||||
|
lastPTag = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastETag === undefined || lastPTag === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lastETag[1],
|
||||||
|
relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined),
|
||||||
|
author: lastPTag[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-env jest */
|
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||||
|
import {getDelegator, createDelegation} from './nip26.ts'
|
||||||
const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
import {buildEvent} from './test-helpers.ts'
|
||||||
|
|
||||||
test('parse good delegation from NIP', async () => {
|
test('parse good delegation from NIP', async () => {
|
||||||
expect(
|
expect(
|
||||||
nip26.getDelegator({
|
getDelegator({
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||||
pubkey:
|
pubkey:
|
||||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||||
@@ -26,7 +26,7 @@ test('parse good delegation from NIP', async () => {
|
|||||||
|
|
||||||
test('parse bad delegations', async () => {
|
test('parse bad delegations', async () => {
|
||||||
expect(
|
expect(
|
||||||
nip26.getDelegator({
|
getDelegator({
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||||
pubkey:
|
pubkey:
|
||||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||||
@@ -46,7 +46,7 @@ test('parse bad delegations', async () => {
|
|||||||
).toEqual(null)
|
).toEqual(null)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
nip26.getDelegator({
|
getDelegator({
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||||
pubkey:
|
pubkey:
|
||||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||||
@@ -66,7 +66,7 @@ test('parse bad delegations', async () => {
|
|||||||
).toEqual(null)
|
).toEqual(null)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
nip26.getDelegator({
|
getDelegator({
|
||||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||||
pubkey:
|
pubkey:
|
||||||
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
||||||
@@ -91,15 +91,15 @@ test('create and verify delegation', async () => {
|
|||||||
let pk1 = getPublicKey(sk1)
|
let pk1 = getPublicKey(sk1)
|
||||||
let sk2 = generatePrivateKey()
|
let sk2 = generatePrivateKey()
|
||||||
let pk2 = getPublicKey(sk2)
|
let pk2 = getPublicKey(sk2)
|
||||||
let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
|
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1})
|
||||||
expect(delegation).toHaveProperty('from', pk1)
|
expect(delegation).toHaveProperty('from', pk1)
|
||||||
expect(delegation).toHaveProperty('to', pk2)
|
expect(delegation).toHaveProperty('to', pk2)
|
||||||
expect(delegation).toHaveProperty('cond', 'kind=1')
|
expect(delegation).toHaveProperty('cond', 'kind=1')
|
||||||
|
|
||||||
let event = {
|
let event = buildEvent({
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
||||||
pubkey: pk2
|
pubkey: pk2,
|
||||||
}
|
})
|
||||||
expect(nip26.getDelegator(event)).toEqual(pk1)
|
expect(getDelegator(event)).toEqual(pk1)
|
||||||
})
|
})
|
||||||
24
nip26.ts
24
nip26.ts
@@ -1,15 +1,17 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
import {schnorr} from '@noble/curves/secp256k1'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils'
|
||||||
import {sha256} from '@noble/hashes/sha256'
|
import {sha256} from '@noble/hashes/sha256'
|
||||||
|
|
||||||
import {Event} from './event'
|
import {utf8Encoder} from './utils.ts'
|
||||||
import {utf8Encoder} from './utils'
|
import {getPublicKey} from './keys.ts'
|
||||||
import {getPublicKey} from './keys'
|
|
||||||
|
import type {Event} from './event.ts'
|
||||||
|
|
||||||
export type Parameters = {
|
export type Parameters = {
|
||||||
pubkey: string // the key to whom the delegation will be given
|
pubkey: string // the key to whom the delegation will be given
|
||||||
kind: number | undefined
|
kind?: number
|
||||||
until: number | undefined // delegation will only be valid until this date
|
until?: number // delegation will only be valid until this date
|
||||||
since: number | undefined // delegation will be valid from this date on
|
since?: number // delegation will be valid from this date on
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Delegation = {
|
export type Delegation = {
|
||||||
@@ -36,8 +38,8 @@ export function createDelegation(
|
|||||||
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
|
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
let sig = secp256k1.utils.bytesToHex(
|
let sig = bytesToHex(
|
||||||
secp256k1.schnorr.signSync(sighash, privateKey)
|
schnorr.sign(sighash, privateKey)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -48,7 +50,7 @@ export function createDelegation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDelegator(event: Event): string | null {
|
export function getDelegator(event: Event<number>): string | null {
|
||||||
// find delegation tag
|
// find delegation tag
|
||||||
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
||||||
if (!tag) return null
|
if (!tag) return null
|
||||||
@@ -84,7 +86,7 @@ export function getDelegator(event: Event): string | null {
|
|||||||
let sighash = sha256(
|
let sighash = sha256(
|
||||||
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
|
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
|
||||||
)
|
)
|
||||||
if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
|
if (!schnorr.verify(sig, sighash, pubkey)) return null
|
||||||
|
|
||||||
return pubkey
|
return pubkey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/* eslint-env jest */
|
import {matchAll, replaceAll} from './nip27.ts'
|
||||||
const {nip27} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('matchAll', () => {
|
test('matchAll', () => {
|
||||||
const result = nip27.matchAll(
|
const result = matchAll(
|
||||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,11 +29,30 @@ test('matchAll', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
test('replaceAll', () => {
|
||||||
const content =
|
const content =
|
||||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
|
|
||||||
const result = nip27.replaceAll(content, ({decoded, value}) => {
|
const result = replaceAll(content, ({decoded, value}) => {
|
||||||
switch (decoded.type) {
|
switch (decoded.type) {
|
||||||
case 'npub':
|
case 'npub':
|
||||||
return '@alex'
|
return '@alex'
|
||||||
21
nip27.ts
21
nip27.ts
@@ -1,12 +1,11 @@
|
|||||||
import * as nip19 from './nip19'
|
import {decode} from './nip19.ts'
|
||||||
import * as nip21 from './nip21'
|
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
|
||||||
|
|
||||||
/** Regex to find NIP-21 URIs inside event content. */
|
/** Regex to find NIP-21 URIs inside event content. */
|
||||||
export const regex = () =>
|
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||||
new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g')
|
|
||||||
|
|
||||||
/** Match result for a Nostr URI in event content. */
|
/** Match result for a Nostr URI in event content. */
|
||||||
export interface NostrURIMatch extends nip21.NostrURI {
|
export interface NostrURIMatch extends NostrURI {
|
||||||
/** Index where the URI begins in the event content. */
|
/** Index where the URI begins in the event content. */
|
||||||
start: number
|
start: number
|
||||||
/** Index where the URI ends in the event content. */
|
/** Index where the URI ends in the event content. */
|
||||||
@@ -18,15 +17,19 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
|||||||
const matches = content.matchAll(regex())
|
const matches = content.matchAll(regex())
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
const [uri, value] = match
|
const [uri, value] = match
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
uri: uri as `nostr:${string}`,
|
uri: uri as `nostr:${string}`,
|
||||||
value,
|
value,
|
||||||
decoded: nip19.decode(value),
|
decoded: decode(value),
|
||||||
start: match.index!,
|
start: match.index!,
|
||||||
end: match.index! + uri.length
|
end: match.index! + uri.length
|
||||||
}
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,13 +54,13 @@ export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
|||||||
*/
|
*/
|
||||||
export function replaceAll(
|
export function replaceAll(
|
||||||
content: string,
|
content: string,
|
||||||
replacer: (match: nip21.NostrURI) => string
|
replacer: (match: NostrURI) => string
|
||||||
): string {
|
): string {
|
||||||
return content.replaceAll(regex(), (uri, value) => {
|
return content.replaceAll(regex(), (uri, value: string) => {
|
||||||
return replacer({
|
return replacer({
|
||||||
uri: uri as `nostr:${string}`,
|
uri: uri as `nostr:${string}`,
|
||||||
value,
|
value,
|
||||||
decoded: nip19.decode(value)
|
decoded: decode(value)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
134
nip28.test.ts
Normal file
134
nip28.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import {Kind} from './event.ts'
|
||||||
|
import {getPublicKey} from './keys.ts'
|
||||||
|
import {
|
||||||
|
channelCreateEvent,
|
||||||
|
channelMetadataEvent,
|
||||||
|
channelMessageEvent,
|
||||||
|
channelHideMessageEvent,
|
||||||
|
channelMuteUserEvent,
|
||||||
|
ChannelMetadata,
|
||||||
|
ChannelMessageEventTemplate
|
||||||
|
} from './nip28.ts'
|
||||||
|
|
||||||
|
const privateKey =
|
||||||
|
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
describe('NIP-28 Functions', () => {
|
||||||
|
const channelMetadata: ChannelMetadata = {
|
||||||
|
name: 'Test Channel',
|
||||||
|
about: 'This is a test channel',
|
||||||
|
picture: 'https://example.com/picture.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('channelCreateEvent should create an event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
content: channelMetadata,
|
||||||
|
created_at: 1617932115
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelCreateEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelCreation)
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('channelMetadataEvent should create a signed event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
channel_create_event_id: 'channel creation event id',
|
||||||
|
content: channelMetadata,
|
||||||
|
created_at: 1617932115
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMetadataEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelMetadata)
|
||||||
|
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event!.id).toEqual('string')
|
||||||
|
expect(typeof event!.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('channelMessageEvent should create a signed message event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
channel_create_event_id: 'channel creation event id',
|
||||||
|
relay_url: 'https://relay.example.com',
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMessageEvent(template, privateKey)
|
||||||
|
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||||
|
expect(event.tags[0]).toEqual([
|
||||||
|
'e',
|
||||||
|
template.channel_create_event_id,
|
||||||
|
template.relay_url,
|
||||||
|
'root'
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual(template.content)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('channelMessageEvent should create a signed message reply event with given template', () => {
|
||||||
|
const template: ChannelMessageEventTemplate = {
|
||||||
|
channel_create_event_id: 'channel creation event id',
|
||||||
|
reply_to_channel_message_event_id: 'channel message event id',
|
||||||
|
relay_url: 'https://relay.example.com',
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMessageEvent(template, privateKey)
|
||||||
|
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||||
|
expect(event.tags).toContainEqual([
|
||||||
|
'e',
|
||||||
|
template.channel_create_event_id,
|
||||||
|
template.relay_url,
|
||||||
|
'root'
|
||||||
|
])
|
||||||
|
expect(event.tags).toContainEqual([
|
||||||
|
'e',
|
||||||
|
template.reply_to_channel_message_event_id,
|
||||||
|
template.relay_url,
|
||||||
|
'reply'
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual(template.content)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('channelHideMessageEvent should create a signed event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
channel_message_event_id: 'channel message event id',
|
||||||
|
content: {reason: 'Inappropriate content'},
|
||||||
|
created_at: 1617932115
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelHideMessageEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
|
||||||
|
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event!.id).toEqual('string')
|
||||||
|
expect(typeof event!.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('channelMuteUserEvent should create a signed event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
content: {reason: 'Spamming'},
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey_to_mute: 'pubkey to mute'
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMuteUserEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
|
||||||
|
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event!.id).toEqual('string')
|
||||||
|
expect(typeof event!.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
163
nip28.ts
Normal file
163
nip28.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import {Event, finishEvent, Kind} from './event.ts'
|
||||||
|
|
||||||
|
export interface ChannelMetadata {
|
||||||
|
name: string
|
||||||
|
about: string
|
||||||
|
picture: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelCreateEventTemplate {
|
||||||
|
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||||
|
content: string | ChannelMetadata
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMetadataEventTemplate {
|
||||||
|
channel_create_event_id: string
|
||||||
|
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||||
|
content: string | ChannelMetadata
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMessageEventTemplate {
|
||||||
|
channel_create_event_id: string
|
||||||
|
reply_to_channel_message_event_id?: string
|
||||||
|
relay_url: string
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelHideMessageEventTemplate {
|
||||||
|
channel_message_event_id: string
|
||||||
|
content: string | {reason: string}
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMuteUserEventTemplate {
|
||||||
|
content: string | {reason: string}
|
||||||
|
created_at: number
|
||||||
|
pubkey_to_mute: string
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelCreateEvent = (
|
||||||
|
t: ChannelCreateEventTemplate,
|
||||||
|
privateKey: string
|
||||||
|
): Event<Kind.ChannelCreation> | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishEvent(
|
||||||
|
{
|
||||||
|
kind: Kind.ChannelCreation,
|
||||||
|
tags: [...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at
|
||||||
|
},
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMetadataEvent = (
|
||||||
|
t: ChannelMetadataEventTemplate,
|
||||||
|
privateKey: string
|
||||||
|
): Event<Kind.ChannelMetadata> | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishEvent(
|
||||||
|
{
|
||||||
|
kind: Kind.ChannelMetadata,
|
||||||
|
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at
|
||||||
|
},
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMessageEvent = (
|
||||||
|
t: ChannelMessageEventTemplate,
|
||||||
|
privateKey: string
|
||||||
|
): Event<Kind.ChannelMessage> => {
|
||||||
|
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
|
||||||
|
|
||||||
|
if (t.reply_to_channel_message_event_id) {
|
||||||
|
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishEvent(
|
||||||
|
{
|
||||||
|
kind: Kind.ChannelMessage,
|
||||||
|
tags: [...tags, ...(t.tags ?? [])],
|
||||||
|
content: t.content,
|
||||||
|
created_at: t.created_at
|
||||||
|
},
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "e" tag should be the kind 42 event to hide */
|
||||||
|
export const channelHideMessageEvent = (
|
||||||
|
t: ChannelHideMessageEventTemplate,
|
||||||
|
privateKey: string
|
||||||
|
): Event<Kind.ChannelHideMessage> | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishEvent(
|
||||||
|
{
|
||||||
|
kind: Kind.ChannelHideMessage,
|
||||||
|
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at
|
||||||
|
},
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMuteUserEvent = (
|
||||||
|
t: ChannelMuteUserEventTemplate,
|
||||||
|
privateKey: string
|
||||||
|
): Event<Kind.ChannelMuteUser> | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishEvent(
|
||||||
|
{
|
||||||
|
kind: Kind.ChannelMuteUser,
|
||||||
|
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at
|
||||||
|
},
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
/* eslint-env jest */
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
const fetch = require('node-fetch')
|
import {useFetchImplementation, validateGithub} from './nip39.ts'
|
||||||
const {nip39} = require('./lib/nostr.cjs.js')
|
|
||||||
|
|
||||||
test('validate github claim', async () => {
|
test('validate github claim', async () => {
|
||||||
nip39.useFetchImplementation(fetch)
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
let result = await nip39.validateGithub(
|
let result = await validateGithub(
|
||||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||||
'vitorpamplona',
|
'vitorpamplona',
|
||||||
'cf19e2d1d7f8dac6348ad37b35ec8421'
|
'cf19e2d1d7f8dac6348ad37b35ec8421'
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
require('websocket-polyfill')
|
|
||||||
const {
|
|
||||||
relayInit,
|
|
||||||
generatePrivateKey,
|
|
||||||
finishEvent,
|
|
||||||
nip42
|
|
||||||
} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('auth flow', done => {
|
|
||||||
const relay = relayInit('wss://nostr.kollider.xyz')
|
|
||||||
relay.connect()
|
|
||||||
const sk = generatePrivateKey()
|
|
||||||
|
|
||||||
relay.on('auth', async challenge => {
|
|
||||||
await expect(
|
|
||||||
nip42.authenticate({
|
|
||||||
challenge,
|
|
||||||
relay,
|
|
||||||
sign: e => finishEvent(e, sk)
|
|
||||||
})
|
|
||||||
).rejects.toBeTruthy()
|
|
||||||
relay.close()
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
26
nip42.test.ts
Normal file
26
nip42.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'websocket-polyfill'
|
||||||
|
|
||||||
|
import {finishEvent} from './event.ts'
|
||||||
|
import {generatePrivateKey} from './keys.ts'
|
||||||
|
import {authenticate} from './nip42.ts'
|
||||||
|
import {relayInit} from './relay.ts'
|
||||||
|
|
||||||
|
test('auth flow', () => {
|
||||||
|
const relay = relayInit('wss://nostr.kollider.xyz')
|
||||||
|
relay.connect()
|
||||||
|
const sk = generatePrivateKey()
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
relay.on('auth', async challenge => {
|
||||||
|
await expect(
|
||||||
|
authenticate({
|
||||||
|
challenge,
|
||||||
|
relay,
|
||||||
|
sign: (e) => finishEvent(e, sk)
|
||||||
|
})
|
||||||
|
).rejects.toBeTruthy()
|
||||||
|
relay.close()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
20
nip42.ts
20
nip42.ts
@@ -1,5 +1,5 @@
|
|||||||
import {EventTemplate, Event, Kind} from './event'
|
import {Kind, type EventTemplate, type Event} from './event.ts'
|
||||||
import {Relay} from './relay'
|
import {Relay} from './relay.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate via NIP-42 flow.
|
* Authenticate via NIP-42 flow.
|
||||||
@@ -17,7 +17,9 @@ export const authenticate = async ({
|
|||||||
}: {
|
}: {
|
||||||
challenge: string
|
challenge: string
|
||||||
relay: Relay
|
relay: Relay
|
||||||
sign: (e: EventTemplate) => Promise<Event>
|
sign: <K extends number = number>(
|
||||||
|
e: EventTemplate<K>
|
||||||
|
) => Promise<Event<K>> | Event<K>
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const e: EventTemplate = {
|
const e: EventTemplate = {
|
||||||
kind: Kind.ClientAuth,
|
kind: Kind.ClientAuth,
|
||||||
@@ -28,15 +30,5 @@ export const authenticate = async ({
|
|||||||
],
|
],
|
||||||
content: ''
|
content: ''
|
||||||
}
|
}
|
||||||
const pub = relay.auth(await sign(e))
|
return relay.auth(await sign(e))
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pub.on('ok', function ok() {
|
|
||||||
pub.off('ok', ok)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
pub.on('failed', function fail(reason: string) {
|
|
||||||
pub.off('failed', fail)
|
|
||||||
reject(reason)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
21
nip44.test.ts
Normal file
21
nip44.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import {hexToBytes} from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
import {encrypt, decrypt, getSharedSecret} from './nip44.ts'
|
||||||
|
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
globalThis.crypto = crypto
|
||||||
|
|
||||||
|
test('encrypt and decrypt message', async () => {
|
||||||
|
let sk1 = generatePrivateKey()
|
||||||
|
let sk2 = generatePrivateKey()
|
||||||
|
let pk1 = getPublicKey(sk1)
|
||||||
|
let pk2 = getPublicKey(sk2)
|
||||||
|
let sharedKey1 = getSharedSecret(sk1, pk2)
|
||||||
|
let sharedKey2 = getSharedSecret(sk2, pk1)
|
||||||
|
|
||||||
|
expect(decrypt(hexToBytes(sk1), encrypt(hexToBytes(sk1), 'hello'))).toEqual('hello')
|
||||||
|
expect(decrypt(sharedKey2, encrypt(sharedKey1, 'hello'))).toEqual('hello')
|
||||||
|
})
|
||||||
40
nip44.ts
Normal file
40
nip44.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {base64} from '@scure/base'
|
||||||
|
import {randomBytes} from '@noble/hashes/utils'
|
||||||
|
import {secp256k1} from '@noble/curves/secp256k1'
|
||||||
|
import {sha256} from '@noble/hashes/sha256'
|
||||||
|
import {xchacha20} from '@noble/ciphers/chacha'
|
||||||
|
|
||||||
|
import {utf8Decoder, utf8Encoder} from './utils.ts'
|
||||||
|
|
||||||
|
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
|
||||||
|
sha256(secp256k1.getSharedSecret(privkey, '02' + pubkey).subarray(1, 33))
|
||||||
|
|
||||||
|
export function encrypt(key: Uint8Array, text: string, v = 1) {
|
||||||
|
if (v !== 1) {
|
||||||
|
throw new Error('NIP44: unknown encryption version')
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonce = randomBytes(24)
|
||||||
|
const plaintext = utf8Encoder.encode(text)
|
||||||
|
const ciphertext = xchacha20(key, nonce, plaintext)
|
||||||
|
|
||||||
|
const payload = new Uint8Array(25 + ciphertext.length)
|
||||||
|
payload.set([v], 0)
|
||||||
|
payload.set(nonce, 1)
|
||||||
|
payload.set(ciphertext, 25)
|
||||||
|
|
||||||
|
return base64.encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(key: Uint8Array, payload: string) {
|
||||||
|
let data = base64.decode(payload)
|
||||||
|
if (data[0] !== 1) {
|
||||||
|
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonce = data.slice(1, 25)
|
||||||
|
const ciphertext = data.slice(25)
|
||||||
|
const plaintext = xchacha20(key, nonce, ciphertext)
|
||||||
|
|
||||||
|
return utf8Decoder.decode(plaintext)
|
||||||
|
}
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
const {bech32} = require('@scure/base')
|
import {finishEvent} from './event.ts'
|
||||||
const {
|
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||||
nip57,
|
import {
|
||||||
generatePrivateKey,
|
getZapEndpoint,
|
||||||
getPublicKey,
|
makeZapReceipt,
|
||||||
finishEvent
|
makeZapRequest,
|
||||||
} = require('./lib/nostr.cjs')
|
useFetchImplementation,
|
||||||
|
validateZapRequest,
|
||||||
|
} from './nip57.ts'
|
||||||
|
import {buildEvent} from './test-helpers.ts'
|
||||||
|
|
||||||
describe('getZapEndpoint', () => {
|
describe('getZapEndpoint', () => {
|
||||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||||
const metadata = {content: '{}'}
|
const metadata = buildEvent({kind: 0, content: '{}'})
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns null if fetch fails', async () => {
|
test('returns null if fetch fails', async () => {
|
||||||
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
||||||
nip57.useFetchImplementation(fetchImplementation)
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||||
@@ -31,10 +34,10 @@ describe('getZapEndpoint', () => {
|
|||||||
const fetchImplementation = jest.fn(() =>
|
const fetchImplementation = jest.fn(() =>
|
||||||
Promise.resolve({json: () => ({allowsNostr: false})})
|
Promise.resolve({json: () => ({allowsNostr: false})})
|
||||||
)
|
)
|
||||||
nip57.useFetchImplementation(fetchImplementation)
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||||
@@ -52,10 +55,10 @@ describe('getZapEndpoint', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
nip57.useFetchImplementation(fetchImplementation)
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||||
const result = await nip57.getZapEndpoint(metadata)
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
expect(result).toBe('callback')
|
expect(result).toBe('callback')
|
||||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||||
@@ -67,7 +70,8 @@ describe('getZapEndpoint', () => {
|
|||||||
describe('makeZapRequest', () => {
|
describe('makeZapRequest', () => {
|
||||||
test('throws an error if amount is not given', () => {
|
test('throws an error if amount is not given', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
nip57.makeZapRequest({
|
// @ts-expect-error
|
||||||
|
makeZapRequest({
|
||||||
profile: 'profile',
|
profile: 'profile',
|
||||||
event: null,
|
event: null,
|
||||||
relays: [],
|
relays: [],
|
||||||
@@ -78,7 +82,8 @@ describe('makeZapRequest', () => {
|
|||||||
|
|
||||||
test('throws an error if profile is not given', () => {
|
test('throws an error if profile is not given', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
nip57.makeZapRequest({
|
// @ts-expect-error
|
||||||
|
makeZapRequest({
|
||||||
event: null,
|
event: null,
|
||||||
amount: 100,
|
amount: 100,
|
||||||
relays: [],
|
relays: [],
|
||||||
@@ -88,7 +93,7 @@ describe('makeZapRequest', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('returns a valid Zap request', () => {
|
test('returns a valid Zap request', () => {
|
||||||
const result = nip57.makeZapRequest({
|
const result = makeZapRequest({
|
||||||
profile: 'profile',
|
profile: 'profile',
|
||||||
event: 'event',
|
event: 'event',
|
||||||
amount: 100,
|
amount: 100,
|
||||||
@@ -111,7 +116,7 @@ describe('makeZapRequest', () => {
|
|||||||
|
|
||||||
describe('validateZapRequest', () => {
|
describe('validateZapRequest', () => {
|
||||||
test('returns an error message for invalid JSON', () => {
|
test('returns an error message for invalid JSON', () => {
|
||||||
expect(nip57.validateZapRequest('invalid JSON')).toBe(
|
expect(validateZapRequest('invalid JSON')).toBe(
|
||||||
'Invalid zap request JSON.'
|
'Invalid zap request JSON.'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -128,7 +133,7 @@ describe('validateZapRequest', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||||
'Zap request is not a valid Nostr event.'
|
'Zap request is not a valid Nostr event.'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -149,7 +154,7 @@ describe('validateZapRequest', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||||
'Invalid signature on zap request.'
|
'Invalid signature on zap request.'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -170,7 +175,7 @@ describe('validateZapRequest', () => {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||||
"Zap request doesn't have a 'p' tag."
|
"Zap request doesn't have a 'p' tag."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -192,7 +197,7 @@ describe('validateZapRequest', () => {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||||
"Zap request 'p' tag is not valid hex."
|
"Zap request 'p' tag is not valid hex."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -216,7 +221,7 @@ describe('validateZapRequest', () => {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||||
"Zap request 'e' tag is not valid hex."
|
"Zap request 'e' tag is not valid hex."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -238,7 +243,7 @@ describe('validateZapRequest', () => {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||||
"Zap request doesn't have a 'relays' tag."
|
"Zap request doesn't have a 'relays' tag."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -261,7 +266,7 @@ describe('validateZapRequest', () => {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,7 +294,7 @@ describe('makeZapReceipt', () => {
|
|||||||
const bolt11 = 'bolt11'
|
const bolt11 = 'bolt11'
|
||||||
const paidAt = new Date()
|
const paidAt = new Date()
|
||||||
|
|
||||||
const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
||||||
|
|
||||||
expect(result.kind).toBe(9735)
|
expect(result.kind).toBe(9735)
|
||||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||||
@@ -322,7 +327,7 @@ describe('makeZapReceipt', () => {
|
|||||||
const bolt11 = 'bolt11'
|
const bolt11 = 'bolt11'
|
||||||
const paidAt = new Date()
|
const paidAt = new Date()
|
||||||
|
|
||||||
const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
|
const result = makeZapReceipt({zapRequest, bolt11, paidAt})
|
||||||
|
|
||||||
expect(result.kind).toBe(9735)
|
expect(result.kind).toBe(9735)
|
||||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||||
27
nip57.ts
27
nip57.ts
@@ -1,7 +1,13 @@
|
|||||||
import {bech32} from '@scure/base'
|
import {bech32} from '@scure/base'
|
||||||
|
|
||||||
import {Event, EventTemplate, validateEvent, verifySignature} from './event'
|
import {
|
||||||
import {utf8Decoder} from './utils'
|
Kind,
|
||||||
|
validateEvent,
|
||||||
|
verifySignature,
|
||||||
|
type Event,
|
||||||
|
type EventTemplate,
|
||||||
|
} from './event.ts'
|
||||||
|
import {utf8Decoder} from './utils.ts'
|
||||||
|
|
||||||
var _fetch: any
|
var _fetch: any
|
||||||
|
|
||||||
@@ -13,7 +19,9 @@ export function useFetchImplementation(fetchImplementation: any) {
|
|||||||
_fetch = fetchImplementation
|
_fetch = fetchImplementation
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
export async function getZapEndpoint(
|
||||||
|
metadata: Event<Kind.Metadata>
|
||||||
|
): Promise<null | string> {
|
||||||
try {
|
try {
|
||||||
let lnurl: string = ''
|
let lnurl: string = ''
|
||||||
let {lud06, lud16} = JSON.parse(metadata.content)
|
let {lud06, lud16} = JSON.parse(metadata.content)
|
||||||
@@ -53,11 +61,11 @@ export function makeZapRequest({
|
|||||||
amount: number
|
amount: number
|
||||||
comment: string
|
comment: string
|
||||||
relays: string[]
|
relays: string[]
|
||||||
}): EventTemplate {
|
}): EventTemplate<Kind.ZapRequest> {
|
||||||
if (!amount) throw new Error('amount not given')
|
if (!amount) throw new Error('amount not given')
|
||||||
if (!profile) throw new Error('profile not given')
|
if (!profile) throw new Error('profile not given')
|
||||||
|
|
||||||
let zr = {
|
let zr: EventTemplate<Kind.ZapRequest> = {
|
||||||
kind: 9734,
|
kind: 9734,
|
||||||
created_at: Math.round(Date.now() / 1000),
|
created_at: Math.round(Date.now() / 1000),
|
||||||
content: comment,
|
content: comment,
|
||||||
@@ -86,6 +94,7 @@ export function validateZapRequest(zapRequestString: string): string | null {
|
|||||||
|
|
||||||
if (!validateEvent(zapRequest))
|
if (!validateEvent(zapRequest))
|
||||||
return 'Zap request is not a valid Nostr event.'
|
return 'Zap request is not a valid Nostr event.'
|
||||||
|
|
||||||
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
||||||
|
|
||||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||||
@@ -110,16 +119,16 @@ export function makeZapReceipt({
|
|||||||
paidAt
|
paidAt
|
||||||
}: {
|
}: {
|
||||||
zapRequest: string
|
zapRequest: string
|
||||||
preimage: string | null
|
preimage?: string
|
||||||
bolt11: string
|
bolt11: string
|
||||||
paidAt: Date
|
paidAt: Date
|
||||||
}): EventTemplate {
|
}): EventTemplate<Kind.Zap> {
|
||||||
let zr: Event = JSON.parse(zapRequest)
|
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
|
||||||
let tagsFromZapRequest = zr.tags.filter(
|
let tagsFromZapRequest = zr.tags.filter(
|
||||||
([t]) => t === 'e' || t === 'p' || t === 'a'
|
([t]) => t === 'e' || t === 'p' || t === 'a'
|
||||||
)
|
)
|
||||||
|
|
||||||
let zap = {
|
let zap: EventTemplate<Kind.Zap> = {
|
||||||
kind: 9735,
|
kind: 9735,
|
||||||
created_at: Math.round(paidAt.getTime() / 1000),
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
|
|||||||
170
nip98.test.ts
Normal file
170
nip98.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {getToken, unpackEventFromToken, validateEvent, validateToken} from './nip98.ts'
|
||||||
|
import {Event, Kind, finishEvent} from './event.ts'
|
||||||
|
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||||
|
|
||||||
|
const sk = generatePrivateKey()
|
||||||
|
|
||||||
|
describe('getToken', () => {
|
||||||
|
test('getToken GET returns without authorization scheme', async () => {
|
||||||
|
let result = await getToken('http://test.com', 'get', e =>
|
||||||
|
finishEvent(e, sk)
|
||||||
|
)
|
||||||
|
|
||||||
|
const decodedResult: Event = await unpackEventFromToken(result)
|
||||||
|
|
||||||
|
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||||
|
expect(decodedResult.content).toBe('')
|
||||||
|
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||||
|
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(decodedResult.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'get']
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getToken POST returns token without authorization scheme', async () => {
|
||||||
|
let result = await getToken('http://test.com', 'post', e =>
|
||||||
|
finishEvent(e, sk)
|
||||||
|
)
|
||||||
|
|
||||||
|
const decodedResult: Event = await unpackEventFromToken(result)
|
||||||
|
|
||||||
|
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||||
|
expect(decodedResult.content).toBe('')
|
||||||
|
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||||
|
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(decodedResult.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post']
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||||
|
const authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
|
let result = await getToken(
|
||||||
|
'http://test.com',
|
||||||
|
'post',
|
||||||
|
e => finishEvent(e, sk),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||||
|
|
||||||
|
const decodedResult: Event = await unpackEventFromToken(result)
|
||||||
|
|
||||||
|
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||||
|
expect(decodedResult.content).toBe('')
|
||||||
|
expect(decodedResult.kind).toBe(Kind.HttpAuth)
|
||||||
|
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(decodedResult.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post']
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getToken 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||||
|
const validToken = await getToken(
|
||||||
|
'http://test.com',
|
||||||
|
'get',
|
||||||
|
e => finishEvent(e, sk),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||||
|
|
||||||
|
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateEvent throws an error for a wrong url', async () => {
|
||||||
|
const validToken = await getToken(
|
||||||
|
'http://test.com',
|
||||||
|
'get',
|
||||||
|
e => finishEvent(e, sk),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||||
|
|
||||||
|
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||||
|
await expect(result).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateEvent throws an error for a wrong method', async () => {
|
||||||
|
const validToken = await getToken(
|
||||||
|
'http://test.com',
|
||||||
|
'get',
|
||||||
|
e => finishEvent(e, sk),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||||
|
|
||||||
|
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||||
|
await expect(result).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
121
nip98.ts
Normal file
121
nip98.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {base64} from '@scure/base'
|
||||||
|
import {
|
||||||
|
Event,
|
||||||
|
EventTemplate,
|
||||||
|
Kind,
|
||||||
|
getBlankEvent,
|
||||||
|
verifySignature
|
||||||
|
} from './event'
|
||||||
|
import {utf8Decoder, utf8Encoder} from './utils'
|
||||||
|
|
||||||
|
const _authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const sign = window.nostr.signEvent
|
||||||
|
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
|
||||||
|
*/
|
||||||
|
export async function getToken(
|
||||||
|
loginUrl: string,
|
||||||
|
httpMethod: string,
|
||||||
|
sign: <K extends number = number>(
|
||||||
|
e: EventTemplate<K>
|
||||||
|
) => Promise<Event<K>> | Event<K>,
|
||||||
|
includeAuthorizationScheme: boolean = false
|
||||||
|
): Promise<string> {
|
||||||
|
if (!loginUrl || !httpMethod)
|
||||||
|
throw new Error('Missing loginUrl or httpMethod')
|
||||||
|
|
||||||
|
const event = getBlankEvent(Kind.HttpAuth)
|
||||||
|
|
||||||
|
event.tags = [
|
||||||
|
['u', loginUrl],
|
||||||
|
['method', httpMethod]
|
||||||
|
]
|
||||||
|
event.created_at = Math.round(new Date().getTime() / 1000)
|
||||||
|
|
||||||
|
const signedEvent = await sign(event)
|
||||||
|
|
||||||
|
const authorizationScheme = includeAuthorizationScheme
|
||||||
|
? _authorizationScheme
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
authorizationScheme +
|
||||||
|
base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||||
|
*/
|
||||||
|
export async function validateToken(
|
||||||
|
token: string,
|
||||||
|
url: string,
|
||||||
|
method: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const event = await unpackEventFromToken(token).catch((error) => { throw(error) })
|
||||||
|
const valid = await validateEvent(event, url, method).catch((error) => { throw(error) })
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Missing token')
|
||||||
|
}
|
||||||
|
token = token.replace(_authorizationScheme, '')
|
||||||
|
|
||||||
|
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||||
|
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
|
||||||
|
throw new Error('Invalid token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(eventB64) as Event
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateEvent(
|
||||||
|
event: Event,
|
||||||
|
url: string,
|
||||||
|
method: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('Invalid nostr event')
|
||||||
|
}
|
||||||
|
if (!verifySignature(event)) {
|
||||||
|
throw new Error('Invalid nostr event, signature invalid')
|
||||||
|
}
|
||||||
|
if (event.kind !== Kind.HttpAuth) {
|
||||||
|
throw new Error('Invalid nostr event, kind invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.created_at) {
|
||||||
|
throw new Error('Invalid nostr event, created_at invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event must be less than 60 seconds old
|
||||||
|
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||||
|
throw new Error('Invalid nostr event, expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||||
|
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||||
|
throw new Error('Invalid nostr event, url tag invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||||
|
if (
|
||||||
|
methodTag?.length !== 1 &&
|
||||||
|
methodTag?.[1].toLowerCase() !== method.toLowerCase()
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid nostr event, method tag invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "1.10.1",
|
"version": "1.14.2",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -14,15 +14,17 @@
|
|||||||
"module": "lib/esm/nostr.mjs",
|
"module": "lib/esm/nostr.mjs",
|
||||||
"exports": {
|
"exports": {
|
||||||
"import": "./lib/esm/nostr.mjs",
|
"import": "./lib/esm/nostr.mjs",
|
||||||
"require": "./lib/nostr.cjs.js"
|
"require": "./lib/nostr.cjs.js",
|
||||||
|
"types": "./lib/index.d.ts"
|
||||||
},
|
},
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "1.2.0",
|
"@noble/curves": "1.1.0",
|
||||||
"@noble/secp256k1": "1.7.1",
|
"@noble/hashes": "1.3.1",
|
||||||
|
"@noble/ciphers": "^0.2.0",
|
||||||
"@scure/base": "1.1.1",
|
"@scure/base": "1.1.1",
|
||||||
"@scure/bip32": "1.1.4",
|
"@scure/bip32": "1.3.1",
|
||||||
"@scure/bip39": "1.1.1"
|
"@scure/bip39": "1.2.1"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"decentralization",
|
"decentralization",
|
||||||
@@ -34,24 +36,27 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build",
|
"build": "node build",
|
||||||
"format": "prettier --plugin-search-dir . --write .",
|
"format": "prettier --plugin-search-dir . --write .",
|
||||||
"test": "node build && jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.1",
|
||||||
"@types/node": "^18.13.0",
|
"@types/node": "^18.13.0",
|
||||||
|
"@types/node-fetch": "^2.6.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||||
"@typescript-eslint/parser": "^5.51.0",
|
"@typescript-eslint/parser": "^5.51.0",
|
||||||
"esbuild": "0.16.9",
|
"esbuild": "0.16.9",
|
||||||
"esbuild-plugin-alias": "^0.2.1",
|
"esbuild-plugin-alias": "^0.2.1",
|
||||||
"eslint": "^8.33.0",
|
"eslint": "^8.40.0",
|
||||||
"eslint-plugin-babel": "^5.3.1",
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
|
"eslint-plugin-jest": "^27.2.1",
|
||||||
"esm-loader-typescript": "^1.0.3",
|
"esm-loader-typescript": "^1.0.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jest": "^29.4.2",
|
"jest": "^29.5.0",
|
||||||
"node-fetch": "^2.6.9",
|
"node-fetch": "^2.6.9",
|
||||||
"prettier": "^2.8.4",
|
"prettier": "^2.8.4",
|
||||||
"ts-jest": "^29.0.5",
|
"ts-jest": "^29.1.0",
|
||||||
"tsd": "^0.22.0",
|
"tsd": "^0.22.0",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^5.0.4",
|
||||||
"websocket-polyfill": "^0.0.3"
|
"websocket-polyfill": "^0.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
/* eslint-env jest */
|
import 'websocket-polyfill'
|
||||||
|
|
||||||
require('websocket-polyfill')
|
import {finishEvent, type Event} from './event.ts'
|
||||||
const {
|
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||||
SimplePool,
|
import {SimplePool} from './pool.ts'
|
||||||
generatePrivateKey,
|
|
||||||
getPublicKey,
|
|
||||||
getEventHash,
|
|
||||||
signEvent
|
|
||||||
} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
let pool = new SimplePool()
|
let pool = new SimplePool()
|
||||||
|
|
||||||
@@ -33,7 +28,7 @@ test('removing duplicates when querying', async () => {
|
|||||||
let pub = getPublicKey(priv)
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
let sub = pool.sub(relays, [{authors: [pub]}])
|
let sub = pool.sub(relays, [{authors: [pub]}])
|
||||||
let received = []
|
let received: Event[] = []
|
||||||
|
|
||||||
sub.on('event', event => {
|
sub.on('event', event => {
|
||||||
// this should be called only once even though we're listening
|
// this should be called only once even though we're listening
|
||||||
@@ -42,15 +37,12 @@ test('removing duplicates when querying', async () => {
|
|||||||
received.push(event)
|
received.push(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
let event = {
|
let event = finishEvent({
|
||||||
pubkey: pub,
|
|
||||||
created_at: Math.round(Date.now() / 1000),
|
created_at: Math.round(Date.now() / 1000),
|
||||||
content: 'test',
|
content: 'test',
|
||||||
kind: 22345,
|
kind: 22345,
|
||||||
tags: []
|
tags: []
|
||||||
}
|
}, priv)
|
||||||
event.id = getEventHash(event)
|
|
||||||
event.sig = signEvent(event, priv)
|
|
||||||
|
|
||||||
pool.publish(relays, event)
|
pool.publish(relays, event)
|
||||||
|
|
||||||
@@ -66,7 +58,7 @@ test('same with double querying', async () => {
|
|||||||
let sub1 = pool.sub(relays, [{authors: [pub]}])
|
let sub1 = pool.sub(relays, [{authors: [pub]}])
|
||||||
let sub2 = pool.sub(relays, [{authors: [pub]}])
|
let sub2 = pool.sub(relays, [{authors: [pub]}])
|
||||||
|
|
||||||
let received = []
|
let received: Event[] = []
|
||||||
|
|
||||||
sub1.on('event', event => {
|
sub1.on('event', event => {
|
||||||
received.push(event)
|
received.push(event)
|
||||||
@@ -76,15 +68,12 @@ test('same with double querying', async () => {
|
|||||||
received.push(event)
|
received.push(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
let event = {
|
let event = finishEvent({
|
||||||
pubkey: pub,
|
|
||||||
created_at: Math.round(Date.now() / 1000),
|
created_at: Math.round(Date.now() / 1000),
|
||||||
content: 'test2',
|
content: 'test2',
|
||||||
kind: 22346,
|
kind: 22346,
|
||||||
tags: []
|
tags: []
|
||||||
}
|
}, priv)
|
||||||
event.id = getEventHash(event)
|
|
||||||
event.sig = signEvent(event, priv)
|
|
||||||
|
|
||||||
pool.publish(relays, event)
|
pool.publish(relays, event)
|
||||||
|
|
||||||
@@ -122,6 +111,7 @@ test('list()', async () => {
|
|||||||
expect(events.length).toEqual(
|
expect(events.length).toEqual(
|
||||||
events
|
events
|
||||||
.map(evt => evt.id)
|
.map(evt => evt.id)
|
||||||
|
// @ts-ignore ???
|
||||||
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
|
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
|
||||||
.length
|
.length
|
||||||
)
|
)
|
||||||
@@ -131,3 +121,20 @@ test('list()', async () => {
|
|||||||
.reduce((acc, n) => acc.concat(n), [])
|
.reduce((acc, n) => acc.concat(n), [])
|
||||||
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
|
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)
|
||||||
|
})
|
||||||
154
pool.ts
154
pool.ts
@@ -1,20 +1,44 @@
|
|||||||
import {Relay, relayInit} from './relay'
|
import {
|
||||||
import {normalizeURL} from './utils'
|
relayInit,
|
||||||
import {Filter} from './filter'
|
type Relay,
|
||||||
import {Event} from './event'
|
type Sub,
|
||||||
import {SubscriptionOptions, Sub, Pub, CountPayload} from './relay'
|
type SubscriptionOptions
|
||||||
|
} from './relay.ts'
|
||||||
|
import {normalizeURL} from './utils.ts'
|
||||||
|
|
||||||
|
import type {Event} from './event.ts'
|
||||||
|
import {matchFilters, type Filter} from './filter.ts'
|
||||||
|
|
||||||
|
type BatchedRequest = {
|
||||||
|
filters: Filter<any>[]
|
||||||
|
relays: string[]
|
||||||
|
resolve: (events: Event<any>[]) => void
|
||||||
|
events: Event<any>[]
|
||||||
|
}
|
||||||
|
|
||||||
export class SimplePool {
|
export class SimplePool {
|
||||||
private _conn: {[url: string]: Relay}
|
private _conn: {[url: string]: Relay}
|
||||||
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
|
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
|
||||||
|
private batchedByKey: {[batchKey: string]: BatchedRequest[]} = {}
|
||||||
|
|
||||||
private eoseSubTimeout: number
|
private eoseSubTimeout: number
|
||||||
private getTimeout: number
|
private getTimeout: number
|
||||||
|
private seenOnEnabled: boolean = true
|
||||||
|
private batchInterval: number = 100
|
||||||
|
|
||||||
constructor(options: {eoseSubTimeout?: number; getTimeout?: number} = {}) {
|
constructor(
|
||||||
|
options: {
|
||||||
|
eoseSubTimeout?: number
|
||||||
|
getTimeout?: number
|
||||||
|
seenOnEnabled?: boolean
|
||||||
|
batchInterval?: number
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
this._conn = {}
|
this._conn = {}
|
||||||
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
this.eoseSubTimeout = options.eoseSubTimeout || 3400
|
||||||
this.getTimeout = options.getTimeout || 3400
|
this.getTimeout = options.getTimeout || 3400
|
||||||
|
this.seenOnEnabled = options.seenOnEnabled !== false
|
||||||
|
this.batchInterval = options.batchInterval || 100
|
||||||
}
|
}
|
||||||
|
|
||||||
close(relays: string[]): void {
|
close(relays: string[]): void {
|
||||||
@@ -39,16 +63,22 @@ export class SimplePool {
|
|||||||
return relay
|
return relay
|
||||||
}
|
}
|
||||||
|
|
||||||
sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
|
sub<K extends number = number>(
|
||||||
|
relays: string[],
|
||||||
|
filters: Filter<K>[],
|
||||||
|
opts?: SubscriptionOptions
|
||||||
|
): Sub<K> {
|
||||||
let _knownIds: Set<string> = new Set()
|
let _knownIds: Set<string> = new Set()
|
||||||
let modifiedOpts = {...(opts || {})}
|
let modifiedOpts = {...(opts || {})}
|
||||||
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
||||||
if (opts?.alreadyHaveEvent?.(id, url)) {
|
if (opts?.alreadyHaveEvent?.(id, url)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (this.seenOnEnabled) {
|
||||||
let set = this._seenOn[id] || new Set()
|
let set = this._seenOn[id] || new Set()
|
||||||
set.add(url)
|
set.add(url)
|
||||||
this._seenOn[id] = set
|
this._seenOn[id] = set
|
||||||
|
}
|
||||||
return _knownIds.has(id)
|
return _knownIds.has(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +93,9 @@ export class SimplePool {
|
|||||||
for (let cb of eoseListeners.values()) cb()
|
for (let cb of eoseListeners.values()) cb()
|
||||||
}, this.eoseSubTimeout)
|
}, this.eoseSubTimeout)
|
||||||
|
|
||||||
relays.forEach(async relay => {
|
relays
|
||||||
|
.filter((r, i, a) => a.indexOf(r) == i)
|
||||||
|
.forEach(async relay => {
|
||||||
let r
|
let r
|
||||||
try {
|
try {
|
||||||
r = await this.ensureRelay(relay)
|
r = await this.ensureRelay(relay)
|
||||||
@@ -73,7 +105,7 @@ export class SimplePool {
|
|||||||
}
|
}
|
||||||
if (!r) return
|
if (!r) return
|
||||||
let s = r.sub(filters, modifiedOpts)
|
let s = r.sub(filters, modifiedOpts)
|
||||||
s.on('event', (event: Event) => {
|
s.on('event', event => {
|
||||||
_knownIds.add(event.id as string)
|
_knownIds.add(event.id as string)
|
||||||
for (let cb of eventListeners.values()) cb(event)
|
for (let cb of eventListeners.values()) cb(event)
|
||||||
})
|
})
|
||||||
@@ -118,18 +150,18 @@ export class SimplePool {
|
|||||||
return greaterSub
|
return greaterSub
|
||||||
}
|
}
|
||||||
|
|
||||||
get(
|
get<K extends number = number>(
|
||||||
relays: string[],
|
relays: string[],
|
||||||
filter: Filter,
|
filter: Filter<K>,
|
||||||
opts?: SubscriptionOptions
|
opts?: SubscriptionOptions
|
||||||
): Promise<Event | null> {
|
): Promise<Event<K> | null> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let sub = this.sub(relays, [filter], opts)
|
let sub = this.sub(relays, [filter], opts)
|
||||||
let timeout = setTimeout(() => {
|
let timeout = setTimeout(() => {
|
||||||
sub.unsub()
|
sub.unsub()
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}, this.getTimeout)
|
}, this.getTimeout)
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', event => {
|
||||||
resolve(event)
|
resolve(event)
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
sub.unsub()
|
sub.unsub()
|
||||||
@@ -137,16 +169,16 @@ export class SimplePool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
list(
|
list<K extends number = number>(
|
||||||
relays: string[],
|
relays: string[],
|
||||||
filters: Filter[],
|
filters: Filter<K>[],
|
||||||
opts?: SubscriptionOptions
|
opts?: SubscriptionOptions
|
||||||
): Promise<Event[]> {
|
): Promise<Event<K>[]> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let events: Event[] = []
|
let events: Event<K>[] = []
|
||||||
let sub = this.sub(relays, filters, opts)
|
let sub = this.sub(relays, filters, opts)
|
||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', event => {
|
||||||
events.push(event)
|
events.push(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,39 +190,63 @@ export class SimplePool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(relays: string[], event: Event): Pub {
|
batchedList<K extends number = number>(
|
||||||
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
|
batchKey: string,
|
||||||
let r
|
relays: string[],
|
||||||
try {
|
filters: Filter<K>[]
|
||||||
r = await this.ensureRelay(relay)
|
): Promise<Event<K>[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!this.batchedByKey[batchKey]) {
|
||||||
|
this.batchedByKey[batchKey] = [
|
||||||
|
{
|
||||||
|
filters,
|
||||||
|
relays,
|
||||||
|
resolve,
|
||||||
|
events: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
Object.keys(this.batchedByKey).forEach(async batchKey => {
|
||||||
|
const batchedRequests = this.batchedByKey[batchKey]
|
||||||
|
|
||||||
|
const filters = [] as Filter[]
|
||||||
|
const relays = [] as string[]
|
||||||
|
batchedRequests.forEach(br => {
|
||||||
|
filters.push(...br.filters)
|
||||||
|
relays.push(...br.relays)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sub = this.sub(relays, filters)
|
||||||
|
sub.on('event', event => {
|
||||||
|
batchedRequests.forEach(
|
||||||
|
br => matchFilters(br.filters, event) && br.events.push(event)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
sub.on('eose', () => {
|
||||||
|
sub.unsub()
|
||||||
|
batchedRequests.forEach(br => br.resolve(br.events))
|
||||||
|
})
|
||||||
|
|
||||||
|
delete this.batchedByKey[batchKey]
|
||||||
|
})
|
||||||
|
}, this.batchInterval)
|
||||||
|
} else {
|
||||||
|
this.batchedByKey[batchKey].push({
|
||||||
|
filters,
|
||||||
|
relays,
|
||||||
|
resolve,
|
||||||
|
events: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(relays: string[], event: Event<number>): Promise<void>[] {
|
||||||
|
return relays.map(async relay => {
|
||||||
|
let r = await this.ensureRelay(relay)
|
||||||
return r.publish(event)
|
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[] {
|
seenOn(id: string): string[] {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/* eslint-env jest */
|
import {parseReferences} from './references.ts'
|
||||||
|
import {buildEvent} from './test-helpers.ts'
|
||||||
const {parseReferences} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
test('parse mentions', () => {
|
test('parse mentions', () => {
|
||||||
let evt = {
|
let evt = buildEvent({
|
||||||
tags: [
|
tags: [
|
||||||
[
|
[
|
||||||
'p',
|
'p',
|
||||||
@@ -23,8 +22,8 @@ test('parse mentions', () => {
|
|||||||
]
|
]
|
||||||
],
|
],
|
||||||
content:
|
content:
|
||||||
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]'
|
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
|
||||||
}
|
})
|
||||||
|
|
||||||
expect(parseReferences(evt)).toEqual([
|
expect(parseReferences(evt)).toEqual([
|
||||||
{
|
{
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import {Event} from './event'
|
import {
|
||||||
import {decode, AddressPointer, ProfilePointer, EventPointer} from './nip19'
|
decode,
|
||||||
|
type AddressPointer,
|
||||||
|
type ProfilePointer,
|
||||||
|
type EventPointer,
|
||||||
|
} from './nip19.ts'
|
||||||
|
|
||||||
|
import type {Event} from './event.ts'
|
||||||
|
|
||||||
type Reference = {
|
type Reference = {
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
/* eslint-env jest */
|
import 'websocket-polyfill'
|
||||||
|
|
||||||
require('websocket-polyfill')
|
import {finishEvent} from './event.ts'
|
||||||
const {
|
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||||
relayInit,
|
import {relayInit} from './relay.ts'
|
||||||
generatePrivateKey,
|
|
||||||
getPublicKey,
|
|
||||||
getEventHash,
|
|
||||||
signEvent
|
|
||||||
} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
let relay = relayInit('wss://relay.damus.io/')
|
let relay = relayInit('wss://relay.damus.io/')
|
||||||
|
|
||||||
@@ -33,8 +28,8 @@ test('connectivity', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('querying', async () => {
|
test('querying', async () => {
|
||||||
var resolve1
|
var resolve1: (value: boolean) => void
|
||||||
var resolve2
|
var resolve2: (value: boolean) => void
|
||||||
|
|
||||||
let sub = relay.sub([
|
let sub = relay.sub([
|
||||||
{
|
{
|
||||||
@@ -53,10 +48,10 @@ test('querying', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let [t1, t2] = await Promise.all([
|
let [t1, t2] = await Promise.all([
|
||||||
new Promise(resolve => {
|
new Promise<boolean>(resolve => {
|
||||||
resolve1 = resolve
|
resolve1 = resolve
|
||||||
}),
|
}),
|
||||||
new Promise(resolve => {
|
new Promise<boolean>(resolve => {
|
||||||
resolve2 = resolve
|
resolve2 = resolve
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
@@ -93,8 +88,8 @@ test('list()', async () => {
|
|||||||
test('listening (twice) and publishing', async () => {
|
test('listening (twice) and publishing', async () => {
|
||||||
let sk = generatePrivateKey()
|
let sk = generatePrivateKey()
|
||||||
let pk = getPublicKey(sk)
|
let pk = getPublicKey(sk)
|
||||||
var resolve1
|
var resolve1: (value: boolean) => void
|
||||||
var resolve2
|
var resolve2: (value: boolean) => void
|
||||||
|
|
||||||
let sub = relay.sub([
|
let sub = relay.sub([
|
||||||
{
|
{
|
||||||
@@ -116,15 +111,12 @@ test('listening (twice) and publishing', async () => {
|
|||||||
resolve2(true)
|
resolve2(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
let event = {
|
let event = finishEvent({
|
||||||
kind: 27572,
|
kind: 27572,
|
||||||
pubkey: pk,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
content: 'nostr-tools test suite'
|
content: 'nostr-tools test suite'
|
||||||
}
|
}, sk)
|
||||||
event.id = getEventHash(event)
|
|
||||||
event.sig = signEvent(event, sk)
|
|
||||||
|
|
||||||
relay.publish(event)
|
relay.publish(event)
|
||||||
return expect(
|
return expect(
|
||||||
133
relay.ts
133
relay.ts
@@ -1,8 +1,9 @@
|
|||||||
/* global WebSocket */
|
/* global WebSocket */
|
||||||
|
|
||||||
import {Event, verifySignature, validateEvent} from './event'
|
import {verifySignature, validateEvent, type Event} from './event.ts'
|
||||||
import {Filter, matchFilters} from './filter'
|
import {matchFilters, type Filter} from './filter.ts'
|
||||||
import {getHex64, getSubscriptionId} from './fakejson'
|
import {getHex64, getSubscriptionId} from './fakejson.ts'
|
||||||
|
import {MessageQueue} from './utils.ts'
|
||||||
|
|
||||||
type RelayEvent = {
|
type RelayEvent = {
|
||||||
connect: () => void | Promise<void>
|
connect: () => void | Promise<void>
|
||||||
@@ -14,8 +15,8 @@ type RelayEvent = {
|
|||||||
export type CountPayload = {
|
export type CountPayload = {
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
type SubEvent = {
|
type SubEvent<K extends number> = {
|
||||||
event: (event: Event) => void | Promise<void>
|
event: (event: Event<K>) => void | Promise<void>
|
||||||
count: (payload: CountPayload) => void | Promise<void>
|
count: (payload: CountPayload) => void | Promise<void>
|
||||||
eose: () => void | Promise<void>
|
eose: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
@@ -24,15 +25,24 @@ export type Relay = {
|
|||||||
status: number
|
status: number
|
||||||
connect: () => Promise<void>
|
connect: () => Promise<void>
|
||||||
close: () => void
|
close: () => void
|
||||||
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
|
sub: <K extends number = number>(
|
||||||
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
|
filters: Filter<K>[],
|
||||||
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
|
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: (
|
count: (
|
||||||
filters: Filter[],
|
filters: Filter[],
|
||||||
opts?: SubscriptionOptions
|
opts?: SubscriptionOptions
|
||||||
) => Promise<CountPayload | null>
|
) => Promise<CountPayload | null>
|
||||||
publish: (event: Event) => Pub
|
publish: (event: Event<number>) => Promise<void>
|
||||||
auth: (event: Event) => Pub
|
auth: (event: Event<number>) => Promise<void>
|
||||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||||
event: T,
|
event: T,
|
||||||
listener: U
|
listener: U
|
||||||
@@ -42,18 +52,17 @@ export type Relay = {
|
|||||||
listener: U
|
listener: U
|
||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
export type Pub = {
|
export type Sub<K extends number = number> = {
|
||||||
on: (type: 'ok' | 'failed', cb: any) => void
|
sub: <K extends number = number>(
|
||||||
off: (type: 'ok' | 'failed', cb: any) => void
|
filters: Filter<K>[],
|
||||||
}
|
opts: SubscriptionOptions
|
||||||
export type Sub = {
|
) => Sub<K>
|
||||||
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
|
|
||||||
unsub: () => void
|
unsub: () => void
|
||||||
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||||
event: T,
|
event: T,
|
||||||
listener: U
|
listener: U
|
||||||
) => void
|
) => void
|
||||||
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||||
event: T,
|
event: T,
|
||||||
listener: U
|
listener: U
|
||||||
) => void
|
) => void
|
||||||
@@ -88,13 +97,12 @@ export function relayInit(
|
|||||||
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
||||||
var listeners = newListeners()
|
var listeners = newListeners()
|
||||||
var subListeners: {
|
var subListeners: {
|
||||||
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
|
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]}
|
||||||
} = {}
|
} = {}
|
||||||
var pubListeners: {
|
var pubListeners: {
|
||||||
[eventid: string]: {
|
[eventid: string]: {
|
||||||
ok: Array<() => void>
|
resolve: (_: unknown) => void
|
||||||
seen: Array<() => void>
|
reject: (err: Error) => void
|
||||||
failed: Array<(reason: string) => void>
|
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
@@ -122,24 +130,24 @@ export function relayInit(
|
|||||||
listeners.disconnect.forEach(cb => cb())
|
listeners.disconnect.forEach(cb => cb())
|
||||||
}
|
}
|
||||||
|
|
||||||
let incomingMessageQueue: string[] = []
|
let incomingMessageQueue: MessageQueue = new MessageQueue()
|
||||||
let handleNextInterval: any
|
let handleNextInterval: any
|
||||||
|
|
||||||
ws.onmessage = e => {
|
ws.onmessage = e => {
|
||||||
incomingMessageQueue.push(e.data)
|
incomingMessageQueue.enqueue(e.data)
|
||||||
if (!handleNextInterval) {
|
if (!handleNextInterval) {
|
||||||
handleNextInterval = setInterval(handleNext, 0)
|
handleNextInterval = setInterval(handleNext, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
if (incomingMessageQueue.length === 0) {
|
if (incomingMessageQueue.size === 0) {
|
||||||
clearInterval(handleNextInterval)
|
clearInterval(handleNextInterval)
|
||||||
handleNextInterval = null
|
handleNextInterval = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = incomingMessageQueue.shift()
|
var json = incomingMessageQueue.dequeue()
|
||||||
if (!json) return
|
if (!json) return
|
||||||
|
|
||||||
let subid = getSubscriptionId(json)
|
let subid = getSubscriptionId(json)
|
||||||
@@ -195,10 +203,9 @@ export function relayInit(
|
|||||||
let ok: boolean = data[2]
|
let ok: boolean = data[2]
|
||||||
let reason: string = data[3] || ''
|
let reason: string = data[3] || ''
|
||||||
if (id in pubListeners) {
|
if (id in pubListeners) {
|
||||||
if (ok) pubListeners[id].ok.forEach(cb => cb())
|
let {resolve, reject} = pubListeners[id]
|
||||||
else pubListeners[id].failed.forEach(cb => cb(reason))
|
if (ok) resolve(null)
|
||||||
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here
|
else reject(new Error(reason))
|
||||||
pubListeners[id].failed = []
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -245,15 +252,15 @@ export function relayInit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = (
|
const sub = <K extends number = number>(
|
||||||
filters: Filter[],
|
filters: Filter<K>[],
|
||||||
{
|
{
|
||||||
verb = 'REQ',
|
verb = 'REQ',
|
||||||
skipVerification = false,
|
skipVerification = false,
|
||||||
alreadyHaveEvent = null,
|
alreadyHaveEvent = null,
|
||||||
id = Math.random().toString().slice(2)
|
id = Math.random().toString().slice(2)
|
||||||
}: SubscriptionOptions = {}
|
}: SubscriptionOptions = {}
|
||||||
): Sub => {
|
): Sub<K> => {
|
||||||
let subid = id
|
let subid = id
|
||||||
|
|
||||||
openSubs[subid] = {
|
openSubs[subid] = {
|
||||||
@@ -276,10 +283,7 @@ export function relayInit(
|
|||||||
delete subListeners[subid]
|
delete subListeners[subid]
|
||||||
trySend(['CLOSE', subid])
|
trySend(['CLOSE', subid])
|
||||||
},
|
},
|
||||||
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
on: (type, cb) => {
|
||||||
type: T,
|
|
||||||
cb: U
|
|
||||||
): void => {
|
|
||||||
subListeners[subid] = subListeners[subid] || {
|
subListeners[subid] = subListeners[subid] || {
|
||||||
event: [],
|
event: [],
|
||||||
count: [],
|
count: [],
|
||||||
@@ -287,10 +291,7 @@ export function relayInit(
|
|||||||
}
|
}
|
||||||
subListeners[subid][type].push(cb)
|
subListeners[subid][type].push(cb)
|
||||||
},
|
},
|
||||||
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
off: (type, cb): void => {
|
||||||
type: T,
|
|
||||||
cb: U
|
|
||||||
): void => {
|
|
||||||
let listeners = subListeners[subid]
|
let listeners = subListeners[subid]
|
||||||
let idx = listeners[type].indexOf(cb)
|
let idx = listeners[type].indexOf(cb)
|
||||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||||
@@ -298,27 +299,17 @@ export function relayInit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _publishEvent(event: Event, type: string) {
|
function _publishEvent(event: Event<number>, type: string) {
|
||||||
if (!event.id) throw new Error(`event ${event} has no id`)
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!event.id) {
|
||||||
|
reject(new Error(`event ${event} has no id`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let id = event.id
|
let id = event.id
|
||||||
|
|
||||||
trySend([type, event])
|
trySend([type, event])
|
||||||
|
pubListeners[id] = {resolve, reject}
|
||||||
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 {
|
return {
|
||||||
@@ -341,10 +332,10 @@ export function relayInit(
|
|||||||
let index = listeners[type].indexOf(cb)
|
let index = listeners[type].indexOf(cb)
|
||||||
if (index !== -1) listeners[type].splice(index, 1)
|
if (index !== -1) listeners[type].splice(index, 1)
|
||||||
},
|
},
|
||||||
list: (filters: Filter[], opts?: SubscriptionOptions): Promise<Event[]> =>
|
list: (filters, opts?: SubscriptionOptions) =>
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
let s = sub(filters, opts)
|
let s = sub(filters, opts)
|
||||||
let events: Event[] = []
|
let events: Event<any>[] = []
|
||||||
let timeout = setTimeout(() => {
|
let timeout = setTimeout(() => {
|
||||||
s.unsub()
|
s.unsub()
|
||||||
resolve(events)
|
resolve(events)
|
||||||
@@ -354,18 +345,18 @@ export function relayInit(
|
|||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
resolve(events)
|
resolve(events)
|
||||||
})
|
})
|
||||||
s.on('event', (event: Event) => {
|
s.on('event', event => {
|
||||||
events.push(event)
|
events.push(event)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
get: (filter: Filter, opts?: SubscriptionOptions): Promise<Event | null> =>
|
get: (filter, opts?: SubscriptionOptions) =>
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
let s = sub([filter], opts)
|
let s = sub([filter], opts)
|
||||||
let timeout = setTimeout(() => {
|
let timeout = setTimeout(() => {
|
||||||
s.unsub()
|
s.unsub()
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}, getTimeout)
|
}, getTimeout)
|
||||||
s.on('event', (event: Event) => {
|
s.on('event', event => {
|
||||||
s.unsub()
|
s.unsub()
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
resolve(event)
|
resolve(event)
|
||||||
@@ -384,19 +375,19 @@ export function relayInit(
|
|||||||
resolve(event)
|
resolve(event)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
publish(event): Pub {
|
async publish(event): Promise<void> {
|
||||||
return _publishEvent(event, 'EVENT')
|
await _publishEvent(event, 'EVENT')
|
||||||
},
|
},
|
||||||
auth(event): Pub {
|
async auth(event): Promise<void> {
|
||||||
return _publishEvent(event, 'AUTH')
|
await _publishEvent(event, 'AUTH')
|
||||||
},
|
},
|
||||||
connect,
|
connect,
|
||||||
close(): void {
|
close(): void {
|
||||||
listeners = newListeners()
|
listeners = newListeners()
|
||||||
subListeners = {}
|
subListeners = {}
|
||||||
pubListeners = {}
|
pubListeners = {}
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
ws?.close()
|
ws.close()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
get status() {
|
get status() {
|
||||||
|
|||||||
17
test-helpers.ts
Normal file
17
test-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type {Event} from './event.ts'
|
||||||
|
|
||||||
|
type EventParams<K extends number> = Partial<Event<K>>
|
||||||
|
|
||||||
|
/** Build an event for testing purposes. */
|
||||||
|
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
kind: 1 as K,
|
||||||
|
pubkey: '',
|
||||||
|
created_at: 0,
|
||||||
|
content: '',
|
||||||
|
tags: [],
|
||||||
|
sig: '',
|
||||||
|
...params
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"outDir": "lib",
|
"outDir": "lib",
|
||||||
"rootDir": "."
|
"rootDir": ".",
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
183
utils.test.js
183
utils.test.js
@@ -1,183 +0,0 @@
|
|||||||
/* eslint-env jest */
|
|
||||||
|
|
||||||
const {utils} = require('./lib/nostr.cjs')
|
|
||||||
|
|
||||||
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
|
|
||||||
|
|
||||||
describe('inserting into a desc sorted list of events', () => {
|
|
||||||
test('insert into an empty list', async () => {
|
|
||||||
const list0 = []
|
|
||||||
expect(
|
|
||||||
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
|
|
||||||
).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the beginning of a list', async () => {
|
|
||||||
const list0 = [{created_at: 20}, {created_at: 10}]
|
|
||||||
const list1 = insertEventIntoDescendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 30
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(3)
|
|
||||||
expect(list1[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the beginning of a list with same created_at', async () => {
|
|
||||||
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
|
|
||||||
const list1 = insertEventIntoDescendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 30
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(4)
|
|
||||||
expect(list1[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the middle of a list', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 30},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 10},
|
|
||||||
{created_at: 1}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoDescendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 15
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(5)
|
|
||||||
expect(list1[2].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the end of a list', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 10}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoDescendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 5
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(6)
|
|
||||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 10}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoDescendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 10
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(6)
|
|
||||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('do not insert duplicates', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 10, id: 'abc'}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoDescendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 10
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('inserting into a asc sorted list of events', () => {
|
|
||||||
test('insert into an empty list', async () => {
|
|
||||||
const list0 = []
|
|
||||||
expect(
|
|
||||||
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
|
|
||||||
).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the beginning of a list', async () => {
|
|
||||||
const list0 = [{created_at: 10}, {created_at: 20}]
|
|
||||||
const list1 = insertEventIntoAscendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 1
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(3)
|
|
||||||
expect(list1[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the beginning of a list with same created_at', async () => {
|
|
||||||
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
|
|
||||||
const list1 = insertEventIntoAscendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 10
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(4)
|
|
||||||
expect(list1[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the middle of a list', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 10},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 30},
|
|
||||||
{created_at: 40}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoAscendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 25
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(5)
|
|
||||||
expect(list1[2].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the end of a list', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 40}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoAscendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 50
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(6)
|
|
||||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 30}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoAscendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 30
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(6)
|
|
||||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('do not insert duplicates', async () => {
|
|
||||||
const list0 = [
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 20},
|
|
||||||
{created_at: 30, id: 'abc'}
|
|
||||||
]
|
|
||||||
const list1 = insertEventIntoAscendingList(list0, {
|
|
||||||
id: 'abc',
|
|
||||||
created_at: 30
|
|
||||||
})
|
|
||||||
expect(list1).toHaveLength(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
239
utils.test.ts
Normal file
239
utils.test.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
86
utils.ts
86
utils.ts
@@ -1,4 +1,4 @@
|
|||||||
import {Event} from './event'
|
import type {Event} from './event.ts'
|
||||||
|
|
||||||
export const utf8Decoder = new TextDecoder('utf-8')
|
export const utf8Decoder = new TextDecoder('utf-8')
|
||||||
export const utf8Encoder = new TextEncoder()
|
export const utf8Encoder = new TextEncoder()
|
||||||
@@ -21,8 +21,8 @@ export function normalizeURL(url: string): string {
|
|||||||
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
|
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
|
||||||
//
|
//
|
||||||
export function insertEventIntoDescendingList(
|
export function insertEventIntoDescendingList(
|
||||||
sortedArray: Event[],
|
sortedArray: Event<number>[],
|
||||||
event: Event
|
event: Event<number>
|
||||||
) {
|
) {
|
||||||
let start = 0
|
let start = 0
|
||||||
let end = sortedArray.length - 1
|
let end = sortedArray.length - 1
|
||||||
@@ -66,8 +66,8 @@ export function insertEventIntoDescendingList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function insertEventIntoAscendingList(
|
export function insertEventIntoAscendingList(
|
||||||
sortedArray: Event[],
|
sortedArray: Event<number>[],
|
||||||
event: Event
|
event: Event<number>
|
||||||
) {
|
) {
|
||||||
let start = 0
|
let start = 0
|
||||||
let end = sortedArray.length - 1
|
let end = sortedArray.length - 1
|
||||||
@@ -109,3 +109,79 @@ export function insertEventIntoAscendingList(
|
|||||||
|
|
||||||
return sortedArray
|
return sortedArray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MessageNode {
|
||||||
|
private _value: string
|
||||||
|
private _next: MessageNode | null
|
||||||
|
|
||||||
|
public get value(): string {
|
||||||
|
return this._value
|
||||||
|
}
|
||||||
|
public set value(message: string) {
|
||||||
|
this._value = message
|
||||||
|
}
|
||||||
|
public get next(): MessageNode | null {
|
||||||
|
return this._next
|
||||||
|
}
|
||||||
|
public set next(node: MessageNode | null) {
|
||||||
|
this._next = node
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
this._value = message
|
||||||
|
this._next = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageQueue {
|
||||||
|
private _first: MessageNode | null
|
||||||
|
private _last: MessageNode | null
|
||||||
|
|
||||||
|
public get first(): MessageNode | null {
|
||||||
|
return this._first
|
||||||
|
}
|
||||||
|
public set first(messageNode: MessageNode | null) {
|
||||||
|
this._first = messageNode
|
||||||
|
}
|
||||||
|
public get last(): MessageNode | null {
|
||||||
|
return this._last
|
||||||
|
}
|
||||||
|
public set last(messageNode: MessageNode | null) {
|
||||||
|
this._last = messageNode
|
||||||
|
}
|
||||||
|
private _size: number
|
||||||
|
public get size(): number {
|
||||||
|
return this._size
|
||||||
|
}
|
||||||
|
public set size(v: number) {
|
||||||
|
this._size = v
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._first = null
|
||||||
|
this._last = null
|
||||||
|
this._size = 0
|
||||||
|
}
|
||||||
|
enqueue(message: string): boolean {
|
||||||
|
const newNode = new MessageNode(message)
|
||||||
|
if (this._size === 0 || !this._last) {
|
||||||
|
this._first = newNode
|
||||||
|
this._last = newNode
|
||||||
|
} else {
|
||||||
|
this._last.next = newNode
|
||||||
|
this._last = newNode
|
||||||
|
}
|
||||||
|
this._size++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
dequeue(): string | null {
|
||||||
|
if (this._size === 0 || !this._first) return null
|
||||||
|
|
||||||
|
let prev = this._first
|
||||||
|
this._first = prev.next
|
||||||
|
prev.next = null
|
||||||
|
|
||||||
|
this._size--
|
||||||
|
return prev.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user