Compare commits

...

18 Commits

Author SHA1 Message Date
fiatjaf
55ff796b9f v1.10.1 2023-04-22 21:59:41 -03:00
fiatjaf_
3ef2ad5bc4 Merge pull request #194 from alexgleason/nip27 2023-04-22 21:58:36 -03:00
Alex Gleason
45c07a5f45 nip27: make matchAll a generator function 2023-04-22 19:22:06 -05:00
Alex Gleason
6a037d1658 nip27.find --> nip27.matchAll 2023-04-22 19:06:53 -05:00
Alex Gleason
dcf101c6c2 yarn format 2023-04-22 18:33:09 -05:00
Alex Gleason
eb97dbd9ef Add NIP-21 and NIP-27 modules for parsing nostr URIs 2023-04-22 18:30:57 -05:00
Alex Gleason
92988051c6 Add Unlicense 2023-04-20 12:22:39 -03:00
fiatjaf
bf7e00d32a hotfix types. 2023-04-18 15:29:28 -03:00
fiatjaf
9241089997 v1.10.0 with @noble/secp256k1 back, nip42 support and nip19 typing improvements. 2023-04-18 15:17:57 -03:00
Lynn Zenn
32c47e9bd8 relay: separate auth() and publish() methods 2023-04-18 15:16:40 -03:00
Lynn Zenn
6e58fe371c relay: add support for NIP42 authentication 2023-04-18 15:16:40 -03:00
Lynn Zenn
26e35d50e0 relay: fix type errors 2023-04-18 15:16:40 -03:00
fiatjaf
ef3184a6e0 remove @noble/curves. people are not ready for it, causes BigInt issues. 2023-04-18 15:14:21 -03:00
Alex Gleason
56fe3dd5dd nip19: improve return type 2023-04-18 07:30:43 -03:00
Jonathan Staab
f1bb5030c8 Add support for count 2023-04-16 06:43:38 -03:00
fiatjaf
ac212cb5c8 tag v1.9.0 using @noble/curves. 2023-04-14 17:09:28 -03:00
Paul Miller
204ae0eff1 Switch from noble-secp256k1 to noble-curves 2023-04-14 16:45:01 -03:00
Alejandro Gomez
f17ab41d72 NIP-19: Add nrelay encoding and decoding 2023-04-14 13:26:31 -03:00
15 changed files with 1189 additions and 860 deletions

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@@ -325,4 +325,4 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
## License
Public domain.
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.

View File

@@ -11,8 +11,11 @@ export * as nip06 from './nip06'
export * as nip10 from './nip10'
export * as nip13 from './nip13'
export * as nip19 from './nip19'
export * as nip21 from './nip21'
export * as nip26 from './nip26'
export * as nip27 from './nip27'
export * as nip39 from './nip39'
export * as nip42 from './nip42'
export * as nip57 from './nip57'
export * as fj from './fakejson'

View File

@@ -100,3 +100,12 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = "wss://relay.nostr.example"
let nrelay = nip19.nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = nip19.decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

View File

