Compare commits

...

28 Commits

Author SHA1 Message Date
fiatjaf
9b08550885 some beginnings of nip29 helpers. 2024-01-19 16:13:00 -03:00
fiatjaf
3b81e5e762 use mock relays on pool tests. 2024-01-19 16:12:02 -03:00
fiatjaf
8b2b050c0d unify mock-socket interface into a single implementation. 2024-01-19 16:01:06 -03:00
Sepehr Safari
d4090dae2b refactor relay test cases with mock websocket 2024-01-19 15:32:36 -03:00
Sepehr Safari
49596d24c3 install mock-socket as dev dependency 2024-01-19 15:32:36 -03:00
Sepehr Safari
ac83eeff1c format with prettier 2024-01-18 11:51:13 -03:00
Sepehr Safari
85b741b39a suppress eqeqeq eslint rule 2024-01-18 11:50:35 -03:00
fiatjaf_
c69c528ab0 Merge pull request #352 from sepehr-safari/nip99-implementation
Nip99 implementation
2024-01-18 11:00:41 -03:00
fiatjaf_
1aad9ad0bd Merge pull request #350 from sepehr-safari/nip98-enhancement
Nip98 enhancement
2024-01-18 11:00:28 -03:00
Sepehr Safari
f6ed374f2f remove un-used imports 2024-01-18 17:16:24 +03:30
Sepehr Safari
6d7ad22677 add test cases for nip99 2024-01-18 17:13:39 +03:30
Sepehr Safari
340a4a6799 implement nip99 2024-01-18 17:13:31 +03:30
Sepehr Safari
5ec136a365 refactor and add more test cases 2024-01-17 18:13:10 +03:30
Sepehr Safari
75eb08b170 fix some bugs, refactor to smaller parts, add docs 2024-01-17 17:50:48 +03:30
Alex Gleason
677b679c2c NIP-57: build lnurl in more secure way 2024-01-15 21:26:34 -03:00
fiatjaf
7b79d6a899 nostr-wasm as optional and v2.1.3 2024-01-09 16:58:47 -03:00
Alex Gleason
c1efbbd919 Add NIP-40 module for event expiration 2024-01-09 16:16:43 -03:00
Akiomi Kamakura
7d58705e9a Fix typo 2024-01-08 13:50:48 -03:00
Akiomi Kamakura
f1d315632c Sort kinds 2024-01-08 13:50:36 -03:00
Alex Gleason
348d118ce4 Add getFilterLimit function 2024-01-04 09:56:02 -03:00
fiatjaf
498c1603b0 nip57: implement "P" tag for sender. 2024-01-01 11:39:22 -03:00
Shusui MOYATANI
4cfc67e294 fix yieldThread memory leak 2023-12-30 13:50:44 -03:00
fiatjaf
da51418f04 update readme example.
fixes https://github.com/nbd-wtf/nostr-tools/issues/337
2023-12-27 11:14:19 -03:00
fiatjaf
75df47421f v2.1.1 2023-12-26 07:57:36 -03:00
fiatjaf
1cfe705baf auth() returns a promise that resolves on OK.
fixes https://github.com/nbd-wtf/nostr-tools/issues/336
2023-12-26 07:56:55 -03:00
fiatjaf
566437fe2e nip19: length 0 on TLV is not forbidden. 2023-12-26 07:56:55 -03:00
fiatjaf
5d6c2b9e5d nip19: reverse TLV ordering just to keep other implementations honest. 2023-12-26 07:56:55 -03:00
jiftechnify
a43f2a708c fix problem when required from CommonJS 2023-12-23 08:31:39 -03:00
24 changed files with 1575 additions and 287 deletions

View File

@@ -45,7 +45,6 @@
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"handle-callback-err": [2, "^(err|error)$"],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],

View File

