Compare commits

...

35 Commits

Author SHA1 Message Date
fiatjaf
aec8ff5946 fix for updated typescript. 2025-04-02 11:44:41 -03:00
fiatjaf
e498c9144d nip46: auto-reconnect. 2025-04-02 10:58:26 -03:00
fiatjaf
42d47abba1 update readme and add more examples. 2025-04-02 10:53:33 -03:00
fiatjaf
303c35120c pool: deprecate subscribeManyMap and introduce subscribe/subscribeEose methods that take a single filter. 2025-04-02 10:37:10 -03:00
fiatjaf
4a738c93d0 nip46: stop supporting nip04-encrypted messages. 2025-04-02 10:25:19 -03:00
fiatjaf
2a11c9ec91 nip04: functions shouldn't be async. 2025-04-02 10:19:27 -03:00
fiatjaf
cbe3a9d683 pool subscribe methods accept an onauth param. 2025-04-01 19:16:42 -03:00
fiatjaf
2944a932b8 nip46: mark connection as closed when relays disconnect. 2025-03-29 18:03:39 -03:00
codytseng
6b39de04d7 Fix auth() not returning on consecutive calls 2025-03-17 13:31:24 -03:00
fiatjaf
9a612e59a2 update nip11 test. 2025-03-14 09:30:35 -03:00
fiatjaf
266dbdf766 nip27: rewrite to support urls and references in a simpler API for rich UIs. 2025-03-14 09:26:40 -03:00
fiatjaf
19ae9837a7 nip19: decodeNostrURI() function that doesn't throw. 2025-03-14 09:26:40 -03:00
António Conselheiro
4188f2c596 Generic repost 2025-03-10 01:58:00 -03:00
fiatjaf
97bded8f5b prevent a relay from eoseing then closing and causing pool handlers to fire twice. 2025-03-02 01:25:39 -03:00
fiatjaf
174d36a440 nip07: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
0177b130c3 nip55: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
05eb62da5b support subscription label, not only an absolute id. 2025-03-02 01:25:39 -03:00
Baris Aydek
3c4019a154 nip54 normalizeIdentifier function 2025-02-25 13:52:40 -03:00
fiatjaf
e7e8db1dbd nip46: take EventTemplate instead of UnsignedEvent. 2025-02-24 14:48:47 -03:00
bitcoinpirate
44a679e642 added support for zapping replaceable events (#424)
* added support for zapping replaceable events

* Update nip57.ts

* Update nip57.ts

Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>

* apply @SnowCait's suggestions.

* fix lint error.

---------

Co-authored-by: AsaiToshiya <to.asai.60@gmail.com>
Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>
2025-02-24 00:46:51 +09:00
Asai Toshiya
c1172caf1d mark getRelays and get_relays as deprecated. 2025-02-21 15:08:55 -03:00
Jon Staab
86f37d6003 Clean up nip96 upload validation and make it less strict 2025-02-11 15:58:20 -03:00
Sandwich
3daade322c export retention details
pain to use without being available as an export.
2025-02-10 09:25:33 -03:00
Asai Toshiya
fcf10541c8 rename "parameterized replaceable" to "addressable". 2025-01-23 14:08:22 -03:00
Asai Toshiya
548abb5d4a nip18: tweak test data. 2025-01-23 20:03:52 +09:00
Asai Toshiya
1e5bfe856b nip18: don't stringify protected event. 2025-01-17 21:30:09 -03:00
Anderson Juhasc
3266b4d4c2 added NIP-55 2025-01-04 14:15:11 -03:00
Asai Toshiya
a0b950ab12 remove unnecessary id from Omit keys. 2025-01-02 15:57:39 -03:00
Asai Toshiya
be741159d7 nip29: update GroupAdminPermission. 2024-12-17 13:33:00 -03:00
im-adithya
9c50b2c655 fix: clear timeout in publish and auth 2024-12-03 11:09:11 -03:00
Egge
bbb09420fe export nip17 2024-11-26 11:59:58 -03:00
Asai Toshiya
2e85f7a5fe Revert "nip19: remove note1."
This reverts commit a8a805fb71.
2024-11-26 11:59:58 -03:00
Asai Toshiya
b22e2465cc nip19: remove note1. 2024-11-26 11:59:58 -03:00
fiatjaf
43ce7f9377 fix reference to nostr-wasm dependency so it can be installed on deno.
fixes https://github.com/nbd-wtf/nostr-tools/issues/459
2024-11-25 21:33:25 -03:00
fiatjaf
5a55c670fb nip10: fix. 2024-11-13 01:21:54 -03:00
27 changed files with 1101 additions and 321 deletions

207
README.md
View File

@@ -57,43 +57,43 @@ let event = finalizeEvent({
let isGood = verifyEvent(event) let isGood = verifyEvent(event)
``` ```
### Interacting with a relay ### Interacting with one or multiple relays
Doesn't matter what you do, you always should be using a `SimplePool`:
```js ```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure' import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { SimplePool } from 'nostr-tools/pool'
const relay = await Relay.connect('wss://relay.example.com') const pool = new SimplePool()
console.log(`connected to ${relay.url}`)
// let's query for an event that exists // let's query for an event that exists
const sub = relay.subscribe([ const event = relay.get(
['wss://relay.example.com'],
{ {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'], ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
}, },
], { )
onevent(event) { if (event) {
console.log('we got the event we wanted:', event) console.log('it exists indeed on this relay:', event)
}, }
oneose() {
sub.close()
}
})
// let's publish a new event while simultaneously monitoring the relay for it // let's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey() let sk = generateSecretKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)
relay.subscribe([ pool.subscribe(
['wss://a.com', 'wss://b.com', 'wss://c.com'],
{ {
kinds: [1], kinds: [1],
authors: [pk], authors: [pk],
}, },
], { {
onevent(event) { onevent(event) {
console.log('got event:', event) console.log('got event:', event)
}
} }
}) )
let eventTemplate = { let eventTemplate = {
kind: 1, kind: 1,
@@ -104,7 +104,7 @@ let eventTemplate = {
// this assigns the pubkey, calculates the event id and signs the event in a single step // this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk) const signedEvent = finalizeEvent(eventTemplate, sk)
await relay.publish(signedEvent) await pool.publish(['wss://a.com', 'wss://b.com'], signedEvent)
relay.close() relay.close()
``` ```
@@ -119,59 +119,116 @@ import WebSocket from 'ws'
useWebSocketImplementation(WebSocket) useWebSocketImplementation(WebSocket)
``` ```
### Interacting with multiple relays ### Parsing references (mentions) from a content based on NIP-27
```js ```js
import { SimplePool } from 'nostr-tools/pool' import * as nip27 from '@nostr/tools/nip27'
const pool = new SimplePool() for (let block of nip27.parse(evt.content)) {
switch (block.type) {
let relays = ['wss://relay.example.com', 'wss://relay.example2.com'] case 'text':
console.log(block.text)
let h = pool.subscribeMany( break
[...relays, 'wss://relay.example3.com'], case 'reference': {
[ if ('id' in block.pointer) {
{ console.log("it's a nevent1 uri", block.pointer)
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'], } else if ('identifier' in block.pointer) {
}, console.log("it's a naddr1 uri", block.pointer)
], } else {
{ console.log("it's an npub1 or nprofile1 uri", block.pointer)
onevent(event) { }
// this will only be called once the first time the event is received break
// ...
},
oneose() {
h.close()
} }
case 'url': {
console.log("it's a normal url:", block.url)
break
}
case 'image':
case 'video':
case 'audio':
console.log("it's a media url:", block.url)
case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url)
default:
break
} }
) }
await Promise.any(pool.publish(relays, newEvent))
console.log('published to at least one relay!')
let events = await pool.querySync(relays, { kinds: [0, 1] })
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
``` ```
### Parsing references (mentions) from a content using NIP-10 and NIP-27 ### Connecting to a bunker using NIP-46
```js ```js
import { parseReferences } from 'nostr-tools/references' import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
let references = parseReferences(event) // the client needs a local secret key (which is generally persisted) for communicating with the bunker
let simpleAugmentedContent = event.content const localSecretKey = generateSecretKey()
for (let i = 0; i < references.length; i++) {
let { text, profile, event, address } = references[i] // parse a bunker URI
let augmentedReference = profile const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
? `<strong>@${profilesCache[profile.pubkey].name}</strong>` if (!bunkerPointer) {
: event throw new Error('Invalid bunker input')
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>` }
: address
? `<a href="${text}">[link]</a>` // create the bunker instance
: text const pool = new SimplePool()
simpleAugmentedContent.replaceAll(text, augmentedReference) const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
await bunker.connect()
// and use it
const pubkey = await bunker.getPublicKey()
const event = await bunker.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello from bunker!'
})
// cleanup
await signer.close()
pool.close([])
```
### Parsing thread from any note based on NIP-10
```js
import * as nip10 from '@nostr/tools/nip10'
// event is a nostr event with tags
const refs = nip10.parse(event)
// get the root event of the thread
if (refs.root) {
console.log('root event:', refs.root.id)
console.log('root event relay hints:', refs.root.relays)
console.log('root event author:', refs.root.author)
}
// get the immediate parent being replied to
if (refs.reply) {
console.log('reply to:', refs.reply.id)
console.log('reply relay hints:', refs.reply.relays)
console.log('reply author:', refs.reply.author)
}
// get any mentioned events
for (let mention of refs.mentions) {
console.log('mentioned event:', mention.id)
console.log('mention relay hints:', mention.relays)
console.log('mention author:', mention.author)
}
// get any quoted events
for (let quote of refs.quotes) {
console.log('quoted event:', quote.id)
console.log('quote relay hints:', quote.relays)
}
// get any referenced profiles
for (let profile of refs.profiles) {
console.log('referenced profile:', profile.pubkey)
console.log('profile relay hints:', profile.relays)
} }
``` ```
@@ -205,32 +262,6 @@ declare global {
} }
``` ```
### Generating NIP-06 keys
```js
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey
} from 'nostr-tools/nip06'
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' // optional
const accountIndex = 0
const sk0 = privateKeyFromSeedWords(mnemonic, passphrase, accountIndex)
const { privateKey: sk1, publicKey: pk1 } = accountFromSeedWords(mnemonic, passphrase, accountIndex)
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(mnemonic, passphrase, extendedAccountIndex)
const { privateKey: sk2, publicKey: pk2 } = accountFromExtendedKey(privateExtendedKey)
const { publicKey: pk3 } = accountFromExtendedKey(publicExtendedKey)
```
### Encoding and decoding NIP-19 codes ### Encoding and decoding NIP-19 codes
```js ```js

View File

@@ -8,7 +8,7 @@ import {
} from './abstract-relay.ts' } from './abstract-relay.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
import type { Event, Nostr } from './core.ts' import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts' import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts' import { alwaysTrue } from './helpers.ts'
@@ -16,10 +16,12 @@ export type SubCloser = { close: () => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {} export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string id?: string
label?: string
} }
export class AbstractSimplePool { export class AbstractSimplePool {
@@ -60,10 +62,127 @@ export class AbstractSimplePool {
}) })
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
return this.subscribeManyMap(Object.fromEntries(relays.map(url => [url, filters])), params) return this.subscribeMap(
relays.map(url => ({ url, filter })),
params,
)
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
return this.subscribeMap(
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
params,
)
}
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id)
if (!set) {
set = new Set()
this.seenOn.set(id, set)
}
set.add(relay)
}
}
const _knownIds = new Set<string>()
const subs: Subscription[] = []
// batch all EOSEs into a single
const eosesReceived: boolean[] = []
let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === requests.length) {
params.oneose?.()
handleEose = () => {}
}
}
// batch all closes into a single
const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i)
closesReceived[i] = reason
if (closesReceived.filter(a => a).length === requests.length) {
params.onclose?.(closesReceived)
handleClose = () => {}
}
}
const localAlreadyHaveEventHandler = (id: string) => {
if (params.alreadyHaveEvent?.(id)) {
return true
}
const have = _knownIds.has(id)
_knownIds.add(id)
return have
}
// open a subscription in all given relays
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => {
url = normalizeURL(url)
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
})
} catch (err) {
handleClose(i, (err as any)?.message || String(err))
return
}
let subscription = relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
if (reason.startsWith('auth-required:') && params.doauth) {
relay
.auth(params.doauth)
.then(() => {
relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
subs.push(subscription)
}),
)
return {
async close() {
await allOpened
subs.forEach(sub => {
sub.close()
})
},
}
}
/**
* @deprecated Use subscribeMap instead.
*/
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser { subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser {
if (this.trackRelays) { if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => { params.receivedEvent = (relay: AbstractRelay, id: string) => {
@@ -83,6 +202,7 @@ export class AbstractSimplePool {
// batch all EOSEs into a single // batch all EOSEs into a single
const eosesReceived: boolean[] = [] const eosesReceived: boolean[] = []
let handleEose = (i: number) => { let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relaysLength) { if (eosesReceived.filter(a => a).length === relaysLength) {
params.oneose?.() params.oneose?.()
@@ -92,6 +212,7 @@ export class AbstractSimplePool {
// batch all closes into a single // batch all closes into a single
const closesReceived: string[] = [] const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => { let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i) handleEose(i)
closesReceived[i] = reason closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relaysLength) { if (closesReceived.filter(a => a).length === relaysLength) {
@@ -134,7 +255,28 @@ export class AbstractSimplePool {
let subscription = relay.subscribe(filters, { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => handleClose(i, reason), onclose: reason => {
if (reason.startsWith('auth-required:') && params.doauth) {
relay
.auth(params.doauth)
.then(() => {
relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler, alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait, eoseTimeout: params.maxWait,
}) })
@@ -153,10 +295,24 @@ export class AbstractSimplePool {
} }
} }
subscribeEose(
relays: string[],
filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>,
): SubCloser {
const subcloser = this.subscribe(relays, filter, {
...params,
oneose() {
subcloser.close()
},
})
return subcloser
}
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filters: Filter[],
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribeMany(relays, filters, {
...params, ...params,
@@ -170,11 +326,11 @@ export class AbstractSimplePool {
async querySync( async querySync(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> { ): Promise<Event[]> {
return new Promise(async resolve => { return new Promise(async resolve => {
const events: Event[] = [] const events: Event[] = []
this.subscribeManyEose(relays, [filter], { this.subscribeEose(relays, filter, {
...params, ...params,
onevent(event: Event) { onevent(event: Event) {
events.push(event) events.push(event)
@@ -189,7 +345,7 @@ export class AbstractSimplePool {
async get( async get(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> { ): Promise<Event | null> {
filter.limit = 1 filter.limit = 1
const events = await this.querySync(relays, filter, params) const events = await this.querySync(relays, filter, params)

View File

@@ -35,6 +35,7 @@ export class AbstractRelay {
private incomingMessageQueue = new Queue<string>() private incomingMessageQueue = new Queue<string>()
private queueRunning = false private queueRunning = false
private challenge: string | undefined private challenge: string | undefined
private authPromise: Promise<string> | undefined
private serial: number = 0 private serial: number = 0
private verifyEvent: Nostr['verifyEvent'] private verifyEvent: Nostr['verifyEvent']
@@ -77,6 +78,7 @@ export class AbstractRelay {
if (this.connectionPromise) return this.connectionPromise if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined this.challenge = undefined
this.authPromise = undefined
this.connectionPromise = new Promise((resolve, reject) => { this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => { this.connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out') reject('connection timed out')
@@ -200,6 +202,7 @@ export class AbstractRelay {
const reason: string = data[3] const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ep) { if (ep) {
clearTimeout(ep.timeout)
if (ok) ep.resolve(reason) if (ok) ep.resolve(reason)
else ep.reject(new Error(reason)) else ep.reject(new Error(reason))
this.openEventPublishes.delete(id) this.openEventPublishes.delete(id)
@@ -219,6 +222,7 @@ export class AbstractRelay {
return return
case 'AUTH': { case 'AUTH': {
this.challenge = data[1] as string this.challenge = data[1] as string
this.authPromise = undefined
this._onauth?.(data[1] as string) this._onauth?.(data[1] as string)
return return
} }
@@ -238,26 +242,34 @@ export class AbstractRelay {
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> { public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
if (!this.challenge) throw new Error("can't perform auth, no challenge was received") if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
if (this.authPromise) return this.authPromise
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge)) const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
const ret = new Promise<string>((resolve, reject) => { this.authPromise = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(evt.id, { resolve, reject }) const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
}) })
this.send('["AUTH",' + JSON.stringify(evt) + ']') this.send('["AUTH",' + JSON.stringify(evt) + ']')
return ret return this.authPromise
} }
public async publish(event: Event): Promise<string> { public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => { const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(event.id, { resolve, reject }) const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('publish timed out'))
this.openEventPublishes.delete(event.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
}) })
this.send('["EVENT",' + JSON.stringify(event) + ']') this.send('["EVENT",' + JSON.stringify(event) + ']')
setTimeout(() => {
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('publish timed out'))
this.openEventPublishes.delete(event.id)
}
}, this.publishTimeout)
return ret return ret
} }
@@ -271,15 +283,21 @@ export class AbstractRelay {
return ret return ret
} }
public subscribe(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription { public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
const subscription = this.prepareSubscription(filters, params) const subscription = this.prepareSubscription(filters, params)
subscription.fire() subscription.fire()
return subscription return subscription
} }
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription { public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++ this.serial++
const id = params.id || 'sub:' + this.serial const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const subscription = new Subscription(this, id, filters, params) const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription) this.openSubs.set(id, subscription)
return subscription return subscription
@@ -381,4 +399,5 @@ export type CountResolver = {
export type EventPublishResolver = { export type EventPublishResolver = {
resolve: (reason: string) => void resolve: (reason: string) => void
reject: (err: Error) => void reject: (err: Error) => void
timeout: ReturnType<typeof setTimeout>
} }

View File

@@ -1,5 +1,5 @@
import { Event } from './core.ts' import { Event } from './core.ts'
import { isParameterizedReplaceableKind, isReplaceableKind } from './kinds.ts' import { isAddressableKind, isReplaceableKind } from './kinds.ts'
export type Filter = { export type Filter = {
ids?: string[] ids?: string[]
@@ -98,7 +98,7 @@ export function getFilterLimit(filter: Filter): number {
: Infinity, : Infinity,
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags. // Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
filter.authors?.length && filter.kinds?.every(kind => isParameterizedReplaceableKind(kind)) && filter['#d']?.length filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
? filter.authors.length * filter.kinds.length * filter['#d'].length ? filter.authors.length * filter.kinds.length * filter['#d'].length
: Infinity, : Infinity,
) )

View File

@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts' export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts' export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts' export * as nip13 from './nip13.ts'
export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts' export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts' export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts' export * as nip21 from './nip21.ts'
@@ -20,6 +21,7 @@ export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts' export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts' export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts' export * as nip47 from './nip47.ts'
export * as nip54 from './nip54.ts'
export * as nip57 from './nip57.ts' export * as nip57 from './nip57.ts'
export * as nip59 from './nip59.ts' export * as nip59 from './nip59.ts'
export * as nip98 from './nip98.ts' export * as nip98 from './nip98.ts'

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.10.2", "version": "2.12.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",
@@ -34,6 +34,7 @@
"./nip44": "./nip44.ts", "./nip44": "./nip44.ts",
"./nip46": "./nip46.ts", "./nip46": "./nip46.ts",
"./nip49": "./nip49.ts", "./nip49": "./nip49.ts",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts", "./nip57": "./nip57.ts",
"./nip58": "./nip58.ts", "./nip58": "./nip58.ts",
"./nip59": "./nip59.ts", "./nip59": "./nip59.ts",

View File

@@ -15,11 +15,14 @@ export function isEphemeralKind(kind: number): boolean {
return 20000 <= kind && kind < 30000 return 20000 <= kind && kind < 30000
} }
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */ /** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
export function isParameterizedReplaceableKind(kind: number): boolean { export function isAddressableKind(kind: number): boolean {
return 30000 <= kind && kind < 40000 return 30000 <= kind && kind < 40000
} }
/** @deprecated use isAddressableKind instead */
export const isParameterizedReplaceableKind = isAddressableKind
/** Classification of the event kind. */ /** Classification of the event kind. */
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown' export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
@@ -28,7 +31,7 @@ export function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular' if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable' if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral' if (isEphemeralKind(kind)) return 'ephemeral'
if (isParameterizedReplaceableKind(kind)) return 'parameterized' if (isAddressableKind(kind)) return 'parameterized'
return 'unknown' return 'unknown'
} }

View File

@@ -5,7 +5,7 @@ import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts' import { utf8Decoder, utf8Encoder } from './utils.ts'
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> { export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key) const normalizedKey = getNormalizedX(key)
@@ -21,7 +21,7 @@ export async function encrypt(secretKey: string | Uint8Array, pubkey: string, te
return `${ctb64}?iv=${ivb64}` return `${ctb64}?iv=${ivb64}`
} }
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> { export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
let [ctb64, ivb64] = data.split('?iv=') let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)

View File

@@ -1,10 +1,8 @@
import { EventTemplate, NostrEvent } from './core.ts' import { EventTemplate, NostrEvent } from './core.ts'
import { RelayRecord } from './relay.ts'
export interface WindowNostr { export interface WindowNostr {
getPublicKey(): Promise<string> getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<NostrEvent> signEvent(event: EventTemplate): Promise<NostrEvent>
getRelays(): Promise<RelayRecord>
nip04?: { nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string> encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string> decrypt(pubkey: string, ciphertext: string): Promise<string>

View File

@@ -108,23 +108,26 @@ export function parse(event: Pick<Event, 'tags'>): {
// remove root and reply from mentions, inherit relay hints from authors if any // remove root and reply from mentions, inherit relay hints from authors if any
;[result.reply, result.root].forEach(ref => { ;[result.reply, result.root].forEach(ref => {
let idx = result.mentions.indexOf(ref!) if (!ref) return
let idx = result.mentions.indexOf(ref)
if (idx !== -1) { if (idx !== -1) {
result.mentions.splice(idx, 1) result.mentions.splice(idx, 1)
} }
if (ref!.author) { if (ref.author) {
let author = result.profiles.find(p => p.pubkey === ref!.author) let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) { if (author && author.relays) {
if (!ref!.relays) { if (!ref.relays) {
ref!.relays = [] ref.relays = []
} }
author.relays.forEach(url => { author.relays.forEach(url => {
if (ref?.relays!?.indexOf(url) === -1) ref!.relays!.push(url) if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
}) })
author.relays = ref!.relays author.relays = ref.relays
} }
} }
}) })
result.mentions.forEach(ref => { result.mentions.forEach(ref => {
if (ref!.author) { if (ref!.author) {
let author = result.profiles.find(p => p.pubkey === ref.author) let author = result.profiles.find(p => p.pubkey === ref.author)

View File

@@ -10,7 +10,9 @@ describe('requesting relay as for NIP11', () => {
const info = await fetchRelayInformation('wss://nos.lol') const info = await fetchRelayInformation('wss://nos.lol')
expect(info.name).toEqual('nos.lol') expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.') expect(info.description).toContain('Generally accepts notes, except spammy ones.')
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40]) expect(info.supported_nips).toContain(1)
expect(info.supported_nips).toContain(11)
expect(info.supported_nips).toContain(70)
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git') expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
}) })
}) })

View File

@@ -126,7 +126,7 @@ export interface Limitations {
restricted_writes: boolean restricted_writes: boolean
} }
interface RetentionDetails { export interface RetentionDetails {
kinds: (number | number[])[] kinds: (number | number[])[]
time?: number | null time?: number | null
count?: number | null count?: number | null

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts' import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { Repost, ShortTextNote } from './kinds.ts' import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts' import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
}) })
}) })
describe('GenericRepost', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const eventTemplate: EventTemplate = {
content: '',
created_at: 1617932114,
kind: BadgeDefinitionKind,
tags: [
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
],
}
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
test('should create a generic reposted event', () => {
const template = { created_at: 1617932115 }
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(GenericRepost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
['k', '30009'],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
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)
})
})
describe('getRepostedEventPointer', () => { describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => { test('should parse an event with only an `e` tag', () => {
const event = buildEvent({ const event = buildEvent({
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
expect(repostedEventPointer!.relays).toEqual([relayUrl]) expect(repostedEventPointer!.relays).toEqual([relayUrl])
}) })
}) })
describe('finishRepostEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
test('should create an event with empty content if the reposted event is protected', () => {
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [['-']],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.content).toBe('')
})
})

View File

@@ -1,6 +1,6 @@
import { Event, finalizeEvent, verifyEvent } from './pure.ts' import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
import { Repost } from './kinds.ts'
import { EventPointer } from './nip19.ts' import { EventPointer } from './nip19.ts'
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
export type RepostEventTemplate = { export type RepostEventTemplate = {
/** /**
@@ -25,11 +25,20 @@ export function finishRepostEvent(
relayUrl: string, relayUrl: string,
privateKey: Uint8Array, privateKey: Uint8Array,
): Event { ): Event {
let kind: Repost | GenericRepost
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
if (reposted.kind === ShortTextNote) {
kind = Repost
} else {
kind = GenericRepost
tags.push(['k', String(reposted.kind)])
}
return finalizeEvent( return finalizeEvent(
{ {
kind: Repost, kind,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]], tags,
content: t.content === '' ? '' : JSON.stringify(reposted), content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
created_at: t.created_at, created_at: t.created_at,
}, },
privateKey, privateKey,
@@ -37,7 +46,7 @@ export function finishRepostEvent(
} }
export function getRepostedEventPointer(event: Event): undefined | EventPointer { export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Repost) { if (![Repost, GenericRepost].includes(event.kind)) {
return undefined return undefined
} }

View File

@@ -79,6 +79,15 @@ export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P> [P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes] }[keyof Prefixes]
export function decodeNostrURI(nip19code: string): DecodeResult | { type: 'invalid'; data: null } {
try {
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
return decode(nip19code)
} catch (_err) {
return { type: 'invalid', data: null }
}
}
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix> export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult { export function decode(nip19: string): DecodeResult {

View File

@@ -1,68 +1,77 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip27.ts' import { parse } from './nip27.ts'
test('matchAll', () => { test('first: parse simple content with 1 url and 1 nostr uri', () => {
const result = matchAll( const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', const blocks = Array.from(parse(content))
)
expect([...result]).toEqual([ expect(blocks).toEqual([
{ { type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'text', text: ' check out my profile:' },
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
decoded: { { type: 'text', text: '; and this cool image ' },
type: 'npub', { type: 'image', url: 'https://images.com/image.jpg' },
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
start: 6,
end: 75,
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
start: 78,
end: 147,
},
]) ])
}) })
test('matchAll with an invalid nip19', () => { test('second: parse content with 3 urls of different types', () => {
const result = matchAll( const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', http://music.com/song.mp3
) and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
const blocks = Array.from(parse(content))
expect([...result]).toEqual([ expect(blocks).toEqual([
{ type: 'text', text: ':' },
{ type: 'relay', url: 'wss://oa.ao/' },
{ type: 'text', text: "; this was a relay and now here's a video -> " },
{ type: 'video', url: 'https://videos.com/video.mp4' },
{ type: 'text', text: '! and some music:\n' },
{ type: 'audio', url: 'http://music.com/song.mp3' },
{ type: 'text', text: '\nand a regular link: ' },
{ type: 'url', url: 'https://regular.com/page?ok=true' },
{ {
decoded: { type: 'text',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
type: 'note',
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
}, },
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
]) ])
}) })
test('replaceAll', () => { test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const content = const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
with a video https://example.com/vid.webm and finally https://example.com/docs`
const blocks = Array.from(parse(content))
const result = replaceAll(content, ({ decoded, value }) => { expect(blocks).toEqual([
switch (decoded.type) { { type: 'text', text: 'Look at these profiles ' },
case 'npub': { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
return '@alex' { type: 'text', text: ' ' },
case 'note': {
return '!1234' type: 'reference',
default: pointer: {
return value pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
} relays: ['wss://qwieu.com'],
}) },
},
expect(result).toEqual('Hello @alex!\n\n!1234') { type: 'text', text: ' check this event ' },
{
type: 'reference',
pointer: {
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
relays: ['wss://zjbdksa.aswjdkn'],
author: undefined,
kind: undefined,
},
},
{ type: 'text', text: "\n here's an image " },
{ type: 'image', url: 'https://example.com/pic.png' },
{ type: 'text', text: ' and another profile ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '\n with a video ' },
{ type: 'video', url: 'https://example.com/vid.webm' },
{ type: 'text', text: ' and finally ' },
{ type: 'url', url: 'https://example.com/docs' },
])
}) })

212
nip27.ts
View File

@@ -1,63 +1,169 @@
import { decode } from './nip19.ts' import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */ export type Block =
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') | {
type: 'text'
text: string
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
}
| {
type: 'url'
url: string
}
| {
type: 'relay'
url: string
}
| {
type: 'image'
url: string
}
| {
type: 'video'
url: string
}
| {
type: 'audio'
url: string
}
/** Match result for a Nostr URI in event content. */ const noCharacter = /\W/m
export interface NostrURIMatch extends NostrURI { const noURLCharacter = /\W |\W$|$|,| /m
/** 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* parse(content: string): Iterable<Block> {
export function* matchAll(content: string): Iterable<NostrURIMatch> { const max = content.length
const matches = content.matchAll(regex()) let prevIndex = 0
let index = 0
while (index < max) {
let u = content.indexOf(':', index)
if (u === -1) {
// reached end
break
}
for (const match of matches) { if (content.substring(u - 5, u) === 'nostr') {
try { const m = content.substring(u + 60).match(noCharacter)
const [uri, value] = match const end = m ? u + 60 + m.index! : max
try {
let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.substring(u + 1, end))
yield { switch (type) {
uri: uri as `nostr:${string}`, case 'npub':
value, pointer = { pubkey: data } as ProfilePointer
decoded: decode(value), break
start: match.index!, case 'nsec':
end: match.index! + uri.length, case 'note':
// ignore this, treat it as not a valid uri
index = end + 1
continue
default:
pointer = data as any
}
if (prevIndex !== u - 5) {
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
}
yield { type: 'reference', pointer }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid nostr uri
index = u + 1
continue
} }
} catch (_e) { } else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
// do nothing const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 5 : 4
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index
continue
}
yield { type: 'url', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 3 : 2
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid ws url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
yield { type: 'relay', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else {
// ignore this, it is nothing
index = u + 1
continue
} }
} }
}
/** if (prevIndex !== max) {
* Replace all occurrences of Nostr URIs in the text. yield { type: 'text', text: content.substring(prevIndex) }
* }
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
})
})
} }

View File

@@ -58,13 +58,21 @@ export type GroupAdmin = {
* Represents the permissions that a NIP29 group admin can have. * Represents the permissions that a NIP29 group admin can have.
*/ */
export enum GroupAdminPermission { export enum GroupAdminPermission {
/** @deprecated use PutUser instead */
AddUser = 'add-user', AddUser = 'add-user',
EditMetadata = 'edit-metadata', EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event', DeleteEvent = 'delete-event',
RemoveUser = 'remove-user', RemoveUser = 'remove-user',
/** @deprecated removed from NIP */
AddPermission = 'add-permission', AddPermission = 'add-permission',
/** @deprecated removed from NIP */
RemovePermission = 'remove-permission', RemovePermission = 'remove-permission',
/** @deprecated removed from NIP */
EditGroupStatus = 'edit-group-status', EditGroupStatus = 'edit-group-status',
PutUser = 'put-user',
CreateGroup = 'create-group',
DeleteGroup = 'delete-group',
CreateInvite = 'create-invite',
} }
/** /**

View File

@@ -1,7 +1,7 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { v2 } from './nip44.js' import { v2 } from './nip44.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { default as vec } from './nip44.vectors.json' assert { type: 'json' } import { default as vec } from './nip44.vectors.json' with { type: 'json' }
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
const v2vec = vec.v2 const v2vec = vec.v2

View File

@@ -1,7 +1,6 @@
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts' import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts' import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts' import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt as legacyDecrypt } from './nip04.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts' import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts' import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts' import { SimplePool } from './pool.ts'
@@ -74,7 +73,7 @@ export type BunkerSignerParams = {
export class BunkerSigner { export class BunkerSigner {
private pool: AbstractSimplePool private pool: AbstractSimplePool
private subCloser: SubCloser private subCloser: SubCloser | undefined
private isOpen: boolean private isOpen: boolean
private serial: number private serial: number
private idPrefix: string private idPrefix: string
@@ -112,22 +111,20 @@ export class BunkerSigner {
this.listeners = {} this.listeners = {}
this.waitingForAuth = {} this.waitingForAuth = {}
this.setupSubscription(params)
}
private setupSubscription(params: BunkerSignerParams) {
const listeners = this.listeners const listeners = this.listeners
const waitingForAuth = this.waitingForAuth const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey const convKey = this.conversationKey
this.subCloser = this.pool.subscribeMany( this.subCloser = this.pool.subscribe(
this.bp.relays, this.bp.relays,
[{ kinds: [NostrConnect], authors: [bp.pubkey], '#p': [getPublicKey(this.secretKey)] }], { kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
{ {
async onevent(event: NostrEvent) { onevent: async (event: NostrEvent) => {
let o const o = JSON.parse(decrypt(event.content, convKey))
try {
o = JSON.parse(decrypt(event.content, convKey))
} catch (err) {
o = JSON.parse(await legacyDecrypt(clientSecretKey, event.pubkey, event.content))
}
const { id, result, error } = o const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) { if (result === 'auth_url' && waitingForAuth[id]) {
@@ -137,7 +134,7 @@ export class BunkerSigner {
params.onauth(error) params.onauth(error)
} else { } else {
console.warn( console.warn(
`nostr-tools/nip46: remote signer ${bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`, `nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
) )
} }
return return
@@ -150,6 +147,13 @@ export class BunkerSigner {
delete listeners[id] delete listeners[id]
} }
}, },
onclose: () => {
if (this.isOpen) {
// If we get onclose but isOpen is still true, that means the client still wants to stay connected
this.subCloser!.close()
this.setupSubscription(params)
}
},
}, },
) )
this.isOpen = true this.isOpen = true
@@ -158,7 +162,7 @@ export class BunkerSigner {
// closes the subscription -- this object can't be used anymore after this // closes the subscription -- this object can't be used anymore after this
async close() { async close() {
this.isOpen = false this.isOpen = false
this.subCloser.close() this.subCloser!.close()
} }
async sendRequest(method: string, params: string[]): Promise<string> { async sendRequest(method: string, params: string[]): Promise<string> {
@@ -223,7 +227,7 @@ export class BunkerSigner {
} }
/** /**
* Calls the "get_relays" method on the bunker. * @deprecated removed from NIP
*/ */
async getRelays(): Promise<RelayRecord> { async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', [])) return JSON.parse(await this.sendRequest('get_relays', []))
@@ -234,7 +238,7 @@ export class BunkerSigner {
* @param event - The event to sign. * @param event - The event to sign.
* @returns A Promise that resolves to the signed event. * @returns A Promise that resolves to the signed event.
*/ */
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> { async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]) let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp) let signed: NostrEvent = JSON.parse(resp)
if (verifyEvent(signed)) { if (verifyEvent(signed)) {

42
nip54.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, test, expect } from 'bun:test'
import { normalizeIdentifier } from './nip54.ts'
describe('normalizeIdentifier', () => {
test('converts to lowercase', () => {
expect(normalizeIdentifier('HELLO')).toBe('hello')
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
})
test('trims whitespace', () => {
expect(normalizeIdentifier(' hello ')).toBe('hello')
expect(normalizeIdentifier('\thello\n')).toBe('hello')
})
test('normalizes Unicode to NFKC form', () => {
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
expect(normalizeIdentifier('café')).toBe('café')
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
})
test('replaces non-alphanumeric characters with hyphens', () => {
expect(normalizeIdentifier('hello world')).toBe('hello-world')
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
})
test('preserves numbers', () => {
expect(normalizeIdentifier('user123')).toBe('user123')
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
})
test('handles multiple consecutive special characters', () => {
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
})
test('handles Unicode letters from different scripts', () => {
expect(normalizeIdentifier('привет')).toBe('привет')
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
})
})

19
nip54.ts Normal file
View File

@@ -0,0 +1,19 @@
export function normalizeIdentifier(name: string): string {
// Trim and lowercase
name = name.trim().toLowerCase()
// Normalize Unicode to NFKC form
name = name.normalize('NFKC')
// Convert to array of characters and map each one
return Array.from(name)
.map(char => {
// Check if character is letter or number using Unicode ranges
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
return char
}
return '-'
})
.join('')
}

166
nip55.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { test, expect } from 'bun:test'
import * as nip55 from './nip55.js'
// Function to parse the NostrSigner URI
function parseNostrSignerUri(uri: string) {
const [base, query] = uri.split('?')
const basePart = base.replace('nostrsigner:', '')
let jsonObject = null
if (basePart) {
try {
jsonObject = JSON.parse(decodeURIComponent(basePart))
} catch (e) {
console.warn('Failed to parse base JSON:', e)
}
}
const urlSearchParams = new URLSearchParams(query)
const queryParams = Object.fromEntries(urlSearchParams.entries())
if (queryParams.permissions) {
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
}
return {
base: jsonObject,
...queryParams,
}
}
// Test cases
test('Get Public Key URI', () => {
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
const callbackUrl = 'https://example.com/?event='
const uri = nip55.getPublicKeyUri({
permissions,
callbackUrl,
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'get_public_key')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
})
test('Sign Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.signEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('base.content', 'test')
expect(jsonObject).toHaveProperty('type', 'sign_event')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})
test('Encrypt NIP-04 URI', () => {
const callbackUrl = 'https://example.com/?event='
const uri = nip55.encryptNip04Uri({
callbackUrl,
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-04 URI', () => {
const uri = nip55.decryptNip04Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Encrypt NIP-44 URI', () => {
const uri = nip55.encryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-44 URI', () => {
const uri = nip55.decryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Decrypt Zap Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.decryptZapEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
returnType: 'event',
compressionType: 'gzip',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
expect(jsonObject).toHaveProperty('returnType', 'event')
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})

123
nip55.ts Normal file
View File

@@ -0,0 +1,123 @@
type BaseParams = {
callbackUrl?: string
returnType?: 'signature' | 'event'
compressionType?: 'none' | 'gzip'
}
type PermissionsParams = BaseParams & {
permissions?: { type: string; kind?: number }[]
}
type EventUriParams = BaseParams & {
eventJson: Record<string, unknown>
id?: string
currentUser?: string
}
type EncryptDecryptParams = BaseParams & {
pubKey: string
content: string
id?: string
currentUser?: string
}
type UriParams = BaseParams & {
base: string
type: string
id?: string
currentUser?: string
permissions?: { type: string; kind?: number }[]
pubKey?: string
plainText?: string
encryptedText?: string
appName?: string
}
function encodeParams(params: Record<string, unknown>): string {
return new URLSearchParams(params as Record<string, string>).toString()
}
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
}
function buildUri({
base,
type,
callbackUrl,
returnType = 'signature',
compressionType = 'none',
...params
}: UriParams): string {
const baseParams = {
type,
compressionType,
returnType,
callbackUrl,
id: params.id,
current_user: params.currentUser,
permissions:
params.permissions && params.permissions.length > 0
? encodeURIComponent(JSON.stringify(params.permissions))
: undefined,
pubKey: params.pubKey,
plainText: params.plainText,
encryptedText: params.encryptedText,
appName: params.appName,
}
const filteredParams = filterUndefined(baseParams)
return `${base}?${encodeParams(filteredParams)}`
}
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
return buildUri({
base: 'nostrsigner:',
type,
...params,
})
}
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
return buildDefaultUri('get_public_key', { permissions, ...params })
}
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'sign_event',
...params,
})
}
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, plainText: params.content })
}
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, encryptedText: params.content })
}
export function encryptNip04Uri(params: EncryptDecryptParams): string {
return encryptUri('nip04_encrypt', params)
}
export function decryptNip04Uri(params: EncryptDecryptParams): string {
return decryptUri('nip04_decrypt', params)
}
export function encryptNip44Uri(params: EncryptDecryptParams): string {
return encryptUri('nip44_encrypt', params)
}
export function decryptNip44Uri(params: EncryptDecryptParams): string {
return decryptUri('nip44_decrypt', params)
}
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'decrypt_zap_event',
...params,
})
}

