mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-09 16:48:50 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b08550885 | ||
|
|
3b81e5e762 | ||
|
|
8b2b050c0d | ||
|
|
d4090dae2b | ||
|
|
49596d24c3 | ||
|
|
ac83eeff1c | ||
|
|
85b741b39a | ||
|
|
c69c528ab0 | ||
|
|
1aad9ad0bd | ||
|
|
f6ed374f2f | ||
|
|
6d7ad22677 | ||
|
|
340a4a6799 | ||
|
|
5ec136a365 | ||
|
|
75eb08b170 | ||
|
|
677b679c2c | ||
|
|
7b79d6a899 | ||
|
|
c1efbbd919 | ||
|
|
7d58705e9a | ||
|
|
f1d315632c | ||
|
|
348d118ce4 | ||
|
|
498c1603b0 | ||
|
|
4cfc67e294 | ||
|
|
da51418f04 | ||
|
|
75df47421f | ||
|
|
1cfe705baf | ||
|
|
566437fe2e | ||
|
|
5d6c2b9e5d | ||
|
|
a43f2a708c |
@@ -45,7 +45,6 @@
|
|||||||
"curly": [0, "multi-line"],
|
"curly": [0, "multi-line"],
|
||||||
"dot-location": [2, "property"],
|
"dot-location": [2, "property"],
|
||||||
"eol-last": 2,
|
"eol-last": 2,
|
||||||
"eqeqeq": [2, "allow-null"],
|
|
||||||
"handle-callback-err": [2, "^(err|error)$"],
|
"handle-callback-err": [2, "^(err|error)$"],
|
||||||
"indent": 0,
|
"indent": 0,
|
||||||
"jsx-quotes": [2, "prefer-double"],
|
"jsx-quotes": [2, "prefer-double"],
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -66,18 +66,18 @@ const sub = relay.subscribe([
|
|||||||
let sk = generateSecretKey()
|
let sk = generateSecretKey()
|
||||||
let pk = getPublicKey(sk)
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
let sub = relay.sub([
|
relay.sub([
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
},
|
},
|
||||||
])
|
], {
|
||||||
|
onevent(event) {
|
||||||
sub.on('event', event => {
|
|
||||||
console.log('got event:', event)
|
console.log('got event:', event)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let event = {
|
let eventTemplate = {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -85,14 +85,9 @@ let event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
// 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)
|
await relay.publish(signedEvent)
|
||||||
|
|
||||||
let events = await relay.list([{ kinds: [0, 1] }])
|
|
||||||
let event = await relay.get({
|
|
||||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
|
||||||
})
|
|
||||||
|
|
||||||
relay.close()
|
relay.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* global WebSocket */
|
/* 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 { matchFilters, type Filter } from './filter.ts'
|
||||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||||
import { Queue, normalizeURL } from './utils.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")
|
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||||
const evt = makeAuthEvent(this.url, this.challenge)
|
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||||
await signAuthEvent(evt)
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||||
|
})
|
||||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||||
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
public async publish(event: Event): Promise<string> {
|
public async publish(event: Event): Promise<string> {
|
||||||
|
|||||||
14
build.js
14
build.js
@@ -28,12 +28,7 @@ esbuild
|
|||||||
format: 'esm',
|
format: 'esm',
|
||||||
packages: 'external',
|
packages: 'external',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => console.log('esm build success.'))
|
||||||
const packageJson = JSON.stringify({ type: 'module' })
|
|
||||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
|
||||||
|
|
||||||
console.log('esm build success.')
|
|
||||||
})
|
|
||||||
|
|
||||||
esbuild
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
@@ -42,7 +37,12 @@ esbuild
|
|||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
packages: 'external',
|
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
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
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'
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
describe('Filter', () => {
|
describe('Filter', () => {
|
||||||
@@ -241,4 +241,27 @@ describe('Filter', () => {
|
|||||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
16
filter.ts
16
filter.ts
@@ -1,4 +1,5 @@
|
|||||||
import { Event } from './core.ts'
|
import { Event } from './core.ts'
|
||||||
|
import { isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
export type Filter = {
|
export type Filter = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@@ -70,3 +71,18 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
|||||||
|
|
||||||
return result
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||||
|
|
||||||
export async function yieldThread() {
|
export async function yieldThread() {
|
||||||
return new Promise(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
const ch = new MessageChannel()
|
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`)
|
// @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.port2.postMessage(0)
|
||||||
ch.port1.start()
|
ch.port1.start()
|
||||||
})
|
})
|
||||||
|
|||||||
18
kinds.ts
18
kinds.ts
@@ -35,27 +35,22 @@ export const ShortTextNote = 1
|
|||||||
export const RecommendRelay = 2
|
export const RecommendRelay = 2
|
||||||
export const Contacts = 3
|
export const Contacts = 3
|
||||||
export const EncryptedDirectMessage = 4
|
export const EncryptedDirectMessage = 4
|
||||||
|
export const EncryptedDirectMessages = 4
|
||||||
export const EventDeletion = 5
|
export const EventDeletion = 5
|
||||||
export const Repost = 6
|
export const Repost = 6
|
||||||
export const Reaction = 7
|
export const Reaction = 7
|
||||||
export const BadgeAward = 8
|
export const BadgeAward = 8
|
||||||
|
export const GenericRepost = 16
|
||||||
export const ChannelCreation = 40
|
export const ChannelCreation = 40
|
||||||
export const ChannelMetadata = 41
|
export const ChannelMetadata = 41
|
||||||
export const ChannelMessage = 42
|
export const ChannelMessage = 42
|
||||||
export const ChannelHideMessage = 43
|
export const ChannelHideMessage = 43
|
||||||
export const ChannelMuteUser = 44
|
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 OpenTimestamps = 1040
|
||||||
|
export const FileMetadata = 1063
|
||||||
export const LiveChatMessage = 1311
|
export const LiveChatMessage = 1311
|
||||||
export const ProblemTracker = 1971
|
export const ProblemTracker = 1971
|
||||||
|
export const Report = 1984
|
||||||
export const Reporting = 1984
|
export const Reporting = 1984
|
||||||
export const Label = 1985
|
export const Label = 1985
|
||||||
export const CommunityPostApproval = 4550
|
export const CommunityPostApproval = 4550
|
||||||
@@ -63,9 +58,12 @@ export const JobRequest = 5999
|
|||||||
export const JobResult = 6999
|
export const JobResult = 6999
|
||||||
export const JobFeedback = 7000
|
export const JobFeedback = 7000
|
||||||
export const ZapGoal = 9041
|
export const ZapGoal = 9041
|
||||||
|
export const ZapRequest = 9734
|
||||||
|
export const Zap = 9735
|
||||||
export const Highlights = 9802
|
export const Highlights = 9802
|
||||||
export const Mutelist = 10000
|
export const Mutelist = 10000
|
||||||
export const Pinlist = 10001
|
export const Pinlist = 10001
|
||||||
|
export const RelayList = 10002
|
||||||
export const BookmarkList = 10003
|
export const BookmarkList = 10003
|
||||||
export const CommunitiesList = 10004
|
export const CommunitiesList = 10004
|
||||||
export const PublicChatsList = 10005
|
export const PublicChatsList = 10005
|
||||||
@@ -75,6 +73,7 @@ export const InterestsList = 10015
|
|||||||
export const UserEmojiList = 10030
|
export const UserEmojiList = 10030
|
||||||
export const NWCWalletInfo = 13194
|
export const NWCWalletInfo = 13194
|
||||||
export const LightningPubRPC = 21000
|
export const LightningPubRPC = 21000
|
||||||
|
export const ClientAuth = 22242
|
||||||
export const NWCWalletRequest = 23194
|
export const NWCWalletRequest = 23194
|
||||||
export const NWCWalletResponse = 23195
|
export const NWCWalletResponse = 23195
|
||||||
export const NostrConnect = 24133
|
export const NostrConnect = 24133
|
||||||
@@ -85,6 +84,7 @@ export const Relaysets = 30002
|
|||||||
export const Bookmarksets = 30003
|
export const Bookmarksets = 30003
|
||||||
export const Curationsets = 30004
|
export const Curationsets = 30004
|
||||||
export const ProfileBadges = 30008
|
export const ProfileBadges = 30008
|
||||||
|
export const BadgeDefinition = 30009
|
||||||
export const Interestsets = 30015
|
export const Interestsets = 30015
|
||||||
export const CreateOrUpdateStall = 30017
|
export const CreateOrUpdateStall = 30017
|
||||||
export const CreateOrUpdateProduct = 30018
|
export const CreateOrUpdateProduct = 30018
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ test('encode and decode naddr', () => {
|
|||||||
test('encode and decode nevent', () => {
|
test('encode and decode nevent', () => {
|
||||||
let pk = getPublicKey(generateSecretKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
let naddr = neventEncode({
|
let nevent = neventEncode({
|
||||||
id: pk,
|
id: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/nevent1\w+/)
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
let { type, data } = decode(naddr)
|
let { type, data } = decode(nevent)
|
||||||
expect(type).toEqual('nevent')
|
expect(type).toEqual('nevent')
|
||||||
const pointer = data as EventPointer
|
const pointer = data as EventPointer
|
||||||
expect(pointer.id).toEqual(pk)
|
expect(pointer.id).toEqual(pk)
|
||||||
@@ -95,13 +95,13 @@ test('encode and decode nevent', () => {
|
|||||||
test('encode and decode nevent with kind 0', () => {
|
test('encode and decode nevent with kind 0', () => {
|
||||||
let pk = getPublicKey(generateSecretKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
let naddr = neventEncode({
|
let nevent = neventEncode({
|
||||||
id: pk,
|
id: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/nevent1\w+/)
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
let { type, data } = decode(naddr)
|
let { type, data } = decode(nevent)
|
||||||
expect(type).toEqual('nevent')
|
expect(type).toEqual('nevent')
|
||||||
const pointer = data as EventPointer
|
const pointer = data as EventPointer
|
||||||
expect(pointer.id).toEqual(pk)
|
expect(pointer.id).toEqual(pk)
|
||||||
@@ -109,6 +109,25 @@ test('encode and decode nevent with kind 0', () => {
|
|||||||
expect(pointer.kind).toEqual(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', () => {
|
test('decode naddr from habla.news', () => {
|
||||||
let { type, data } = decode(
|
let { type, data } = decode(
|
||||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||||
|
|||||||
5
nip19.ts
5
nip19.ts
@@ -149,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
|
|||||||
while (rest.length > 0) {
|
while (rest.length > 0) {
|
||||||
let t = rest[0]
|
let t = rest[0]
|
||||||
let l = rest[1]
|
let l = rest[1]
|
||||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
|
||||||
let v = rest.slice(2, 2 + l)
|
let v = rest.slice(2, 2 + l)
|
||||||
rest = rest.slice(2 + l)
|
rest = rest.slice(2 + l)
|
||||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
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 {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
let entries: Uint8Array[] = []
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
Object.entries(tlv).forEach(([t, vs]) => {
|
Object.entries(tlv)
|
||||||
|
.reverse()
|
||||||
|
.forEach(([t, vs]) => {
|
||||||
vs.forEach(v => {
|
vs.forEach(v => {
|
||||||
let entry = new Uint8Array(v.length + 2)
|
let entry = new Uint8Array(v.length + 2)
|
||||||
entry.set([parseInt(t)], 0)
|
entry.set([parseInt(t)], 0)
|
||||||
|
|||||||
60
nip29.ts
Normal file
60
nip29.ts
Normal 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
44
nip40.test.ts
Normal 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
49
nip40.ts
Normal 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 }
|
||||||
@@ -242,10 +242,11 @@ describe('validateZapRequest', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('makeZapReceipt', () => {
|
describe('makeZapReceipt', () => {
|
||||||
test('returns a valid Zap receipt with a preimage', () => {
|
|
||||||
const privateKey = generateSecretKey()
|
const privateKey = generateSecretKey()
|
||||||
const publicKey = getPublicKey(privateKey)
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt with a preimage', () => {
|
||||||
const zapRequest = JSON.stringify(
|
const zapRequest = JSON.stringify(
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -253,7 +254,7 @@ describe('makeZapReceipt', () => {
|
|||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
content: 'content',
|
content: 'content',
|
||||||
tags: [
|
tags: [
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
['amount', '100'],
|
['amount', '100'],
|
||||||
['relays', 'relay1', 'relay2'],
|
['relays', 'relay1', 'relay2'],
|
||||||
],
|
],
|
||||||
@@ -274,16 +275,14 @@ describe('makeZapReceipt', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
['bolt11', bolt11],
|
['bolt11', bolt11],
|
||||||
['description', zapRequest],
|
['description', zapRequest],
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
['preimage', preimage],
|
['preimage', preimage],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns a valid Zap receipt without a preimage', () => {
|
test('returns a valid Zap receipt without a preimage', () => {
|
||||||
const privateKey = generateSecretKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = JSON.stringify(
|
const zapRequest = JSON.stringify(
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -291,7 +290,7 @@ describe('makeZapReceipt', () => {
|
|||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
content: 'content',
|
content: 'content',
|
||||||
tags: [
|
tags: [
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
['amount', '100'],
|
['amount', '100'],
|
||||||
['relays', 'relay1', 'relay2'],
|
['relays', 'relay1', 'relay2'],
|
||||||
],
|
],
|
||||||
@@ -311,7 +310,8 @@ describe('makeZapReceipt', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
['bolt11', bolt11],
|
['bolt11', bolt11],
|
||||||
['description', zapRequest],
|
['description', zapRequest],
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||||
|
|||||||
4
nip57.ts
4
nip57.ts
@@ -23,7 +23,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
|||||||
lnurl = utf8Decoder.decode(data)
|
lnurl = utf8Decoder.decode(data)
|
||||||
} else if (lud16) {
|
} else if (lud16) {
|
||||||
let [name, domain] = lud16.split('@')
|
let [name, domain] = lud16.split('@')
|
||||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ export function makeZapReceipt({
|
|||||||
kind: 9735,
|
kind: 9735,
|
||||||
created_at: Math.round(paidAt.getTime() / 1000),
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preimage) {
|
if (preimage) {
|
||||||
|
|||||||
397
nip98.test.ts
397
nip98.test.ts
@@ -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 { sha256 } from '@noble/hashes/sha256'
|
||||||
import { utf8Encoder } from './utils.ts'
|
|
||||||
import { bytesToHex } from '@noble/hashes/utils'
|
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', () => {
|
describe('getToken', () => {
|
||||||
test('getToken GET returns without authorization scheme', async () => {
|
test('returns without authorization scheme for GET', async () => {
|
||||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
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(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'get'],
|
['method', 'get'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken POST returns token without authorization scheme', async () => {
|
test('returns token without authorization scheme for POST', async () => {
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
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(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
test('returns token WITH authorization scheme for POST', async () => {
|
||||||
const authorizationScheme = 'Nostr '
|
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(token.startsWith(authorizationScheme)).toBe(true)
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
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([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['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 payload = { test: 'payload' }
|
||||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
const payloadHash = hashPayload(payload)
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, 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(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
['payload', payloadHash],
|
['payload', payloadHash],
|
||||||
@@ -79,81 +86,265 @@ describe('getToken', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('validateToken', () => {
|
describe('validateToken', () => {
|
||||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
test('returns true for valid token without authorization scheme', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
|
||||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
expect(result).toBe(true)
|
expect(isTokenValid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
test('returns true for valid token with authorization scheme', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
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(isTokenValid).toBe(true)
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for invalid token', async () => {
|
test('throws an error for invalid token', async () => {
|
||||||
const result = validateToken('fake', 'http://test.com', 'get')
|
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for missing token', async () => {
|
test('throws an error for missing token', async () => {
|
||||||
const result = validateToken('', 'http://test.com', 'get')
|
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for a wrong url', async () => {
|
test('throws an error for invalid event kind', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
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(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for a wrong method', async () => {
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
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(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
test('throws an error for invalid url', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
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(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent throws an error for a wrong url', async () => {
|
test('throws an error for invalid method', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
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(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent throws an error for a wrong method', async () => {
|
describe('validateEvent', () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
expect(result).rejects.toThrow(Error)
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
})
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
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)
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
expect(result).toBe(true)
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
})
|
unpackedEvent.kind = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
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' })
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
})
|
||||||
|
|
||||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
expect(result).rejects.toThrow(Error)
|
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
141
nip98.ts
@@ -1,17 +1,13 @@
|
|||||||
import { bytesToHex } from '@noble/hashes/utils'
|
|
||||||
import { sha256 } from '@noble/hashes/sha256'
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import { base64 } from '@scure/base'
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
import { HTTPAuth } from './kinds.ts'
|
|
||||||
|
|
||||||
const _authorizationScheme = 'Nostr '
|
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.
|
* Generate token for NIP-98 flow.
|
||||||
*
|
*
|
||||||
@@ -37,7 +33,7 @@ export async function getToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
event.tags.push(['payload', hashPayload(payload)])
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedEvent = await sign(event)
|
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 => {
|
const event = await unpackEventFromToken(token).catch(error => {
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
|
||||||
const valid = await validateEvent(event, url, method).catch(error => {
|
const valid = await validateEvent(event, url, method).catch(error => {
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
|||||||
return valid
|
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> {
|
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Missing token')
|
throw new Error('Missing token')
|
||||||
}
|
}
|
||||||
|
|
||||||
token = token.replace(_authorizationScheme, '')
|
token = token.replace(_authorizationScheme, '')
|
||||||
|
|
||||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||||
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
/**
|
||||||
if (!event) {
|
* Validates the timestamp of an event.
|
||||||
throw new Error('Invalid nostr 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)) {
|
if (!verifyEvent(event)) {
|
||||||
throw new Error('Invalid nostr event, signature invalid')
|
throw new Error('Invalid nostr event, signature invalid')
|
||||||
}
|
}
|
||||||
if (event.kind !== HTTPAuth) {
|
|
||||||
|
if (!validateEventKind(event)) {
|
||||||
throw new Error('Invalid nostr event, kind invalid')
|
throw new Error('Invalid nostr event, kind invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.created_at) {
|
if (!validateEventTimestamp(event)) {
|
||||||
throw new Error('Invalid nostr event, created_at invalid')
|
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event must be less than 60 seconds old
|
if (!validateEventUrlTag(event, url)) {
|
||||||
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) {
|
|
||||||
throw new Error('Invalid nostr event, url tag invalid')
|
throw new Error('Invalid nostr event, url tag invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
if (!validateEventMethodTag(event, method)) {
|
||||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
|
||||||
throw new Error('Invalid nostr event, method tag invalid')
|
throw new Error('Invalid nostr event, method tag invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
if (!validateEventPayloadTag(event, body)) {
|
||||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||||
if (payloadTag?.[1] !== payloadHash) {
|
|
||||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal 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
228
nip99.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "2.1.0",
|
"version": "2.1.4",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -130,6 +130,11 @@
|
|||||||
"require": "./lib/cjs/nip28.js",
|
"require": "./lib/cjs/nip28.js",
|
||||||
"types": "./lib/types/nip28.d.ts"
|
"types": "./lib/types/nip28.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip29": {
|
||||||
|
"import": "./lib/esm/nip29.js",
|
||||||
|
"require": "./lib/cjs/nip29.js",
|
||||||
|
"types": "./lib/types/nip29.d.ts"
|
||||||
|
},
|
||||||
"./nip30": {
|
"./nip30": {
|
||||||
"import": "./lib/esm/nip30.js",
|
"import": "./lib/esm/nip30.js",
|
||||||
"require": "./lib/cjs/nip30.js",
|
"require": "./lib/cjs/nip30.js",
|
||||||
@@ -173,8 +178,9 @@
|
|||||||
"@noble/hashes": "1.3.1",
|
"@noble/hashes": "1.3.1",
|
||||||
"@scure/base": "1.1.1",
|
"@scure/base": "1.1.1",
|
||||||
"@scure/bip32": "1.3.1",
|
"@scure/bip32": "1.3.1",
|
||||||
"@scure/bip39": "1.2.1",
|
"@scure/bip39": "1.2.1"
|
||||||
"mitata": "^0.1.6",
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
"nostr-wasm": "v0.1.0"
|
"nostr-wasm": "v0.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -205,6 +211,8 @@
|
|||||||
"eslint-plugin-babel": "^5.3.1",
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
"esm-loader-typescript": "^1.0.3",
|
"esm-loader-typescript": "^1.0.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
|
"mitata": "^0.1.6",
|
||||||
|
"mock-socket": "^9.3.1",
|
||||||
"node-fetch": "^2.6.9",
|
"node-fetch": "^2.6.9",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"tsd": "^0.22.0",
|
"tsd": "^0.22.0",
|
||||||
|
|||||||
27
pool.test.ts
27
pool.test.ts
@@ -3,13 +3,17 @@ import { test, expect, afterAll } from 'bun:test'
|
|||||||
import { finalizeEvent, type Event } from './pure.ts'
|
import { finalizeEvent, type Event } from './pure.ts'
|
||||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
import { SimplePool } from './pool.ts'
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { newMockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
let pool = new SimplePool()
|
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(() => {
|
afterAll(() => {
|
||||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
pool.close(relays)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('removing duplicates when subscribing', async () => {
|
test('removing duplicates when subscribing', async () => {
|
||||||
@@ -19,7 +23,7 @@ test('removing duplicates when subscribing', async () => {
|
|||||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||||
onevent(event: Event) {
|
onevent(event: Event) {
|
||||||
// this should be called only once even though we're listening
|
// 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)
|
// deduplicated efficiently (without even being parsed)
|
||||||
received.push(event)
|
received.push(event)
|
||||||
},
|
},
|
||||||
@@ -80,8 +84,8 @@ test('query a bunch of events and cancel on eose', async () => {
|
|||||||
let events = new Set<string>()
|
let events = new Set<string>()
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
pool.subscribeManyEose(
|
pool.subscribeManyEose(
|
||||||
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
[...relays, ...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
||||||
[{ kinds: [0, 1], limit: 40 }],
|
[{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
events.add(event.id)
|
events.add(event.id)
|
||||||
@@ -94,11 +98,14 @@ test('query a bunch of events and cancel on eose', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('querySync()', async () => {
|
test('querySync()', async () => {
|
||||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
let events = await pool.querySync(
|
||||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
[...relays.slice(0, 2), ...relays.slice(0, 2), 'wss://offchain.pub', 'wss://eden.nostr.land'],
|
||||||
|
{
|
||||||
|
authors: authors.slice(0, 2),
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// the actual received number will be greater than 2, but there will be no duplicates
|
// the actual received number will be greater than 2, but there will be no duplicates
|
||||||
expect(events.length).toBeGreaterThan(2)
|
expect(events.length).toBeGreaterThan(2)
|
||||||
@@ -108,9 +115,9 @@ test('querySync()', async () => {
|
|||||||
|
|
||||||
test('get()', async () => {
|
test('get()', async () => {
|
||||||
let event = await pool.get(relays, {
|
let event = await pool.get(relays, {
|
||||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
ids: [ids[0]],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(event).not.toBeNull()
|
expect(event).not.toBeNull()
|
||||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
expect(event).toHaveProperty('id', ids[0])
|
||||||
})
|
})
|
||||||
|
|||||||
113
relay.test.ts
113
relay.test.ts
@@ -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'
|
import { Relay } from './relay.ts'
|
||||||
|
import { newMockRelay } from './test-helpers.ts'
|
||||||
let relay = new Relay('wss://relay.nostr.bg')
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
relay.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('connectivity', async () => {
|
test('connectivity', async () => {
|
||||||
|
const { url } = newMockRelay()
|
||||||
|
const relay = new Relay(url)
|
||||||
await relay.connect()
|
await relay.connect()
|
||||||
expect(relay.connected).toBeTrue()
|
expect(relay.connected).toBeTrue()
|
||||||
|
relay.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('connectivity, with Relay.connect()', async () => {
|
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()
|
expect(relay.connected).toBeTrue()
|
||||||
relay.close()
|
relay.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('querying', async () => {
|
test('querying', async done => {
|
||||||
|
const { url, authors } = newMockRelay()
|
||||||
|
const kind = 0
|
||||||
|
|
||||||
|
const relay = new Relay(url)
|
||||||
await relay.connect()
|
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(
|
relay.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
|
authors: authors,
|
||||||
kinds: [0],
|
kinds: [kind],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
|
expect(authors).toContain(event.pubkey)
|
||||||
expect(event).toHaveProperty('kind', 0)
|
expect(event).toHaveProperty('kind', kind)
|
||||||
resolveEvent()
|
|
||||||
},
|
relay.close()
|
||||||
oneose() {
|
done()
|
||||||
resolveEose()
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
await eosed
|
test('listening and publishing and closing', async done => {
|
||||||
await evented
|
const sk = generateSecretKey()
|
||||||
}, 10000)
|
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()
|
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(
|
let sub = relay.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [23571],
|
kinds: [kind],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
eventReceived = event
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
resolveEvent()
|
expect(event).toHaveProperty('kind', kind)
|
||||||
},
|
expect(event).toHaveProperty('content', 'content')
|
||||||
oneose() {
|
sub.close()
|
||||||
resolveEose()
|
|
||||||
},
|
},
|
||||||
|
oneose() {},
|
||||||
onclose() {
|
onclose() {
|
||||||
resolveClose()
|
relay.close()
|
||||||
|
done()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await eosed
|
relay.publish(
|
||||||
|
finalizeEvent(
|
||||||
let event = finalizeEvent(
|
|
||||||
{
|
{
|
||||||
kind: 23571,
|
kind,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
content: 'content',
|
||||||
|
created_at: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: 'nostr-tools test suite',
|
|
||||||
},
|
},
|
||||||
sk,
|
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')
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
export function buildEvent(params: Partial<Event>): Event {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
@@ -13,3 +15,81 @@ export function buildEvent(params: Partial<Event>): Event {
|
|||||||
...params,
|
...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) }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user