Compare commits

..

18 Commits

Author SHA1 Message Date
fiatjaf
5a55c670fb nip10: fix. 2024-11-13 01:21:54 -03:00
fiatjaf
bf0c4d4988 nip10: improve, support quotes, author hints, change the way legacy refs are discovered. 2024-11-04 15:37:39 -03:00
fiatjaf
50fe7c2a8b streamline jsr publishes. 2024-11-02 08:35:52 -03:00
fiatjaf
29270c8c9d nip46: fix legacyDecrypt argument. 2024-11-02 08:13:33 -03:00
fiatjaf
cb29d62033 add links to jsr.io 2024-10-31 16:33:24 -03:00
Asai Toshiya
0d237405d9 fix lint error. 2024-10-31 20:43:03 +09:00
Asai Toshiya
659ad36b62 nip05: use stub to test queryProfile(). 2024-10-31 07:51:53 -03:00
Asai Toshiya
d062ab8afd make publish() timeout. 2024-10-30 11:55:04 -03:00
Fishcake
94f841f347 Fix fetch to work in the edge and node environments, cleanup type issues
- Fix "TypeError: Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code)." that is thrown when trying to use fetch in the edge environment  (e.g., workers)
- Cleanup types and variable definitions.
2024-10-28 14:56:10 -03:00
fiatjaf
c1d03cf00b nip46: only encrypt with nip44 (breaking). 2024-10-27 14:59:27 -03:00
fiatjaf
29ecdfc5ec fix slow types so we can publish to jsr.io 2024-10-26 14:23:28 -03:00
fiatjaf
d3fc4734b4 export missing modules. 2024-10-26 13:10:26 -03:00
fiatjaf
66d0b8a4e1 nip46: export queryBunkerProfile() 2024-10-26 07:24:13 -03:00
fiatjaf
e2ec7a4b55 fix types, imports and other stuff on nip17 and nip59. 2024-10-25 22:10:05 -03:00
fiatjaf
a72e47135a nip46: we have no business checking the pubkey of the sign_event result. 2024-10-25 21:58:03 -03:00
Anderson Juhasc
de7bbfc6a2 organizing and improving nip17 and nip59 2024-10-25 11:49:28 -03:00
fiatjaf
f2d421fa4f nip46: remove "nip44_get_key" method as it was removed from the spec. 2024-10-25 10:22:21 -03:00
Anderson Juhasc
cae06fc4fe Implemented NIP-17 support (#449) 2024-10-24 21:10:09 -03:00
17 changed files with 627 additions and 177 deletions

View File

@@ -1,4 +1,4 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) nostr-tools
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
@@ -9,11 +9,19 @@ This package is only providing lower-level functionality. If you want more highe
## Installation
```bash
npm install nostr-tools # or yarn add nostr-tools
# npm
npm install --save nostr-tools
# jsr
npx jsr add @nostr/tools
```
If using TypeScript, this package requires TypeScript >= 5.0.
## Documentation
https://jsr.io/@nostr/tools/doc
## Usage
### Generating a private key and a public key

View File

@@ -23,7 +23,7 @@ export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
}
export class AbstractSimplePool {
protected relays = new Map<string, AbstractRelay>()
protected relays: Map<string, AbstractRelay> = new Map()
public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false

View File

@@ -24,6 +24,7 @@ export class AbstractRelay {
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public openSubs: Map<string, Subscription> = new Map()
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
@@ -198,9 +199,11 @@ export class AbstractRelay {
const ok: boolean = data[2]
const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
if (ep) {
if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
}
return
}
case 'CLOSED': {
@@ -248,6 +251,13 @@ export class AbstractRelay {
this.openEventPublishes.set(event.id, { resolve, reject })
})
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
}

View File

@@ -1,6 +1,6 @@
{
"name": "@nostr/tools",
"version": "2.3.2",
"version": "2.10.3",
"exports": {
".": "./index.ts",
"./core": "./core.ts",
@@ -20,6 +20,7 @@
"./nip10": "./nip10.ts",
"./nip11": "./nip11.ts",
"./nip13": "./nip13.ts",
"./nip17": "./nip17.ts",
"./nip18": "./nip18.ts",
"./nip19": "./nip19.ts",
"./nip21": "./nip21.ts",
@@ -34,6 +35,8 @@
"./nip46": "./nip46.ts",
"./nip49": "./nip49.ts",
"./nip57": "./nip57.ts",
"./nip58": "./nip58.ts",
"./nip59": "./nip59.ts",
"./nip75": "./nip75.ts",
"./nip94": "./nip94.ts",
"./nip96": "./nip96.ts",
@@ -42,4 +45,4 @@
"./fakejson": "./fakejson.ts",
"./utils": "./utils.ts"
}
}
}

