Compare commits

..

46 Commits

Author SHA1 Message Date
fiatjaf
cbe3a9d683 pool subscribe methods accept an onauth param. 2025-04-01 19:16:42 -03:00
fiatjaf
2944a932b8 nip46: mark connection as closed when relays disconnect. 2025-03-29 18:03:39 -03:00
codytseng
6b39de04d7 Fix auth() not returning on consecutive calls 2025-03-17 13:31:24 -03:00
fiatjaf
9a612e59a2 update nip11 test. 2025-03-14 09:30:35 -03:00
fiatjaf
266dbdf766 nip27: rewrite to support urls and references in a simpler API for rich UIs. 2025-03-14 09:26:40 -03:00
fiatjaf
19ae9837a7 nip19: decodeNostrURI() function that doesn't throw. 2025-03-14 09:26:40 -03:00
António Conselheiro
4188f2c596 Generic repost 2025-03-10 01:58:00 -03:00
fiatjaf
97bded8f5b prevent a relay from eoseing then closing and causing pool handlers to fire twice. 2025-03-02 01:25:39 -03:00
fiatjaf
174d36a440 nip07: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
0177b130c3 nip55: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
05eb62da5b support subscription label, not only an absolute id. 2025-03-02 01:25:39 -03:00
Baris Aydek
3c4019a154 nip54 normalizeIdentifier function 2025-02-25 13:52:40 -03:00
fiatjaf
e7e8db1dbd nip46: take EventTemplate instead of UnsignedEvent. 2025-02-24 14:48:47 -03:00
bitcoinpirate
44a679e642 added support for zapping replaceable events (#424)
* added support for zapping replaceable events

* Update nip57.ts

* Update nip57.ts

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

* apply @SnowCait's suggestions.

* fix lint error.

---------

Co-authored-by: AsaiToshiya <to.asai.60@gmail.com>
Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>
2025-02-24 00:46:51 +09:00
Asai Toshiya
c1172caf1d mark getRelays and get_relays as deprecated. 2025-02-21 15:08:55 -03:00
Jon Staab
86f37d6003 Clean up nip96 upload validation and make it less strict 2025-02-11 15:58:20 -03:00
Sandwich
3daade322c export retention details
pain to use without being available as an export.
2025-02-10 09:25:33 -03:00
Asai Toshiya
fcf10541c8 rename "parameterized replaceable" to "addressable". 2025-01-23 14:08:22 -03:00
Asai Toshiya
548abb5d4a nip18: tweak test data. 2025-01-23 20:03:52 +09:00
Asai Toshiya
1e5bfe856b nip18: don't stringify protected event. 2025-01-17 21:30:09 -03:00
Anderson Juhasc
3266b4d4c2 added NIP-55 2025-01-04 14:15:11 -03:00
Asai Toshiya
a0b950ab12 remove unnecessary id from Omit keys. 2025-01-02 15:57:39 -03:00
Asai Toshiya
be741159d7 nip29: update GroupAdminPermission. 2024-12-17 13:33:00 -03:00
im-adithya
9c50b2c655 fix: clear timeout in publish and auth 2024-12-03 11:09:11 -03:00
Egge
bbb09420fe export nip17 2024-11-26 11:59:58 -03:00
Asai Toshiya
2e85f7a5fe Revert "nip19: remove note1."
This reverts commit a8a805fb71.
2024-11-26 11:59:58 -03:00
Asai Toshiya
b22e2465cc nip19: remove note1. 2024-11-26 11:59:58 -03:00
fiatjaf
43ce7f9377 fix reference to nostr-wasm dependency so it can be installed on deno.
fixes https://github.com/nbd-wtf/nostr-tools/issues/459
2024-11-25 21:33:25 -03:00
fiatjaf
5a55c670fb nip10: fix. 2024-11-13 01:21:54 -03:00
fiatjaf
bf0c4d4988 nip10: improve, support quotes, author hints, change the way legacy refs are discovered. 2024-11-04 15:37:39 -03:00
fiatjaf
50fe7c2a8b streamline jsr publishes. 2024-11-02 08:35:52 -03:00
fiatjaf
29270c8c9d nip46: fix legacyDecrypt argument. 2024-11-02 08:13:33 -03:00
fiatjaf
cb29d62033 add links to jsr.io 2024-10-31 16:33:24 -03:00
Asai Toshiya
0d237405d9 fix lint error. 2024-10-31 20:43:03 +09:00
Asai Toshiya
659ad36b62 nip05: use stub to test queryProfile(). 2024-10-31 07:51:53 -03:00
Asai Toshiya
d062ab8afd make publish() timeout. 2024-10-30 11:55:04 -03:00
Fishcake
94f841f347 Fix fetch to work in the edge and node environments, cleanup type issues
- Fix "TypeError: Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code)." that is thrown when trying to use fetch in the edge environment  (e.g., workers)
- Cleanup types and variable definitions.
2024-10-28 14:56:10 -03:00
fiatjaf
c1d03cf00b nip46: only encrypt with nip44 (breaking). 2024-10-27 14:59:27 -03:00
fiatjaf
29ecdfc5ec fix slow types so we can publish to jsr.io 2024-10-26 14:23:28 -03:00
fiatjaf
d3fc4734b4 export missing modules. 2024-10-26 13:10:26 -03:00
fiatjaf
66d0b8a4e1 nip46: export queryBunkerProfile() 2024-10-26 07:24:13 -03:00
fiatjaf
e2ec7a4b55 fix types, imports and other stuff on nip17 and nip59. 2024-10-25 22:10:05 -03:00
fiatjaf
a72e47135a nip46: we have no business checking the pubkey of the sign_event result. 2024-10-25 21:58:03 -03:00
Anderson Juhasc
de7bbfc6a2 organizing and improving nip17 and nip59 2024-10-25 11:49:28 -03:00
fiatjaf
f2d421fa4f nip46: remove "nip44_get_key" method as it was removed from the spec. 2024-10-25 10:22:21 -03:00
Anderson Juhasc
cae06fc4fe Implemented NIP-17 support (#449) 2024-10-24 21:10:09 -03:00
35 changed files with 1437 additions and 373 deletions

View File

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

View File

@@ -8,7 +8,7 @@ import {
} from './abstract-relay.ts' } from './abstract-relay.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
import type { Event, Nostr } from './core.ts' import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts' import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts' import { alwaysTrue } from './helpers.ts'
@@ -16,14 +16,16 @@ export type SubCloser = { close: () => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {} export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string id?: string
label?: string
} }
export class AbstractSimplePool { export class AbstractSimplePool {
protected relays = new Map<string, AbstractRelay>() protected relays: Map<string, AbstractRelay> = new Map()
public seenOn: Map<string, Set<AbstractRelay>> = new Map() public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false public trackRelays: boolean = false
@@ -83,6 +85,7 @@ export class AbstractSimplePool {
// batch all EOSEs into a single // batch all EOSEs into a single
const eosesReceived: boolean[] = [] const eosesReceived: boolean[] = []
let handleEose = (i: number) => { let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relaysLength) { if (eosesReceived.filter(a => a).length === relaysLength) {
params.oneose?.() params.oneose?.()
@@ -92,6 +95,7 @@ export class AbstractSimplePool {
// batch all closes into a single // batch all closes into a single
const closesReceived: string[] = [] const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => { let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i) handleEose(i)
closesReceived[i] = reason closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relaysLength) { if (closesReceived.filter(a => a).length === relaysLength) {
@@ -134,7 +138,28 @@ export class AbstractSimplePool {
let subscription = relay.subscribe(filters, { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => handleClose(i, reason), onclose: reason => {
if (reason.startsWith('auth-required:') && params.doauth) {
relay
.auth(params.doauth)
.then(() => {
relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler, alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait, eoseTimeout: params.maxWait,
}) })
@@ -156,7 +181,7 @@ export class AbstractSimplePool {
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filters: Filter[],
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribeMany(relays, filters, {
...params, ...params,
@@ -170,7 +195,7 @@ export class AbstractSimplePool {
async querySync( async querySync(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> { ): Promise<Event[]> {
return new Promise(async resolve => { return new Promise(async resolve => {
const events: Event[] = [] const events: Event[] = []
@@ -189,7 +214,7 @@ export class AbstractSimplePool {
async get( async get(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> { ): Promise<Event | null> {
filter.limit = 1 filter.limit = 1
const events = await this.querySync(relays, filter, params) const events = await this.querySync(relays, filter, params)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.3.2", "version": "2.11.1",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",
@@ -20,6 +20,7 @@
"./nip10": "./nip10.ts", "./nip10": "./nip10.ts",
"./nip11": "./nip11.ts", "./nip11": "./nip11.ts",
"./nip13": "./nip13.ts", "./nip13": "./nip13.ts",
"./nip17": "./nip17.ts",
"./nip18": "./nip18.ts", "./nip18": "./nip18.ts",
"./nip19": "./nip19.ts", "./nip19": "./nip19.ts",
"./nip21": "./nip21.ts", "./nip21": "./nip21.ts",
@@ -33,7 +34,10 @@
"./nip44": "./nip44.ts", "./nip44": "./nip44.ts",
"./nip46": "./nip46.ts", "./nip46": "./nip46.ts",
"./nip49": "./nip49.ts", "./nip49": "./nip49.ts",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts", "./nip57": "./nip57.ts",
"./nip58": "./nip58.ts",
"./nip59": "./nip59.ts",
"./nip75": "./nip75.ts", "./nip75": "./nip75.ts",
"./nip94": "./nip94.ts", "./nip94": "./nip94.ts",
"./nip96": "./nip96.ts", "./nip96": "./nip96.ts",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

124
nip10.ts
View File

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

View File

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

View File

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

97
nip17.test.ts Normal file
View File

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

77
nip17.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

212
nip27.ts
View File

@@ -1,63 +1,169 @@
import { decode } from './nip19.ts' import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */ export type Block =
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') | {
type: 'text'
text: string
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
}
| {
type: 'url'
url: string
}
| {
type: 'relay'
url: string
}
| {
type: 'image'
url: string
}
| {
type: 'video'
url: string
}
| {
type: 'audio'
url: string
}
/** Match result for a Nostr URI in event content. */ const noCharacter = /\W/m
export interface NostrURIMatch extends NostrURI { const noURLCharacter = /\W |\W$|$|,| /m
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */ export function* parse(content: string): Iterable<Block> {
export function* matchAll(content: string): Iterable<NostrURIMatch> { const max = content.length
const matches = content.matchAll(regex()) let prevIndex = 0
let index = 0
while (index < max) {
let u = content.indexOf(':', index)
if (u === -1) {
// reached end
break
}
for (const match of matches) { if (content.substring(u - 5, u) === 'nostr') {
const m = content.substring(u + 60).match(noCharacter)
const end = m ? u + 60 + m.index! : max
try { try {
const [uri, value] = match let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.substring(u + 1, end))
yield { switch (type) {
uri: uri as `nostr:${string}`, case 'npub':
value, pointer = { pubkey: data } as ProfilePointer
decoded: decode(value), break
start: match.index!, case 'nsec':
end: match.index! + uri.length, case 'note':
// ignore this, treat it as not a valid uri
index = end + 1
continue
default:
pointer = data as any
} }
} catch (_e) {
// do nothing 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
}
} 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
}
}
if (prevIndex !== max) {
yield { type: 'text', text: content.substring(prevIndex) }
} }
} }
/**
* 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),
})
})
}

View File

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

View File

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

View File

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

42
nip54.test.ts Normal file
View File

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

19
nip54.ts Normal file
View File

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

166
nip55.test.ts Normal file
View File

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

123
nip55.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.9.1", "version": "2.11.1",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -103,6 +103,11 @@
"require": "./lib/cjs/nip13.js", "require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts" "types": "./lib/types/nip13.d.ts"
}, },
"./nip17": {
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": { "./nip18": {
"import": "./lib/esm/nip18.js", "import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js", "require": "./lib/cjs/nip18.js",
@@ -168,6 +173,11 @@
"require": "./lib/cjs/nip49.js", "require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts" "types": "./lib/types/nip49.d.ts"
}, },
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": { "./nip57": {
"import": "./lib/esm/nip57.js", "import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js", "require": "./lib/cjs/nip57.js",
@@ -229,7 +239,7 @@
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"nostr-wasm": "v0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"

View File

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