View File

@@ -2,6 +2,7 @@ import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts' import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts' import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
var _fetch: any var _fetch: any
@@ -49,7 +50,7 @@ export function makeZapRequest({
comment = '', comment = '',
}: { }: {
profile: string profile: string
event: string | null event: string | Event | null
amount: number amount: number
comment: string comment: string
relays: string[] relays: string[]
@@ -68,9 +69,22 @@ export function makeZapRequest({
], ],
} }
if (event) { if (event && typeof event === 'string') {
zr.tags.push(['e', event]) zr.tags.push(['e', event])
} }
if (event && typeof event === 'object') {
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
zr.tags.push(a)
// addressable event
} else if (isAddressableKind(event.kind)) {
let d = event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
zr.tags.push(a)
}
}
return zr return zr
} }

View File

@@ -267,13 +267,11 @@ export async function readServerConfig(serverUrl: string): Promise<ServerConfigu
* @returns true if the object is a valid FileUploadResponse, otherwise false. * @returns true if the object is a valid FileUploadResponse, otherwise false.
*/ */
export function validateFileUploadResponse(response: any): response is FileUploadResponse { export function validateFileUploadResponse(response: any): response is FileUploadResponse {
if (typeof response !== 'object' || response === null) return false if (typeof response !== 'object' || response === null) {
if (!response.status || !response.message) {
return false return false
} }
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') { if (!['success', 'error', 'processing'].includes(response.status)) {
return false return false
} }
@@ -285,10 +283,8 @@ export function validateFileUploadResponse(response: any): response is FileUploa
return false return false
} }
if (response.processing_url) { if (response.processing_url && typeof response.processing_url !== 'string') {
if (typeof response.processing_url !== 'string') { return false
return false
}
} }
if (response.status === 'success' && !response.nip94_event) { if (response.status === 'success' && !response.nip94_event) {
@@ -296,25 +292,21 @@ export function validateFileUploadResponse(response: any): response is FileUploa
} }
if (response.nip94_event) { if (response.nip94_event) {
if ( const tags = response.nip94_event.tags as string[][]
!response.nip94_event.tags ||
!Array.isArray(response.nip94_event.tags) || if (!Array.isArray(tags)) {
response.nip94_event.tags.length === 0
) {
return false return false
} }
for (const tag of response.nip94_event.tags) { if (tags.some(t => t.length < 2 || t.some(x => typeof x !== 'string'))) {
if (!Array.isArray(tag) || tag.length !== 2) return false
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
}
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
return false return false
} }
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) { if (!tags.some(t => t[0] === 'url')) {
return false
}
if (!tags.some(t => t[0] === 'ox')) {
return false return false
} }
} }
@@ -385,17 +377,13 @@ export async function uploadFile(
throw new Error('Unknown error in uploading file!') throw new Error('Unknown error in uploading file!')
} }
try { const parsedResponse = await response.json()
const parsedResponse = await response.json()
if (!validateFileUploadResponse(parsedResponse)) { if (!validateFileUploadResponse(parsedResponse)) {
throw new Error('Invalid response from the server!') throw new Error('Failed to validate upload response!')
}
return parsedResponse
} catch (error) {
throw new Error('Error parsing JSON response!')
} }
return parsedResponse
} }
/** /**
@@ -512,33 +500,28 @@ export async function checkFileProcessingStatus(
} }
// Parse the response // Parse the response
try { const parsedResponse = await response.json()
const parsedResponse = await response.json()
// 201 Created: Indicates the processing is over. // 201 Created: Indicates the processing is over.
if (response.status === 201) { if (response.status === 201) {
// Validate the response if (!validateFileUploadResponse(parsedResponse)) {
if (!validateFileUploadResponse(parsedResponse)) { throw new Error('Failed to validate upload response!')
throw new Error('Invalid response from the server!')
}
return parsedResponse
} }
// 200 OK: Indicates the processing is still ongoing. return parsedResponse as FileUploadResponse
if (response.status === 200) {
// Validate the response
if (!validateDelayedProcessingResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
}
throw new Error('Invalid response from the server!')
} catch (error) {
throw new Error('Error parsing JSON response!')
} }
// 200 OK: Indicates the processing is still ongoing.
if (response.status === 200) {
// Validate the response
if (!validateDelayedProcessingResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
}
throw new Error('Invalid response from the server!')
} }
/** /**

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.10.2", "version": "2.12.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -173,6 +173,11 @@
"require": "./lib/cjs/nip49.js", "require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts" "types": "./lib/types/nip49.d.ts"
}, },
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": { "./nip57": {
"import": "./lib/esm/nip57.js", "import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js", "require": "./lib/cjs/nip57.js",
@@ -234,7 +239,7 @@
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"nostr-wasm": "v0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
@@ -269,7 +274,7 @@
"msw": "^2.1.4", "msw": "^2.1.4",
"node-fetch": "^2.6.9", "node-fetch": "^2.6.9",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"typescript": "^5.0.4" "typescript": "^5.8.2"
}, },
"scripts": { "scripts": {
"prepublish": "just build" "prepublish": "just build"