mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a612e59a2 | ||
|
|
266dbdf766 | ||
|
|
19ae9837a7 | ||
|
|
4188f2c596 | ||
|
|
97bded8f5b | ||
|
|
174d36a440 | ||
|
|
0177b130c3 | ||
|
|
05eb62da5b | ||
|
|
3c4019a154 | ||
|
|
e7e8db1dbd | ||
|
|
44a679e642 | ||
|
|
c1172caf1d | ||
|
|
86f37d6003 | ||
|
|
3daade322c | ||
|
|
fcf10541c8 | ||
|
|
548abb5d4a | ||
|
|
1e5bfe856b | ||
|
|
3266b4d4c2 | ||
|
|
a0b950ab12 | ||
|
|
be741159d7 | ||
|
|
9c50b2c655 | ||
|
|
bbb09420fe | ||
|
|
2e85f7a5fe | ||
|
|
b22e2465cc | ||
|
|
43ce7f9377 | ||
|
|
5a55c670fb |
@@ -16,10 +16,11 @@ export type SubCloser = { close: () => void }
|
||||
|
||||
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
|
||||
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
|
||||
maxWait?: number
|
||||
onclose?: (reasons: string[]) => void
|
||||
id?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export class AbstractSimplePool {
|
||||
@@ -83,6 +84,7 @@ export class AbstractSimplePool {
|
||||
// batch all EOSEs into a single
|
||||
const eosesReceived: boolean[] = []
|
||||
let handleEose = (i: number) => {
|
||||
if (eosesReceived[i]) return // do not act twice for the same relay
|
||||
eosesReceived[i] = true
|
||||
if (eosesReceived.filter(a => a).length === relaysLength) {
|
||||
params.oneose?.()
|
||||
@@ -92,6 +94,7 @@ export class AbstractSimplePool {
|
||||
// batch all closes into a single
|
||||
const closesReceived: string[] = []
|
||||
let handleClose = (i: number, reason: string) => {
|
||||
if (closesReceived[i]) return // do not act twice for the same relay
|
||||
handleEose(i)
|
||||
closesReceived[i] = reason
|
||||
if (closesReceived.filter(a => a).length === relaysLength) {
|
||||
@@ -156,7 +159,7 @@ export class AbstractSimplePool {
|
||||
subscribeManyEose(
|
||||
relays: string[],
|
||||
filters: Filter[],
|
||||
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||
): SubCloser {
|
||||
const subcloser = this.subscribeMany(relays, filters, {
|
||||
...params,
|
||||
@@ -170,7 +173,7 @@ export class AbstractSimplePool {
|
||||
async querySync(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event[]> {
|
||||
return new Promise(async resolve => {
|
||||
const events: Event[] = []
|
||||
@@ -189,7 +192,7 @@ export class AbstractSimplePool {
|
||||
async get(
|
||||
relays: string[],
|
||||
filter: Filter,
|
||||
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||
params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
|
||||
): Promise<Event | null> {
|
||||
filter.limit = 1
|
||||
const events = await this.querySync(relays, filter, params)
|
||||
|
||||
@@ -200,6 +200,7 @@ export class AbstractRelay {
|
||||
const reason: string = data[3]
|
||||
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||
if (ep) {
|
||||
clearTimeout(ep.timeout)
|
||||
if (ok) ep.resolve(reason)
|
||||
else ep.reject(new Error(reason))
|
||||
this.openEventPublishes.delete(id)
|
||||
@@ -240,7 +241,14 @@ export class AbstractRelay {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||
const timeout = setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('auth timed out'))
|
||||
this.openEventPublishes.delete(evt.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
|
||||
})
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
return ret
|
||||
@@ -248,16 +256,16 @@ export class AbstractRelay {
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||
const timeout = setTimeout(() => {
|
||||
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
|
||||
if (ep) {
|
||||
ep.reject(new Error('publish timed out'))
|
||||
this.openEventPublishes.delete(event.id)
|
||||
}
|
||||
}, this.publishTimeout)
|
||||
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
|
||||
})
|
||||
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||
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
|
||||
}
|
||||
|
||||
@@ -271,15 +279,21 @@ export class AbstractRelay {
|
||||
return ret
|
||||
}
|
||||
|
||||
public subscribe(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
public subscribe(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
const subscription = this.prepareSubscription(filters, params)
|
||||
subscription.fire()
|
||||
return subscription
|
||||
}
|
||||
|
||||
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||
public prepareSubscription(
|
||||
filters: Filter[],
|
||||
params: Partial<SubscriptionParams> & { label?: string; id?: string },
|
||||
): Subscription {
|
||||
this.serial++
|
||||
const id = params.id || 'sub:' + this.serial
|
||||
const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
|
||||
const subscription = new Subscription(this, id, filters, params)
|
||||
this.openSubs.set(id, subscription)
|
||||
return subscription
|
||||
@@ -381,4 +395,5 @@ export type CountResolver = {
|
||||
export type EventPublishResolver = {
|
||||
resolve: (reason: string) => void
|
||||
reject: (err: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Event } from './core.ts'
|
||||
import { isParameterizedReplaceableKind, isReplaceableKind } from './kinds.ts'
|
||||
import { isAddressableKind, isReplaceableKind } from './kinds.ts'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
@@ -98,7 +98,7 @@ export function getFilterLimit(filter: Filter): number {
|
||||
: 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?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
|
||||
? filter.authors.length * filter.kinds.length * filter['#d'].length
|
||||
: Infinity,
|
||||
)
|
||||
|
||||
2
index.ts
2
index.ts
@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
|
||||
export * as nip10 from './nip10.ts'
|
||||
export * as nip11 from './nip11.ts'
|
||||
export * as nip13 from './nip13.ts'
|
||||
export * as nip17 from './nip17.ts'
|
||||
export * as nip18 from './nip18.ts'
|
||||
export * as nip19 from './nip19.ts'
|
||||
export * as nip21 from './nip21.ts'
|
||||
@@ -20,6 +21,7 @@ export * as nip39 from './nip39.ts'
|
||||
export * as nip42 from './nip42.ts'
|
||||
export * as nip44 from './nip44.ts'
|
||||
export * as nip47 from './nip47.ts'
|
||||
export * as nip54 from './nip54.ts'
|
||||
export * as nip57 from './nip57.ts'
|
||||
export * as nip59 from './nip59.ts'
|
||||
export * as nip98 from './nip98.ts'
|
||||
|
||||
3
jsr.json
3
jsr.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nostr/tools",
|
||||
"version": "2.10.2",
|
||||
"version": "2.11.0",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./core": "./core.ts",
|
||||
@@ -34,6 +34,7 @@
|
||||
"./nip44": "./nip44.ts",
|
||||
"./nip46": "./nip46.ts",
|
||||
"./nip49": "./nip49.ts",
|
||||
"./nip54": "./nip54.ts",
|
||||
"./nip57": "./nip57.ts",
|
||||
"./nip58": "./nip58.ts",
|
||||
"./nip59": "./nip59.ts",
|
||||
|
||||
9
kinds.ts
9
kinds.ts
@@ -15,11 +15,14 @@ export function isEphemeralKind(kind: number): boolean {
|
||||
return 20000 <= kind && kind < 30000
|
||||
}
|
||||
|
||||
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isParameterizedReplaceableKind(kind: number): boolean {
|
||||
/** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
|
||||
export function isAddressableKind(kind: number): boolean {
|
||||
return 30000 <= kind && kind < 40000
|
||||
}
|
||||
|
||||
/** @deprecated use isAddressableKind instead */
|
||||
export const isParameterizedReplaceableKind = isAddressableKind
|
||||
|
||||
/** Classification of the event kind. */
|
||||
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||
|
||||
@@ -28,7 +31,7 @@ export function classifyKind(kind: number): KindClassification {
|
||||
if (isRegularKind(kind)) return 'regular'
|
||||
if (isReplaceableKind(kind)) return 'replaceable'
|
||||
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||
if (isAddressableKind(kind)) return 'parameterized'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
|
||||
2
nip07.ts
2
nip07.ts
@@ -1,10 +1,8 @@
|
||||
import { EventTemplate, NostrEvent } from './core.ts'
|
||||
import { RelayRecord } from './relay.ts'
|
||||
|
||||
export interface WindowNostr {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<NostrEvent>
|
||||
getRelays(): Promise<RelayRecord>
|
||||
nip04?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
|
||||
17
nip10.ts
17
nip10.ts
@@ -108,23 +108,26 @@ export function parse(event: Pick<Event, 'tags'>): {
|
||||
|
||||
// remove root and reply from mentions, inherit relay hints from authors if any
|
||||
;[result.reply, result.root].forEach(ref => {
|
||||
let idx = result.mentions.indexOf(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 (ref.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
if (author && author.relays) {
|
||||
if (!ref!.relays) {
|
||||
ref!.relays = []
|
||||
if (!ref.relays) {
|
||||
ref.relays = []
|
||||
}
|
||||
author.relays.forEach(url => {
|
||||
if (ref?.relays!?.indexOf(url) === -1) ref!.relays!.push(url)
|
||||
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
|
||||
})
|
||||
author.relays = ref!.relays
|
||||
author.relays = ref.relays
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result.mentions.forEach(ref => {
|
||||
if (ref!.author) {
|
||||
let author = result.profiles.find(p => p.pubkey === ref.author)
|
||||
|
||||
@@ -10,7 +10,9 @@ describe('requesting relay as for NIP11', () => {
|
||||
const info = await fetchRelayInformation('wss://nos.lol')
|
||||
expect(info.name).toEqual('nos.lol')
|
||||
expect(info.description).toContain('Generally accepts notes, except spammy ones.')
|
||||
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||
expect(info.supported_nips).toContain(1)
|
||||
expect(info.supported_nips).toContain(11)
|
||||
expect(info.supported_nips).toContain(70)
|
||||
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
|
||||
})
|
||||
})
|
||||
|
||||
2
nip11.ts
2
nip11.ts
@@ -126,7 +126,7 @@ export interface Limitations {
|
||||
restricted_writes: boolean
|
||||
}
|
||||
|
||||
interface RetentionDetails {
|
||||
export interface RetentionDetails {
|
||||
kinds: (number | number[])[]
|
||||
time?: number | null
|
||||
count?: number | null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
|
||||
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericRepost', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: 1617932114,
|
||||
kind: BadgeDefinitionKind,
|
||||
tags: [
|
||||
['d', 'badge-id'],
|
||||
['name', 'Badge Name'],
|
||||
['description', 'Badge Description'],
|
||||
['image', 'https://example.com/badge.png', '1024x1024'],
|
||||
['thumb', 'https://example.com/thumb.png', '100x100'],
|
||||
['thumb', 'https://example.com/thumb2.png', '200x200'],
|
||||
],
|
||||
}
|
||||
|
||||
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
|
||||
test('should create a generic reposted event', () => {
|
||||
const template = { created_at: 1617932115 }
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.kind).toEqual(GenericRepost)
|
||||
expect(event.tags).toEqual([
|
||||
['e', repostedEvent.id, relayUrl],
|
||||
['p', repostedEvent.pubkey],
|
||||
['k', '30009'],
|
||||
])
|
||||
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||
expect(event.created_at).toEqual(template.created_at)
|
||||
expect(event.pubkey).toEqual(publicKey)
|
||||
|
||||
const repostedEventPointer = getRepostedEventPointer(event)
|
||||
|
||||
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
|
||||
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
|
||||
const repostedEventFromContent = getRepostedEvent(event)
|
||||
|
||||
expect(repostedEventFromContent).toEqual(repostedEvent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepostedEventPointer', () => {
|
||||
test('should parse an event with only an `e` tag', () => {
|
||||
const event = buildEvent({
|
||||
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
|
||||
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||
})
|
||||
})
|
||||
|
||||
describe('finishRepostEvent', () => {
|
||||
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||
|
||||
test('should create an event with empty content if the reposted event is protected', () => {
|
||||
const repostedEvent = finalizeEvent(
|
||||
{
|
||||
kind: ShortTextNote,
|
||||
tags: [['-']],
|
||||
content: 'Replied to a post',
|
||||
created_at: 1617932115,
|
||||
},
|
||||
privateKey,
|
||||
)
|
||||
const template = {
|
||||
created_at: 1617932115,
|
||||
}
|
||||
|
||||
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||
|
||||
expect(event.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
21
nip18.ts
21
nip18.ts
@@ -1,6 +1,6 @@
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
import { Repost } from './kinds.ts'
|
||||
import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
|
||||
import { EventPointer } from './nip19.ts'
|
||||
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||
|
||||
export type RepostEventTemplate = {
|
||||
/**
|
||||
@@ -25,11 +25,20 @@ export function finishRepostEvent(
|
||||
relayUrl: string,
|
||||
privateKey: Uint8Array,
|
||||
): Event {
|
||||
let kind: Repost | GenericRepost
|
||||
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
|
||||
if (reposted.kind === ShortTextNote) {
|
||||
kind = Repost
|
||||
} else {
|
||||
kind = GenericRepost
|
||||
tags.push(['k', String(reposted.kind)])
|
||||
}
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: Repost,
|
||||
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||
kind,
|
||||
tags,
|
||||
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
|
||||
created_at: t.created_at,
|
||||
},
|
||||
privateKey,
|
||||
@@ -37,7 +46,7 @@ export function finishRepostEvent(
|
||||
}
|
||||
|
||||
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||
if (event.kind !== Repost) {
|
||||
if (![Repost, GenericRepost].includes(event.kind)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
9
nip19.ts
9
nip19.ts
@@ -79,6 +79,15 @@ export type DecodeResult = {
|
||||
[P in keyof Prefixes]: DecodeValue<P>
|
||||
}[keyof Prefixes]
|
||||
|
||||
export function decodeNostrURI(nip19code: string): DecodeResult | { type: 'invalid'; data: null } {
|
||||
try {
|
||||
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
|
||||
return decode(nip19code)
|
||||
} catch (_err) {
|
||||
return { type: 'invalid', data: null }
|
||||
}
|
||||
}
|
||||
|
||||
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
|
||||
export function decode(nip19: string): DecodeResult
|
||||
export function decode(nip19: string): DecodeResult {
|
||||
|
||||
117
nip27.test.ts
117
nip27.test.ts
@@ -1,68 +1,77 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { matchAll, replaceAll } from './nip27.ts'
|
||||
import { parse } from './nip27.ts'
|
||||
|
||||
test('matchAll', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('first: parse simple content with 1 url and 1 nostr uri', () => {
|
||||
const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
{
|
||||
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||
decoded: {
|
||||
type: 'npub',
|
||||
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||
},
|
||||
start: 6,
|
||||
end: 75,
|
||||
},
|
||||
{
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
decoded: {
|
||||
type: 'note',
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
},
|
||||
start: 78,
|
||||
end: 147,
|
||||
},
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
|
||||
{ type: 'text', text: ' check out my profile:' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '; and this cool image ' },
|
||||
{ type: 'image', url: 'https://images.com/image.jpg' },
|
||||
])
|
||||
})
|
||||
|
||||
test('matchAll with an invalid nip19', () => {
|
||||
const result = matchAll(
|
||||
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
)
|
||||
test('second: parse content with 3 urls of different types', () => {
|
||||
const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
|
||||
http://music.com/song.mp3
|
||||
and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
expect([...result]).toEqual([
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: ':' },
|
||||
{ type: 'relay', url: 'wss://oa.ao/' },
|
||||
{ type: 'text', text: "; this was a relay and now here's a video -> " },
|
||||
{ type: 'video', url: 'https://videos.com/video.mp4' },
|
||||
{ type: 'text', text: '! and some music:\n' },
|
||||
{ type: 'audio', url: 'http://music.com/song.mp3' },
|
||||
{ type: 'text', text: '\nand a regular link: ' },
|
||||
{ type: 'url', url: 'https://regular.com/page?ok=true' },
|
||||
{
|
||||
decoded: {
|
||||
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||
type: 'note',
|
||||
},
|
||||
end: 193,
|
||||
start: 124,
|
||||
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||
type: 'text',
|
||||
text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
|
||||
},
|
||||
{ type: 'url', url: 'https://ok.com/' },
|
||||
{ type: 'text', text: '!' },
|
||||
])
|
||||
})
|
||||
|
||||
test('replaceAll', () => {
|
||||
const content =
|
||||
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||
test('third: parse complex content with 4 nostr uris and 3 urls', () => {
|
||||
const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
|
||||
here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
|
||||
with a video https://example.com/vid.webm and finally https://example.com/docs`
|
||||
const blocks = Array.from(parse(content))
|
||||
|
||||
const result = replaceAll(content, ({ decoded, value }) => {
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return '@alex'
|
||||
case 'note':
|
||||
return '!1234'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'text', text: 'Look at these profiles ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: ' ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
|
||||
relays: ['wss://qwieu.com'],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' check this event ' },
|
||||
{
|
||||
type: 'reference',
|
||||
pointer: {
|
||||
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
|
||||
relays: ['wss://zjbdksa.aswjdkn'],
|
||||
author: undefined,
|
||||
kind: undefined,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: "\n here's an image " },
|
||||
{ type: 'image', url: 'https://example.com/pic.png' },
|
||||
{ type: 'text', text: ' and another profile ' },
|
||||
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
|
||||
{ type: 'text', text: '\n with a video ' },
|
||||
{ type: 'video', url: 'https://example.com/vid.webm' },
|
||||
{ type: 'text', text: ' and finally ' },
|
||||
{ type: 'url', url: 'https://example.com/docs' },
|
||||
])
|
||||
})
|
||||
|
||||
212
nip27.ts
212
nip27.ts
@@ -1,63 +1,169 @@
|
||||
import { decode } from './nip19.ts'
|
||||
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||
import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
|
||||
|
||||
/** Regex to find NIP-21 URIs inside event content. */
|
||||
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||
export type Block =
|
||||
| {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: 'reference'
|
||||
pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
}
|
||||
| {
|
||||
type: 'url'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'relay'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'image'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'audio'
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Match result for a Nostr URI in event content. */
|
||||
export interface NostrURIMatch extends NostrURI {
|
||||
/** Index where the URI begins in the event content. */
|
||||
start: number
|
||||
/** Index where the URI ends in the event content. */
|
||||
end: number
|
||||
}
|
||||
const noCharacter = /\W/m
|
||||
const noURLCharacter = /\W |\W$|$|,| /m
|
||||
|
||||
/** Find and decode all NIP-21 URIs. */
|
||||
export function* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||
const matches = content.matchAll(regex())
|
||||
export function* parse(content: string): Iterable<Block> {
|
||||
const max = content.length
|
||||
let prevIndex = 0
|
||||
let index = 0
|
||||
while (index < max) {
|
||||
let u = content.indexOf(':', index)
|
||||
if (u === -1) {
|
||||
// reached end
|
||||
break
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const [uri, value] = match
|
||||
if (content.substring(u - 5, u) === 'nostr') {
|
||||
const m = content.substring(u + 60).match(noCharacter)
|
||||
const end = m ? u + 60 + m.index! : max
|
||||
try {
|
||||
let pointer: ProfilePointer | AddressPointer | EventPointer
|
||||
let { data, type } = decode(content.substring(u + 1, end))
|
||||
|
||||
yield {
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
start: match.index!,
|
||||
end: match.index! + uri.length,
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pointer = { pubkey: data } as ProfilePointer
|
||||
break
|
||||
case 'nsec':
|
||||
case 'note':
|
||||
// ignore this, treat it as not a valid uri
|
||||
index = end + 1
|
||||
continue
|
||||
default:
|
||||
pointer = data as any
|
||||
}
|
||||
|
||||
if (prevIndex !== u - 5) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
|
||||
}
|
||||
yield { type: 'reference', pointer }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid nostr uri
|
||||
index = u + 1
|
||||
continue
|
||||
}
|
||||
} catch (_e) {
|
||||
// do nothing
|
||||
} else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
|
||||
const m = content.substring(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 5 : 4
|
||||
try {
|
||||
let url = new URL(content.substring(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith('.png') ||
|
||||
url.pathname.endsWith('.jpg') ||
|
||||
url.pathname.endsWith('.jpeg') ||
|
||||
url.pathname.endsWith('.gif') ||
|
||||
url.pathname.endsWith('.webp')
|
||||
) {
|
||||
yield { type: 'image', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
if (
|
||||
url.pathname.endsWith('.mp4') ||
|
||||
url.pathname.endsWith('.avi') ||
|
||||
url.pathname.endsWith('.webm') ||
|
||||
url.pathname.endsWith('.mkv')
|
||||
) {
|
||||
yield { type: 'video', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
if (
|
||||
url.pathname.endsWith('.mp3') ||
|
||||
url.pathname.endsWith('.aac') ||
|
||||
url.pathname.endsWith('.ogg') ||
|
||||
url.pathname.endsWith('.opus')
|
||||
) {
|
||||
yield { type: 'audio', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
}
|
||||
|
||||
yield { type: 'url', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
|
||||
const m = content.substring(u + 4).match(noURLCharacter)
|
||||
const end = m ? u + 4 + m.index! : max
|
||||
const prefixLen = content[u - 1] === 's' ? 3 : 2
|
||||
try {
|
||||
let url = new URL(content.substring(u - prefixLen, end))
|
||||
if (url.hostname.indexOf('.') === -1) {
|
||||
throw new Error('invalid ws url')
|
||||
}
|
||||
|
||||
if (prevIndex !== u - prefixLen) {
|
||||
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
|
||||
}
|
||||
yield { type: 'relay', url: url.toString() }
|
||||
index = end
|
||||
prevIndex = index
|
||||
continue
|
||||
} catch (_err) {
|
||||
// ignore this, not a valid url
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// ignore this, it is nothing
|
||||
index = u + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all occurrences of Nostr URIs in the text.
|
||||
*
|
||||
* WARNING: using this on an HTML string is potentially unsafe!
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* nip27.replaceAll(event.content, ({ decoded, value }) => {
|
||||
* switch(decoded.type) {
|
||||
* case 'npub':
|
||||
* return renderMention(decoded)
|
||||
* case 'note':
|
||||
* return renderNote(decoded)
|
||||
* default:
|
||||
* return value
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
|
||||
return content.replaceAll(regex(), (uri, value: string) => {
|
||||
return replacer({
|
||||
uri: uri as `nostr:${string}`,
|
||||
value,
|
||||
decoded: decode(value),
|
||||
})
|
||||
})
|
||||
if (prevIndex !== max) {
|
||||
yield { type: 'text', text: content.substring(prevIndex) }
|
||||
}
|
||||
}
|
||||
|
||||
8
nip29.ts
8
nip29.ts
@@ -58,13 +58,21 @@ export type GroupAdmin = {
|
||||
* Represents the permissions that a NIP29 group admin can have.
|
||||
*/
|
||||
export enum GroupAdminPermission {
|
||||
/** @deprecated use PutUser instead */
|
||||
AddUser = 'add-user',
|
||||
EditMetadata = 'edit-metadata',
|
||||
DeleteEvent = 'delete-event',
|
||||
RemoveUser = 'remove-user',
|
||||
/** @deprecated removed from NIP */
|
||||
AddPermission = 'add-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
RemovePermission = 'remove-permission',
|
||||
/** @deprecated removed from NIP */
|
||||
EditGroupStatus = 'edit-group-status',
|
||||
PutUser = 'put-user',
|
||||
CreateGroup = 'create-group',
|
||||
DeleteGroup = 'delete-group',
|
||||
CreateInvite = 'create-invite',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
6
nip46.ts
6
nip46.ts
@@ -1,4 +1,4 @@
|
||||
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
|
||||
import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
|
||||
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||
import { decrypt as legacyDecrypt } from './nip04.ts'
|
||||
@@ -223,7 +223,7 @@ export class BunkerSigner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the "get_relays" method on the bunker.
|
||||
* @deprecated removed from NIP
|
||||
*/
|
||||
async getRelays(): Promise<RelayRecord> {
|
||||
return JSON.parse(await this.sendRequest('get_relays', []))
|
||||
@@ -234,7 +234,7 @@ export class BunkerSigner {
|
||||
* @param event - The event to sign.
|
||||
* @returns A Promise that resolves to the signed event.
|
||||
*/
|
||||
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
|
||||
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
|
||||
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||
let signed: NostrEvent = JSON.parse(resp)
|
||||
if (verifyEvent(signed)) {
|
||||
|
||||
42
nip54.test.ts
Normal file
42
nip54.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { normalizeIdentifier } from './nip54.ts'
|
||||
|
||||
describe('normalizeIdentifier', () => {
|
||||
test('converts to lowercase', () => {
|
||||
expect(normalizeIdentifier('HELLO')).toBe('hello')
|
||||
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
|
||||
})
|
||||
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeIdentifier(' hello ')).toBe('hello')
|
||||
expect(normalizeIdentifier('\thello\n')).toBe('hello')
|
||||
})
|
||||
|
||||
test('normalizes Unicode to NFKC form', () => {
|
||||
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
|
||||
expect(normalizeIdentifier('café')).toBe('café')
|
||||
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
|
||||
})
|
||||
|
||||
test('replaces non-alphanumeric characters with hyphens', () => {
|
||||
expect(normalizeIdentifier('hello world')).toBe('hello-world')
|
||||
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
|
||||
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
|
||||
})
|
||||
|
||||
test('preserves numbers', () => {
|
||||
expect(normalizeIdentifier('user123')).toBe('user123')
|
||||
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
|
||||
})
|
||||
|
||||
test('handles multiple consecutive special characters', () => {
|
||||
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
|
||||
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
|
||||
})
|
||||
|
||||
test('handles Unicode letters from different scripts', () => {
|
||||
expect(normalizeIdentifier('привет')).toBe('привет')
|
||||
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
|
||||
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
|
||||
})
|
||||
})
|
||||
19
nip54.ts
Normal file
19
nip54.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function normalizeIdentifier(name: string): string {
|
||||
// Trim and lowercase
|
||||
name = name.trim().toLowerCase()
|
||||
|
||||
// Normalize Unicode to NFKC form
|
||||
name = name.normalize('NFKC')
|
||||
|
||||
// Convert to array of characters and map each one
|
||||
return Array.from(name)
|
||||
.map(char => {
|
||||
// Check if character is letter or number using Unicode ranges
|
||||
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
|
||||
return char
|
||||
}
|
||||
|
||||
return '-'
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
166
nip55.test.ts
Normal file
166
nip55.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import * as nip55 from './nip55.js'
|
||||
|
||||
// Function to parse the NostrSigner URI
|
||||
function parseNostrSignerUri(uri: string) {
|
||||
const [base, query] = uri.split('?')
|
||||
const basePart = base.replace('nostrsigner:', '')
|
||||
|
||||
let jsonObject = null
|
||||
if (basePart) {
|
||||
try {
|
||||
jsonObject = JSON.parse(decodeURIComponent(basePart))
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse base JSON:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const urlSearchParams = new URLSearchParams(query)
|
||||
const queryParams = Object.fromEntries(urlSearchParams.entries())
|
||||
if (queryParams.permissions) {
|
||||
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
|
||||
}
|
||||
|
||||
return {
|
||||
base: jsonObject,
|
||||
...queryParams,
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases
|
||||
test('Get Public Key URI', () => {
|
||||
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.getPublicKeyUri({
|
||||
permissions,
|
||||
callbackUrl,
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'get_public_key')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
|
||||
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
|
||||
})
|
||||
|
||||
test('Sign Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.signEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('base.content', 'test')
|
||||
expect(jsonObject).toHaveProperty('type', 'sign_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-04 URI', () => {
|
||||
const callbackUrl = 'https://example.com/?event='
|
||||
|
||||
const uri = nip55.encryptNip04Uri({
|
||||
callbackUrl,
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-04 URI', () => {
|
||||
const uri = nip55.decryptNip04Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Encrypt NIP-44 URI', () => {
|
||||
const uri = nip55.encryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'plainText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('plainText', 'plainText')
|
||||
})
|
||||
|
||||
test('Decrypt NIP-44 URI', () => {
|
||||
const uri = nip55.decryptNip44Uri({
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
pubKey: 'hex_pub_key',
|
||||
content: 'encryptedText',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'none')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'signature')
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
|
||||
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
|
||||
})
|
||||
|
||||
test('Decrypt Zap Event URI', () => {
|
||||
const eventJson = { kind: 1, content: 'test' }
|
||||
|
||||
const uri = nip55.decryptZapEventUri({
|
||||
eventJson,
|
||||
id: 'some_id',
|
||||
currentUser: 'hex_pub_key',
|
||||
returnType: 'event',
|
||||
compressionType: 'gzip',
|
||||
})
|
||||
|
||||
const jsonObject = parseNostrSignerUri(uri)
|
||||
|
||||
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
|
||||
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
|
||||
expect(jsonObject).toHaveProperty('returnType', 'event')
|
||||
expect(jsonObject).toHaveProperty('base.kind', 1)
|
||||
expect(jsonObject).toHaveProperty('id', 'some_id')
|
||||
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
|
||||
})
|
||||
123
nip55.ts
Normal file
123
nip55.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
type BaseParams = {
|
||||
callbackUrl?: string
|
||||
returnType?: 'signature' | 'event'
|
||||
compressionType?: 'none' | 'gzip'
|
||||
}
|
||||
|
||||
type PermissionsParams = BaseParams & {
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
}
|
||||
|
||||
type EventUriParams = BaseParams & {
|
||||
eventJson: Record<string, unknown>
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type EncryptDecryptParams = BaseParams & {
|
||||
pubKey: string
|
||||
content: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
}
|
||||
|
||||
type UriParams = BaseParams & {
|
||||
base: string
|
||||
type: string
|
||||
id?: string
|
||||
currentUser?: string
|
||||
permissions?: { type: string; kind?: number }[]
|
||||
pubKey?: string
|
||||
plainText?: string
|
||||
encryptedText?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
function encodeParams(params: Record<string, unknown>): string {
|
||||
return new URLSearchParams(params as Record<string, string>).toString()
|
||||
}
|
||||
|
||||
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
|
||||
}
|
||||
|
||||
function buildUri({
|
||||
base,
|
||||
type,
|
||||
callbackUrl,
|
||||
returnType = 'signature',
|
||||
compressionType = 'none',
|
||||
...params
|
||||
}: UriParams): string {
|
||||
const baseParams = {
|
||||
type,
|
||||
compressionType,
|
||||
returnType,
|
||||
callbackUrl,
|
||||
id: params.id,
|
||||
current_user: params.currentUser,
|
||||
permissions:
|
||||
params.permissions && params.permissions.length > 0
|
||||
? encodeURIComponent(JSON.stringify(params.permissions))
|
||||
: undefined,
|
||||
pubKey: params.pubKey,
|
||||
plainText: params.plainText,
|
||||
encryptedText: params.encryptedText,
|
||||
appName: params.appName,
|
||||
}
|
||||
|
||||
const filteredParams = filterUndefined(baseParams)
|
||||
return `${base}?${encodeParams(filteredParams)}`
|
||||
}
|
||||
|
||||
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
|
||||
return buildUri({
|
||||
base: 'nostrsigner:',
|
||||
type,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
|
||||
return buildDefaultUri('get_public_key', { permissions, ...params })
|
||||
}
|
||||
|
||||
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'sign_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, plainText: params.content })
|
||||
}
|
||||
|
||||
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
|
||||
return buildDefaultUri(type, { ...params, encryptedText: params.content })
|
||||
}
|
||||
|
||||
export function encryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip04_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip04Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip04_decrypt', params)
|
||||
}
|
||||
|
||||
export function encryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return encryptUri('nip44_encrypt', params)
|
||||
}
|
||||
|
||||
export function decryptNip44Uri(params: EncryptDecryptParams): string {
|
||||
return decryptUri('nip44_decrypt', params)
|
||||
}
|
||||
|
||||
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
|
||||
return buildUri({
|
||||
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
|
||||
type: 'decrypt_zap_event',
|
||||
...params,
|
||||
})
|
||||
}
|
||||
18
nip57.ts
18
nip57.ts
@@ -2,6 +2,7 @@ import { bech32 } from '@scure/base'
|
||||
|
||||
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||
import { utf8Decoder } from './utils.ts'
|
||||
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
|
||||
|
||||
var _fetch: any
|
||||
|
||||
@@ -49,7 +50,7 @@ export function makeZapRequest({
|
||||
comment = '',
|
||||
}: {
|
||||
profile: string
|
||||
event: string | null
|
||||
event: string | Event | null
|
||||
amount: number
|
||||
comment: string
|
||||
relays: string[]
|
||||
@@ -68,9 +69,22 @@ export function makeZapRequest({
|
||||
],
|
||||
}
|
||||
|
||||
if (event) {
|
||||
if (event && typeof event === 'string') {
|
||||
zr.tags.push(['e', event])
|
||||
}
|
||||
if (event && typeof event === 'object') {
|
||||
// replacable event
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
const a = ['a', `${event.kind}:${event.pubkey}:`]
|
||||
zr.tags.push(a)
|
||||
// addressable event
|
||||
} else if (isAddressableKind(event.kind)) {
|
||||
let d = event.tags.find(([t, v]) => t === 'd' && v)
|
||||
if (!d) throw new Error('d tag not found or is empty')
|
||||
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
|
||||
zr.tags.push(a)
|
||||
}
|
||||
}
|
||||
|
||||
return zr
|
||||
}
|
||||
|
||||
89
nip96.ts
89
nip96.ts
@@ -267,13 +267,11 @@ export async function readServerConfig(serverUrl: string): Promise<ServerConfigu
|
||||
* @returns true if the object is a valid FileUploadResponse, otherwise false.
|
||||
*/
|
||||
export function validateFileUploadResponse(response: any): response is FileUploadResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message) {
|
||||
if (typeof response !== 'object' || response === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') {
|
||||
if (!['success', 'error', 'processing'].includes(response.status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -285,10 +283,8 @@ export function validateFileUploadResponse(response: any): response is FileUploa
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.processing_url) {
|
||||
if (typeof response.processing_url !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (response.processing_url && typeof response.processing_url !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status === 'success' && !response.nip94_event) {
|
||||
@@ -296,25 +292,21 @@ export function validateFileUploadResponse(response: any): response is FileUploa
|
||||
}
|
||||
|
||||
if (response.nip94_event) {
|
||||
if (
|
||||
!response.nip94_event.tags ||
|
||||
!Array.isArray(response.nip94_event.tags) ||
|
||||
response.nip94_event.tags.length === 0
|
||||
) {
|
||||
const tags = response.nip94_event.tags as string[][]
|
||||
|
||||
if (!Array.isArray(tags)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const tag of response.nip94_event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length !== 2) return false
|
||||
|
||||
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
|
||||
if (tags.some(t => t.length < 2 || t.some(x => typeof x !== 'string'))) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) {
|
||||
if (!tags.some(t => t[0] === 'url')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!tags.some(t => t[0] === 'ox')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -385,17 +377,13 @@ export async function uploadFile(
|
||||
throw new Error('Unknown error in uploading file!')
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Failed to validate upload response!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -512,33 +500,28 @@ export async function checkFileProcessingStatus(
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
// 201 Created: Indicates the processing is over.
|
||||
if (response.status === 201) {
|
||||
// Validate the response
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
// 201 Created: Indicates the processing is over.
|
||||
if (response.status === 201) {
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Failed to validate upload response!')
|
||||
}
|
||||
|
||||
// 200 OK: Indicates the processing is still ongoing.
|
||||
if (response.status === 200) {
|
||||
// Validate the response
|
||||
if (!validateDelayedProcessingResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
throw new Error('Invalid response from the server!')
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
return parsedResponse as FileUploadResponse
|
||||
}
|
||||
|
||||
// 200 OK: Indicates the processing is still ongoing.
|
||||
if (response.status === 200) {
|
||||
// Validate the response
|
||||
if (!validateDelayedProcessingResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "2.10.2",
|
||||
"version": "2.11.0",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -173,6 +173,11 @@
|
||||
"require": "./lib/cjs/nip49.js",
|
||||
"types": "./lib/types/nip49.d.ts"
|
||||
},
|
||||
"./nip54": {
|
||||
"import": "./lib/esm/nip54.js",
|
||||
"require": "./lib/cjs/nip54.js",
|
||||
"types": "./lib/types/nip54.d.ts"
|
||||
},
|
||||
"./nip57": {
|
||||
"import": "./lib/esm/nip57.js",
|
||||
"require": "./lib/cjs/nip57.js",
|
||||
@@ -234,7 +239,7 @@
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nostr-wasm": "v0.1.0"
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
|
||||
Reference in New Issue
Block a user