@@ -23,10 +23,16 @@ export type AddressPointer = {
relays?: string[]
}
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | AddressPointer | string
} {
export type DecodeResult =
| {type: 'nprofile'; data: ProfilePointer}
| {type: 'nrelay'; data: string}
| {type: 'nevent'; data: EventPointer}
| {type: 'naddr'; data: AddressPointer}
| {type: 'nsec'; data: string}
| {type: 'npub'; data: string}
| {type: 'note'; data: string}
export function decode(nip19: string): DecodeResult {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
@@ -82,6 +88,16 @@ export function decode(nip19: string): {
}
}
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0])
}
}
case 'nsec':
case 'npub':
case 'note':
@@ -160,6 +176,14 @@ export function naddrEncode(addr: AddressPointer): string {
return bech32.encode('naddr', words, Bech32MaxSize)
}
export function nrelayEncode(url: string): string {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
let words = bech32.toWords(data)
return bech32.encode('nrelay', words, Bech32MaxSize)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []

42
nip21.test.js Normal file
View File

@@ -0,0 +1,42 @@
/* eslint-env jest */
const {nip21} = require('./lib/nostr.cjs')
test('test', () => {
expect(
nip21.test(
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(true)
expect(
nip21.test(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
nip21.test(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(nip21.test('nostr:')).toBe(false)
expect(
nip21.test(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(nip21.test('gggggg')).toBe(false)
})
test('parse', () => {
const result = nip21.parse(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
}
})
})

41
nip21.ts Normal file
View File

@@ -0,0 +1,41 @@
import * as nip19 from './nip19'
import * as nip21 from './nip21'
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX =
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
/** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` {
return (
typeof value === 'string' &&
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
)
}
/** Parsed Nostr URI data. */
export interface NostrURI {
/** Full URI including the `nostr:` protocol. */
uri: `nostr:${string}`
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded: nip19.DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${nip21.NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: nip19.decode(match[1])
}
}

49
nip27.test.js Normal file
View File

@@ -0,0 +1,49 @@
/* eslint-env jest */
const {nip27} = require('./lib/nostr.cjs')
test('matchAll', () => {
const result = nip27.matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6'
},
start: 6,
end: 75
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
},
start: 78,
end: 147
}
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = nip27.replaceAll(content, ({decoded, value}) => {
switch (decoded.type) {
case 'npub':
return '@alex'
case 'note':
return '!1234'
default:
return value
}
})
expect(result).toEqual('Hello @alex!\n\n!1234')
})

63
nip27.ts Normal file
View File

@@ -0,0 +1,63 @@
import * as nip19 from './nip19'
import * as nip21 from './nip21'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () =>
new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends nip21.NostrURI {
/** 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 * matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: nip19.decode(value),
start: match.index!,
end: match.index! + uri.length
}
}
}
/**
* 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: nip21.NostrURI) => string
): string {
return content.replaceAll(regex(), (uri, value) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: nip19.decode(value)
})
})
}

27
nip42.test.js Normal file
View File

@@ -0,0 +1,27 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
relayInit,
generatePrivateKey,
finishEvent,
nip42
} = require('./lib/nostr.cjs')
test('auth flow', done => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
relay.on('auth', async challenge => {
await expect(
nip42.authenticate({
challenge,
relay,
sign: e => finishEvent(e, sk)
})
).rejects.toBeTruthy()
relay.close()
done()
})
})

42
nip42.ts Normal file
View File

@@ -0,0 +1,42 @@
import {EventTemplate, Event, Kind} from './event'
import {Relay} from './relay'
/**
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
*/
export const authenticate = async ({
challenge,
relay,
sign
}: {
challenge: string
relay: Relay
sign: (e: EventTemplate) => Promise<Event>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: ''
}
const pub = relay.auth(await sign(e))
return new Promise((resolve, reject) => {
pub.on('ok', function ok() {
pub.off('ok', ok)
resolve()
})
pub.on('failed', function fail(reason: string) {
pub.off('failed', fail)
reject(reason)
})
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "nostr-tools",
"version": "1.8.4",
"version": "1.10.1",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -16,10 +16,10 @@
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js"
},
"license": "Public domain",
"license": "Unlicense",
"dependencies": {
"@noble/hashes": "1.2.0",
"@noble/secp256k1": "1.7.0",
"@noble/secp256k1": "1.7.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.1.4",
"@scure/bip39": "1.1.1"

View File

@@ -2,7 +2,7 @@ import {Relay, relayInit} from './relay'
import {normalizeURL} from './utils'
import {Filter} from './filter'
import {Event} from './event'
import {SubscriptionOptions, Sub, Pub} from './relay'
import {SubscriptionOptions, Sub, Pub, CountPayload} from './relay'
export class SimplePool {
private _conn: {[url: string]: Relay}
@@ -53,7 +53,7 @@ export class SimplePool {
}
let subs: Sub[] = []
let eventListeners: Set<(event: Event) => void> = new Set()
let eventListeners: Set<any> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length

110
relay.ts
View File

@@ -9,9 +9,14 @@ type RelayEvent = {
disconnect: () => void | Promise<void>
error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void>
auth: (challenge: string) => void | Promise<void>
}
export type CountPayload = {
count: number
}
type SubEvent = {
event: (event: Event) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
@@ -22,7 +27,12 @@ export type Relay = {
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
count: (
filters: Filter[],
opts?: SubscriptionOptions
) => Promise<CountPayload | null>
publish: (event: Event) => Pub
auth: (event: Event) => Pub
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
@@ -51,27 +61,32 @@ export type Sub = {
export type SubscriptionOptions = {
id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
}
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({
connect: [],
disconnect: [],
error: [],
notice: [],
auth: []
})
export function relayInit(
url: string,
options: {
getTimeout?: number
listTimeout?: number
countTimeout?: number
} = {}
): Relay {
let {listTimeout = 3000, getTimeout = 3000} = options
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options
var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
connect: [],
disconnect: [],
error: [],
notice: []
}
var listeners = newListeners()
var subListeners: {
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
} = {}
@@ -146,7 +161,7 @@ export function relayInit(
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT':
case 'EVENT': {
let id = data[1]
let event = data[2]
if (
@@ -159,6 +174,14 @@ export function relayInit(
;(subListeners[id]?.event || []).forEach(cb => cb(event))
}
return
}
case 'COUNT':
let id = data[1]
let payload = data[2]
if (openSubs[id]) {
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
}
return
case 'EOSE': {
let id = data[1]
if (id in subListeners) {
@@ -183,6 +206,11 @@ export function relayInit(
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
case 'AUTH': {
let challenge = data[1]
listeners.auth?.forEach(cb => cb(challenge))
return
}
}
} catch (err) {
return
@@ -220,6 +248,7 @@ export function relayInit(
const sub = (
filters: Filter[],
{
verb = 'REQ',
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2)
@@ -233,7 +262,7 @@ export function relayInit(
skipVerification,
alreadyHaveEvent
}
trySend(['REQ', subid, ...filters])
trySend([verb, subid, ...filters])
return {
sub: (newFilters, newOpts = {}) =>
@@ -253,6 +282,7 @@ export function relayInit(
): void => {
subListeners[subid] = subListeners[subid] || {
event: [],
count: [],
eose: []
}
subListeners[subid][type].push(cb)
@@ -268,6 +298,29 @@ export function relayInit(
}
}
function _publishEvent(event: Event, type: string) {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend([type, event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
return {
url,
sub,
@@ -318,31 +371,28 @@ export function relayInit(
resolve(event)
})
}),
publish(event: Event): Pub {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend(['EVENT', event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
count: (filters: Filter[]): Promise<CountPayload | null> =>
new Promise(resolve => {
let s = sub(filters, {...sub, verb: 'COUNT'})
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, countTimeout)
s.on('count', (event: CountPayload) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
publish(event): Pub {
return _publishEvent(event, 'EVENT')
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
auth(event): Pub {
return _publishEvent(event, 'AUTH')
},
connect,
close(): void {
listeners = {connect: [], disconnect: [], error: [], notice: []}
listeners = newListeners()
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {

1593
yarn.lock

File diff suppressed because it is too large Load Diff