View File

@@ -13,6 +13,9 @@ test-only file:
publish: build
npm publish
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
jsr publish --allow-dirty
git checkout -- package.json
format:
eslint --ext .ts --fix *.ts

View File

@@ -1,5 +1,4 @@
import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
@@ -16,7 +15,27 @@ test('validate NIP05_REGEX', () => {
})
test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch)
const fetchStub = async (url: string) => ({
status: 200,
async json() {
return {
'https://compile-error.net/.well-known/nostr.json?name=_': {
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
},
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
relays: {
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
'wss://pyramid.fiatjaf.com',
'wss://nos.lol',
],
},
},
}[url]
},
})
useFetchImplementation(fetchStub)
let p2 = await queryProfile('compile-error.net')
expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')

View File

@@ -12,20 +12,26 @@ export type Nip05 = `${string}@${string}`
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
var _fetch: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _fetch: any
try {
_fetch = fetch
} catch {}
} catch (_) {
null
}
export function useFetchImplementation(fetchImplementation: any) {
export function useFetchImplementation(fetchImplementation: unknown) {
_fetch = fetchImplementation
}
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
try {
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
const res = await _fetch(url, { redirect: 'error' })
const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
return json.names
} catch (_) {
@@ -37,20 +43,24 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
const match = fullname.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
const [, name = '_', domain] = match
try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json()
const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
let pubkey = res.names[name]
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null
const pubkey = json.names[name]
return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
} catch (_e) {
return null
}
}
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
let res = await queryProfile(nip05)
const res = await queryProfile(nip05)
return res ? res.pubkey === pubkey : false
}

View File

@@ -5,20 +5,21 @@ describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -55,33 +56,80 @@ describe('parse NIP10-referenced events', () => {
relays: [],
},
],
reply: {
root: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [],
},
root: {
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 3 events', () => {
test('modern', () => {
let event = {
tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern, inverted, author hint', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
'wss://banana.com',
'root',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
],
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
relays: ['wss://banana.com', 'wss://goiaba.com'],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: ['wss://banana.com', 'wss://goiaba.com'],
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
},
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 2 events', () => {
test('1 event, relay hint from author', () => {
let event = {
tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
[
'e',
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
'',
'root',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [],
profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
relays: ['wss://banana.com'],
},
],
reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
],
reply: undefined,
root: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: [],
relays: ['wss://banana.com'],
},
root: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
},
})
})
test('recommended + 1 event', () => {
test('many p 1 reply', () => {
let event = {
tags: [
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [],
profiles: [
{
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: undefined,
})
})
})

152
nip10.ts
View File

@@ -1,7 +1,7 @@
import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts'
export type NIP10Result = {
export function parse(event: Pick<Event, 'tags'>): {
/**
* Pointer to the root of the thread.
*/
@@ -13,29 +13,80 @@ export type NIP10Result = {
reply: EventPointer | undefined
/**
* Pointers to events which may or may not be in the reply chain.
* Pointers to events that may or may not be in the reply chain.
*/
mentions: EventPointer[]
/**
* Pointers to events that were directly quoted.
*/
quotes: EventPointer[]
/**
* List of pubkeys that are involved in the thread in no particular order.
*/
profiles: ProfilePointer[]
}
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
} {
const result: ReturnType<typeof parse> = {
reply: undefined,
root: undefined,
mentions: [],
profiles: [],
quotes: [],
}
const eTags: string[][] = []
let maybeParent: EventPointer | undefined
let maybeRoot: EventPointer | undefined
for (let i = event.tags.length - 1; i >= 0; i--) {
const tag = event.tags[i]
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) {
eTags.push(tag)
const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
string,
string,
undefined | string,
undefined | string,
undefined | string,
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
author: eTagAuthor,
}
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (!maybeParent) {
maybeParent = eventPointer
} else {
maybeRoot = eventPointer
}
result.mentions.push(eventPointer)
continue
}
if (tag[0] === 'q' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
result.quotes.push({
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
})
}
if (tag[0] === 'p' && tag[1]) {
@@ -43,49 +94,54 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [],
})
continue
}
}
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
// get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
if (!result.root) {
result.root = maybeRoot || maybeParent || result.reply
}
if (!result.reply) {
result.reply = maybeParent || result.root
}
// remove root and reply from mentions, inherit relay hints from authors if any
;[result.reply, result.root].forEach(ref => {
if (!ref) return
let idx = result.mentions.indexOf(ref)
if (idx !== -1) {
result.mentions.splice(idx, 1)
}
if (ref.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
result.mentions.forEach(ref => {
if (ref!.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
return result
}

97
nip17.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import { test, expect } from 'bun:test'
import { getPublicKey } from './pure.ts'
import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
const recipients = [
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
]
const message = 'Hello, this is a direct message!'
const conversationTitle = 'Private Group Conversation' // Optional
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
[Symbol('verified')]: true,
}
expect(wrappedEvent.kind).toEqual(expected.kind)
expect(wrappedEvent.tags).toEqual(expected.tags)
})
test('wrapManyEvents', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729560014,
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 14,
content: 'Hello, this is a direct message!',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
['e', 'previousEventId123', '', 'reply'],
['subject', 'Private Group Conversation'],
],
}
const result = unwrapEvent(wrappedEvent, sk1)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})

