mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6f39e4ae | ||
|
|
08885ab8da | ||
|
|
9f896479d0 | ||
|
|
82caa2aad9 | ||
|
|
67a8ee23ce | ||
|
|
18e8227123 | ||
|
|
64caef9cda | ||
|
|
6a07d2d9d3 | ||
|
|
341ccc5ac5 | ||
|
|
d2a9af2586 | ||
|
|
5d92be05bb | ||
|
|
03cc18d53b | ||
|
|
ac7598b5e3 | ||
|
|
424449c773 | ||
|
|
ab6abe6815 | ||
|
|
30fd6b6215 | ||
|
|
8a53b3b8b3 | ||
|
|
d0bd599ce8 | ||
|
|
1cbb62e6b9 | ||
|
|
977316915b | ||
|
|
dd8f555094 | ||
|
|
87f5ea4291 | ||
|
|
595ae21baf | ||
|
|
9fa554ca8e | ||
|
|
1647601727 | ||
|
|
b66ca1787a | ||
|
|
278cdda9c2 | ||
|
|
552530fa3f | ||
|
|
13e9b4aa3e | ||
|
|
9a3e05ce5f | ||
|
|
55ff796b9f | ||
|
|
3ef2ad5bc4 | ||
|
|
45c07a5f45 | ||
|
|
6a037d1658 | ||
|
|
dcf101c6c2 | ||
|
|
eb97dbd9ef | ||
|
|
92988051c6 | ||
|
|
bf7e00d32a | ||
|
|
9241089997 | ||
|
|
32c47e9bd8 | ||
|
|
6e58fe371c | ||
|
|
26e35d50e0 | ||
|
|
ef3184a6e0 | ||
|
|
56fe3dd5dd | ||
|
|
f1bb5030c8 | ||
|
|
ac212cb5c8 | ||
|
|
204ae0eff1 | ||
|
|
f17ab41d72 | ||
|
|
f6f5ee8223 | ||
|
|
a05506468d | ||
|
|
674ff66b6f | ||
|
|
731705047a | ||
|
|
94b382a49f | ||
|
|
199411a971 | ||
|
|
a1dc6f41b9 | ||
|
|
5b59b93d86 | ||
|
|
12acd7bdca | ||
|
|
3bdb68020d | ||
|
|
b0a58e2ca4 | ||
|
|
b063be76ae |
@@ -2,7 +2,7 @@
|
||||
"root": true,
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"plugins": ["@typescript-eslint", "babel"],
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
@@ -18,8 +18,6 @@
|
||||
"node": true
|
||||
},
|
||||
|
||||
"plugins": ["babel"],
|
||||
|
||||
"globals": {
|
||||
"document": false,
|
||||
"navigator": false,
|
||||
@@ -103,7 +101,6 @@
|
||||
"no-octal-escape": 2,
|
||||
"no-path-concat": 0,
|
||||
"no-proto": 2,
|
||||
"no-redeclare": 2,
|
||||
"no-regex-spaces": 2,
|
||||
"no-return-assign": 0,
|
||||
"no-self-assign": 2,
|
||||
@@ -153,5 +150,13 @@
|
||||
"wrap-iife": [2, "any"],
|
||||
"yield-star-spacing": [2, "both"],
|
||||
"yoda": [0]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.ts"],
|
||||
"env": { "jest/globals": true },
|
||||
"plugins": ["jest"],
|
||||
"extends": ["plugin:jest/recommended"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -15,5 +15,4 @@ jobs:
|
||||
node-version: 18
|
||||
- uses: extractions/setup-just@v1
|
||||
- run: just install-dependencies
|
||||
- run: just build
|
||||
- run: just test
|
||||
|
||||
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
12
README.md
12
README.md
@@ -27,7 +27,7 @@ let pk = getPublicKey(sk) // `pk` is a hex string
|
||||
import {
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
signEvent,
|
||||
getSignature,
|
||||
getEventHash,
|
||||
getPublicKey
|
||||
} from 'nostr-tools'
|
||||
@@ -41,7 +41,7 @@ let event = {
|
||||
}
|
||||
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, privateKey)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
|
||||
let ok = validateEvent(event)
|
||||
let veryOk = verifySignature(event)
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
signEvent
|
||||
getSignature
|
||||
} from 'nostr-tools'
|
||||
|
||||
const relay = relayInit('wss://relay.example.com')
|
||||
@@ -104,7 +104,7 @@ let event = {
|
||||
content: 'hello world'
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, sk)
|
||||
event.sig = getSignature(event, sk)
|
||||
|
||||
let pub = relay.publish(event)
|
||||
pub.on('ok', () => {
|
||||
@@ -266,7 +266,7 @@ sendEvent(event)
|
||||
|
||||
// on the receiver side
|
||||
sub.on('event', event => {
|
||||
let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
||||
let sender = event.pubkey
|
||||
pk1 === sender
|
||||
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
|
||||
})
|
||||
@@ -325,4 +325,4 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
|
||||
|
||||
## License
|
||||
|
||||
Public domain.
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const {
|
||||
import {
|
||||
getBlankEvent,
|
||||
finishEvent,
|
||||
serializeEvent,
|
||||
getEventHash,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
signEvent,
|
||||
getPublicKey,
|
||||
Kind
|
||||
} = require('./lib/nostr.cjs')
|
||||
getSignature,
|
||||
Kind,
|
||||
} from './event.ts'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
|
||||
describe('Event', () => {
|
||||
describe('getBlankEvent', () => {
|
||||
@@ -20,6 +20,15 @@ describe('Event', () => {
|
||||
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', () => {
|
||||
@@ -88,6 +97,7 @@ describe('Event', () => {
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error
|
||||
serializeEvent(invalidEvent)
|
||||
}).toThrow("can't serialize event with wrong or missing properties")
|
||||
})
|
||||
@@ -281,8 +291,8 @@ describe('Event', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('signEvent', () => {
|
||||
it('should sign an event object', () => {
|
||||
describe('getSignature', () => {
|
||||
it('should produce the correct signature for an event object', () => {
|
||||
const privateKey =
|
||||
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
@@ -295,9 +305,10 @@ describe('Event', () => {
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const sig = signEvent(unsignedEvent, privateKey)
|
||||
const sig = getSignature(unsignedEvent, privateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig
|
||||
@@ -324,9 +335,10 @@ describe('Event', () => {
|
||||
pubkey: publicKey
|
||||
}
|
||||
|
||||
const sig = signEvent(unsignedEvent, wrongPrivateKey)
|
||||
const sig = getSignature(unsignedEvent, wrongPrivateKey)
|
||||
|
||||
// verify the signature
|
||||
// @ts-expect-error
|
||||
const isValid = verifySignature({
|
||||
...unsignedEvent,
|
||||
sig
|
||||
76
event.ts
76
event.ts
@@ -1,8 +1,9 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {schnorr} from '@noble/curves/secp256k1'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
|
||||
import {utf8Encoder} from './utils'
|
||||
import {getPublicKey} from './keys'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
import {utf8Encoder} from './utils.ts'
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum Kind {
|
||||
@@ -12,54 +13,64 @@ export enum Kind {
|
||||
Contacts = 3,
|
||||
EncryptedDirectMessage = 4,
|
||||
EventDeletion = 5,
|
||||
Repost = 6,
|
||||
Reaction = 7,
|
||||
BadgeAward = 8,
|
||||
ChannelCreation = 40,
|
||||
ChannelMetadata = 41,
|
||||
ChannelMessage = 42,
|
||||
ChannelHideMessage = 43,
|
||||
ChannelMuteUser = 44,
|
||||
Blank = 255,
|
||||
Report = 1984,
|
||||
ZapRequest = 9734,
|
||||
Zap = 9735,
|
||||
RelayList = 10002,
|
||||
ClientAuth = 22242,
|
||||
BadgeDefinition = 30008,
|
||||
ProfileBadge = 30009,
|
||||
Article = 30023
|
||||
}
|
||||
|
||||
export type EventTemplate = {
|
||||
kind: Kind
|
||||
export type EventTemplate<K extends number = Kind> = {
|
||||
kind: K
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type UnsignedEvent = EventTemplate & {
|
||||
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export type Event = UnsignedEvent & {
|
||||
export type Event<K extends number = Kind> = UnsignedEvent<K> & {
|
||||
id: 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 {
|
||||
kind: 255,
|
||||
kind,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function finishEvent(t: EventTemplate, privateKey: string): Event {
|
||||
let event = t as Event
|
||||
export function finishEvent<K extends number = Kind>(
|
||||
t: EventTemplate<K>,
|
||||
privateKey: string
|
||||
): Event<K> {
|
||||
let event = t as Event<K>
|
||||
event.pubkey = getPublicKey(privateKey)
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, privateKey)
|
||||
event.sig = getSignature(event, privateKey)
|
||||
return event
|
||||
}
|
||||
|
||||
export function serializeEvent(evt: UnsignedEvent): string {
|
||||
export function serializeEvent(evt: UnsignedEvent<number>): string {
|
||||
if (!validateEvent(evt))
|
||||
throw new Error("can't serialize event with wrong or missing properties")
|
||||
|
||||
@@ -73,13 +84,16 @@ 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)))
|
||||
return secp256k1.utils.bytesToHex(eventHash)
|
||||
return bytesToHex(eventHash)
|
||||
}
|
||||
|
||||
export function validateEvent(event: UnsignedEvent): boolean {
|
||||
if (typeof event !== 'object') return false
|
||||
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
|
||||
obj instanceof Object
|
||||
|
||||
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
|
||||
if (!isRecord(event)) return false
|
||||
if (typeof event.kind !== 'number') return false
|
||||
if (typeof event.content !== 'string') return false
|
||||
if (typeof event.created_at !== 'number') return false
|
||||
@@ -98,16 +112,26 @@ export function validateEvent(event: UnsignedEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function verifySignature(event: Event & {sig: string}): boolean {
|
||||
return secp256k1.schnorr.verifySync(
|
||||
event.sig,
|
||||
getEventHash(event),
|
||||
event.pubkey
|
||||
)
|
||||
export function verifySignature(event: Event<number>): boolean {
|
||||
try {
|
||||
return schnorr.verify(event.sig, getEventHash(event), event.pubkey)
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function signEvent(event: UnsignedEvent, key: string): string {
|
||||
return secp256k1.utils.bytesToHex(
|
||||
secp256k1.schnorr.signSync(getEventHash(event), key)
|
||||
/** @deprecated Use `getSignature` instead. */
|
||||
export function signEvent(event: UnsignedEvent<number>, key: string): string {
|
||||
console.warn(
|
||||
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.'
|
||||
)
|
||||
return getSignature(event, key)
|
||||
}
|
||||
|
||||
/** Calculate the signature for an event. */
|
||||
export function getSignature(
|
||||
event: UnsignedEvent<number>,
|
||||
key: string
|
||||
): string {
|
||||
return bytesToHex(schnorr.sign(getEventHash(event), key))
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {fj} = require('./lib/nostr.cjs')
|
||||
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts'
|
||||
|
||||
test('match id', () => {
|
||||
expect(
|
||||
fj.matchEventId(
|
||||
matchEventId(
|
||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||
)
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
fj.matchEventId(
|
||||
matchEventId(
|
||||
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
|
||||
)
|
||||
@@ -20,14 +18,14 @@ test('match id', () => {
|
||||
|
||||
test('match kind', () => {
|
||||
expect(
|
||||
fj.matchEventKind(
|
||||
matchEventKind(
|
||||
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||
1
|
||||
)
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
fj.matchEventKind(
|
||||
matchEventKind(
|
||||
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||
12720
|
||||
)
|
||||
@@ -35,14 +33,14 @@ test('match kind', () => {
|
||||
})
|
||||
|
||||
test('match subscription id', () => {
|
||||
expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||
expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||
expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||
expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
||||
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
|
||||
'kasjbdjkav'
|
||||
)
|
||||
expect(
|
||||
fj.getSubscriptionId(
|
||||
getSubscriptionId(
|
||||
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
|
||||
)
|
||||
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
|
||||
import {matchFilter, matchFilters} from './filter.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
describe('matchFilter', () => {
|
||||
@@ -14,13 +13,13 @@ describe('Filter', () => {
|
||||
'#tag': ['value']
|
||||
}
|
||||
|
||||
const event = {
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
created_at: 150,
|
||||
tags: [['tag', 'value']]
|
||||
}
|
||||
tags: [['tag', 'value']],
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -30,17 +29,27 @@ describe('Filter', () => {
|
||||
it('should return false when the event id is not in the filter', () => {
|
||||
const filter = {ids: ['123', '456']}
|
||||
|
||||
const event = {id: '789'}
|
||||
const event = buildEvent({id: '789'})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true when the event id starts with a prefix', () => {
|
||||
const filter = {ids: ['22', '00']}
|
||||
|
||||
const event = buildEvent({id: '001'})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false when the event kind is not in the filter', () => {
|
||||
const filter = {kinds: [1, 2, 3]}
|
||||
|
||||
const event = {kind: 4}
|
||||
const event = buildEvent({kind: 4})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -50,7 +59,7 @@ describe('Filter', () => {
|
||||
it('should return false when the event author is not in the filter', () => {
|
||||
const filter = {authors: ['abc', 'def']}
|
||||
|
||||
const event = {pubkey: 'ghi'}
|
||||
const event = buildEvent({pubkey: 'ghi'})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -60,7 +69,7 @@ describe('Filter', () => {
|
||||
it('should return false when a tag is not present in the event', () => {
|
||||
const filter = {'#tag': ['value1', 'value2']}
|
||||
|
||||
const event = {tags: [['not_tag', 'value1']]}
|
||||
const event = buildEvent({tags: [['not_tag', 'value1']]})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -70,7 +79,7 @@ describe('Filter', () => {
|
||||
it('should return false when a tag value is not present in the event', () => {
|
||||
const filter = {'#tag': ['value1', 'value2']}
|
||||
|
||||
const event = {tags: [['tag', 'value3']]}
|
||||
const event = buildEvent({tags: [['tag', 'value3']]})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -80,7 +89,7 @@ describe('Filter', () => {
|
||||
it('should return true when filter has tags that is present in the event', () => {
|
||||
const filter = {'#tag1': ['foo']}
|
||||
|
||||
const event = {
|
||||
const event = buildEvent({
|
||||
id: '123',
|
||||
kind: 1,
|
||||
pubkey: 'abc',
|
||||
@@ -89,7 +98,7 @@ describe('Filter', () => {
|
||||
['tag1', 'foo'],
|
||||
['tag2', 'bar']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -99,7 +108,7 @@ describe('Filter', () => {
|
||||
it('should return false when the event is before the filter since value', () => {
|
||||
const filter = {since: 100}
|
||||
|
||||
const event = {created_at: 50}
|
||||
const event = buildEvent({created_at: 50})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -109,7 +118,7 @@ describe('Filter', () => {
|
||||
it('should return false when the event is after the filter until value', () => {
|
||||
const filter = {until: 100}
|
||||
|
||||
const event = {created_at: 150}
|
||||
const event = buildEvent({created_at: 150})
|
||||
|
||||
const result = matchFilter(filter, event)
|
||||
|
||||
@@ -125,7 +134,21 @@ describe('Filter', () => {
|
||||
{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)
|
||||
|
||||
expect(result).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return true when at least one prefix matches the event', () => {
|
||||
const filters = [
|
||||
{ids: ['1'], kinds: [1], authors: ['a']},
|
||||
{ids: ['4'], kinds: [2], authors: ['d']},
|
||||
{ids: ['9'], kinds: [3], authors: ['g']}
|
||||
]
|
||||
|
||||
const event = buildEvent({id: '987', kind: 3, pubkey: 'ghi'})
|
||||
|
||||
const result = matchFilters(filters, event)
|
||||
|
||||
@@ -139,7 +162,7 @@ describe('Filter', () => {
|
||||
{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)
|
||||
|
||||
@@ -153,7 +176,7 @@ describe('Filter', () => {
|
||||
{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)
|
||||
|
||||
@@ -166,7 +189,7 @@ describe('Filter', () => {
|
||||
{kinds: [1], limit: 2},
|
||||
{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)
|
||||
|
||||
27
filter.ts
27
filter.ts
@@ -1,8 +1,8 @@
|
||||
import {Event} from './event'
|
||||
import {Event, type Kind} from './event.ts'
|
||||
|
||||
export type Filter = {
|
||||
export type Filter<K extends number = Kind> = {
|
||||
ids?: string[]
|
||||
kinds?: number[]
|
||||
kinds?: K[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
@@ -12,13 +12,20 @@ export type Filter = {
|
||||
}
|
||||
|
||||
export function matchFilter(
|
||||
filter: Filter,
|
||||
event: Event & {id: string}
|
||||
filter: Filter<number>,
|
||||
event: Event<number>
|
||||
): boolean {
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
|
||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
|
||||
return false
|
||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
|
||||
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (let f in filter) {
|
||||
if (f[0] === '#') {
|
||||
@@ -41,8 +48,8 @@ export function matchFilter(
|
||||
}
|
||||
|
||||
export function matchFilters(
|
||||
filters: Filter[],
|
||||
event: Event & {id: string}
|
||||
filters: Filter<number>[],
|
||||
event: Event<number>
|
||||
): boolean {
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (matchFilter(filters[i], event)) return true
|
||||
|
||||
47
index.ts
47
index.ts
@@ -1,27 +1,24 @@
|
||||
export * from './keys'
|
||||
export * from './relay'
|
||||
export * from './event'
|
||||
export * from './filter'
|
||||
export * from './pool'
|
||||
export * from './references'
|
||||
export * from './keys.ts'
|
||||
export * from './relay.ts'
|
||||
export * from './event.ts'
|
||||
export * from './filter.ts'
|
||||
export * from './pool.ts'
|
||||
export * from './references.ts'
|
||||
|
||||
export * as nip04 from './nip04'
|
||||
export * as nip05 from './nip05'
|
||||
export * as nip06 from './nip06'
|
||||
export * as nip10 from './nip10'
|
||||
export * as nip19 from './nip19'
|
||||
export * as nip26 from './nip26'
|
||||
export * as nip39 from './nip39'
|
||||
export * as nip57 from './nip57'
|
||||
export * as nip04 from './nip04.ts'
|
||||
export * as nip05 from './nip05.ts'
|
||||
export * as nip06 from './nip06.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
export * as nip25 from './nip25.ts'
|
||||
export * as nip26 from './nip26.ts'
|
||||
export * as nip27 from './nip27.ts'
|
||||
export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
|
||||
export * as fj from './fakejson'
|
||||
export * as utils from './utils'
|
||||
|
||||
// monkey patch secp256k1
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {hmac} from '@noble/hashes/hmac'
|
||||
import {sha256} from '@noble/hashes/sha256'
|
||||
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
|
||||
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
|
||||
secp256k1.utils.sha256Sync = (...msgs) =>
|
||||
sha256(secp256k1.utils.concatBytes(...msgs))
|
||||
export * as fj from './fakejson.ts'
|
||||
export * as utils from './utils.ts'
|
||||
|
||||
5
jest.config.js
Normal file
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
}
|
||||
4
justfile
4
justfile
@@ -7,10 +7,10 @@ build:
|
||||
rm -rf lib
|
||||
node build.js
|
||||
|
||||
test: build
|
||||
test:
|
||||
jest
|
||||
|
||||
test-only file: build
|
||||
test-only file:
|
||||
jest {{file}}
|
||||
|
||||
emit-types:
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/* eslint-env jest */
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
|
||||
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
||||
|
||||
test('test private key generation', () => {
|
||||
test('private key generation', () => {
|
||||
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('test public key generation', () => {
|
||||
test('public key generation', () => {
|
||||
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
|
||||
})
|
||||
|
||||
test('test public key from private key deterministic', () => {
|
||||
test('public key from private key deterministic', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
7
keys.ts
7
keys.ts
@@ -1,9 +1,10 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {schnorr} from '@noble/curves/secp256k1'
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
|
||||
return bytesToHex(schnorr.utils.randomPrivateKey())
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey: string): string {
|
||||
return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
|
||||
return bytesToHex(schnorr.getPublicKey(privateKey))
|
||||
}
|
||||
|
||||
@@ -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 * as secp256k1 from '@noble/secp256k1'
|
||||
import {secp256k1} from '@noble/curves/secp256k1'
|
||||
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(
|
||||
privkey: string,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const fetch = require('node-fetch')
|
||||
const {nip05} = require('./lib/nostr.cjs')
|
||||
|
||||
test('fetch nip05 profiles', async () => {
|
||||
nip05.useFetchImplementation(fetch)
|
||||
|
||||
let p1 = await nip05.queryProfile('jb55.com')
|
||||
expect(p1.pubkey).toEqual(
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
expect(p1.relays).toEqual(['wss://relay.damus.io'])
|
||||
|
||||
let p2 = await nip05.queryProfile('jb55@jb55.com')
|
||||
expect(p2.pubkey).toEqual(
|
||||
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
)
|
||||
expect(p2.relays).toEqual(['wss://relay.damus.io'])
|
||||
})
|
||||
38
nip05.test.ts
Normal file
38
nip05.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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('channel.ninja@channel.ninja')
|
||||
expect(p3!.pubkey).toEqual(
|
||||
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
|
||||
)
|
||||
expect(p3!.relays).toEqual(undefined)
|
||||
|
||||
let p4 = await queryProfile('_@fiatjaf.com')
|
||||
expect(p4!.pubkey).toEqual(
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
)
|
||||
expect(p4!.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
|
||||
|
||||
@@ -25,36 +34,53 @@ export async function searchDomain(
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryProfile(
|
||||
fullname: string
|
||||
): Promise<ProfilePointer | null> {
|
||||
let [name, domain] = fullname.split('@')
|
||||
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||
const match = fullname.match(NIP05_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
if (!domain) {
|
||||
// if there is no @, it is because it is just a domain, so assume the name is "_"
|
||||
domain = name
|
||||
name = '_'
|
||||
}
|
||||
const [_, name = '_', domain] = match
|
||||
|
||||
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
|
||||
if (!domain.includes('.')) return null
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await (
|
||||
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
).json()
|
||||
} catch (err) {
|
||||
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
const { names, relays } = parseNIP05Result(await res.json())
|
||||
|
||||
const pubkey = names[name]
|
||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
||||
} catch (_e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!res?.names?.[name]) return null
|
||||
|
||||
let pubkey = res.names[name] as string
|
||||
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relays
|
||||
/** nostr.json result. */
|
||||
export interface NIP05Result {
|
||||
names: {
|
||||
[name: string]: string
|
||||
}
|
||||
relays?: {
|
||||
[pubkey: string]: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the nostr.json and throw if it's not valid. */
|
||||
function parseNIP05Result(json: any): NIP05Result {
|
||||
const result: NIP05Result = {
|
||||
names: {},
|
||||
}
|
||||
|
||||
for (const [name, pubkey] of Object.entries(json.names)) {
|
||||
if (typeof name === 'string' && typeof pubkey === 'string') {
|
||||
result.names[name] = pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if (json.relays) {
|
||||
result.relays = {}
|
||||
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
||||
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
||||
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
const {nip06} = require('./lib/nostr.cjs')
|
||||
import {privateKeyFromSeedWords} from './nip06.ts'
|
||||
|
||||
test('generate private key from a mnemonic', async () => {
|
||||
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||
expect(privateKey).toEqual(
|
||||
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
|
||||
)
|
||||
@@ -12,7 +11,7 @@ test('generate private key from a mnemonic', 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 passphrase = '123'
|
||||
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||
expect(privateKey).toEqual(
|
||||
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
|
||||
)
|
||||
4
nip06.ts
4
nip06.ts
@@ -1,4 +1,4 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import {bytesToHex} from '@noble/hashes/utils'
|
||||
import {wordlist} from '@scure/bip39/wordlists/english.js'
|
||||
import {
|
||||
generateMnemonic,
|
||||
@@ -14,7 +14,7 @@ export function privateKeyFromSeedWords(
|
||||
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
|
||||
if (!privateKey) throw new Error('could not derive private key')
|
||||
return secp256k1.utils.bytesToHex(privateKey)
|
||||
return bytesToHex(privateKey)
|
||||
}
|
||||
|
||||
export function generateSeedWords(): string {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {nip10} = require('./lib/nostr.cjs')
|
||||
import {parse} from './nip10.ts'
|
||||
|
||||
describe('parse NIP10-referenced 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: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
@@ -130,7 +128,7 @@ describe('parse NIP10-referenced events', () => {
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [
|
||||
{
|
||||
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||
@@ -191,7 +189,7 @@ describe('parse NIP10-referenced events', () => {
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
@@ -235,7 +233,7 @@ describe('parse NIP10-referenced events', () => {
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
@@ -304,7 +302,7 @@ describe('parse NIP10-referenced events', () => {
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip10.parse(event)).toEqual({
|
||||
expect(parse(event)).toEqual({
|
||||
mentions: [],
|
||||
profiles: [
|
||||
{
|
||||
4
nip10.ts
4
nip10.ts
@@ -1,5 +1,5 @@
|
||||
import type {Event} from './event'
|
||||
import type {EventPointer, ProfilePointer} from './nip19'
|
||||
import type {Event} from './event.ts'
|
||||
import type {EventPointer, ProfilePointer} from './nip19.ts'
|
||||
|
||||
export type NIP10Result = {
|
||||
/**
|
||||
|
||||
7
nip13.test.ts
Normal file
7
nip13.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {getPow} from './nip13.ts'
|
||||
|
||||
test('identifies proof-of-work difficulty', async () => {
|
||||
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||
const difficulty = getPow(id)
|
||||
expect(difficulty).toEqual(21)
|
||||
})
|
||||
42
nip13.ts
Normal file
42
nip13.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {hexToBytes} from '@noble/hashes/utils'
|
||||
|
||||
/** Get POW difficulty from a Nostr hex ID. */
|
||||
export function getPow(id: string): number {
|
||||
return getLeadingZeroBits(hexToBytes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of leading 0 bits. Adapted from nostream.
|
||||
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
|
||||
*/
|
||||
function getLeadingZeroBits(hash: Uint8Array): number {
|
||||
let total: number, i: number, bits: number
|
||||
|
||||
for (i = 0, total = 0; i < hash.length; i++) {
|
||||
bits = msb(hash[i])
|
||||
total += bits
|
||||
if (bits !== 8) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapted from nostream.
|
||||
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
|
||||
*/
|
||||
function msb(b: number) {
|
||||
let n = 0
|
||||
|
||||
if (b === 0) {
|
||||
return 8
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while (b >>= 1) {
|
||||
n++
|
||||
}
|
||||
|
||||
return 7 - n
|
||||
}
|
||||
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 */
|
||||
|
||||
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import {
|
||||
decode,
|
||||
naddrEncode,
|
||||
nprofileEncode,
|
||||
npubEncode,
|
||||
nrelayEncode,
|
||||
nsecEncode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
} from './nip19.ts'
|
||||
|
||||
test('encode and decode nsec', () => {
|
||||
let sk = generatePrivateKey()
|
||||
let nsec = nip19.nsecEncode(sk)
|
||||
let nsec = nsecEncode(sk)
|
||||
expect(nsec).toMatch(/nsec1\w+/)
|
||||
let {type, data} = nip19.decode(nsec)
|
||||
let {type, data} = decode(nsec)
|
||||
expect(type).toEqual('nsec')
|
||||
expect(data).toEqual(sk)
|
||||
})
|
||||
|
||||
test('encode and decode npub', () => {
|
||||
let pk = getPublicKey(generatePrivateKey())
|
||||
let npub = nip19.npubEncode(pk)
|
||||
let npub = npubEncode(pk)
|
||||
expect(npub).toMatch(/npub1\w+/)
|
||||
let {type, data} = nip19.decode(npub)
|
||||
let {type, data} = decode(npub)
|
||||
expect(type).toEqual('npub')
|
||||
expect(data).toEqual(pk)
|
||||
})
|
||||
@@ -26,19 +34,20 @@ test('encode and decode nprofile', () => {
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
|
||||
let nprofile = nprofileEncode({pubkey: pk, relays})
|
||||
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||
let {type, data} = nip19.decode(nprofile)
|
||||
let {type, data} = decode(nprofile)
|
||||
expect(type).toEqual('nprofile')
|
||||
expect(data.pubkey).toEqual(pk)
|
||||
expect(data.relays).toContain(relays[0])
|
||||
expect(data.relays).toContain(relays[1])
|
||||
const pointer = data as ProfilePointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
})
|
||||
|
||||
test('decode nprofile without relays', () => {
|
||||
expect(
|
||||
nip19.decode(
|
||||
nip19.nprofileEncode({
|
||||
decode(
|
||||
nprofileEncode({
|
||||
pubkey:
|
||||
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||
relays: []
|
||||
@@ -56,47 +65,59 @@ test('encode and decode naddr', () => {
|
||||
'wss://relay.nostr.example.mydomain.example.com',
|
||||
'wss://nostr.banana.com'
|
||||
]
|
||||
let naddr = nip19.naddrEncode({
|
||||
let naddr = naddrEncode({
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
identifier: 'banana'
|
||||
})
|
||||
expect(naddr).toMatch(/naddr1\w+/)
|
||||
let {type, data} = nip19.decode(naddr)
|
||||
let {type, data} = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
expect(data.pubkey).toEqual(pk)
|
||||
expect(data.relays).toContain(relays[0])
|
||||
expect(data.relays).toContain(relays[1])
|
||||
expect(data.kind).toEqual(30023)
|
||||
expect(data.identifier).toEqual('banana')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.relays).toContain(relays[1])
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let {type, data} = nip19.decode(
|
||||
let {type, data} = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
|
||||
)
|
||||
expect(type).toEqual('naddr')
|
||||
expect(data.pubkey).toEqual(
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(
|
||||
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
|
||||
)
|
||||
expect(data.kind).toEqual(30023)
|
||||
expect(data.identifier).toEqual('references')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('references')
|
||||
})
|
||||
|
||||
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||
let {type, data} = nip19.decode(
|
||||
let {type, data} = decode(
|
||||
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
|
||||
)
|
||||
|
||||
expect(type).toEqual('naddr')
|
||||
expect(data.pubkey).toEqual(
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.pubkey).toEqual(
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
|
||||
)
|
||||
expect(data.relays).toContain(
|
||||
expect(pointer.relays).toContain(
|
||||
'wss://relay.nostr.example.mydomain.example.com'
|
||||
)
|
||||
expect(data.relays).toContain('wss://nostr.banana.com')
|
||||
expect(data.kind).toEqual(30023)
|
||||
expect(data.identifier).toEqual('banana')
|
||||
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||
expect(pointer.kind).toEqual(30023)
|
||||
expect(pointer.identifier).toEqual('banana')
|
||||
})
|
||||
|
||||
test('encode and decode nrelay', () => {
|
||||
let url = 'wss://relay.nostr.example'
|
||||
let nrelay = nrelayEncode(url)
|
||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||
let {type, data} = decode(nrelay)
|
||||
expect(type).toEqual('nrelay')
|
||||
expect(data).toEqual(url)
|
||||
})
|
||||
67
nip19.ts
67
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 {utf8Decoder, utf8Encoder} from './utils'
|
||||
import {utf8Decoder, utf8Encoder} from './utils.ts'
|
||||
|
||||
const Bech32MaxSize = 5000
|
||||
|
||||
/**
|
||||
* Bech32 regex.
|
||||
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||
*/
|
||||
export const BECH32_REGEX =
|
||||
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||
|
||||
export type ProfilePointer = {
|
||||
pubkey: string // hex
|
||||
relays?: string[]
|
||||
@@ -23,10 +30,16 @@ export type AddressPointer = {
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export function decode(nip19: string): {
|
||||
type: string
|
||||
data: ProfilePointer | EventPointer | AddressPointer | string
|
||||
} {
|
||||
export type DecodeResult =
|
||||
| {type: 'nprofile'; data: ProfilePointer}
|
||||
| {type: 'nrelay'; data: string}
|
||||
| {type: 'nevent'; data: EventPointer}
|
||||
| {type: 'naddr'; data: AddressPointer}
|
||||
| {type: 'nsec'; data: string}
|
||||
| {type: 'npub'; data: string}
|
||||
| {type: 'note'; data: string}
|
||||
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
|
||||
let data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
@@ -39,7 +52,7 @@ export function decode(nip19: string): {
|
||||
return {
|
||||
type: 'nprofile',
|
||||
data: {
|
||||
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
|
||||
pubkey: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||
}
|
||||
}
|
||||
@@ -54,10 +67,10 @@ export function decode(nip19: string): {
|
||||
return {
|
||||
type: 'nevent',
|
||||
data: {
|
||||
id: secp256k1.utils.bytesToHex(tlv[0][0]),
|
||||
id: bytesToHex(tlv[0][0]),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||
author: tlv[2]?.[0]
|
||||
? secp256k1.utils.bytesToHex(tlv[2][0])
|
||||
? bytesToHex(tlv[2][0])
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -75,17 +88,27 @@ export function decode(nip19: string): {
|
||||
type: 'naddr',
|
||||
data: {
|
||||
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||
pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
|
||||
pubkey: bytesToHex(tlv[2][0]),
|
||||
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'nrelay': {
|
||||
let tlv = parseTLV(data)
|
||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
||||
|
||||
return {
|
||||
type: 'nrelay',
|
||||
data: utf8Decoder.decode(tlv[0][0])
|
||||
}
|
||||
}
|
||||
|
||||
case 'nsec':
|
||||
case 'npub':
|
||||
case 'note':
|
||||
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
|
||||
return {type: prefix, data: bytesToHex(data)}
|
||||
|
||||
default:
|
||||
throw new Error(`unknown prefix ${prefix}`)
|
||||
@@ -122,14 +145,14 @@ export function noteEncode(hex: string): string {
|
||||
}
|
||||
|
||||
function encodeBytes(prefix: string, hex: string): string {
|
||||
let data = secp256k1.utils.hexToBytes(hex)
|
||||
let data = hexToBytes(hex)
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode(prefix, words, Bech32MaxSize)
|
||||
}
|
||||
|
||||
export function nprofileEncode(profile: ProfilePointer): string {
|
||||
let data = encodeTLV({
|
||||
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
|
||||
0: [hexToBytes(profile.pubkey)],
|
||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
@@ -138,9 +161,9 @@ export function nprofileEncode(profile: ProfilePointer): string {
|
||||
|
||||
export function neventEncode(event: EventPointer): string {
|
||||
let data = encodeTLV({
|
||||
0: [secp256k1.utils.hexToBytes(event.id)],
|
||||
0: [hexToBytes(event.id)],
|
||||
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
|
||||
2: event.author ? [hexToBytes(event.author)] : []
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode('nevent', words, Bech32MaxSize)
|
||||
@@ -153,13 +176,21 @@ export function naddrEncode(addr: AddressPointer): string {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(addr.identifier)],
|
||||
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||
2: [secp256k1.utils.hexToBytes(addr.pubkey)],
|
||||
2: [hexToBytes(addr.pubkey)],
|
||||
3: [new Uint8Array(kind)]
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode('naddr', words, Bech32MaxSize)
|
||||
}
|
||||
|
||||
export function nrelayEncode(url: string): string {
|
||||
let data = encodeTLV({
|
||||
0: [utf8Encoder.encode(url)]
|
||||
})
|
||||
let words = bech32.toWords(data)
|
||||
return bech32.encode('nrelay', words, Bech32MaxSize)
|
||||
}
|
||||
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
@@ -173,5 +204,5 @@ function encodeTLV(tlv: TLV): Uint8Array {
|
||||
})
|
||||
})
|
||||
|
||||
return secp256k1.utils.concatBytes(...entries)
|
||||
return concatBytes(...entries)
|
||||
}
|
||||
|
||||
41
nip21.test.ts
Normal file
41
nip21.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {test as testRegex, parse} from './nip21.ts'
|
||||
|
||||
test('test()', () => {
|
||||
expect(
|
||||
testRegex(
|
||||
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
testRegex(
|
||||
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
testRegex(
|
||||
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(testRegex('nostr:')).toBe(false)
|
||||
expect(
|
||||
testRegex(
|
||||
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(testRegex('gggggg')).toBe(false)
|
||||
})
|
||||
|
||||
test('parse', () => {
|
||||
const result = parse(
|
||||
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
|
||||
}
|
||||
})
|
||||
})
|
||||
33
nip21.ts
Normal file
33
nip21.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts'
|
||||
|
||||
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||
|
||||
/** Test whether the value is a Nostr URI. */
|
||||
export function test(value: unknown): value is `nostr:${string}` {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||
)
|
||||
}
|
||||
|
||||
/** Parsed Nostr URI data. */
|
||||
export interface NostrURI {
|
||||
/** Full URI including the `nostr:` protocol. */
|
||||
uri: `nostr:${string}`
|
||||
/** The bech32-encoded data (eg `npub1...`). */
|
||||
value: string
|
||||
/** Decoded bech32 string, according to NIP-19. */
|
||||
decoded: DecodeResult
|
||||
}
|
||||
|
||||
/** Parse and decode a Nostr URI. */
|
||||
export function parse(uri: string): NostrURI {
|
||||
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
|
||||
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
||||
return {
|
||||
uri: match[0] as `nostr:${string}`,
|
||||
value: match[1],
|
||||
decoded: decode(match[1])
|
||||
}
|
||||
}
|
||||
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 */
|
||||
|
||||
const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
|
||||
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||
import {getDelegator, createDelegation} from './nip26.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
|
||||
test('parse good delegation from NIP', async () => {
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
@@ -26,7 +26,7 @@ test('parse good delegation from NIP', async () => {
|
||||
|
||||
test('parse bad delegations', async () => {
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
@@ -46,7 +46,7 @@ test('parse bad delegations', async () => {
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
|
||||
@@ -66,7 +66,7 @@ test('parse bad delegations', async () => {
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
nip26.getDelegator({
|
||||
getDelegator({
|
||||
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
|
||||
pubkey:
|
||||
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
|
||||
@@ -91,15 +91,15 @@ test('create and verify delegation', async () => {
|
||||
let pk1 = getPublicKey(sk1)
|
||||
let sk2 = generatePrivateKey()
|
||||
let pk2 = getPublicKey(sk2)
|
||||
let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
|
||||
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1})
|
||||
expect(delegation).toHaveProperty('from', pk1)
|
||||
expect(delegation).toHaveProperty('to', pk2)
|
||||
expect(delegation).toHaveProperty('cond', 'kind=1')
|
||||
|
||||
let event = {
|
||||
let event = buildEvent({
|
||||
kind: 1,
|
||||
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
|
||||
pubkey: pk2
|
||||
}
|
||||
expect(nip26.getDelegator(event)).toEqual(pk1)
|
||||
pubkey: pk2,
|
||||
})
|
||||
expect(getDelegator(event)).toEqual(pk1)
|
||||
})
|
||||
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 {Event} from './event'
|
||||
import {utf8Encoder} from './utils'
|
||||
import {getPublicKey} from './keys'
|
||||
import {utf8Encoder} from './utils.ts'
|
||||
import {getPublicKey} from './keys.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
|
||||
export type Parameters = {
|
||||
pubkey: string // the key to whom the delegation will be given
|
||||
kind: number | undefined
|
||||
until: number | undefined // delegation will only be valid until this date
|
||||
since: number | undefined // delegation will be valid from this date on
|
||||
kind?: number
|
||||
until?: number // delegation will only be valid until this date
|
||||
since?: number // delegation will be valid from this date on
|
||||
}
|
||||
|
||||
export type Delegation = {
|
||||
@@ -36,8 +38,8 @@ export function createDelegation(
|
||||
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
|
||||
)
|
||||
|
||||
let sig = secp256k1.utils.bytesToHex(
|
||||
secp256k1.schnorr.signSync(sighash, privateKey)
|
||||
let sig = bytesToHex(
|
||||
schnorr.sign(sighash, privateKey)
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -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
|
||||
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
|
||||
if (!tag) return null
|
||||
@@ -84,7 +86,7 @@ export function getDelegator(event: Event): string | null {
|
||||
let sighash = sha256(
|
||||
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
|
||||
}
|
||||
|
||||
48
nip27.test.ts
Normal file
48
nip27.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {matchAll, replaceAll} from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6'
|
||||
},
|
||||
start: 6,
|
||||
end: 75
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
|
||||
},
|
||||
start: 78,
|
||||
end: 147
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
|
||||
const result = replaceAll(content, ({decoded, value}) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
})
|
||||
63
nip27.ts
Normal file
63
nip27.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {decode} from './nip19.ts'
|
||||
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = () =>
|
||||
new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
/** Index where the URI begins in the event content. */
|
||||
start: number
|
||||
/** Index where the URI ends in the event content. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function * matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
|
||||
for (const match of matches) {
|
||||
const [uri, value] = match
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of Nostr URIs in the text.
|
||||
*
|
||||
* WARNING: using this on an HTML string is potentially unsafe!
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||
* switch(decoded.type) {
|
||||
* case 'npub':
|
||||
* return renderMention(decoded)
|
||||
* case 'note':
|
||||
* return renderNote(decoded)
|
||||
* default:
|
||||
* return value
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(
|
||||
content: string,
|
||||
replacer: (match: NostrURI) => string
|
||||
): string {
|
||||
return content.replaceAll(regex(), (uri, value) => {
|
||||
return replacer({
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-env jest */
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
const fetch = require('node-fetch')
|
||||
const {nip39} = require('./lib/nostr.cjs.js')
|
||||
import {useFetchImplementation, validateGithub} from './nip39.ts'
|
||||
|
||||
test('validate github claim', async () => {
|
||||
nip39.useFetchImplementation(fetch)
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
let result = await nip39.validateGithub(
|
||||
let result = await validateGithub(
|
||||
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||
'vitorpamplona',
|
||||
'cf19e2d1d7f8dac6348ad37b35ec8421'
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
42
nip42.ts
Normal file
42
nip42.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {Kind, type EventTemplate, type Event} from './event.ts'
|
||||
import {Relay} from './relay.ts'
|
||||
|
||||
/**
|
||||
* Authenticate via NIP-42 flow.
|
||||
*
|
||||
* @example
|
||||
* const sign = window.nostr.signEvent
|
||||
* relay.on('auth', challenge =>
|
||||
* authenticate({ relay, sign, challenge })
|
||||
* )
|
||||
*/
|
||||
export const authenticate = async ({
|
||||
challenge,
|
||||
relay,
|
||||
sign
|
||||
}: {
|
||||
challenge: string
|
||||
relay: Relay
|
||||
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
|
||||
}): Promise<void> => {
|
||||
const e: EventTemplate = {
|
||||
kind: Kind.ClientAuth,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relay.url],
|
||||
['challenge', challenge]
|
||||
],
|
||||
content: ''
|
||||
}
|
||||
const pub = relay.auth(await sign(e))
|
||||
return new Promise((resolve, reject) => {
|
||||
pub.on('ok', function ok() {
|
||||
pub.off('ok', ok)
|
||||
resolve()
|
||||
})
|
||||
pub.on('failed', function fail(reason: string) {
|
||||
pub.off('failed', fail)
|
||||
reject(reason)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
const {bech32} = require('@scure/base')
|
||||
const {
|
||||
nip57,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
finishEvent
|
||||
} = require('./lib/nostr.cjs')
|
||||
import {finishEvent} from './event.ts'
|
||||
import {getPublicKey, generatePrivateKey} from './keys.ts'
|
||||
import {
|
||||
getZapEndpoint,
|
||||
makeZapReceipt,
|
||||
makeZapRequest,
|
||||
useFetchImplementation,
|
||||
validateZapRequest,
|
||||
} from './nip57.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
|
||||
describe('getZapEndpoint', () => {
|
||||
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||
const metadata = {content: '{}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({kind: 0, content: '{}'})
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null if fetch fails', async () => {
|
||||
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
|
||||
nip57.useFetchImplementation(fetchImplementation)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||
@@ -31,10 +34,10 @@ describe('getZapEndpoint', () => {
|
||||
const fetchImplementation = jest.fn(() =>
|
||||
Promise.resolve({json: () => ({allowsNostr: false})})
|
||||
)
|
||||
nip57.useFetchImplementation(fetchImplementation)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||
@@ -52,10 +55,10 @@ describe('getZapEndpoint', () => {
|
||||
})
|
||||
})
|
||||
)
|
||||
nip57.useFetchImplementation(fetchImplementation)
|
||||
useFetchImplementation(fetchImplementation)
|
||||
|
||||
const metadata = {content: '{"lud16": "name@domain"}'}
|
||||
const result = await nip57.getZapEndpoint(metadata)
|
||||
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
|
||||
const result = await getZapEndpoint(metadata)
|
||||
|
||||
expect(result).toBe('callback')
|
||||
expect(fetchImplementation).toHaveBeenCalledWith(
|
||||
@@ -67,7 +70,8 @@ describe('getZapEndpoint', () => {
|
||||
describe('makeZapRequest', () => {
|
||||
test('throws an error if amount is not given', () => {
|
||||
expect(() =>
|
||||
nip57.makeZapRequest({
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: null,
|
||||
relays: [],
|
||||
@@ -78,7 +82,8 @@ describe('makeZapRequest', () => {
|
||||
|
||||
test('throws an error if profile is not given', () => {
|
||||
expect(() =>
|
||||
nip57.makeZapRequest({
|
||||
// @ts-expect-error
|
||||
makeZapRequest({
|
||||
event: null,
|
||||
amount: 100,
|
||||
relays: [],
|
||||
@@ -88,7 +93,7 @@ describe('makeZapRequest', () => {
|
||||
})
|
||||
|
||||
test('returns a valid Zap request', () => {
|
||||
const result = nip57.makeZapRequest({
|
||||
const result = makeZapRequest({
|
||||
profile: 'profile',
|
||||
event: 'event',
|
||||
amount: 100,
|
||||
@@ -111,7 +116,7 @@ describe('makeZapRequest', () => {
|
||||
|
||||
describe('validateZapRequest', () => {
|
||||
test('returns an error message for invalid JSON', () => {
|
||||
expect(nip57.validateZapRequest('invalid JSON')).toBe(
|
||||
expect(validateZapRequest('invalid JSON')).toBe(
|
||||
'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.'
|
||||
)
|
||||
})
|
||||
@@ -149,7 +154,7 @@ describe('validateZapRequest', () => {
|
||||
]
|
||||
}
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
'Invalid signature on zap request.'
|
||||
)
|
||||
})
|
||||
@@ -170,7 +175,7 @@ describe('validateZapRequest', () => {
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request doesn't have a 'p' tag."
|
||||
)
|
||||
})
|
||||
@@ -192,7 +197,7 @@ describe('validateZapRequest', () => {
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request 'p' tag is not valid hex."
|
||||
)
|
||||
})
|
||||
@@ -216,7 +221,7 @@ describe('validateZapRequest', () => {
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request 'e' tag is not valid hex."
|
||||
)
|
||||
})
|
||||
@@ -238,7 +243,7 @@ describe('validateZapRequest', () => {
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe(
|
||||
"Zap request doesn't have a 'relays' tag."
|
||||
)
|
||||
})
|
||||
@@ -261,7 +266,7 @@ describe('validateZapRequest', () => {
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,7 +294,7 @@ describe('makeZapReceipt', () => {
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
||||
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
@@ -322,7 +327,7 @@ describe('makeZapReceipt', () => {
|
||||
const bolt11 = 'bolt11'
|
||||
const paidAt = new Date()
|
||||
|
||||
const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
|
||||
const result = makeZapReceipt({zapRequest, bolt11, paidAt})
|
||||
|
||||
expect(result.kind).toBe(9735)
|
||||
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||
27
nip57.ts
27
nip57.ts
@@ -1,7 +1,13 @@
|
||||
import {bech32} from '@scure/base'
|
||||
|
||||
import {Event, EventTemplate, validateEvent, verifySignature} from './event'
|
||||
import {utf8Decoder} from './utils'
|
||||
import {
|
||||
Kind,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
type Event,
|
||||
type EventTemplate,
|
||||
} from './event.ts'
|
||||
import {utf8Decoder} from './utils.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -13,7 +19,9 @@ export function useFetchImplementation(fetchImplementation: any) {
|
||||
_fetch = fetchImplementation
|
||||
}
|
||||
|
||||
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
export async function getZapEndpoint(
|
||||
metadata: Event<Kind.Metadata>
|
||||
): Promise<null | string> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
let {lud06, lud16} = JSON.parse(metadata.content)
|
||||
@@ -53,11 +61,11 @@ export function makeZapRequest({
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
}): EventTemplate {
|
||||
}): EventTemplate<Kind.ZapRequest> {
|
||||
if (!amount) throw new Error('amount not given')
|
||||
if (!profile) throw new Error('profile not given')
|
||||
|
||||
let zr = {
|
||||
let zr: EventTemplate<Kind.ZapRequest> = {
|
||||
kind: 9734,
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: comment,
|
||||
@@ -86,6 +94,7 @@ export function validateZapRequest(zapRequestString: string): string | null {
|
||||
|
||||
if (!validateEvent(zapRequest))
|
||||
return 'Zap request is not a valid Nostr event.'
|
||||
|
||||
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
|
||||
|
||||
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||
@@ -110,16 +119,16 @@ export function makeZapReceipt({
|
||||
paidAt
|
||||
}: {
|
||||
zapRequest: string
|
||||
preimage: string | null
|
||||
preimage?: string
|
||||
bolt11: string
|
||||
paidAt: Date
|
||||
}): EventTemplate {
|
||||
let zr: Event = JSON.parse(zapRequest)
|
||||
}): EventTemplate<Kind.Zap> {
|
||||
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
|
||||
let tagsFromZapRequest = zr.tags.filter(
|
||||
([t]) => t === 'e' || t === 'p' || t === 'a'
|
||||
)
|
||||
|
||||
let zap = {
|
||||
let zap: EventTemplate<Kind.Zap> = {
|
||||
kind: 9735,
|
||||
created_at: Math.round(paidAt.getTime() / 1000),
|
||||
content: '',
|
||||
|
||||
35
package.json
35
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nostr-tools",
|
||||
"version": "1.8.2",
|
||||
"version": "1.11.1",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -14,16 +14,16 @@
|
||||
"module": "lib/esm/nostr.mjs",
|
||||
"exports": {
|
||||
"import": "./lib/esm/nostr.mjs",
|
||||
"require": "./lib/nostr.cjs.js"
|
||||
"require": "./lib/nostr.cjs.js",
|
||||
"types": "./lib/index.d.ts"
|
||||
},
|
||||
"license": "Public domain",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.0.0",
|
||||
"@noble/secp256k1": "^1.7.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^1.1.5",
|
||||
"@scure/bip39": "^1.1.1",
|
||||
"prettier": "^2.8.4"
|
||||
"@noble/curves": "1.0.0",
|
||||
"@noble/hashes": "1.3.0",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.0",
|
||||
"@scure/bip39": "1.2.0"
|
||||
},
|
||||
"keywords": [
|
||||
"decentralization",
|
||||
@@ -32,21 +32,30 @@
|
||||
"client",
|
||||
"nostr"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"esbuild": "0.16.9",
|
||||
"esbuild-plugin-alias": "^0.2.1",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-jest": "^27.2.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"jest": "^29.4.2",
|
||||
"jest": "^29.5.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"ts-jest": "^29.0.5",
|
||||
"prettier": "^2.8.4",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.0.4",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
import 'websocket-polyfill'
|
||||
|
||||
require('websocket-polyfill')
|
||||
const {
|
||||
SimplePool,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
signEvent
|
||||
} = require('./lib/nostr.cjs')
|
||||
import {finishEvent, type Event} from './event.ts'
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import {SimplePool} from './pool.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
|
||||
@@ -33,7 +28,7 @@ test('removing duplicates when querying', async () => {
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
let sub = pool.sub(relays, [{authors: [pub]}])
|
||||
let received = []
|
||||
let received: Event[] = []
|
||||
|
||||
sub.on('event', event => {
|
||||
// this should be called only once even though we're listening
|
||||
@@ -42,15 +37,12 @@ test('removing duplicates when querying', async () => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = {
|
||||
pubkey: pub,
|
||||
let event = finishEvent({
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test',
|
||||
kind: 22345,
|
||||
tags: []
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, priv)
|
||||
}, priv)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
@@ -66,7 +58,7 @@ test('same with double querying', async () => {
|
||||
let sub1 = pool.sub(relays, [{authors: [pub]}])
|
||||
let sub2 = pool.sub(relays, [{authors: [pub]}])
|
||||
|
||||
let received = []
|
||||
let received: Event[] = []
|
||||
|
||||
sub1.on('event', event => {
|
||||
received.push(event)
|
||||
@@ -76,15 +68,12 @@ test('same with double querying', async () => {
|
||||
received.push(event)
|
||||
})
|
||||
|
||||
let event = {
|
||||
pubkey: pub,
|
||||
let event = finishEvent({
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
content: 'test2',
|
||||
kind: 22346,
|
||||
tags: []
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, priv)
|
||||
}, priv)
|
||||
|
||||
pool.publish(relays, event)
|
||||
|
||||
@@ -122,6 +111,7 @@ test('list()', async () => {
|
||||
expect(events.length).toEqual(
|
||||
events
|
||||
.map(evt => evt.id)
|
||||
// @ts-ignore ???
|
||||
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
|
||||
.length
|
||||
)
|
||||
41
pool.ts
41
pool.ts
@@ -1,9 +1,14 @@
|
||||
import {Relay, relayInit} from './relay'
|
||||
import {normalizeURL} from './utils'
|
||||
import {Filter} from './filter'
|
||||
import {Event} from './event'
|
||||
import {SubscriptionOptions, Sub, Pub} from './relay'
|
||||
import {
|
||||
relayInit,
|
||||
type Pub,
|
||||
type Relay,
|
||||
type Sub,
|
||||
type SubscriptionOptions,
|
||||
} from './relay.ts'
|
||||
import {normalizeURL} from './utils.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
import type {Filter} from './filter.ts'
|
||||
export class SimplePool {
|
||||
private _conn: {[url: string]: Relay}
|
||||
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
|
||||
@@ -39,7 +44,7 @@ export class SimplePool {
|
||||
return relay
|
||||
}
|
||||
|
||||
sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
|
||||
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
|
||||
let _knownIds: Set<string> = new Set()
|
||||
let modifiedOpts = {...(opts || {})}
|
||||
modifiedOpts.alreadyHaveEvent = (id, url) => {
|
||||
@@ -53,7 +58,7 @@ export class SimplePool {
|
||||
}
|
||||
|
||||
let subs: Sub[] = []
|
||||
let eventListeners: Set<(event: Event) => void> = new Set()
|
||||
let eventListeners: Set<any> = new Set()
|
||||
let eoseListeners: Set<() => void> = new Set()
|
||||
let eosesMissing = relays.length
|
||||
|
||||
@@ -73,7 +78,7 @@ export class SimplePool {
|
||||
}
|
||||
if (!r) return
|
||||
let s = r.sub(filters, modifiedOpts)
|
||||
s.on('event', (event: Event) => {
|
||||
s.on('event', (event) => {
|
||||
_knownIds.add(event.id as string)
|
||||
for (let cb of eventListeners.values()) cb(event)
|
||||
})
|
||||
@@ -118,18 +123,18 @@ export class SimplePool {
|
||||
return greaterSub
|
||||
}
|
||||
|
||||
get(
|
||||
get<K extends number = number>(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
filter: Filter<K>,
|
||||
opts?: SubscriptionOptions
|
||||
): Promise<Event | null> {
|
||||
): Promise<Event<K> | null> {
|
||||
return new Promise(resolve => {
|
||||
let sub = this.sub(relays, [filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
sub.unsub()
|
||||
resolve(null)
|
||||
}, this.getTimeout)
|
||||
sub.on('event', (event: Event) => {
|
||||
sub.on('event', (event) => {
|
||||
resolve(event)
|
||||
clearTimeout(timeout)
|
||||
sub.unsub()
|
||||
@@ -137,16 +142,16 @@ export class SimplePool {
|
||||
})
|
||||
}
|
||||
|
||||
list(
|
||||
list<K extends number = number>(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
filters: Filter<K>[],
|
||||
opts?: SubscriptionOptions
|
||||
): Promise<Event[]> {
|
||||
): Promise<Event<K>[]> {
|
||||
return new Promise(resolve => {
|
||||
let events: Event[] = []
|
||||
let events: Event<K>[] = []
|
||||
let sub = this.sub(relays, filters, opts)
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
sub.on('event', (event) => {
|
||||
events.push(event)
|
||||
})
|
||||
|
||||
@@ -158,7 +163,7 @@ export class SimplePool {
|
||||
})
|
||||
}
|
||||
|
||||
publish(relays: string[], event: Event): Pub {
|
||||
publish(relays: string[], event: Event<number>): Pub {
|
||||
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
|
||||
let r
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {parseReferences} = require('./lib/nostr.cjs')
|
||||
import {parseReferences} from './references.ts'
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
|
||||
test('parse mentions', () => {
|
||||
let evt = {
|
||||
let evt = buildEvent({
|
||||
tags: [
|
||||
[
|
||||
'p',
|
||||
@@ -23,8 +22,8 @@ test('parse mentions', () => {
|
||||
]
|
||||
],
|
||||
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([
|
||||
{
|
||||
@@ -1,5 +1,11 @@
|
||||
import {Event} from './event'
|
||||
import {decode, AddressPointer, ProfilePointer, EventPointer} from './nip19'
|
||||
import {
|
||||
decode,
|
||||
type AddressPointer,
|
||||
type ProfilePointer,
|
||||
type EventPointer,
|
||||
} from './nip19.ts'
|
||||
|
||||
import type {Event} from './event.ts'
|
||||
|
||||
type Reference = {
|
||||
text: string
|
||||
@@ -81,7 +87,7 @@ export function parseReferences(evt: Event): Reference[] {
|
||||
}
|
||||
case 'a': {
|
||||
try {
|
||||
let [kind, pubkey, identifier] = ref[1].split(':')
|
||||
let [kind, pubkey, identifier] = tag[1].split(':')
|
||||
references.push({
|
||||
text: ref[0],
|
||||
address: {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/* eslint-env jest */
|
||||
import 'websocket-polyfill'
|
||||
|
||||
require('websocket-polyfill')
|
||||
const {
|
||||
relayInit,
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
signEvent
|
||||
} = require('./lib/nostr.cjs')
|
||||
import {finishEvent} from './event.ts'
|
||||
import {generatePrivateKey, getPublicKey} from './keys.ts'
|
||||
import {relayInit} from './relay.ts'
|
||||
|
||||
let relay = relayInit('wss://relay.damus.io/')
|
||||
|
||||
@@ -33,8 +28,8 @@ test('connectivity', () => {
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
var resolve1
|
||||
var resolve2
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
@@ -53,10 +48,10 @@ test('querying', async () => {
|
||||
})
|
||||
|
||||
let [t1, t2] = await Promise.all([
|
||||
new Promise(resolve => {
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve1 = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
new Promise<boolean>(resolve => {
|
||||
resolve2 = resolve
|
||||
})
|
||||
])
|
||||
@@ -93,8 +88,8 @@ test('list()', async () => {
|
||||
test('listening (twice) and publishing', async () => {
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
var resolve1
|
||||
var resolve2
|
||||
var resolve1: (value: boolean) => void
|
||||
var resolve2: (value: boolean) => void
|
||||
|
||||
let sub = relay.sub([
|
||||
{
|
||||
@@ -116,15 +111,12 @@ test('listening (twice) and publishing', async () => {
|
||||
resolve2(true)
|
||||
})
|
||||
|
||||
let event = {
|
||||
let event = finishEvent({
|
||||
kind: 27572,
|
||||
pubkey: pk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite'
|
||||
}
|
||||
event.id = getEventHash(event)
|
||||
event.sig = signEvent(event, sk)
|
||||
}, sk)
|
||||
|
||||
relay.publish(event)
|
||||
return expect(
|
||||
166
relay.ts
166
relay.ts
@@ -1,17 +1,22 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import {Event, verifySignature, validateEvent} from './event'
|
||||
import {Filter, matchFilters} from './filter'
|
||||
import {getHex64, getSubscriptionId} from './fakejson'
|
||||
import {verifySignature, validateEvent, type Event} from './event.ts'
|
||||
import {matchFilters, type Filter} from './filter.ts'
|
||||
import {getHex64, getSubscriptionId} from './fakejson.ts'
|
||||
|
||||
type RelayEvent = {
|
||||
connect: () => void | Promise<void>
|
||||
disconnect: () => void | Promise<void>
|
||||
error: () => void | Promise<void>
|
||||
notice: (msg: string) => void | Promise<void>
|
||||
auth: (challenge: string) => void | Promise<void>
|
||||
}
|
||||
type SubEvent = {
|
||||
event: (event: Event) => void | Promise<void>
|
||||
export type CountPayload = {
|
||||
count: number
|
||||
}
|
||||
type SubEvent<K extends number> = {
|
||||
event: (event: Event<K>) => void | Promise<void>
|
||||
count: (payload: CountPayload) => void | Promise<void>
|
||||
eose: () => void | Promise<void>
|
||||
}
|
||||
export type Relay = {
|
||||
@@ -19,10 +24,15 @@ export type Relay = {
|
||||
status: number
|
||||
connect: () => Promise<void>
|
||||
close: () => void
|
||||
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
|
||||
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
|
||||
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
|
||||
publish: (event: Event) => Pub
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
|
||||
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
|
||||
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
|
||||
count: (
|
||||
filters: Filter[],
|
||||
opts?: SubscriptionOptions
|
||||
) => Promise<CountPayload | null>
|
||||
publish: (event: Event<number>) => Pub
|
||||
auth: (event: Event<number>) => Pub
|
||||
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
@@ -36,14 +46,14 @@ export type Pub = {
|
||||
on: (type: 'ok' | 'failed', cb: any) => void
|
||||
off: (type: 'ok' | 'failed', cb: any) => void
|
||||
}
|
||||
export type Sub = {
|
||||
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
|
||||
export type Sub<K extends number = number> = {
|
||||
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
|
||||
unsub: () => void
|
||||
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
|
||||
event: T,
|
||||
listener: U
|
||||
) => void
|
||||
@@ -51,29 +61,34 @@ export type Sub = {
|
||||
|
||||
export type SubscriptionOptions = {
|
||||
id?: string
|
||||
verb?: 'REQ' | 'COUNT'
|
||||
skipVerification?: boolean
|
||||
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
|
||||
}
|
||||
|
||||
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: [],
|
||||
auth: []
|
||||
})
|
||||
|
||||
export function relayInit(
|
||||
url: string,
|
||||
options: {
|
||||
getTimeout?: number
|
||||
listTimeout?: number
|
||||
countTimeout?: number
|
||||
} = {}
|
||||
): Relay {
|
||||
let {listTimeout = 3000, getTimeout = 3000} = options
|
||||
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options
|
||||
|
||||
var ws: WebSocket
|
||||
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
|
||||
var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
error: [],
|
||||
notice: []
|
||||
}
|
||||
var listeners = newListeners()
|
||||
var subListeners: {
|
||||
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
|
||||
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]}
|
||||
} = {}
|
||||
var pubListeners: {
|
||||
[eventid: string]: {
|
||||
@@ -146,7 +161,7 @@ export function relayInit(
|
||||
// will naturally be caught by the encompassing try..catch block
|
||||
|
||||
switch (data[0]) {
|
||||
case 'EVENT':
|
||||
case 'EVENT': {
|
||||
let id = data[1]
|
||||
let event = data[2]
|
||||
if (
|
||||
@@ -159,6 +174,14 @@ export function relayInit(
|
||||
;(subListeners[id]?.event || []).forEach(cb => cb(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'COUNT':
|
||||
let id = data[1]
|
||||
let payload = data[2]
|
||||
if (openSubs[id]) {
|
||||
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
|
||||
}
|
||||
return
|
||||
case 'EOSE': {
|
||||
let id = data[1]
|
||||
if (id in subListeners) {
|
||||
@@ -183,6 +206,11 @@ export function relayInit(
|
||||
let notice = data[1]
|
||||
listeners.notice.forEach(cb => cb(notice))
|
||||
return
|
||||
case 'AUTH': {
|
||||
let challenge = data[1]
|
||||
listeners.auth?.forEach(cb => cb(challenge))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return
|
||||
@@ -217,14 +245,15 @@ export function relayInit(
|
||||
}
|
||||
}
|
||||
|
||||
const sub = (
|
||||
filters: Filter[],
|
||||
const sub = <K extends number = number>(
|
||||
filters: Filter<K>[],
|
||||
{
|
||||
verb = 'REQ',
|
||||
skipVerification = false,
|
||||
alreadyHaveEvent = null,
|
||||
id = Math.random().toString().slice(2)
|
||||
}: SubscriptionOptions = {}
|
||||
): Sub => {
|
||||
): Sub<K> => {
|
||||
let subid = id
|
||||
|
||||
openSubs[subid] = {
|
||||
@@ -233,7 +262,7 @@ export function relayInit(
|
||||
skipVerification,
|
||||
alreadyHaveEvent
|
||||
}
|
||||
trySend(['REQ', subid, ...filters])
|
||||
trySend([verb, subid, ...filters])
|
||||
|
||||
return {
|
||||
sub: (newFilters, newOpts = {}) =>
|
||||
@@ -247,20 +276,15 @@ export function relayInit(
|
||||
delete subListeners[subid]
|
||||
trySend(['CLOSE', subid])
|
||||
},
|
||||
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
on: (type, cb) => {
|
||||
subListeners[subid] = subListeners[subid] || {
|
||||
event: [],
|
||||
count: [],
|
||||
eose: []
|
||||
}
|
||||
subListeners[subid][type].push(cb)
|
||||
},
|
||||
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
|
||||
type: T,
|
||||
cb: U
|
||||
): void => {
|
||||
off: (type, cb): void => {
|
||||
let listeners = subListeners[subid]
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
@@ -268,6 +292,29 @@ export function relayInit(
|
||||
}
|
||||
}
|
||||
|
||||
function _publishEvent(event: Event<number>, type: string) {
|
||||
if (!event.id) throw new Error(`event ${event} has no id`)
|
||||
let id = event.id
|
||||
|
||||
trySend([type, event])
|
||||
|
||||
return {
|
||||
on: (type: 'ok' | 'failed', cb: any) => {
|
||||
pubListeners[id] = pubListeners[id] || {
|
||||
ok: [],
|
||||
failed: []
|
||||
}
|
||||
pubListeners[id][type].push(cb)
|
||||
},
|
||||
off: (type: 'ok' | 'failed', cb: any) => {
|
||||
let listeners = pubListeners[id]
|
||||
if (!listeners) return
|
||||
let idx = listeners[type].indexOf(cb)
|
||||
if (idx >= 0) listeners[type].splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sub,
|
||||
@@ -288,10 +335,10 @@ export function relayInit(
|
||||
let index = listeners[type].indexOf(cb)
|
||||
if (index !== -1) listeners[type].splice(index, 1)
|
||||
},
|
||||
list: (filters: Filter[], opts?: SubscriptionOptions): Promise<Event[]> =>
|
||||
list: (filters, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, opts)
|
||||
let events: Event[] = []
|
||||
let events: Event<any>[] = []
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(events)
|
||||
@@ -301,48 +348,45 @@ export function relayInit(
|
||||
clearTimeout(timeout)
|
||||
resolve(events)
|
||||
})
|
||||
s.on('event', (event: Event) => {
|
||||
s.on('event', (event) => {
|
||||
events.push(event)
|
||||
})
|
||||
}),
|
||||
get: (filter: Filter, opts?: SubscriptionOptions): Promise<Event | null> =>
|
||||
get: (filter, opts?: SubscriptionOptions) =>
|
||||
new Promise(resolve => {
|
||||
let s = sub([filter], opts)
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, getTimeout)
|
||||
s.on('event', (event: Event) => {
|
||||
s.on('event', (event) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
publish(event: Event): Pub {
|
||||
if (!event.id) throw new Error(`event ${event} has no id`)
|
||||
let id = event.id
|
||||
|
||||
trySend(['EVENT', event])
|
||||
|
||||
return {
|
||||
on: (type: 'ok' | 'failed', cb: any) => {
|
||||
pubListeners[id] = pubListeners[id] || {
|
||||
ok: [],
|
||||
failed: []
|
||||
}
|
||||
pubListeners[id][type].push(cb)
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
count: (filters: Filter[]): Promise<CountPayload | null> =>
|
||||
new Promise(resolve => {
|
||||
let s = sub(filters, {...sub, verb: 'COUNT'})
|
||||
let timeout = setTimeout(() => {
|
||||
s.unsub()
|
||||
resolve(null)
|
||||
}, countTimeout)
|
||||
s.on('count', (event: CountPayload) => {
|
||||
s.unsub()
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
}),
|
||||
publish(event): Pub {
|
||||
return _publishEvent(event, 'EVENT')
|
||||
},
|
||||
auth(event): Pub {
|
||||
return _publishEvent(event, 'AUTH')
|
||||
},
|
||||
connect,
|
||||
close(): void {
|
||||
listeners = {connect: [], disconnect: [], error: [], notice: []}
|
||||
listeners = newListeners()
|
||||
subListeners = {}
|
||||
pubListeners = {}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
|
||||
17
test-helpers.ts
Normal file
17
test-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {Event} from './event.ts'
|
||||
|
||||
type EventParams<K extends number> = Partial<Event<K>>
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
|
||||
return {
|
||||
id: '',
|
||||
kind: 1 as K,
|
||||
pubkey: '',
|
||||
created_at: 0,
|
||||
content: '',
|
||||
tags: [],
|
||||
sig: '',
|
||||
...params
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"esModuleInterop": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": "."
|
||||
"rootDir": ".",
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
|
||||
183
utils.test.js
183
utils.test.js
@@ -1,183 +0,0 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
const {utils} = require('./lib/nostr.cjs')
|
||||
|
||||
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
|
||||
|
||||
describe('inserting into a desc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0 = []
|
||||
expect(
|
||||
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [{created_at: 20}, {created_at: 10}]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 30},
|
||||
{created_at: 20},
|
||||
{created_at: 10},
|
||||
{created_at: 1}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 15
|
||||
})
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 10}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 5
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 10}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 10, id: 'abc'}
|
||||
]
|
||||
const list1 = insertEventIntoDescendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inserting into a asc sorted list of events', () => {
|
||||
test('insert into an empty list', async () => {
|
||||
const list0 = []
|
||||
expect(
|
||||
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list', async () => {
|
||||
const list0 = [{created_at: 10}, {created_at: 20}]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 1
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the beginning of a list with same created_at', async () => {
|
||||
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 10
|
||||
})
|
||||
expect(list1).toHaveLength(4)
|
||||
expect(list1[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the middle of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 10},
|
||||
{created_at: 20},
|
||||
{created_at: 30},
|
||||
{created_at: 40}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 25
|
||||
})
|
||||
expect(list1).toHaveLength(5)
|
||||
expect(list1[2].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the end of a list', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 40}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 50
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 30}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(6)
|
||||
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||
})
|
||||
|
||||
test('do not insert duplicates', async () => {
|
||||
const list0 = [
|
||||
{created_at: 20},
|
||||
{created_at: 20},
|
||||
{created_at: 30, id: 'abc'}
|
||||
]
|
||||
const list1 = insertEventIntoAscendingList(list0, {
|
||||
id: 'abc',
|
||||
created_at: 30
|
||||
})
|
||||
expect(list1).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
193
utils.test.ts
Normal file
193
utils.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import {buildEvent} from './test-helpers.ts'
|
||||
import {
|
||||
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)
|
||||
})
|
||||
})
|
||||
10
utils.ts
10
utils.ts
@@ -1,4 +1,4 @@
|
||||
import {Event} from './event'
|
||||
import type {Event} from './event.ts'
|
||||
|
||||
export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
@@ -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
|
||||
//
|
||||
export function insertEventIntoDescendingList(
|
||||
sortedArray: Event[],
|
||||
event: Event
|
||||
sortedArray: Event<number>[],
|
||||
event: Event<number>
|
||||
) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
@@ -66,8 +66,8 @@ export function insertEventIntoDescendingList(
|
||||
}
|
||||
|
||||
export function insertEventIntoAscendingList(
|
||||
sortedArray: Event[],
|
||||
event: Event
|
||||
sortedArray: Event<number>[],
|
||||
event: Event<number>
|
||||
) {
|
||||
let start = 0
|
||||
let end = sortedArray.length - 1
|
||||
|
||||
Reference in New Issue
Block a user