mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-09 08:38:50 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e398617fdc | ||
|
|
1b236faa7b | ||
|
|
7064e0b828 | ||
|
|
4f6976f6f8 | ||
|
|
a61cde77ea | ||
|
|
23d95acb26 | ||
|
|
13ac04b8f8 | ||
|
|
45b25c5bf5 | ||
|
|
ee76d69b4b | ||
|
|
21433049b8 | ||
|
|
e8ff68f0b3 | ||
|
|
1b77d6e080 | ||
|
|
76d3a91600 | ||
|
|
6f334f31a7 | ||
|
|
9c009ac543 | ||
|
|
a87099fa5c | ||
|
|
475a22a95f |
@@ -1,6 +1,6 @@
|
|||||||
FROM node:20
|
FROM node:20
|
||||||
|
|
||||||
RUN npm install typescript -g
|
RUN npm install typescript eslint prettier -g
|
||||||
|
|
||||||
# Install bun
|
# Install bun
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
|||||||
@@ -116,7 +116,8 @@
|
|||||||
"no-unexpected-multiline": 2,
|
"no-unexpected-multiline": 2,
|
||||||
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||||
"no-unreachable": 2,
|
"no-unreachable": 2,
|
||||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
|
||||||
"no-useless-call": 2,
|
"no-useless-call": 2,
|
||||||
"no-useless-constructor": 2,
|
"no-useless-constructor": 2,
|
||||||
"no-with": 2,
|
"no-with": 2,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
|||||||
|
|
||||||
Only depends on _@scure_ and _@noble_ packages.
|
Only depends on _@scure_ and _@noble_ packages.
|
||||||
|
|
||||||
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ const sub = relay.subscribe([
|
|||||||
let sk = generateSecretKey()
|
let sk = generateSecretKey()
|
||||||
let pk = getPublicKey(sk)
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
relay.sub([
|
relay.subscribe([
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
|
|||||||
@@ -205,7 +205,29 @@ export class AbstractSimplePool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let r = await this.ensureRelay(url)
|
let r = await this.ensureRelay(url)
|
||||||
return r.publish(event)
|
return r.publish(event).then(reason => {
|
||||||
|
if (this.trackRelays) {
|
||||||
|
let set = this.seenOn.get(event.id)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.seenOn.set(event.id, set)
|
||||||
|
}
|
||||||
|
set.add(r)
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listConnectionStatus(): Map<string, boolean> {
|
||||||
|
const map = new Map<string, boolean>()
|
||||||
|
this.relays.forEach((relay, url) => map.set(url, relay.connected))
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.relays.forEach(conn => conn.close())
|
||||||
|
this.relays = new Map()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { test, expect } from 'bun:test'
|
import { test, expect } from 'bun:test'
|
||||||
|
|
||||||
import { sortEvents } from './core.ts'
|
import { sortEvents } from './core.ts'
|
||||||
|
|
||||||
test('sortEvents', () => {
|
test('sortEvents', () => {
|
||||||
|
|||||||
@@ -215,6 +215,16 @@ describe('Filter', () => {
|
|||||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should handle parameterized replaceable events', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
|
||||||
|
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
|
||||||
|
expect(
|
||||||
|
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
|
||||||
|
).toEqual(8)
|
||||||
|
})
|
||||||
|
|
||||||
test('should return Infinity for authors with regular kinds', () => {
|
test('should return Infinity for authors with regular kinds', () => {
|
||||||
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||||
})
|
})
|
||||||
|
|||||||
17
filter.ts
17
filter.ts
@@ -1,5 +1,5 @@
|
|||||||
import { Event } from './core.ts'
|
import { Event } from './core.ts'
|
||||||
import { isReplaceableKind } from './kinds.ts'
|
import { isParameterizedReplaceableKind, isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
export type Filter = {
|
export type Filter = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@@ -72,7 +72,10 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
|
/**
|
||||||
|
* Calculate the intrinsic limit of a filter.
|
||||||
|
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
|
||||||
|
*/
|
||||||
export function getFilterLimit(filter: Filter): number {
|
export function getFilterLimit(filter: Filter): number {
|
||||||
if (filter.ids && !filter.ids.length) return 0
|
if (filter.ids && !filter.ids.length) return 0
|
||||||
if (filter.kinds && !filter.kinds.length) return 0
|
if (filter.kinds && !filter.kinds.length) return 0
|
||||||
@@ -83,10 +86,20 @@ export function getFilterLimit(filter: Filter): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Math.min(
|
return Math.min(
|
||||||
|
// The `limit` property creates an artificial limit.
|
||||||
Math.max(0, filter.limit ?? Infinity),
|
Math.max(0, filter.limit ?? Infinity),
|
||||||
|
|
||||||
|
// There can only be one event per `id`.
|
||||||
filter.ids?.length ?? Infinity,
|
filter.ids?.length ?? Infinity,
|
||||||
|
|
||||||
|
// Replaceable events are limited by the number of authors and kinds.
|
||||||
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||||
? filter.authors.length * filter.kinds.length
|
? filter.authors.length * filter.kinds.length
|
||||||
: Infinity,
|
: Infinity,
|
||||||
|
|
||||||
|
// 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.length * filter['#d'].length
|
||||||
|
: Infinity,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
5
kinds.ts
5
kinds.ts
@@ -35,11 +35,12 @@ export const ShortTextNote = 1
|
|||||||
export const RecommendRelay = 2
|
export const RecommendRelay = 2
|
||||||
export const Contacts = 3
|
export const Contacts = 3
|
||||||
export const EncryptedDirectMessage = 4
|
export const EncryptedDirectMessage = 4
|
||||||
export const EncryptedDirectMessages = 4
|
|
||||||
export const EventDeletion = 5
|
export const EventDeletion = 5
|
||||||
export const Repost = 6
|
export const Repost = 6
|
||||||
export const Reaction = 7
|
export const Reaction = 7
|
||||||
export const BadgeAward = 8
|
export const BadgeAward = 8
|
||||||
|
export const Seal = 13
|
||||||
|
export const PrivateDirectMessage = 14
|
||||||
export const GenericRepost = 16
|
export const GenericRepost = 16
|
||||||
export const ChannelCreation = 40
|
export const ChannelCreation = 40
|
||||||
export const ChannelMetadata = 41
|
export const ChannelMetadata = 41
|
||||||
@@ -71,6 +72,8 @@ export const BlockedRelaysList = 10006
|
|||||||
export const SearchRelaysList = 10007
|
export const SearchRelaysList = 10007
|
||||||
export const InterestsList = 10015
|
export const InterestsList = 10015
|
||||||
export const UserEmojiList = 10030
|
export const UserEmojiList = 10030
|
||||||
|
export const DirectMessageRelaysList = 10050
|
||||||
|
export const GiftWrap = 10059
|
||||||
export const FileServerPreference = 10096
|
export const FileServerPreference = 10096
|
||||||
export const NWCWalletInfo = 13194
|
export const NWCWalletInfo = 13194
|
||||||
export const LightningPubRPC = 21000
|
export const LightningPubRPC = 21000
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test, expect } from 'bun:test'
|
import { test, expect } from 'bun:test'
|
||||||
import fetch from 'node-fetch'
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
|
||||||
|
|
||||||
test('fetch nip05 profiles', async () => {
|
test('fetch nip05 profiles', async () => {
|
||||||
useFetchImplementation(fetch)
|
useFetchImplementation(fetch)
|
||||||
@@ -18,3 +18,15 @@ test('fetch nip05 profiles', async () => {
|
|||||||
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||||
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('validate NIP05_REGEX', () => {
|
||||||
|
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
|
||||||
|
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
|
||||||
|
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
|
||||||
|
|
||||||
|
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
|
||||||
|
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
|
||||||
|
|
||||||
|
expect(isNip05('bob@bob.com.br')).toBeTrue()
|
||||||
|
expect(isNip05('b&b@bob.com.br')).toBeFalse()
|
||||||
|
})
|
||||||
|
|||||||
5
nip05.ts
5
nip05.ts
@@ -1,5 +1,7 @@
|
|||||||
import { ProfilePointer } from './nip19.ts'
|
import { ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
|
export type Nip05 = `${string}@${string}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||||
*
|
*
|
||||||
@@ -8,6 +10,7 @@ import { ProfilePointer } from './nip19.ts'
|
|||||||
* - 2: domain
|
* - 2: domain
|
||||||
*/
|
*/
|
||||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
||||||
|
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
|
||||||
|
|
||||||
var _fetch: any
|
var _fetch: any
|
||||||
|
|
||||||
@@ -47,7 +50,7 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isValid(pubkey: string, nip05: string): Promise<boolean> {
|
export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
|
||||||
let res = await queryProfile(nip05)
|
let res = await queryProfile(nip05)
|
||||||
return res ? res.pubkey === pubkey : false
|
return res ? res.pubkey === pubkey : false
|
||||||
}
|
}
|
||||||
|
|||||||
161
nip19.test.ts
161
nip19.test.ts
@@ -1,16 +1,16 @@
|
|||||||
import { test, expect } from 'bun:test'
|
import { test, expect, describe } from 'bun:test'
|
||||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
import {
|
import {
|
||||||
decode,
|
decode,
|
||||||
naddrEncode,
|
naddrEncode,
|
||||||
nprofileEncode,
|
nprofileEncode,
|
||||||
npubEncode,
|
npubEncode,
|
||||||
nrelayEncode,
|
|
||||||
nsecEncode,
|
nsecEncode,
|
||||||
neventEncode,
|
neventEncode,
|
||||||
type AddressPointer,
|
type AddressPointer,
|
||||||
type ProfilePointer,
|
type ProfilePointer,
|
||||||
EventPointer,
|
EventPointer,
|
||||||
|
NostrTypeGuard,
|
||||||
} from './nip19.ts'
|
} from './nip19.ts'
|
||||||
|
|
||||||
test('encode and decode nsec', () => {
|
test('encode and decode nsec', () => {
|
||||||
@@ -153,11 +153,154 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
|
|||||||
expect(pointer.identifier).toEqual('banana')
|
expect(pointer.identifier).toEqual('banana')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('encode and decode nrelay', () => {
|
describe('NostrTypeGuard', () => {
|
||||||
let url = 'wss://relay.nostr.example'
|
test('isNProfile', () => {
|
||||||
let nrelay = nrelayEncode(url)
|
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||||
expect(nrelay).toMatch(/nrelay1\w+/)
|
|
||||||
let { type, data } = decode(nrelay)
|
expect(is).toBeTrue()
|
||||||
expect(type).toEqual('nrelay')
|
})
|
||||||
expect(data).toEqual(url)
|
|
||||||
|
test('isNProfile invalid nprofile', () => {
|
||||||
|
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNProfile with invalid nprofile', () => {
|
||||||
|
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNRelay', () => {
|
||||||
|
const is = NostrTypeGuard.isNRelay('nrelay1qqt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueq4r295t')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNRelay with invalid nrelay', () => {
|
||||||
|
const is = NostrTypeGuard.isNRelay('nrelay1qqt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueã4r295t')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNRelay with invalid nrelay', () => {
|
||||||
|
const is = NostrTypeGuard.isNRelay(
|
||||||
|
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNEvent', () => {
|
||||||
|
const is = NostrTypeGuard.isNEvent(
|
||||||
|
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNEvent with invalid nevent', () => {
|
||||||
|
const is = NostrTypeGuard.isNEvent(
|
||||||
|
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNEvent with invalid nevent', () => {
|
||||||
|
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNAddr', () => {
|
||||||
|
const is = NostrTypeGuard.isNAddr(
|
||||||
|
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNAddr with invalid nadress', () => {
|
||||||
|
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNSec', () => {
|
||||||
|
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNSec with invalid nsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNSec with invalid nsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNPub', () => {
|
||||||
|
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNPub with invalid npub', () => {
|
||||||
|
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNPub with invalid npub', () => {
|
||||||
|
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNote', () => {
|
||||||
|
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNote with invalid note', () => {
|
||||||
|
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNote with invalid note', () => {
|
||||||
|
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNcryptsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNcryptsec(
|
||||||
|
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNcryptsec(
|
||||||
|
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNcryptsec with invalid ncrytpsec', () => {
|
||||||
|
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
|
||||||
|
|
||||||
|
expect(is).toBeFalse()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
50
nip19.ts
50
nip19.ts
@@ -3,6 +3,26 @@ import { bech32 } from '@scure/base'
|
|||||||
|
|
||||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
export type NProfile = `nprofile1${string}`
|
||||||
|
export type NRelay = `nrelay1${string}`
|
||||||
|
export type NEvent = `nevent1${string}`
|
||||||
|
export type NAddr = `naddr1${string}`
|
||||||
|
export type NSec = `nsec1${string}`
|
||||||
|
export type NPub = `npub1${string}`
|
||||||
|
export type Note = `note1${string}`
|
||||||
|
export type Ncryptsec = `ncryptsec1${string}`
|
||||||
|
|
||||||
|
export const NostrTypeGuard = {
|
||||||
|
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNRelay: (value?: string | null): value is NRelay => /^nrelay1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
|
||||||
|
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
|
||||||
|
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
|
||||||
|
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
|
||||||
|
}
|
||||||
|
|
||||||
export const Bech32MaxSize = 5000
|
export const Bech32MaxSize = 5000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +65,6 @@ export type AddressPointer = {
|
|||||||
|
|
||||||
type Prefixes = {
|
type Prefixes = {
|
||||||
nprofile: ProfilePointer
|
nprofile: ProfilePointer
|
||||||
nrelay: string
|
|
||||||
nevent: EventPointer
|
nevent: EventPointer
|
||||||
naddr: AddressPointer
|
naddr: AddressPointer
|
||||||
nsec: Uint8Array
|
nsec: Uint8Array
|
||||||
@@ -119,16 +138,6 @@ export function decode(nip19: string): DecodeResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'nrelay': {
|
|
||||||
let tlv = parseTLV(data)
|
|
||||||
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'nrelay',
|
|
||||||
data: utf8Decoder.decode(tlv[0][0]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'nsec':
|
case 'nsec':
|
||||||
return { type: prefix, data }
|
return { type: prefix, data }
|
||||||
|
|
||||||
@@ -158,15 +167,15 @@ function parseTLV(data: Uint8Array): TLV {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
|
export function nsecEncode(key: Uint8Array): NSec {
|
||||||
return encodeBytes('nsec', key)
|
return encodeBytes('nsec', key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function npubEncode(hex: string): `npub1${string}` {
|
export function npubEncode(hex: string): NPub {
|
||||||
return encodeBytes('npub', hexToBytes(hex))
|
return encodeBytes('npub', hexToBytes(hex))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noteEncode(hex: string): `note1${string}` {
|
export function noteEncode(hex: string): Note {
|
||||||
return encodeBytes('note', hexToBytes(hex))
|
return encodeBytes('note', hexToBytes(hex))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +188,7 @@ export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8A
|
|||||||
return encodeBech32(prefix, bytes)
|
return encodeBech32(prefix, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
export function nprofileEncode(profile: ProfilePointer): NProfile {
|
||||||
let data = encodeTLV({
|
let data = encodeTLV({
|
||||||
0: [hexToBytes(profile.pubkey)],
|
0: [hexToBytes(profile.pubkey)],
|
||||||
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
@@ -187,7 +196,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
|||||||
return encodeBech32('nprofile', data)
|
return encodeBech32('nprofile', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
export function neventEncode(event: EventPointer): NEvent {
|
||||||
let kindArray
|
let kindArray
|
||||||
if (event.kind !== undefined) {
|
if (event.kind !== undefined) {
|
||||||
kindArray = integerToUint8Array(event.kind)
|
kindArray = integerToUint8Array(event.kind)
|
||||||
@@ -203,7 +212,7 @@ export function neventEncode(event: EventPointer): `nevent1${string}` {
|
|||||||
return encodeBech32('nevent', data)
|
return encodeBech32('nevent', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
export function naddrEncode(addr: AddressPointer): NAddr {
|
||||||
let kind = new ArrayBuffer(4)
|
let kind = new ArrayBuffer(4)
|
||||||
new DataView(kind).setUint32(0, addr.kind, false)
|
new DataView(kind).setUint32(0, addr.kind, false)
|
||||||
|
|
||||||
@@ -216,13 +225,6 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
|||||||
return encodeBech32('naddr', data)
|
return encodeBech32('naddr', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nrelayEncode(url: string): `nrelay1${string}` {
|
|
||||||
let data = encodeTLV({
|
|
||||||
0: [utf8Encoder.encode(url)],
|
|
||||||
})
|
|
||||||
return encodeBech32('nrelay', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
let entries: Uint8Array[] = []
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
|
|||||||
684
nip29.ts
684
nip29.ts
@@ -1,80 +1,514 @@
|
|||||||
import { AbstractSimplePool } from './abstract-pool.ts'
|
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||||
import { Subscription } from './abstract-relay.ts'
|
import { Subscription } from './abstract-relay.ts'
|
||||||
import { decode } from './nip19.ts'
|
import type { Event, EventTemplate } from './core.ts'
|
||||||
import type { Event } from './core.ts'
|
import { fetchRelayInformation, RelayInformation } from './nip11.ts'
|
||||||
import { fetchRelayInformation } from './nip11.ts'
|
import { AddressPointer, decode } from './nip19.ts'
|
||||||
import { normalizeURL } from './utils.ts'
|
import { normalizeURL } from './utils.ts'
|
||||||
import { AddressPointer } from './nip19.ts'
|
|
||||||
|
|
||||||
export function subscribeRelayGroups(
|
/**
|
||||||
pool: AbstractSimplePool,
|
* Represents a NIP29 group.
|
||||||
url: string,
|
*/
|
||||||
params: {
|
export type Group = {
|
||||||
ongroups: (_: Group[]) => void
|
relay: string
|
||||||
onerror: (_: Error) => void
|
metadata: GroupMetadata
|
||||||
onconnect?: () => void
|
admins?: GroupAdmin[]
|
||||||
},
|
members?: GroupMember[]
|
||||||
): () => void {
|
reference: GroupReference
|
||||||
let normalized = normalizeURL(url)
|
|
||||||
let sub: Subscription
|
|
||||||
let groups: Group[] = []
|
|
||||||
|
|
||||||
fetchRelayInformation(normalized)
|
|
||||||
.then(async info => {
|
|
||||||
let rl = await pool.ensureRelay(normalized)
|
|
||||||
params.onconnect?.()
|
|
||||||
sub = rl.prepareSubscription(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kinds: [39000],
|
|
||||||
limit: 50,
|
|
||||||
authors: [info.pubkey],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
onevent(event: Event) {
|
|
||||||
groups.push(parseGroup(event, normalized))
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
params.ongroups(groups)
|
|
||||||
sub.onevent = (event: Event) => {
|
|
||||||
groups.push(parseGroup(event, normalized))
|
|
||||||
params.ongroups(groups)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
sub.fire()
|
|
||||||
})
|
|
||||||
.catch(params.onerror)
|
|
||||||
|
|
||||||
return () => sub.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> {
|
/**
|
||||||
let normalized = normalizeURL(gr.host)
|
* Represents the metadata for a NIP29 group.
|
||||||
|
*/
|
||||||
let info = await fetchRelayInformation(normalized)
|
export type GroupMetadata = {
|
||||||
let event = await pool.get([normalized], {
|
id: string
|
||||||
kinds: [39000],
|
pubkey: string
|
||||||
authors: [info.pubkey],
|
name?: string
|
||||||
'#d': [gr.id],
|
picture?: string
|
||||||
})
|
about?: string
|
||||||
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`)
|
isPublic?: boolean
|
||||||
return parseGroup(event, normalized)
|
isOpen?: boolean
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
|
||||||
let gr = parseGroupCode(code)
|
|
||||||
if (!gr) throw new Error(`code "${code}" does not identify a group`)
|
|
||||||
return loadGroup(pool, gr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group reference.
|
||||||
|
*/
|
||||||
export type GroupReference = {
|
export type GroupReference = {
|
||||||
id: string
|
id: string
|
||||||
host: string
|
host: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group member.
|
||||||
|
*/
|
||||||
|
export type GroupMember = {
|
||||||
|
pubkey: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a NIP29 group admin.
|
||||||
|
*/
|
||||||
|
export type GroupAdmin = {
|
||||||
|
pubkey: string
|
||||||
|
label?: string
|
||||||
|
permissions: GroupAdminPermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the permissions that a NIP29 group admin can have.
|
||||||
|
*/
|
||||||
|
export enum GroupAdminPermission {
|
||||||
|
AddUser = 'add-user',
|
||||||
|
EditMetadata = 'edit-metadata',
|
||||||
|
DeleteEvent = 'delete-event',
|
||||||
|
RemoveUser = 'remove-user',
|
||||||
|
AddPermission = 'add-permission',
|
||||||
|
RemovePermission = 'remove-permission',
|
||||||
|
EditGroupStatus = 'edit-group-status',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a group metadata event template.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @returns An event template with the generated group metadata that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
group.metadata.name && tags.push(['name', group.metadata.name])
|
||||||
|
group.metadata.picture && tags.push(['picture', group.metadata.picture])
|
||||||
|
group.metadata.about && tags.push(['about', group.metadata.about])
|
||||||
|
group.metadata.isPublic && tags.push(['public'])
|
||||||
|
group.metadata.isOpen && tags.push(['open'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39000,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group metadata event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event is valid.
|
||||||
|
*/
|
||||||
|
export function validateGroupMetadataEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39000) return false
|
||||||
|
|
||||||
|
if (!event.pubkey) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for group admins.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param admins - An array of group admins.
|
||||||
|
* @returns The generated event template with the group admins that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
for (const admin of admins) {
|
||||||
|
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39001,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group admins event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupAdminsEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39001) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate permissions
|
||||||
|
for (const [tag, _value, _label, ...permissions] of event.tags) {
|
||||||
|
if (tag !== 'p') continue
|
||||||
|
|
||||||
|
for (let i = 0; i < permissions.length; i += 1) {
|
||||||
|
if (typeof permissions[i] !== 'string') return false
|
||||||
|
|
||||||
|
// validate permission name from the GroupAdminPermission enum
|
||||||
|
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template for a group with its members.
|
||||||
|
*
|
||||||
|
* @param group - The group object.
|
||||||
|
* @param members - An array of group members.
|
||||||
|
* @returns The generated event template with the group members that can be signed later.
|
||||||
|
*/
|
||||||
|
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
|
||||||
|
const tags: string[][] = [['d', group.metadata.id]]
|
||||||
|
for (const member of members) {
|
||||||
|
tags.push(['p', member.pubkey, member.label || ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 39002,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a group members event.
|
||||||
|
*
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export function validateGroupMembersEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== 39002) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the normalized relay URL based on the provided group reference.
|
||||||
|
*
|
||||||
|
* @param groupReference - The group reference object containing the host.
|
||||||
|
* @returns The normalized relay URL.
|
||||||
|
*/
|
||||||
|
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
|
||||||
|
return normalizeURL(groupReference.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches relay information by group reference.
|
||||||
|
*
|
||||||
|
* @param groupReference The group reference.
|
||||||
|
* @returns A promise that resolves to the relay information.
|
||||||
|
*/
|
||||||
|
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
|
||||||
|
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
|
||||||
|
return fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group metadata event from the specified pool.
|
||||||
|
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||||
|
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
|
||||||
|
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
|
||||||
|
* @throws {Error} If the group is not found on the specified relay.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupMetadataEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39000],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupMetadataEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group metadata event and returns the corresponding GroupMetadata object.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns The parsed GroupMetadata object.
|
||||||
|
* @throws An error if the group metadata event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
|
||||||
|
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
|
||||||
|
|
||||||
|
const metadata: GroupMetadata = {
|
||||||
|
id: '',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tag, value] of event.tags) {
|
||||||
|
switch (tag) {
|
||||||
|
case 'd':
|
||||||
|
metadata.id = value
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
metadata.name = value
|
||||||
|
break
|
||||||
|
case 'picture':
|
||||||
|
metadata.picture = value
|
||||||
|
break
|
||||||
|
case 'about':
|
||||||
|
metadata.about = value
|
||||||
|
break
|
||||||
|
case 'public':
|
||||||
|
metadata.isPublic = true
|
||||||
|
break
|
||||||
|
case 'open':
|
||||||
|
metadata.isOpen = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group admins event from the specified pool.
|
||||||
|
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
|
||||||
|
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
|
||||||
|
* @param {GroupReference} options.groupReference - The reference to the group.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information.
|
||||||
|
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
|
||||||
|
* @throws {Error} If the group admins event is not found on the specified relay.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupAdminsEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39001],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupAdminsEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group admins event and returns an array of GroupAdmin objects.
|
||||||
|
*
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of GroupAdmin objects.
|
||||||
|
* @throws Throws an error if the group admins event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
|
||||||
|
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
|
||||||
|
|
||||||
|
const admins: GroupAdmin[] = []
|
||||||
|
|
||||||
|
for (const [tag, value, label, ...permissions] of event.tags) {
|
||||||
|
if (tag !== 'p') continue
|
||||||
|
|
||||||
|
admins.push({
|
||||||
|
pubkey: value,
|
||||||
|
label,
|
||||||
|
permissions: permissions as GroupAdminPermission[],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return admins
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the group members event from the specified relay.
|
||||||
|
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
|
||||||
|
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options object.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool object.
|
||||||
|
* @param {GroupReference} options.groupReference - The group reference object.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information object.
|
||||||
|
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
|
||||||
|
* @throws {Error} If the group members event is not found.
|
||||||
|
*/
|
||||||
|
export async function fetchGroupMembersEvent({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
relayInformation,
|
||||||
|
normalizedRelayURL,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Event> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMembersEvent = await pool.get([normalizedRelayURL], {
|
||||||
|
kinds: [39002],
|
||||||
|
authors: [relayInformation.pubkey],
|
||||||
|
'#d': [groupReference.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
|
||||||
|
|
||||||
|
return groupMembersEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group members event and returns an array of GroupMember objects.
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns An array of GroupMember objects.
|
||||||
|
* @throws Throws an error if the group members event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseGroupMembersEvent(event: Event): GroupMember[] {
|
||||||
|
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
|
||||||
|
|
||||||
|
const members: GroupMember[] = []
|
||||||
|
|
||||||
|
for (const [tag, value, label] of event.tags) {
|
||||||
|
if (tag !== 'p') continue
|
||||||
|
|
||||||
|
members.push({
|
||||||
|
pubkey: value,
|
||||||
|
label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
|
||||||
|
* If the normalized relay URL is not provided, it will be obtained using the group reference.
|
||||||
|
* If the relay information is not provided, it will be fetched using the normalized relay URL.
|
||||||
|
*
|
||||||
|
* @param {Object} options - The options for loading the group.
|
||||||
|
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
|
||||||
|
* @param {GroupReference} options.groupReference - The reference of the group to load.
|
||||||
|
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
|
||||||
|
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
|
||||||
|
* @returns {Promise<Group>} A promise that resolves to the loaded group.
|
||||||
|
*/
|
||||||
|
export async function loadGroup({
|
||||||
|
pool,
|
||||||
|
groupReference,
|
||||||
|
normalizedRelayURL,
|
||||||
|
relayInformation,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
groupReference: GroupReference
|
||||||
|
normalizedRelayURL?: string
|
||||||
|
relayInformation?: RelayInformation
|
||||||
|
}): Promise<Group> {
|
||||||
|
if (!normalizedRelayURL) {
|
||||||
|
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayInformation) {
|
||||||
|
relayInformation = await fetchRelayInformation(normalizedRelayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||||
|
const metadata = parseGroupMetadataEvent(metadataEvent)
|
||||||
|
|
||||||
|
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||||
|
const admins = parseGroupAdminsEvent(adminsEvent)
|
||||||
|
|
||||||
|
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
|
||||||
|
const members = parseGroupMembersEvent(membersEvent)
|
||||||
|
|
||||||
|
const group: Group = {
|
||||||
|
relay: normalizedRelayURL,
|
||||||
|
metadata,
|
||||||
|
admins,
|
||||||
|
members,
|
||||||
|
reference: groupReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a group from the specified pool using the provided group code.
|
||||||
|
*
|
||||||
|
* @param {AbstractSimplePool} pool - The pool to load the group from.
|
||||||
|
* @param {string} code - The code representing the group.
|
||||||
|
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
|
||||||
|
* @throws {Error} - If the group code is invalid.
|
||||||
|
*/
|
||||||
|
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||||
|
const groupReference = parseGroupCode(code)
|
||||||
|
|
||||||
|
if (!groupReference) throw new Error('invalid group code')
|
||||||
|
|
||||||
|
return loadGroup({ pool, groupReference })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a group code and returns a GroupReference object.
|
||||||
|
*
|
||||||
|
* @param code The group code to parse.
|
||||||
|
* @returns A GroupReference object if the code is valid, otherwise null.
|
||||||
|
*/
|
||||||
export function parseGroupCode(code: string): null | GroupReference {
|
export function parseGroupCode(code: string): null | GroupReference {
|
||||||
if (code.startsWith('naddr1')) {
|
if (code.startsWith('naddr1')) {
|
||||||
try {
|
try {
|
||||||
@@ -99,68 +533,74 @@ export function parseGroupCode(code: string): null | GroupReference {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a group reference into a string.
|
||||||
|
*
|
||||||
|
* @param gr - The group reference to encode.
|
||||||
|
* @returns The encoded group reference as a string.
|
||||||
|
*/
|
||||||
export function encodeGroupReference(gr: GroupReference): string {
|
export function encodeGroupReference(gr: GroupReference): string {
|
||||||
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8)
|
const { host, id } = gr
|
||||||
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6)
|
const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
|
||||||
return `${gr.host}'${gr.id}`
|
|
||||||
|
return `${normalizedHost}'${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Group = {
|
/**
|
||||||
id: string
|
* Subscribes to relay groups metadata events and calls the provided event handler function
|
||||||
relay: string
|
* when an event is received.
|
||||||
pubkey: string
|
*
|
||||||
name?: string
|
* @param {Object} options - The options for subscribing to relay groups metadata events.
|
||||||
picture?: string
|
* @param {AbstractSimplePool} options.pool - The pool to subscribe to.
|
||||||
about?: string
|
* @param {string} options.relayURL - The URL of the relay.
|
||||||
public?: boolean
|
* @param {Function} options.onError - The error handler function.
|
||||||
open?: boolean
|
* @param {Function} options.onEvent - The event handler function.
|
||||||
}
|
* @param {Function} [options.onConnect] - The connect handler function.
|
||||||
|
* @returns {Function} - A function to close the subscription
|
||||||
|
*/
|
||||||
|
export function subscribeRelayGroupsMetadataEvents({
|
||||||
|
pool,
|
||||||
|
relayURL,
|
||||||
|
onError,
|
||||||
|
onEvent,
|
||||||
|
onConnect,
|
||||||
|
}: {
|
||||||
|
pool: AbstractSimplePool
|
||||||
|
relayURL: string
|
||||||
|
onError: (err: Error) => void
|
||||||
|
onEvent: (event: Event) => void
|
||||||
|
onConnect?: () => void
|
||||||
|
}): () => void {
|
||||||
|
let sub: Subscription
|
||||||
|
|
||||||
export function parseGroup(event: Event, relay: string): Group {
|
const normalizedRelayURL = normalizeURL(relayURL)
|
||||||
const group: Partial<Group> = { relay, pubkey: event.pubkey }
|
|
||||||
for (let i = 0; i < event.tags.length; i++) {
|
|
||||||
const tag = event.tags[i]
|
|
||||||
switch (tag[0]) {
|
|
||||||
case 'd':
|
|
||||||
group.id = tag[1] || ''
|
|
||||||
break
|
|
||||||
case 'name':
|
|
||||||
group.name = tag[1] || ''
|
|
||||||
break
|
|
||||||
case 'about':
|
|
||||||
group.about = tag[1] || ''
|
|
||||||
break
|
|
||||||
case 'picture':
|
|
||||||
group.picture = tag[1] || ''
|
|
||||||
break
|
|
||||||
case 'open':
|
|
||||||
group.open = true
|
|
||||||
break
|
|
||||||
case 'public':
|
|
||||||
group.public = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return group as Group
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Member = {
|
fetchRelayInformation(normalizedRelayURL)
|
||||||
pubkey: string
|
.then(async info => {
|
||||||
label?: string
|
const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
|
||||||
permissions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMembers(event: Event): Member[] {
|
onConnect?.()
|
||||||
const members = []
|
|
||||||
for (let i = 0; i < event.tags.length; i++) {
|
sub = abstractedRelay.prepareSubscription(
|
||||||
const tag = event.tags[i]
|
[
|
||||||
if (tag.length < 2) continue
|
{
|
||||||
if (tag[0] !== 'p') continue
|
kinds: [39000],
|
||||||
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
|
limit: 50,
|
||||||
const member: Member = { pubkey: tag[1], permissions: [] }
|
authors: [info.pubkey],
|
||||||
if (tag.length > 2) member.label = tag[2]
|
},
|
||||||
if (tag.length > 3) member.permissions = tag.slice(3)
|
],
|
||||||
members.push(member)
|
{
|
||||||
}
|
onevent(event: Event) {
|
||||||
return members
|
onEvent(event)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
sub.close()
|
||||||
|
|
||||||
|
onError(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => sub.close()
|
||||||
}
|
}
|
||||||
|
|||||||
2
nip46.ts
2
nip46.ts
@@ -254,7 +254,7 @@ export class BunkerSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||||
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext])
|
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
nip49.ts
9
nip49.ts
@@ -1,10 +1,15 @@
|
|||||||
import { scrypt } from '@noble/hashes/scrypt'
|
import { scrypt } from '@noble/hashes/scrypt'
|
||||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||||
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||||
import { Bech32MaxSize, encodeBytes } from './nip19.ts'
|
import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
|
||||||
import { bech32 } from '@scure/base'
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {
|
export function encrypt(
|
||||||
|
sec: Uint8Array,
|
||||||
|
password: string,
|
||||||
|
logn: number = 16,
|
||||||
|
ksb: 0x00 | 0x01 | 0x02 = 0x02,
|
||||||
|
): Ncryptsec {
|
||||||
let salt = randomBytes(16)
|
let salt = randomBytes(16)
|
||||||
let n = 2 ** logn
|
let n = 2 ** logn
|
||||||
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||||
|
|||||||
4
nip96.ts
4
nip96.ts
@@ -340,9 +340,6 @@ export async function uploadFile(
|
|||||||
// Create FormData object
|
// Create FormData object
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
|
||||||
// Append the authorization header to HTML Form Data
|
|
||||||
formData.append('Authorization', nip98AuthorizationHeader)
|
|
||||||
|
|
||||||
// Append optional fields to FormData
|
// Append optional fields to FormData
|
||||||
optionalFormDataFields &&
|
optionalFormDataFields &&
|
||||||
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
|
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
|
||||||
@@ -359,7 +356,6 @@ export async function uploadFile(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: nip98AuthorizationHeader,
|
Authorization: nip98AuthorizationHeader,
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "2.7.1",
|
"version": "2.8.0",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
30
pool.test.ts
30
pool.test.ts
@@ -205,3 +205,33 @@ test('get()', async () => {
|
|||||||
expect(event).not.toBeNull()
|
expect(event).not.toBeNull()
|
||||||
expect(event).toHaveProperty('id', ids[0])
|
expect(event).toHaveProperty('id', ids[0])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('track relays when publishing', async () => {
|
||||||
|
let event1 = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'hello',
|
||||||
|
},
|
||||||
|
generateSecretKey(),
|
||||||
|
)
|
||||||
|
let event2 = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'hello',
|
||||||
|
},
|
||||||
|
generateSecretKey(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pool.trackRelays = true
|
||||||
|
await Promise.all(pool.publish(relayURLs, event1))
|
||||||
|
expect(pool.seenOn.get(event1.id)).toBeDefined()
|
||||||
|
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
|
||||||
|
|
||||||
|
pool.trackRelays = false
|
||||||
|
await Promise.all(pool.publish(relayURLs, event2))
|
||||||
|
expect(pool.seenOn.get(event2.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user