@@ -66,18 +66,18 @@ const sub = relay.subscribe([
let sk = generateSecretKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
relay.sub([
{
kinds: [1],
authors: [pk],
},
])
sub.on('event', event => {
], {
onevent(event) {
console.log('got event:', event)
}
})
let event = {
let eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
@@ -85,14 +85,9 @@ let event = {
}
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(event, sk)
const signedEvent = finalizeEvent(eventTemplate, sk)
await relay.publish(signedEvent)
let events = await relay.list([{ kinds: [0, 1] }])
let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
relay.close()
```

View File

@@ -1,6 +1,6 @@
/* global WebSocket */
import type { Event, EventTemplate, Nostr } from './core.ts'
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts'
@@ -218,11 +218,14 @@ export class AbstractRelay {
})
}
public async auth(signAuthEvent: (authEvent: EventTemplate) => Promise<void>) {
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
const evt = makeAuthEvent(this.url, this.challenge)
await signAuthEvent(evt)
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(evt.id, { resolve, reject })
})
this.send('["AUTH",' + JSON.stringify(evt) + ']')
return ret
}
public async publish(event: Event): Promise<string> {

View File

@@ -28,12 +28,7 @@ esbuild
format: 'esm',
packages: 'external',
})
.then(() => {
const packageJson = JSON.stringify({ type: 'module' })
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
.then(() => console.log('esm build success.'))
esbuild
.build({
@@ -42,7 +37,12 @@ esbuild
format: 'cjs',
packages: 'external',
})
.then(() => console.log('cjs build success.'))
.then(() => {
const packageJson = JSON.stringify({ type: 'commonjs' })
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
console.log('cjs build success.')
})
esbuild
.build({

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { buildEvent } from './test-helpers.ts'
describe('Filter', () => {
@@ -241,4 +241,27 @@ describe('Filter', () => {
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
})
})
describe('getFilterLimit', () => {
test('should handle ids', () => {
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
})
test('should count the authors times replaceable kinds', () => {
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
})
test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
})
test('should return Infinity for empty filters', () => {
expect(getFilterLimit({})).toEqual(Infinity)
})
})
})

View File

@@ -1,4 +1,5 @@
import { Event } from './core.ts'
import { isReplaceableKind } from './kinds.ts'
export type Filter = {
ids?: string[]
@@ -70,3 +71,18 @@ export function mergeFilters(...filters: Filter[]): Filter {
return result
}
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
export function getFilterLimit(filter: Filter): number {
if (filter.ids && !filter.ids.length) return 0
if (filter.kinds && !filter.kinds.length) return 0
if (filter.authors && !filter.authors.length) return 0
return Math.min(
Math.max(0, filter.limit ?? Infinity),
filter.ids?.length ?? Infinity,
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length
: Infinity,
)
}

View File

@@ -1,10 +1,15 @@
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
export async function yieldThread() {
return new Promise(resolve => {
return new Promise<void>(resolve => {
const ch = new MessageChannel()
const handler = () => {
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
ch.port1.removeEventListener('message', handler)
resolve()
}
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', resolve)
ch.port1.addEventListener('message', handler)
ch.port2.postMessage(0)
ch.port1.start()
})

View File

@@ -35,27 +35,22 @@ export const ShortTextNote = 1
export const RecommendRelay = 2
export const Contacts = 3
export const EncryptedDirectMessage = 4
export const EncryptedDirectMessages = 4
export const EventDeletion = 5
export const Repost = 6
export const Reaction = 7
export const BadgeAward = 8
export const GenericRepost = 16
export const ChannelCreation = 40
export const ChannelMetadata = 41
export const ChannelMessage = 42
export const ChannelHideMessage = 43
export const ChannelMuteUser = 44
export const Report = 1984
export const ZapRequest = 9734
export const Zap = 9735
export const RelayList = 10002
export const ClientAuth = 22242
export const BadgeDefinition = 30009
export const FileMetadata = 1063
export const EncryptedDirectMessages = 4
export const GenericRepost = 16
export const OpenTimestamps = 1040
export const FileMetadata = 1063
export const LiveChatMessage = 1311
export const ProblemTracker = 1971
export const Report = 1984
export const Reporting = 1984
export const Label = 1985
export const CommunityPostApproval = 4550
@@ -63,9 +58,12 @@ export const JobRequest = 5999
export const JobResult = 6999
export const JobFeedback = 7000
export const ZapGoal = 9041
export const ZapRequest = 9734
export const Zap = 9735
export const Highlights = 9802
export const Mutelist = 10000
export const Pinlist = 10001
export const RelayList = 10002
export const BookmarkList = 10003
export const CommunitiesList = 10004
export const PublicChatsList = 10005
@@ -75,6 +73,7 @@ export const InterestsList = 10015
export const UserEmojiList = 10030
export const NWCWalletInfo = 13194
export const LightningPubRPC = 21000
export const ClientAuth = 22242
export const NWCWalletRequest = 23194
export const NWCWalletResponse = 23195
export const NostrConnect = 24133
@@ -85,6 +84,7 @@ export const Relaysets = 30002
export const Bookmarksets = 30003
export const Curationsets = 30004
export const ProfileBadges = 30008
export const BadgeDefinition = 30009
export const Interestsets = 30015
export const CreateOrUpdateStall = 30017
export const CreateOrUpdateProduct = 30018

View File

@@ -78,13 +78,13 @@ test('encode and decode naddr', () => {
test('encode and decode nevent', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = neventEncode({
let nevent = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
@@ -95,13 +95,13 @@ test('encode and decode nevent', () => {
test('encode and decode nevent with kind 0', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = neventEncode({
let nevent = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(nevent).toMatch(/nevent1\w+/)
let { type, data } = decode(nevent)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
@@ -109,6 +109,25 @@ test('encode and decode nevent with kind 0', () => {
expect(pointer.kind).toEqual(0)
})
test('encode and decode naddr with empty "d"', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = naddrEncode({
identifier: '',
pubkey: pk,
relays,
kind: 3,
})
expect(naddr).toMatch(/naddr\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.identifier).toEqual('')
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(3)
expect(pointer.pubkey).toEqual(pk)
})
test('decode naddr from habla.news', () => {
let { type, data } = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',

View File

@@ -149,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
if (!l) throw new Error(`malformed TLV ${t}`)
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
@@ -227,7 +226,9 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv).forEach(([t, vs]) => {
Object.entries(tlv)
.reverse()
.forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)

60
nip29.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { Event } from './pure'
export type Group = {
id: string
name?: string
picture?: string
about?: string
relay?: string
public?: boolean
open?: boolean
}
export function parseGroup(event: Event): Group {
const chan: Partial<Group> = {}
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
switch (tag[0]) {
case 'd':
chan.id = tag[1] || ''
break
case 'name':
chan.name = tag[1] || ''
break
case 'about':
chan.about = tag[1] || ''
break
case 'picture':
chan.picture = tag[1] || ''
break
case 'open':
chan.open = true
break
case 'public':
chan.public = true
break
}
}
return chan as Group
}
export type Member = {
pubkey: string
label?: string
permissions: string[]
}
export function parseMembers(event: Event): Member[] {
const members = []
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
if (tag.length < 2) continue
if (tag[0] !== 'p') continue
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
const member: Member = { pubkey: tag[1], permissions: [] }
if (tag.length > 2) member.label = tag[2]
if (tag.length > 3) member.permissions = tag.slice(3)
members.push(member)
}
return members
}

44
nip40.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, test, expect, jest } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
describe('getExpiration', () => {
test('returns the expiration as a Date object', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = getExpiration(event)
expect(result).toEqual(new Date(123000))
})
})
describe('isEventExpired', () => {
test('returns true when the event has expired', () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = isEventExpired(event)
expect(result).toEqual(true)
})
test('returns false when the event has not expired', () => {
const future = Math.floor(Date.now() / 1000) + 10
const event = buildEvent({ tags: [['expiration', future.toString()]] })
const result = isEventExpired(event)
expect(result).toEqual(false)
})
})
describe('waitForExpire', () => {
test('returns a promise that resolves when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const result = await waitForExpire(event)
expect(result).toEqual(event)
})
})
describe('onExpire', () => {
test('calls the callback when the event expires', async () => {
const event = buildEvent({ tags: [['expiration', '123']] })
const callback = jest.fn()
onExpire(event, callback)
await new Promise(resolve => setTimeout(resolve, 200))
expect(callback).toHaveBeenCalled()
})
})

49
nip40.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Event } from './core.ts'
/** Get the expiration of the event as a `Date` object, if any. */
function getExpiration(event: Event): Date | undefined {
const tag = event.tags.find(([name]) => name === 'expiration')
if (tag) {
return new Date(parseInt(tag[1]) * 1000)
}
}
/** Check if the event has expired. */
function isEventExpired(event: Event): boolean {
const expiration = getExpiration(event)
if (expiration) {
return Date.now() > expiration.getTime()
} else {
return false
}
}
/** Returns a promise that resolves when the event expires. */
async function waitForExpire(event: Event): Promise<Event> {
const expiration = getExpiration(event)
if (expiration) {
const diff = expiration.getTime() - Date.now()
if (diff > 0) {
await sleep(diff)
return event
} else {
return event
}
} else {
throw new Error('Event has no expiration')
}
}
/** Calls the callback when the event expires. */
function onExpire(event: Event, callback: (event: Event) => void): void {
waitForExpire(event)
.then(callback)
.catch(() => {})
}
/** Resolves when the given number of milliseconds have elapsed. */
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export { getExpiration, isEventExpired, waitForExpire, onExpire }

View File

@@ -242,10 +242,11 @@ describe('validateZapRequest', () => {
})
describe('makeZapReceipt', () => {
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
test('returns a valid Zap receipt with a preimage', () => {
const zapRequest = JSON.stringify(
finalizeEvent(
{
@@ -253,7 +254,7 @@ describe('makeZapReceipt', () => {
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['p', target],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
@@ -274,16 +275,14 @@ describe('makeZapReceipt', () => {
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', publicKey],
['p', target],
['P', publicKey],
['preimage', preimage],
]),
)
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
{
@@ -291,7 +290,7 @@ describe('makeZapReceipt', () => {
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['p', target],
['amount', '100'],
['relays', 'relay1', 'relay2'],
],
@@ -311,7 +310,8 @@ describe('makeZapReceipt', () => {
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', publicKey],
['p', target],
['P', publicKey],
]),
)
expect(JSON.stringify(result.tags)).not.toContain('preimage')

View File

@@ -23,7 +23,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else {
return null
}
@@ -119,7 +119,7 @@ export function makeZapReceipt({
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
}
if (preimage) {

View File

@@ -1,76 +1,83 @@
import { describe, test, expect } from 'bun:test'
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
import { Event, finalizeEvent } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { bytesToHex } from '@noble/hashes/utils'
import { HTTPAuth } from './kinds.ts'
import { describe, expect, test } from 'bun:test'
const sk = generateSecretKey()
import { HTTPAuth } from './kinds.ts'
import {
getToken,
hashPayload,
unpackEventFromToken,
validateEvent,
validateEventKind,
validateEventMethodTag,
validateEventPayloadTag,
validateEventTimestamp,
validateEventUrlTag,
validateToken,
} from './nip98.ts'
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { utf8Encoder } from './utils.ts'
describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
test('returns without authorization scheme for GET', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get'],
])
})
test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
test('returns token without authorization scheme for POST', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
const unpackedEvent: Event = await unpackEventFromToken(token)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('getToken GET returns token WITH authorization scheme', async () => {
test('returns token WITH authorization scheme for POST', async () => {
const authorizationScheme = 'Nostr '
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
expect(token.startsWith(authorizationScheme)).toBe(true)
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('getToken returns token with a valid payload tag when payload is present', async () => {
test('returns token with a valid payload tag when payload is present', async () => {
const sk = generateSecretKey()
const payload = { test: 'payload' }
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
const payloadHash = hashPayload(payload)
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
const unpackedEvent: Event = await unpackEventFromToken(token)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
expect(unpackedEvent.created_at).toBeGreaterThan(0)
expect(unpackedEvent.content).toBe('')
expect(unpackedEvent.kind).toBe(HTTPAuth)
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
expect(unpackedEvent.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
['payload', payloadHash],
@@ -79,81 +86,265 @@ describe('getToken', () => {
})
describe('validateToken', () => {
test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
test('returns true for valid token without authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
expect(isTokenValid).toBe(true)
})
test('validateToken returns true for valid token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
test('returns true for valid token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
expect(isTokenValid).toBe(true)
})
test('validateToken throws an error for invalid token', async () => {
const result = validateToken('fake', 'http://test.com', 'get')
expect(result).rejects.toThrow(Error)
test('throws an error for invalid token', async () => {
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
expect(result).rejects.toThrow(Error)
test('throws an error for missing token', async () => {
const isTokenValid = validateToken('', 'http://test.com', 'get')
expect(isTokenValid).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.kind = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
expect(result).rejects.toThrow(Error)
expect(isTokenValid).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const invalidToken = await getToken('http://test.com', 'get', e => {
e.created_at = 0
return finalizeEvent(e, sk)
})
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
const result = validateToken(validToken, 'http://test.com', 'post')
expect(result).rejects.toThrow(Error)
expect(isTokenValid).rejects.toThrow(Error)
})
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
test('throws an error for invalid url', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
expect(isTokenValid).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
test('throws an error for invalid method', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const isTokenValid = validateToken(token, 'http://test.com', 'post')
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
expect(result).toBe(true)
})
test('validateEvent returns false for invalid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(result).rejects.toThrow(Error)
expect(isTokenValid).rejects.toThrow(Error)
})
})
describe('validateEvent', () => {
test('returns true for valid decoded token with authorization scheme', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).toBe(true)
})
test('throws an error for invalid event kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid event timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
expect(isEventValid).rejects.toThrow(Error)
})
test('throws an error for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
expect(isEventValid).rejects.toThrow(Error)
})
test('returns true for valid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
expect(isEventValid).toBe(true)
})
test('returns false for invalid payload tag hash', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(isEventValid).rejects.toThrow(Error)
})
})
describe('validateEventTimestamp', () => {
test('returns true for valid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(true)
})
test('returns false for invalid timestamp', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.created_at = 0
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
expect(isEventTimestampValid).toBe(false)
})
})
describe('validateEventKind', () => {
test('returns true for valid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(true)
})
test('returns false for invalid kind', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
unpackedEvent.kind = 0
const isEventKindValid = validateEventKind(unpackedEvent)
expect(isEventKindValid).toBe(false)
})
})
describe('validateEventUrlTag', () => {
test('returns true for valid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
expect(isEventUrlTagValid).toBe(true)
})
test('returns false for invalid url tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
expect(isEventUrlTagValid).toBe(false)
})
})
describe('validateEventMethodTag', () => {
test('returns true for valid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
expect(isEventMethodTagValid).toBe(true)
})
test('returns false for invalid method tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
expect(isEventMethodTagValid).toBe(false)
})
})
describe('validateEventPayloadTag', () => {
test('returns true for valid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
expect(isEventPayloadTagValid).toBe(true)
})
test('returns false for invalid payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
expect(isEventPayloadTagValid).toBe(false)
})
test('returns false for missing payload tag', async () => {
const sk = generateSecretKey()
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const unpackedEvent: Event = await unpackEventFromToken(token)
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
expect(isEventPayloadTagValid).toBe(false)
})
})
describe('hashPayload', () => {
test('returns hash for valid payload', async () => {
const payload = { test: 'payload' }
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
test('returns hash for empty payload', async () => {
const payload = {}
const computedPayloadHash = hashPayload(payload)
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
expect(computedPayloadHash).toBe(expectedPayloadHash)
})
})

141
nip98.ts
View File

@@ -1,17 +1,13 @@
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import { base64 } from '@scure/base'
import { HTTPAuth } from './kinds.ts'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import { HTTPAuth } from './kinds.ts'
const _authorizationScheme = 'Nostr '
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Generate token for NIP-98 flow.
*
@@ -37,7 +33,7 @@ export async function getToken(
}
if (payload) {
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
event.tags.push(['payload', hashPayload(payload)])
}
const signedEvent = await sign(event)
@@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
const event = await unpackEventFromToken(token).catch(error => {
throw error
})
const valid = await validateEvent(event, url, method).catch(error => {
throw error
})
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
return valid
}
/**
* Unpacks an event from a token.
*
* @param token - The token to unpack.
* @returns A promise that resolves to the unpacked event.
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
*/
export async function unpackEventFromToken(token: string): Promise<Event> {
if (!token) {
throw new Error('Missing token')
}
token = token.replace(_authorizationScheme, '')
const eventB64 = utf8Decoder.decode(base64.decode(token))
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
return event
}
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
/**
* Validates the timestamp of an event.
* @param event - The event object to validate.
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
*/
export function validateEventTimestamp(event: Event): boolean {
if (!event.created_at) {
return false
}
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
}
/**
* Validates the kind of an event.
* @param event The event to validate.
* @returns A boolean indicating whether the event kind is valid.
*/
export function validateEventKind(event: Event): boolean {
return event.kind === HTTPAuth
}
/**
* Validates if the given URL matches the URL tag of the event.
* @param event - The event object.
* @param url - The URL to validate.
* @returns A boolean indicating whether the URL is valid or not.
*/
export function validateEventUrlTag(event: Event, url: string): boolean {
const urlTag = event.tags.find(t => t[0] === 'u')
if (!urlTag) {
return false
}
return urlTag.length > 0 && urlTag[1] === url
}
/**
* Validates if the given event has a method tag that matches the specified method.
* @param event - The event to validate.
* @param method - The method to match against the method tag.
* @returns A boolean indicating whether the event has a matching method tag.
*/
export function validateEventMethodTag(event: Event, method: string): boolean {
const methodTag = event.tags.find(t => t[0] === 'method')
if (!methodTag) {
return false
}
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
}
/**
* Calculates the hash of a payload.
* @param payload - The payload to be hashed.
* @returns The hash value as a string.
*/
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Validates the event payload tag against the provided payload.
* @param event The event object.
* @param payload The payload to validate.
* @returns A boolean indicating whether the payload tag is valid.
*/
export function validateEventPayloadTag(event: Event, payload: any): boolean {
const payloadTag = event.tags.find(t => t[0] === 'payload')
if (!payloadTag) {
return false
}
const payloadHash = hashPayload(payload)
return payloadTag.length > 0 && payloadTag[1] === payloadHash
}
/**
* Validates a Nostr event for the NIP-98 flow.
*
* @param event - The Nostr event to validate.
* @param url - The URL associated with the event.
* @param method - The HTTP method associated with the event.
* @param body - The request body associated with the event (optional).
* @returns A promise that resolves to a boolean indicating whether the event is valid.
* @throws An error if the event is invalid.
*/
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!verifyEvent(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (event.kind !== HTTPAuth) {
if (!validateEventKind(event)) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!event.created_at) {
throw new Error('Invalid nostr event, created_at invalid')
if (!validateEventTimestamp(event)) {
throw new Error('Invalid nostr event, created_at timestamp invalid')
}
// Event must be less than 60 seconds old
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
throw new Error('Invalid nostr event, expired')
}
const urlTag = event.tags.find(t => t[0] === 'u')
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
if (!validateEventUrlTag(event, url)) {
throw new Error('Invalid nostr event, url tag invalid')
}
const methodTag = event.tags.find(t => t[0] === 'method')
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
if (!validateEventMethodTag(event, method)) {
throw new Error('Invalid nostr event, method tag invalid')
}
if (Boolean(body) && Object.keys(body).length > 0) {
const payloadTag = event.tags.find(t => t[0] === 'payload')
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
if (payloadTag?.[1] !== payloadHash) {
throw new Error('Invalid payload tag hash, does not match request body hash')
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
if (!validateEventPayloadTag(event, body)) {
throw new Error('Invalid nostr event, payload tag does not match request body hash')
}
}

506
nip99.test.ts Normal file
View File

@@ -0,0 +1,506 @@
import { describe, expect, test } from 'bun:test'
import { Event } from './core'
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
import { finalizeEvent, generateSecretKey } from './pure'
describe('validateEvent', () => {
test('should return true for a valid classified listing event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(true)
})
test('should return false when the "d" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "title" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
// Missing 'title' tag
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "summary" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
// Missing 'summary' tag
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
// Missing 'published_at' tag
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "location" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
// Missing 'location' tag
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag is missing', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
// Missing 'price' tag
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "published_at" tag is not a valid timestamp', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', 'not-a-valid-timestamp'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid price', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', 'not-a-valid-price', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid currency', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'not-a-valid-currency'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event)).toBe(false)
})
test('should return false when the "price" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
})
test('should return false when the "a" tag has not a valid number of elements', () => {
const sk = generateSecretKey()
const event1: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['a', 'extra1'],
['a', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
const event2: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: ClassifiedListing,
content: 'Lorem ipsum dolor sit amet.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['e', 'extra1'],
['e', 'extra2', 'value2', 'extra3'],
],
},
sk,
)
expect(validateEvent(event1)).toBe(false)
expect(validateEvent(event2)).toBe(false)
})
})
describe('parseEvent', () => {
test('should parse a valid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
const expectedListing = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
e: ['value1', 'value2'],
a: ['value1', 'value2'],
},
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
expect(parseEvent(event)).toEqual(expectedListing)
})
test('should throw an error for an invalid event', () => {
const sk = generateSecretKey()
const event: Event = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: DraftClassifiedListing,
content:
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
// Missing 'd' tag
['title', 'Sample Title'],
['summary', 'Sample Summary'],
['published_at', '1296962229'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['e', 'value1', 'value2'],
['a', 'value1', 'value2'],
],
},
sk,
)
expect(() => parseEvent(event)).toThrow(Error)
})
})
describe('generateEventTemplate', () => {
test('should generate the correct event template for a classified listing', () => {
const listing: ClassifiedListingObject = {
title: 'Sample Title',
summary: 'Sample Summary',
publishedAt: '1296962229',
location: 'NYC',
price: {
amount: '100',
currency: 'USD',
},
images: [
{
url: 'https://example.com/image1.jpg',
dimensions: '800x600',
},
{
url: 'https://example.com/image2.jpg',
},
],
hashtags: ['tag1', 'tag2'],
additionalTags: {
extra1: 'value1',
extra2: 'value2',
},
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
isDraft: true,
}
const expectedEventTemplate = {
kind: DraftClassifiedListing,
content:
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
tags: [
['d', 'sample-title'],
['title', 'Sample Title'],
['published_at', '1296962229'],
['summary', 'Sample Summary'],
['location', 'NYC'],
['price', '100', 'USD'],
['image', 'https://example.com/image1.jpg', '800x600'],
['image', 'https://example.com/image2.jpg'],
['t', 'tag1'],
['t', 'tag2'],
['extra1', 'value1'],
['extra2', 'value2'],
],
created_at: expect.any(Number),
}
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
})
})

228
nip99.ts Normal file
View File

@@ -0,0 +1,228 @@
import { Event, EventTemplate } from './core.ts'
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
/**
* Represents the details of a price.
* @example { amount: '100', currency: 'USD', frequency: 'month' }
* @example { amount: '100', currency: 'EUR' }
*/
export type PriceDetails = {
/**
* The amount of the price.
*/
amount: string
/**
* The currency of the price in 3-letter ISO 4217 format.
* @example 'USD'
*/
currency: string
/**
* The optional frequency of payment.
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
*/
frequency?: string
}
/**
* Represents a classified listing object.
*/
export type ClassifiedListingObject = {
/**
* Whether the listing is a draft or not.
*/
isDraft: boolean
/**
* A title of the listing.
*/
title: string
/**
* A short summary or tagline.
*/
summary: string
/**
* A description in Markdown format.
*/
content: string
/**
* Timestamp in unix seconds of when the listing was published.
*/
publishedAt: string
/**
* Location of the listing.
* @example 'NYC'
*/
location: string
/**
* Price details.
*/
price: PriceDetails
/**
* Images of the listing with optional dimensions.
*/
images: Array<{
url: string
dimensions?: string
}>
/**
* Tags/Hashtags (i.e. categories, keywords, etc.)
*/
hashtags: string[]
/**
* Other standard tags.
* @example "g", a geohash for more precise location
*/
additionalTags: Record<string, string | string[]>
}
/**
* Validates an event to ensure it is a valid classified listing event.
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateEvent(event: Event): boolean {
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
const requiredTagCount = requiredTags.length
const tagCounts: Record<string, number> = {}
if (event.tags.length < requiredTagCount) return false
for (const tag of event.tags) {
if (tag.length < 2) return false
const [tagName, ...tagValues] = tag
if (tagName == 'published_at') {
const timestamp = parseInt(tagValues[0])
if (isNaN(timestamp)) return false
} else if (tagName == 'price') {
if (tagValues.length < 2) return false
const price = parseInt(tagValues[0])
if (isNaN(price) || tagValues[1].length != 3) return false
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
return false
}
if (requiredTags.includes(tagName)) {
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
}
}
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
}
/**
* Parses an event and returns a classified listing object.
* @param event - The event to parse.
* @returns The classified listing object.
* @throws Error if the event is invalid.
*/
export function parseEvent(event: Event): ClassifiedListingObject {
if (!validateEvent(event)) {
throw new Error('Invalid event')
}
const listing: ClassifiedListingObject = {
isDraft: event.kind === DraftClassifiedListing,
title: '',
summary: '',
content: event.content,
publishedAt: '',
location: '',
price: {
amount: '',
currency: '',
},
images: [],
hashtags: [],
additionalTags: {},
}
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
const [tagName, ...tagValues] = tag
if (tagName == 'title') {
listing.title = tagValues[0]
} else if (tagName == 'summary') {
listing.summary = tagValues[0]
} else if (tagName == 'published_at') {
listing.publishedAt = tagValues[0]
} else if (tagName == 'location') {
listing.location = tagValues[0]
} else if (tagName == 'price') {
listing.price.amount = tagValues[0]
listing.price.currency = tagValues[1]
if (tagValues.length == 3) {
listing.price.frequency = tagValues[2]
}
} else if (tagName == 'image') {
listing.images.push({
url: tagValues[0],
dimensions: tagValues?.[1] ?? undefined,
})
} else if (tagName == 't') {
listing.hashtags.push(tagValues[0])
} else if (tagName == 'e' || tagName == 'a') {
listing.additionalTags[tagName] = [...tagValues]
}
}
return listing
}
/**
* Generates an event template based on a classified listing object.
*
* @param listing - The classified listing object.
* @returns The event template.
*/
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
const priceTag = ['price', listing.price.amount, listing.price.currency]
if (listing.price.frequency) priceTag.push(listing.price.frequency)
const tags: string[][] = [
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
['title', listing.title],
['published_at', listing.publishedAt],
['summary', listing.summary],
['location', listing.location],
priceTag,
]
for (let i = 0; i < listing.images.length; i++) {
const image = listing.images[i]
const imageTag = ['image', image.url]
if (image.dimensions) imageTag.push(image.dimensions)
tags.push(imageTag)
}
for (let i = 0; i < listing.hashtags.length; i++) {
const t = listing.hashtags[i]
tags.push(['t', t])
}
for (const [key, value] of Object.entries(listing.additionalTags)) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const val = value[i]
tags.push([key, val])
}
} else {
tags.push([key, value])
}
}
return {
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
content: listing.content,
tags,
created_at: Math.floor(Date.now() / 1000),
}
}

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "nostr-tools",
"version": "2.1.0",
"version": "2.1.4",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -130,6 +130,11 @@
"require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts"
},
"./nip29": {
"import": "./lib/esm/nip29.js",
"require": "./lib/cjs/nip29.js",
"types": "./lib/types/nip29.d.ts"
},
"./nip30": {
"import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js",
@@ -173,8 +178,9 @@
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"mitata": "^0.1.6",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
@@ -205,6 +211,8 @@
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"mitata": "^0.1.6",
"mock-socket": "^9.3.1",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"tsd": "^0.22.0",

View File

@@ -3,13 +3,17 @@ import { test, expect, afterAll } from 'bun:test'
import { finalizeEvent, type Event } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { newMockRelay } from './test-helpers.ts'
let pool = new SimplePool()
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
let mockRelays = [newMockRelay(), newMockRelay(), newMockRelay(), newMockRelay()]
let relays = mockRelays.map(mr => mr.url)
let authors = mockRelays.flatMap(mr => mr.authors)
let ids = mockRelays.flatMap(mr => mr.ids)
afterAll(() => {
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
pool.close(relays)
})
test('removing duplicates when subscribing', async () => {
@@ -19,7 +23,7 @@ test('removing duplicates when subscribing', async () => {
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event: Event) {
// this should be called only once even though we're listening
// to multiple relays because the events will be catched and
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
@@ -80,8 +84,8 @@ test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>()
await new Promise<void>(resolve => {
pool.subscribeManyEose(
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
[{ kinds: [0, 1], limit: 40 }],
[...relays, ...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
[{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }],
{
onevent(event) {
events.add(event.id)
@@ -94,11 +98,14 @@ test('query a bunch of events and cancel on eose', async () => {
})
test('querySync()', async () => {
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
let events = await pool.querySync(
[...relays.slice(0, 2), ...relays.slice(0, 2), 'wss://offchain.pub', 'wss://eden.nostr.land'],
{
authors: authors.slice(0, 2),
kinds: [1],
limit: 2,
})
},
)
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toBeGreaterThan(2)
@@ -108,9 +115,9 @@ test('querySync()', async () => {
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
ids: [ids[0]],
})
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
expect(event).toHaveProperty('id', ids[0])
})

View File

@@ -1,121 +1,90 @@
import { afterEach, expect, test } from 'bun:test'
import { expect, test } from 'bun:test'
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay } from './relay.ts'
let relay = new Relay('wss://relay.nostr.bg')
afterEach(() => {
relay.close()
})
import { newMockRelay } from './test-helpers.ts'
test('connectivity', async () => {
const { url } = newMockRelay()
const relay = new Relay(url)
await relay.connect()
expect(relay.connected).toBeTrue()
relay.close()
})
test('connectivity, with Relay.connect()', async () => {
const relay = await Relay.connect('wss://public.relaying.io')
const { url } = newMockRelay()
const relay = await Relay.connect(url)
expect(relay.connected).toBeTrue()
relay.close()
})
test('querying', async () => {
test('querying', async done => {
const { url, authors } = newMockRelay()
const kind = 0
const relay = new Relay(url)
await relay.connect()
let resolveEvent: () => void
let resolveEose: () => void
const evented = new Promise<void>(resolve => {
resolveEvent = resolve
})
const eosed = new Promise<void>(resolve => {
resolveEose = resolve
})
relay.subscribe(
[
{
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
kinds: [0],
authors: authors,
kinds: [kind],
},
],
{
onevent(event) {
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
expect(event).toHaveProperty('kind', 0)
resolveEvent()
},
oneose() {
resolveEose()
expect(authors).toContain(event.pubkey)
expect(event).toHaveProperty('kind', kind)
relay.close()
done()
},
},
)
})
await eosed
await evented
}, 10000)
test('listening and publishing and closing', async done => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const kind = 23571
test('listening and publishing and closing', async () => {
const { url } = newMockRelay()
const relay = new Relay(url)
await relay.connect()
let sk = generateSecretKey()
let pk = getPublicKey(sk)
let resolveEose: (_: void) => void
let resolveEvent: (_: void) => void
let resolveClose: (_: void) => void
let eventReceived: NostrEvent | undefined
const eosed = new Promise(resolve => {
resolveEose = resolve
})
const evented = new Promise(resolve => {
resolveEvent = resolve
})
const closed = new Promise(resolve => {
resolveClose = resolve
})
let sub = relay.subscribe(
[
{
kinds: [23571],
kinds: [kind],
authors: [pk],
},
],
{
onevent(event) {
eventReceived = event
resolveEvent()
},
oneose() {
resolveEose()
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', kind)
expect(event).toHaveProperty('content', 'content')
sub.close()
},
oneose() {},
onclose() {
resolveClose()
relay.close()
done()
},
},
)
await eosed
let event = finalizeEvent(
relay.publish(
finalizeEvent(
{
kind: 23571,
created_at: Math.floor(Date.now() / 1000),
kind,
content: 'content',
created_at: 0,
tags: [],
content: 'nostr-tools test suite',
},
sk,
),
)
await relay.publish(event)
await evented
sub.close()
await closed
expect(eventReceived).toBeDefined()
expect(eventReceived).toHaveProperty('pubkey', pk)
expect(eventReceived).toHaveProperty('kind', 23571)
expect(eventReceived).toHaveProperty('content', 'nostr-tools test suite')
})

View File

@@ -1,6 +1,8 @@
import type { Event } from './pure.ts'
import { Server } from 'mock-socket'
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
import { matchFilters, type Filter } from './filter.ts'
/** Build an event for testing purposes. */
export function buildEvent(params: Partial<Event>): Event {
return {
id: '',
@@ -13,3 +15,81 @@ export function buildEvent(params: Partial<Event>): Event {
...params,
}
}
let serial = 0
// the mock relay will always return some events before eose and then be ok with everything
export function newMockRelay(): { url: string; authors: string[]; ids: string[] } {
serial++
const url = `wss://mock.relay.url/${serial}`
const relay = new Server(url)
const secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
const preloadedEvents = secretKeys.map(sk =>
finalizeEvent(
{
kind: 1,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [],
},
sk,
),
)
relay.on('connection', (conn: any) => {
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
conn.on('message', (message: string) => {
const data = JSON.parse(message)
switch (data[0]) {
case 'REQ': {
let subId = data[1]
let filters = data.slice(2)
subs[subId] = { conn, filters }
preloadedEvents.forEach(event => {
conn.send(JSON.stringify(['EVENT', subId, event]))
})
filters.forEach((filter: Filter) => {
const kinds = filter.kinds?.length ? filter.kinds : [1]
kinds.forEach(kind => {
secretKeys.forEach(sk => {
const event = finalizeEvent(
{
kind,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [],
},
sk,
)
conn.send(JSON.stringify(['EVENT', subId, event]))
})
})
})
conn.send(JSON.stringify(['EOSE', subId]))
break
}
case 'CLOSE': {
let subId = data[1]
delete subs[subId]
break
}
case 'EVENT': {
let event = data[1]
conn.send(JSON.stringify(['OK', event.id, 'true']))
for (let subId in subs) {
const { filters, conn: listener } = subs[subId]
if (matchFilters(filters, event)) {
listener.send(JSON.stringify(['EVENT', subId, event]))
}
}
break
}
}
})
})
return { url, authors: secretKeys.map(getPublicKey), ids: preloadedEvents.map(evt => evt.id) }
}