77
nip17.ts Normal file
View File

@@ -0,0 +1,77 @@
import { PrivateDirectMessage } from './kinds.ts'
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
import * as nip59 from './nip59.ts'
type Recipient = {
publicKey: string
relayUrl?: string
}
type ReplyTo = {
eventId: string
relayUrl?: string
}
function createEvent(
recipients: Recipient | Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): EventTemplate {
const baseEvent: EventTemplate = {
created_at: Math.ceil(Date.now() / 1000),
kind: PrivateDirectMessage,
tags: [],
content: message,
}
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
recipientsArray.forEach(({ publicKey, relayUrl }) => {
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
})
if (replyTo) {
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
}
if (conversationTitle) {
baseEvent.tags.push(['subject', conversationTitle])
}
return baseEvent
}
export function wrapEvent(
senderPrivateKey: Uint8Array,
recipient: Recipient,
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent {
const event = createEvent(recipient, message, conversationTitle, replyTo)
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
}
export function wrapManyEvents(
senderPrivateKey: Uint8Array,
recipients: Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent[] {
if (!recipients || recipients.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
// wrap the event for the sender and then for each recipient
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
)
}
export const unwrapEvent = nip59.unwrapEvent
export const unwrapManyEvents = nip59.unwrapManyEvents

View File

@@ -1,5 +1,11 @@
import { Event, finalizeEvent } from './pure.ts'
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata as KindChannelMetadata, ChannelMuteUser } from './kinds.ts'
import {
ChannelCreation,
ChannelHideMessage,
ChannelMessage,
ChannelMetadata as KindChannelMetadata,
ChannelMuteUser,
} from './kinds.ts'
export interface ChannelMetadata {
name: string

View File

@@ -1,9 +1,8 @@
import { hexToBytes } from '@noble/hashes/utils'
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt, encrypt } from './nip04.ts'
import { getConversationKey, decrypt as nip44decrypt } from './nip44.ts'
import { decrypt as legacyDecrypt } from './nip04.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts'
@@ -49,7 +48,7 @@ export async function parseBunkerInput(input: string): Promise<BunkerPointer | n
return queryBunkerProfile(input)
}
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX)
if (!match) return null
@@ -87,6 +86,7 @@ export class BunkerSigner {
}
private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array
private conversationKey: Uint8Array
public bp: BunkerPointer
private cachedPubKey: string | undefined
@@ -104,6 +104,7 @@ export class BunkerSigner {
this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7)
@@ -113,18 +114,18 @@ export class BunkerSigner {
const listeners = this.listeners
const waitingForAuth = this.waitingForAuth
const skBytes = this.secretKey
const convKey = this.conversationKey
this.subCloser = this.pool.subscribeMany(
this.bp.relays,
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }],
[{ kinds: [NostrConnect], authors: [bp.pubkey], '#p': [getPublicKey(this.secretKey)] }],
{
async onevent(event: NostrEvent) {
let o
try {
o = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
o = JSON.parse(decrypt(event.content, convKey))
} catch (err) {
o = JSON.parse(nip44decrypt(event.content, getConversationKey(skBytes, event.pubkey)))
o = JSON.parse(await legacyDecrypt(clientSecretKey, event.pubkey, event.content))
}
const { id, result, error } = o
@@ -167,7 +168,7 @@ export class BunkerSigner {
this.serial++
const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = await encrypt(this.secretKey, this.bp.pubkey, JSON.stringify({ id, method, params }))
const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
// the request event
const verifiedEvent: VerifiedEvent = finalizeEvent(
@@ -236,7 +237,7 @@ export class BunkerSigner {
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp)
if (signed.pubkey === this.bp.pubkey && verifyEvent(signed)) {
if (verifyEvent(signed)) {
return signed
} else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
@@ -251,11 +252,6 @@ export class BunkerSigner {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
}
async nip44GetKey(thirdPartyPubkey: string): Promise<Uint8Array> {
let resp = await this.sendRequest('nip44_get_key', [thirdPartyPubkey])
return hexToBytes(resp)
}
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
}
@@ -298,9 +294,6 @@ export async function createAccount(
return rpc
}
// @deprecated use fetchBunkerProviders instead
export const fetchCustodialBunkers = fetchBunkerProviders
/**
* Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects.

View File

@@ -1,7 +1,10 @@
import { test, expect } from 'bun:test'
import { wrapEvent, unwrapEvent } from './nip59.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
import { decode } from './nip19.ts'
import { getPublicKey } from './pure.ts'
import { NostrEvent, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data
@@ -11,7 +14,7 @@ const event = {
content: 'Are you going to the party tonight?',
}
const wrapedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
test('wrapEvent', () => {
const expected = {
@@ -30,6 +33,38 @@ test('wrapEvent', () => {
expect(result.tags).toEqual(expected.tags)
})
test('wrapManyEvent', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 1,
@@ -37,10 +72,42 @@ test('unwrapEvent', () => {
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [],
}
const result = unwrapEvent(wrapedEvent, recipientPrivateKey)
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})
test('getWrappedEvents and unwrapManyEvents', async () => {
const expected = [
{
created_at: 1729721879,
content: 'Hello!',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
},
{
created_at: 1729722025,
content: 'How are you?',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
},
]
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
const pool = new SimplePool()
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
unwrappedEvents.forEach((event, index) => {
expect(event).toEqual(expected[index])
})
})

View File

@@ -1,4 +1,4 @@
import { EventTemplate, UnsignedEvent, Event } from './core.ts'
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
import { Seal, GiftWrap } from './kinds.ts'
@@ -15,10 +15,10 @@ const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getC
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
const nip44Decrypt = (data: Event, privateKey: Uint8Array) =>
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array) {
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
const rumor = {
created_at: now(),
content: '',
@@ -32,7 +32,7 @@ export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Arra
return rumor as Rumor
}
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string) {
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
return finalizeEvent(
{
kind: Seal,
@@ -41,10 +41,10 @@ export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublic
tags: [],
},
privateKey,
) as Event
)
}
export function createWrap(seal: Event, recipientPublicKey: string) {
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
const randomKey = generateSecretKey()
return finalizeEvent(
@@ -55,17 +55,53 @@ export function createWrap(seal: Event, recipientPublicKey: string) {
tags: [['p', recipientPublicKey]],
},
randomKey,
) as Event
) as NostrEvent
}
export function wrapEvent(event: Partial<UnsignedEvent>, senderPrivateKey: Uint8Array, recipientPublicKey: string) {
export function wrapEvent(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientPublicKey: string,
): NostrEvent {
const rumor = createRumor(event, senderPrivateKey)
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
return createWrap(seal, recipientPublicKey)
}
export function unwrapEvent(wrap: Event, recipientPrivateKey: Uint8Array) {
export function wrapManyEvents(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientsPublicKeys: string[],
): NostrEvent[] {
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
recipientsPublicKeys.forEach(recipientPublicKey => {
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
})
return wrappeds
}
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
}
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
let unwrappedEvents: Rumor[] = []
wrappedEvents.forEach(e => {
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
})
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
return unwrappedEvents
}

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.9.1",
"version": "2.10.3",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -103,6 +103,11 @@
"require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts"
},
"./nip17": {
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": {
"import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js",

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test'
import { Server } from 'mock-socket'
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay, useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
@@ -92,3 +92,28 @@ test('listening and publishing and closing', async done => {
),
)
})
test('publish timeout', async () => {
const url = 'wss://relay.example.com'
new Server(url)
const relay = new Relay(url)
relay.publishTimeout = 100
await relay.connect()
setTimeout(() => relay.close(), 20000) // close the relay to fail the test on timeout
expect(
relay.publish(
finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
),
),
).rejects.toThrow('publish timed out')
})