mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aebe0d38c | ||
|
|
16cdf40112 | ||
|
|
e36ea11f41 | ||
|
|
31a35a8008 | ||
|
|
0f5b3f397c | ||
|
|
d156f3c0ac | ||
|
|
d656c84ab5 | ||
|
|
2f0ef90bd5 | ||
|
|
967d7fe63a | ||
|
|
12147d4fee | ||
|
|
c453bc5ec3 | ||
|
|
2017b3cabd | ||
|
|
fbcfccda01 | ||
|
|
0357e035f4 | ||
|
|
dd0014aee3 | ||
|
|
2e9798b8ab | ||
|
|
10b800db3a | ||
|
|
dbad25b2fa | ||
|
|
829633b0d6 | ||
|
|
b1bbcd6c46 | ||
|
|
6a9940c850 | ||
|
|
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"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"handle-callback-err": [2, "^(err|error)$"],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
|
||||
23
README.md
23
README.md
@@ -66,18 +66,18 @@ const sub = relay.subscribe([
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
|
||||
let sub = relay.sub([
|
||||
relay.sub([
|
||||
{
|
||||
kinds: [1],
|
||||
authors: [pk],
|
||||
},
|
||||
])
|
||||
|
||||
sub.on('event', event => {
|
||||
console.log('got event:', event)
|
||||
], {
|
||||
onevent(event) {
|
||||
console.log('got event:', event)
|
||||
}
|
||||
})
|
||||
|
||||
let event = {
|
||||
let eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
@@ -85,14 +85,9 @@ let event = {
|
||||
}
|
||||
|
||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||
const signedEvent = finalizeEvent(event, sk)
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
await relay.publish(signedEvent)
|
||||
|
||||
let events = await relay.list([{ kinds: [0, 1] }])
|
||||
let event = await relay.get({
|
||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||
})
|
||||
|
||||
relay.close()
|
||||
```
|
||||
|
||||
@@ -279,3 +274,7 @@ To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just
|
||||
## License
|
||||
|
||||
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||
|
||||
## Contributing to this repository
|
||||
|
||||
Use NIP-34 to send your patches to `naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* global WebSocket */
|
||||
|
||||
import type { Event, EventTemplate, Nostr } from './core.ts'
|
||||
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||
import { Queue, normalizeURL } from './utils.ts'
|
||||
@@ -218,11 +218,14 @@ export class AbstractRelay {
|
||||
})
|
||||
}
|
||||
|
||||
public async auth(signAuthEvent: (authEvent: EventTemplate) => Promise<void>) {
|
||||
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
const evt = makeAuthEvent(this.url, this.challenge)
|
||||
await signAuthEvent(evt)
|
||||
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||
const ret = new Promise<string>((resolve, reject) => {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||
})
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
return ret
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
|
||||
14
build.js
14
build.js
@@ -28,12 +28,7 @@ esbuild
|
||||
format: 'esm',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({ type: 'module' })
|
||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('esm build success.')
|
||||
})
|
||||
.then(() => console.log('esm build success.'))
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
@@ -42,7 +37,12 @@ esbuild
|
||||
format: 'cjs',
|
||||
packages: 'external',
|
||||
})
|
||||
.then(() => console.log('cjs build success.'))
|
||||
.then(() => {
|
||||
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||
|
||||
console.log('cjs build success.')
|
||||
})
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||
import { buildEvent } from './test-helpers.ts'
|
||||
|
||||
describe('Filter', () => {
|
||||
@@ -241,4 +241,27 @@ describe('Filter', () => {
|
||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilterLimit', () => {
|
||||
test('should handle ids', () => {
|
||||
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||
})
|
||||
|
||||
test('should count the authors times replaceable kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||
})
|
||||
|
||||
test('should return Infinity for authors with regular kinds', () => {
|
||||
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||
})
|
||||
|
||||
test('should return Infinity for empty filters', () => {
|
||||
expect(getFilterLimit({})).toEqual(Infinity)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
16
filter.ts
16
filter.ts
@@ -1,4 +1,5 @@
|
||||
import { Event } from './core.ts'
|
||||
import { isReplaceableKind } from './kinds.ts'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
@@ -70,3 +71,18 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
|
||||
export function getFilterLimit(filter: Filter): number {
|
||||
if (filter.ids && !filter.ids.length) return 0
|
||||
if (filter.kinds && !filter.kinds.length) return 0
|
||||
if (filter.authors && !filter.authors.length) return 0
|
||||
|
||||
return Math.min(
|
||||
Math.max(0, filter.limit ?? Infinity),
|
||||
filter.ids?.length ?? Infinity,
|
||||
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||
? filter.authors.length * filter.kinds.length
|
||||
: Infinity,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||
|
||||
export async function yieldThread() {
|
||||
return new Promise(resolve => {
|
||||
return new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel()
|
||||
const handler = () => {
|
||||
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
|
||||
ch.port1.removeEventListener('message', handler)
|
||||
resolve()
|
||||
}
|
||||
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||
ch.port1.addEventListener('message', resolve)
|
||||
ch.port1.addEventListener('message', handler)
|
||||
ch.port2.postMessage(0)
|
||||
ch.port1.start()
|
||||
})
|
||||
|
||||
6
justfile
6
justfile
@@ -3,6 +3,7 @@ export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||
build:
|
||||
rm -rf lib
|
||||
bun run build.js
|
||||
tsc
|
||||
|
||||
test:
|
||||
bun test --timeout 20000
|
||||
@@ -10,10 +11,7 @@ test:
|
||||
test-only file:
|
||||
bun test {{file}}
|
||||
|
||||
emit-types:
|
||||
tsc # see tsconfig.json
|
||||
|
||||
publish: build emit-types
|
||||
publish: build
|
||||
npm publish
|
||||
|
||||
format:
|
||||
|
||||
19
kinds.ts
19
kinds.ts
@@ -35,27 +35,22 @@ export const ShortTextNote = 1
|
||||
export const RecommendRelay = 2
|
||||
export const Contacts = 3
|
||||
export const EncryptedDirectMessage = 4
|
||||
export const EncryptedDirectMessages = 4
|
||||
export const EventDeletion = 5
|
||||
export const Repost = 6
|
||||
export const Reaction = 7
|
||||
export const BadgeAward = 8
|
||||
export const GenericRepost = 16
|
||||
export const ChannelCreation = 40
|
||||
export const ChannelMetadata = 41
|
||||
export const ChannelMessage = 42
|
||||
export const ChannelHideMessage = 43
|
||||
export const ChannelMuteUser = 44
|
||||
export const Report = 1984
|
||||
export const ZapRequest = 9734
|
||||
export const Zap = 9735
|
||||
export const RelayList = 10002
|
||||
export const ClientAuth = 22242
|
||||
export const BadgeDefinition = 30009
|
||||
export const FileMetadata = 1063
|
||||
export const EncryptedDirectMessages = 4
|
||||
export const GenericRepost = 16
|
||||
export const OpenTimestamps = 1040
|
||||
export const FileMetadata = 1063
|
||||
export const LiveChatMessage = 1311
|
||||
export const ProblemTracker = 1971
|
||||
export const Report = 1984
|
||||
export const Reporting = 1984
|
||||
export const Label = 1985
|
||||
export const CommunityPostApproval = 4550
|
||||
@@ -63,9 +58,12 @@ export const JobRequest = 5999
|
||||
export const JobResult = 6999
|
||||
export const JobFeedback = 7000
|
||||
export const ZapGoal = 9041
|
||||
export const ZapRequest = 9734
|
||||
export const Zap = 9735
|
||||
export const Highlights = 9802
|
||||
export const Mutelist = 10000
|
||||
export const Pinlist = 10001
|
||||
export const RelayList = 10002
|
||||
export const BookmarkList = 10003
|
||||
export const CommunitiesList = 10004
|
||||
export const PublicChatsList = 10005
|
||||
@@ -73,8 +71,10 @@ export const BlockedRelaysList = 10006
|
||||
export const SearchRelaysList = 10007
|
||||
export const InterestsList = 10015
|
||||
export const UserEmojiList = 10030
|
||||
export const FileServerPreference = 10096
|
||||
export const NWCWalletInfo = 13194
|
||||
export const LightningPubRPC = 21000
|
||||
export const ClientAuth = 22242
|
||||
export const NWCWalletRequest = 23194
|
||||
export const NWCWalletResponse = 23195
|
||||
export const NostrConnect = 24133
|
||||
@@ -85,6 +85,7 @@ export const Relaysets = 30002
|
||||
export const Bookmarksets = 30003
|
||||
export const Curationsets = 30004
|
||||
export const ProfileBadges = 30008
|
||||
export const BadgeDefinition = 30009
|
||||
export const Interestsets = 30015
|
||||
export const CreateOrUpdateStall = 30017
|
||||
export const CreateOrUpdateProduct = 30018
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'
|
||||
import fetch from 'node-fetch'
|
||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||
|
||||
// TODO: replace with a mock
|
||||
describe('requesting relay as for NIP11', () => {
|
||||
useFetchImplementation(fetch)
|
||||
|
||||
|
||||
@@ -78,13 +78,13 @@ test('encode and decode naddr', () => {
|
||||
test('encode and decode nevent', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
let nevent = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 30023,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
@@ -95,13 +95,13 @@ test('encode and decode nevent', () => {
|
||||
test('encode and decode nevent with kind 0', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = neventEncode({
|
||||
let nevent = neventEncode({
|
||||
id: pk,
|
||||
relays,
|
||||
kind: 0,
|
||||
})
|
||||
expect(naddr).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(nevent).toMatch(/nevent1\w+/)
|
||||
let { type, data } = decode(nevent)
|
||||
expect(type).toEqual('nevent')
|
||||
const pointer = data as EventPointer
|
||||
expect(pointer.id).toEqual(pk)
|
||||
@@ -109,6 +109,25 @@ test('encode and decode nevent with kind 0', () => {
|
||||
expect(pointer.kind).toEqual(0)
|
||||
})
|
||||
|
||||
test('encode and decode naddr with empty "d"', () => {
|
||||
let pk = getPublicKey(generateSecretKey())
|
||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||
let naddr = naddrEncode({
|
||||
identifier: '',
|
||||
pubkey: pk,
|
||||
relays,
|
||||
kind: 3,
|
||||
})
|
||||
expect(naddr).toMatch(/naddr\w+/)
|
||||
let { type, data } = decode(naddr)
|
||||
expect(type).toEqual('naddr')
|
||||
const pointer = data as AddressPointer
|
||||
expect(pointer.identifier).toEqual('')
|
||||
expect(pointer.relays).toContain(relays[0])
|
||||
expect(pointer.kind).toEqual(3)
|
||||
expect(pointer.pubkey).toEqual(pk)
|
||||
})
|
||||
|
||||
test('decode naddr from habla.news', () => {
|
||||
let { type, data } = decode(
|
||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||
|
||||
19
nip19.ts
19
nip19.ts
@@ -149,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
|
||||
while (rest.length > 0) {
|
||||
let t = rest[0]
|
||||
let l = rest[1]
|
||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
||||
let v = rest.slice(2, 2 + l)
|
||||
rest = rest.slice(2 + l)
|
||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||
@@ -227,15 +226,17 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
|
||||
function encodeTLV(tlv: TLV): Uint8Array {
|
||||
let entries: Uint8Array[] = []
|
||||
|
||||
Object.entries(tlv).forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
Object.entries(tlv)
|
||||
.reverse()
|
||||
.forEach(([t, vs]) => {
|
||||
vs.forEach(v => {
|
||||
let entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return concatBytes(...entries)
|
||||
}
|
||||
|
||||
60
nip29.ts
Normal file
60
nip29.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Event } from './pure'
|
||||
|
||||
export type Group = {
|
||||
id: string
|
||||
relay: string
|
||||
name?: string
|
||||
picture?: string
|
||||
about?: string
|
||||
public?: boolean
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
export function parseGroup(event: Event, relay: string): Group {
|
||||
const group: Partial<Group> = { relay }
|
||||
for (let i = 0; i < event.tags.length; i++) {
|
||||
const tag = event.tags[i]
|
||||
switch (tag[0]) {
|
||||
case 'd':
|
||||
group.id = tag[1] || ''
|
||||
break
|
||||
case 'name':
|
||||
group.name = tag[1] || ''
|
||||
break
|
||||
case 'about':
|
||||
group.about = tag[1] || ''
|
||||
break
|
||||
case 'picture':
|
||||
group.picture = tag[1] || ''
|
||||
break
|
||||
case 'open':
|
||||
group.open = true
|
||||
break
|
||||
case 'public':
|
||||
group.public = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return group 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 }
|
||||
@@ -1,14 +1,16 @@
|
||||
import { test, expect } from 'bun:test'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
import { MockRelay } from './test-helpers.ts'
|
||||
|
||||
test('auth flow', async () => {
|
||||
const relay = await Relay.connect('wss://nostr.wine')
|
||||
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||
|
||||
expect(auth.tags).toHaveLength(2)
|
||||
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
|
||||
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
|
||||
@@ -242,10 +242,11 @@ describe('validateZapRequest', () => {
|
||||
})
|
||||
|
||||
describe('makeZapReceipt', () => {
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||
|
||||
test('returns a valid Zap receipt with a preimage', () => {
|
||||
const zapRequest = JSON.stringify(
|
||||
finalizeEvent(
|
||||
{
|
||||
@@ -253,7 +254,7 @@ describe('makeZapReceipt', () => {
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['p', target],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
@@ -274,16 +275,14 @@ describe('makeZapReceipt', () => {
|
||||
expect.arrayContaining([
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest],
|
||||
['p', publicKey],
|
||||
['p', target],
|
||||
['P', publicKey],
|
||||
['preimage', preimage],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('returns a valid Zap receipt without a preimage', () => {
|
||||
const privateKey = generateSecretKey()
|
||||
const publicKey = getPublicKey(privateKey)
|
||||
|
||||
const zapRequest = JSON.stringify(
|
||||
finalizeEvent(
|
||||
{
|
||||
@@ -291,7 +290,7 @@ describe('makeZapReceipt', () => {
|
||||
created_at: Date.now() / 1000,
|
||||
content: 'content',
|
||||
tags: [
|
||||
['p', publicKey],
|
||||
['p', target],
|
||||
['amount', '100'],
|
||||
['relays', 'relay1', 'relay2'],
|
||||
],
|
||||
@@ -311,7 +310,8 @@ describe('makeZapReceipt', () => {
|
||||
expect.arrayContaining([
|
||||
['bolt11', bolt11],
|
||||
['description', zapRequest],
|
||||
['p', publicKey],
|
||||
['p', target],
|
||||
['P', publicKey],
|
||||
]),
|
||||
)
|
||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||
|
||||
4
nip57.ts
4
nip57.ts
@@ -23,7 +23,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
} else if (lud16) {
|
||||
let [name, domain] = lud16.split('@')
|
||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function makeZapReceipt({
|
||||
kind: 9735,
|
||||
created_at: Math.round(paidAt.getTime() / 1000),
|
||||
content: '',
|
||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
||||
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||
}
|
||||
|
||||
if (preimage) {
|
||||
|
||||
374
nip94.test.ts
Normal file
374
nip94.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { Event, EventTemplate } from './core.ts'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
|
||||
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||
|
||||
describe('generateEventTemplate', () => {
|
||||
it('should generate the correct event template', () => {
|
||||
const fileMetadataObject: FileMetadataObject = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
}
|
||||
|
||||
const expectedEventTemplate: EventTemplate = {
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: expect.any(Number),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
}
|
||||
|
||||
const eventTemplate = generateEventTemplate(fileMetadataObject)
|
||||
|
||||
expect(eventTemplate).toEqual(expectedEventTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
it('should return true for a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if kind is not FileMetadataKind', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0, // not FileMetadataKind
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if content is empty', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // empty
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if required tags are missing', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithoutUrl: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
// missing url
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutM: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
// missing m
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutX: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
// missing x
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const eventWithoutOx: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
// missing ox
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithoutUrl)).toBe(false)
|
||||
expect(validateEvent(eventWithoutM)).toBe(false)
|
||||
expect(validateEvent(eventWithoutX)).toBe(false)
|
||||
expect(validateEvent(eventWithoutOx)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if size is not a number', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', 'abc'], // not a number
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if dim is not a valid dimension string', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const eventWithInvalidDim: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', 'abc'], // invalid dim
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(validateEvent(eventWithInvalidDim)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEvent', () => {
|
||||
it('should parse a valid event', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
const parsedEvent = parseEvent(event)
|
||||
|
||||
expect(parsedEvent).toEqual({
|
||||
content: 'Lorem ipsum dolor sit amet',
|
||||
url: 'https://example.com/image.jpg',
|
||||
m: 'image/jpeg',
|
||||
x: 'image',
|
||||
ox: 'original',
|
||||
size: '1024',
|
||||
dim: '800x600',
|
||||
i: 'abc123',
|
||||
blurhash: 'abcdefg',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
image: 'https://example.com/image.jpg',
|
||||
summary: 'Lorem ipsum',
|
||||
alt: 'Image alt text',
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the event is invalid', () => {
|
||||
const sk = generateSecretKey()
|
||||
|
||||
const event: Event = finalizeEvent(
|
||||
{
|
||||
content: '', // invalid
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', 'https://example.com/image.jpg'],
|
||||
['m', 'image/jpeg'],
|
||||
['x', 'image'],
|
||||
['ox', 'original'],
|
||||
['size', '1024'],
|
||||
['dim', '800x600'],
|
||||
['i', 'abc123'],
|
||||
['blurhash', 'abcdefg'],
|
||||
['thumb', 'https://example.com/thumb.jpg'],
|
||||
['image', 'https://example.com/image.jpg'],
|
||||
['summary', 'Lorem ipsum'],
|
||||
['alt', 'Image alt text'],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
)
|
||||
|
||||
expect(() => parseEvent(event)).toThrow('Invalid event')
|
||||
})
|
||||
})
|
||||
201
nip94.ts
Normal file
201
nip94.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Event, EventTemplate } from './core'
|
||||
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||
|
||||
/**
|
||||
* Type definition for File Metadata as specified in NIP-94.
|
||||
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
|
||||
*/
|
||||
export type FileMetadataObject = {
|
||||
/**
|
||||
* A description or caption for the file content.
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* The URL to download the file.
|
||||
*/
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The MIME type of the file, in lowercase.
|
||||
*/
|
||||
m: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the file.
|
||||
*/
|
||||
x: string
|
||||
|
||||
/**
|
||||
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
|
||||
*/
|
||||
ox: string
|
||||
|
||||
/**
|
||||
* Optional: The size of the file in bytes.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
|
||||
*/
|
||||
dim?: string
|
||||
|
||||
/**
|
||||
* Optional: The URI to the magnet file.
|
||||
*/
|
||||
magnet?: string
|
||||
|
||||
/**
|
||||
* Optional: The torrent infohash.
|
||||
*/
|
||||
i?: string
|
||||
|
||||
/**
|
||||
* Optional: The blurhash string to show while the file is being loaded by the client.
|
||||
*/
|
||||
blurhash?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
|
||||
*/
|
||||
thumb?: string
|
||||
|
||||
/**
|
||||
* Optional: The URL of a preview image with the same dimensions as the original file.
|
||||
*/
|
||||
image?: string
|
||||
|
||||
/**
|
||||
* Optional: A text excerpt or summary of the file's content.
|
||||
*/
|
||||
summary?: string
|
||||
|
||||
/**
|
||||
* Optional: A description for accessibility, providing context or a brief description of the file.
|
||||
*/
|
||||
alt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template based on a file metadata object.
|
||||
*
|
||||
* @param fileMetadata - The file metadata object.
|
||||
* @returns The event template.
|
||||
*/
|
||||
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
|
||||
const eventTemplate: EventTemplate = {
|
||||
content: fileMetadata.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: FileMetadataKind,
|
||||
tags: [
|
||||
['url', fileMetadata.url],
|
||||
['m', fileMetadata.m],
|
||||
['x', fileMetadata.x],
|
||||
['ox', fileMetadata.ox],
|
||||
],
|
||||
}
|
||||
|
||||
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
|
||||
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
|
||||
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
|
||||
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
|
||||
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
|
||||
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
|
||||
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
|
||||
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
|
||||
|
||||
return eventTemplate
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an event to ensure it is a valid file metadata event.
|
||||
* @param event - The event to validate.
|
||||
* @returns True if the event is valid, false otherwise.
|
||||
*/
|
||||
export function validateEvent(event: Event): boolean {
|
||||
if (event.kind !== FileMetadataKind) return false
|
||||
|
||||
if (!event.content) return false
|
||||
|
||||
const requiredTags = ['url', 'm', 'x', 'ox'] as const
|
||||
for (const tag of requiredTags) {
|
||||
if (!event.tags.find(([t]) => t == tag)) return false
|
||||
}
|
||||
|
||||
// validate optional size tag
|
||||
const sizeTag = event.tags.find(([t]) => t == 'size')
|
||||
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
|
||||
|
||||
// validate optional dim tag
|
||||
const dimTag = event.tags.find(([t]) => t == 'dim')
|
||||
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event and returns a file metadata object.
|
||||
* @param event - The event to parse.
|
||||
* @returns The file metadata object.
|
||||
* @throws Error if the event is invalid.
|
||||
*/
|
||||
export function parseEvent(event: Event): FileMetadataObject {
|
||||
if (!validateEvent(event)) {
|
||||
throw new Error('Invalid event')
|
||||
}
|
||||
|
||||
const fileMetadata: FileMetadataObject = {
|
||||
content: event.content,
|
||||
url: '',
|
||||
m: '',
|
||||
x: '',
|
||||
ox: '',
|
||||
}
|
||||
|
||||
for (const [tag, value] of event.tags) {
|
||||
switch (tag) {
|
||||
case 'url':
|
||||
fileMetadata.url = value
|
||||
break
|
||||
case 'm':
|
||||
fileMetadata.m = value
|
||||
break
|
||||
case 'x':
|
||||
fileMetadata.x = value
|
||||
break
|
||||
case 'ox':
|
||||
fileMetadata.ox = value
|
||||
break
|
||||
case 'size':
|
||||
fileMetadata.size = value
|
||||
break
|
||||
case 'dim':
|
||||
fileMetadata.dim = value
|
||||
break
|
||||
case 'magnet':
|
||||
fileMetadata.magnet = value
|
||||
break
|
||||
case 'i':
|
||||
fileMetadata.i = value
|
||||
break
|
||||
case 'blurhash':
|
||||
fileMetadata.blurhash = value
|
||||
break
|
||||
case 'thumb':
|
||||
fileMetadata.thumb = value
|
||||
break
|
||||
case 'image':
|
||||
fileMetadata.image = value
|
||||
break
|
||||
case 'summary':
|
||||
fileMetadata.summary = value
|
||||
break
|
||||
case 'alt':
|
||||
fileMetadata.alt = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
572
nip96.test.ts
Normal file
572
nip96.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
import { FileServerPreference } from './kinds.ts'
|
||||
import {
|
||||
calculateFileHash,
|
||||
checkFileProcessingStatus,
|
||||
deleteFile,
|
||||
generateDownloadUrl,
|
||||
generateFSPEventTemplate,
|
||||
readServerConfig,
|
||||
uploadFile,
|
||||
validateDelayedProcessingResponse,
|
||||
validateFileUploadResponse,
|
||||
validateServerConfiguration,
|
||||
type DelayedProcessingResponse,
|
||||
type FileUploadResponse,
|
||||
type ServerConfiguration,
|
||||
} from './nip96.ts'
|
||||
|
||||
describe('validateServerConfiguration', () => {
|
||||
it("should return true if 'api_url' is valid URL", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if 'api_url' is empty", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: '',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false if both 'api_url' and 'delegated_to_url' are provided", () => {
|
||||
const config: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
delegated_to_url: 'http://example.com',
|
||||
}
|
||||
|
||||
expect(validateServerConfiguration(config)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readServerConfig', () => {
|
||||
it('should return a valid ServerConfiguration object', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const validConfig: ServerConfiguration = {
|
||||
api_url: 'http://example.com',
|
||||
}
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(validConfig)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const result = await readServerConfig('http://example.com/')
|
||||
|
||||
expect(result).toEqual(validConfig)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not valid', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const invalidConfig = {
|
||||
// missing api_url
|
||||
}
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(invalidConfig)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not proper json', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return HttpResponse.json(null)
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response status is not 200', async () => {
|
||||
// setup mock server
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if input url is not valid', async () => {
|
||||
expect(readServerConfig('invalid-url')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFileUploadResponse', () => {
|
||||
it('should return true if response is valid', () => {
|
||||
const mockResponse: FileUploadResponse = {
|
||||
status: 'error',
|
||||
message: 'File uploaded failed',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if status is undefined', () => {
|
||||
const mockResponse: Omit<FileUploadResponse, 'status'> = {
|
||||
// status: 'error',
|
||||
message: 'File upload failed',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if message is undefined', () => {
|
||||
const mockResponse: Omit<FileUploadResponse, 'message'> = {
|
||||
status: 'error',
|
||||
// message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is not valid', () => {
|
||||
const mockResponse = {
|
||||
status: 'something else',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "message" is not a string', () => {
|
||||
const mockResponse = {
|
||||
status: 'error',
|
||||
message: 123,
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "processing" and "processing_url" is undefined', () => {
|
||||
const mockResponse = {
|
||||
status: 'processing',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "processing" and "processing_url" is not a string', () => {
|
||||
const mockResponse = {
|
||||
status: 'processing',
|
||||
message: 'message',
|
||||
processing_url: 123,
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if status is "success" and "nip94_event" is undefined', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "nip94_event" tags are invalid', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [
|
||||
// missing url
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if "nip94_event" tags are empty', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true if "nip94_event" tags are valid', () => {
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const result = validateFileUploadResponse(mockResponse)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should return a valid FileUploadResponse object', async () => {
|
||||
// setup mock server
|
||||
const validFileUploadResponse: FileUploadResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
content: '',
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return HttpResponse.json(validFileUploadResponse, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
const result = await uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)
|
||||
|
||||
expect(result).toEqual(validFileUploadResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response status is not ok', async () => {
|
||||
// setup mock server
|
||||
const handler = http.post('http://example.com/upload', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const serverUploadUrl = 'http://example.com/upload'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDownloadUrl', () => {
|
||||
it('should generate a download URL without file extension', () => {
|
||||
const fileHash = 'abc123'
|
||||
const serverDownloadUrl = 'http://example.com/download'
|
||||
const expectedUrl = 'http://example.com/download/abc123'
|
||||
|
||||
const result = generateDownloadUrl(fileHash, serverDownloadUrl)
|
||||
|
||||
expect(result).toBe(expectedUrl)
|
||||
})
|
||||
|
||||
it('should generate a download URL with file extension', () => {
|
||||
const fileHash = 'abc123'
|
||||
const serverDownloadUrl = 'http://example.com/download'
|
||||
const fileExtension = '.jpg'
|
||||
const expectedUrl = 'http://example.com/download/abc123.jpg'
|
||||
|
||||
const result = generateDownloadUrl(fileHash, serverDownloadUrl, fileExtension)
|
||||
|
||||
expect(result).toBe(expectedUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should return a basic json response for successful delete', async () => {
|
||||
// setup mock server
|
||||
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||
return HttpResponse.json({ status: 'success', message: 'File deleted.' }, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const fileHash = 'abc123'
|
||||
const serverDeleteUrl = 'http://example.com/delete'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
const result = await deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)
|
||||
|
||||
expect(result).toEqual({ status: 'success', message: 'File deleted.' })
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error for unsuccessful delete', async () => {
|
||||
// setup mock server
|
||||
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const fileHash = 'abc123'
|
||||
const serverDeleteUrl = 'http://example.com/delete'
|
||||
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||
|
||||
expect(deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDelayedProcessingResponse', () => {
|
||||
it('should return false for non-object input', () => {
|
||||
expect(validateDelayedProcessingResponse('not an object')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(validateDelayedProcessingResponse(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for object missing required properties', () => {
|
||||
const missingStatus: Omit<DelayedProcessingResponse, 'status'> = {
|
||||
// missing status
|
||||
message: 'test',
|
||||
percentage: 50,
|
||||
}
|
||||
const missingMessage: Omit<DelayedProcessingResponse, 'message'> = {
|
||||
status: 'processing',
|
||||
// missing message
|
||||
percentage: 50,
|
||||
}
|
||||
const missingPercentage: Omit<DelayedProcessingResponse, 'percentage'> = {
|
||||
status: 'processing',
|
||||
message: 'test',
|
||||
// missing percentage
|
||||
}
|
||||
|
||||
expect(validateDelayedProcessingResponse(missingStatus)).toBe(false)
|
||||
expect(validateDelayedProcessingResponse(missingMessage)).toBe(false)
|
||||
expect(validateDelayedProcessingResponse(missingPercentage)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid status', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'invalid', message: 'test', percentage: 50 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-string message', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 123, percentage: 50 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-number percentage', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: '50' })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for percentage out of range', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 150 })).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for valid input', () => {
|
||||
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 50 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkFileProcessingStatus', () => {
|
||||
it('should throw an error if response is not ok', async () => {
|
||||
// setup mock server
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should throw an error if response is not a valid json', async () => {
|
||||
// setup mock server
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.text('not a json', { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should return a valid DelayedProcessingResponse object if response status is 200', async () => {
|
||||
// setup mock server
|
||||
const validDelayedProcessingResponse: DelayedProcessingResponse = {
|
||||
status: 'processing',
|
||||
message: 'test',
|
||||
percentage: 50,
|
||||
}
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.json(validDelayedProcessingResponse, { status: 200 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
const result = await checkFileProcessingStatus(processingUrl)
|
||||
|
||||
expect(result).toEqual(validDelayedProcessingResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should return a valid FileUploadResponse object if response status is 201', async () => {
|
||||
// setup mock server
|
||||
const validFileUploadResponse: FileUploadResponse = {
|
||||
status: 'success',
|
||||
message: 'message',
|
||||
nip94_event: {
|
||||
content: '',
|
||||
tags: [
|
||||
['url', 'http://example.com'],
|
||||
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||
],
|
||||
},
|
||||
}
|
||||
const handler = http.get('http://example.com/status/abc123', () => {
|
||||
return HttpResponse.json(validFileUploadResponse, { status: 201 })
|
||||
})
|
||||
const server = setupServer(handler)
|
||||
server.listen()
|
||||
|
||||
const processingUrl = 'http://example.com/status/abc123'
|
||||
|
||||
const result = await checkFileProcessingStatus(processingUrl)
|
||||
|
||||
expect(result).toEqual(validFileUploadResponse)
|
||||
|
||||
// cleanup mock server
|
||||
server.resetHandlers()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateFSPEventTemplate', () => {
|
||||
it('should generate FSP event template', () => {
|
||||
const serverUrls = ['http://example.com', 'https://example.org']
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.kind).toBe(FileServerPreference)
|
||||
expect(eventTemplate.content).toBe('')
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['server', 'http://example.com'],
|
||||
['server', 'https://example.org'],
|
||||
])
|
||||
expect(typeof eventTemplate.created_at).toBe('number')
|
||||
})
|
||||
|
||||
it('should filter invalid server URLs', () => {
|
||||
const serverUrls = ['http://example.com', 'invalid-url', 'https://example.org']
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.tags).toEqual([
|
||||
['server', 'http://example.com'],
|
||||
['server', 'https://example.org'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty server URLs', () => {
|
||||
const serverUrls: string[] = []
|
||||
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||
|
||||
expect(eventTemplate.tags).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateFileHash', () => {
|
||||
it('should calculate file hash', async () => {
|
||||
const file = new File(['hello world'], 'hello.txt')
|
||||
const hash = await calculateFileHash(file)
|
||||
|
||||
expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9')
|
||||
})
|
||||
|
||||
it('should calculate file hash with empty file', async () => {
|
||||
const file = new File([], 'empty.txt')
|
||||
const hash = await calculateFileHash(file)
|
||||
|
||||
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||
})
|
||||
})
|
||||
587
nip96.ts
Normal file
587
nip96.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
import { EventTemplate } from './core'
|
||||
import { FileServerPreference } from './kinds'
|
||||
|
||||
/**
|
||||
* Represents the configuration for a server compliant with NIP-96.
|
||||
*/
|
||||
export type ServerConfiguration = {
|
||||
/**
|
||||
* The base URL from which file upload and deletion operations are served.
|
||||
* Also used for downloads if "download_url" is not specified.
|
||||
*/
|
||||
api_url: string
|
||||
|
||||
/**
|
||||
* Optional. The base URL from which files are downloaded.
|
||||
* Used if different from the "api_url".
|
||||
*/
|
||||
download_url?: string
|
||||
|
||||
/**
|
||||
* Optional. URL of another HTTP file storage server's configuration.
|
||||
* Used by nostr relays to delegate to another server.
|
||||
* In this case, "api_url" must be an empty string.
|
||||
*/
|
||||
delegated_to_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An array of NIP numbers that this server supports.
|
||||
*/
|
||||
supported_nips?: number[]
|
||||
|
||||
/**
|
||||
* Optional. URL to the server's Terms of Service.
|
||||
*/
|
||||
tos_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An array of MIME types supported by the server.
|
||||
*/
|
||||
content_types?: string[]
|
||||
|
||||
/**
|
||||
* Optional. Defines various storage plans offered by the server.
|
||||
*/
|
||||
plans?: {
|
||||
[planKey: string]: {
|
||||
/**
|
||||
* The name of the storage plan.
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Optional. Indicates whether NIP-98 is required for uploads in this plan.
|
||||
*/
|
||||
is_nip98_required?: boolean
|
||||
|
||||
/**
|
||||
* Optional. URL to a landing page providing more information about the plan.
|
||||
*/
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Optional. The maximum file size allowed under this plan, in bytes.
|
||||
*/
|
||||
max_byte_size?: number
|
||||
|
||||
/**
|
||||
* Optional. Defines the range of file expiration in days.
|
||||
* The first value indicates the minimum expiration time, and the second value indicates the maximum.
|
||||
* A value of 0 indicates no expiration.
|
||||
*/
|
||||
file_expiration?: [number, number]
|
||||
|
||||
/**
|
||||
* Optional. Specifies the types of media transformations supported under this plan.
|
||||
* Currently, only image transformations are considered.
|
||||
*/
|
||||
media_transformations?: {
|
||||
/**
|
||||
* Optional. An array of supported image transformation types.
|
||||
*/
|
||||
image?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the optional form data fields for file upload in accordance with NIP-96.
|
||||
*/
|
||||
export type OptionalFormDataFields = {
|
||||
/**
|
||||
* Specifies the desired expiration time of the file on the server.
|
||||
* It should be a string representing a UNIX timestamp in seconds.
|
||||
* An empty string indicates that the file should be stored indefinitely.
|
||||
*/
|
||||
expiration?: string
|
||||
|
||||
/**
|
||||
* Indicates the size of the file in bytes.
|
||||
* This field can be used by the server to pre-validate the file size before processing the upload.
|
||||
*/
|
||||
size?: string
|
||||
|
||||
/**
|
||||
* Provides a strict description of the file for accessibility purposes,
|
||||
* particularly useful for visibility-impaired users.
|
||||
*/
|
||||
alt?: string
|
||||
|
||||
/**
|
||||
* A loose, more descriptive caption for the file.
|
||||
* This can be used for additional context or commentary about the file.
|
||||
*/
|
||||
caption?: string
|
||||
|
||||
/**
|
||||
* Specifies the intended use of the file.
|
||||
* Can be either 'avatar' or 'banner', indicating if the file is to be used as an avatar or a banner.
|
||||
* Absence of this field suggests standard file upload without special treatment.
|
||||
*/
|
||||
media_type?: 'avatar' | 'banner'
|
||||
|
||||
/**
|
||||
* The MIME type of the file being uploaded.
|
||||
* This can be used for early rejection by the server if the file type isn't supported.
|
||||
*/
|
||||
content_type?: string
|
||||
|
||||
/**
|
||||
* Other custom form data fields.
|
||||
*/
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the response from a NIP-96 compliant server after a file upload request.
|
||||
*/
|
||||
export type FileUploadResponse = {
|
||||
/**
|
||||
* The status of the upload request.
|
||||
* - 'success': Indicates the file was successfully uploaded.
|
||||
* - 'error': Indicates there was an error in the upload process.
|
||||
* - 'processing': Indicates the file is still being processed (used in cases of delayed processing).
|
||||
*/
|
||||
status: 'success' | 'error' | 'processing'
|
||||
|
||||
/**
|
||||
* A message provided by the server, which could be a success message, error description, or processing status.
|
||||
*/
|
||||
message: string
|
||||
|
||||
/**
|
||||
* Optional. A URL provided by the server where the upload processing status can be checked.
|
||||
* This is relevant in cases where the file upload involves delayed processing.
|
||||
*/
|
||||
processing_url?: string
|
||||
|
||||
/**
|
||||
* Optional. An event object conforming to NIP-94, which includes details about the uploaded file.
|
||||
* This object is typically provided in the response for a successful upload and contains
|
||||
* essential information such as the download URL and file metadata.
|
||||
*/
|
||||
nip94_event?: {
|
||||
/**
|
||||
* A collection of key-value pairs (tags) providing metadata about the uploaded file.
|
||||
* Standard tags include:
|
||||
* - 'url': The URL where the file can be accessed.
|
||||
* - 'ox': The SHA-256 hash of the original file before any server-side transformations.
|
||||
* Additional optional tags might include file dimensions, MIME type, etc.
|
||||
*/
|
||||
tags: Array<[string, string]>
|
||||
|
||||
/**
|
||||
* A content field, which is typically empty for file upload events but included for consistency with the NIP-94 structure.
|
||||
*/
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the response from a NIP-96 compliant server after a delayed processing request.
|
||||
*/
|
||||
export type DelayedProcessingResponse = {
|
||||
/**
|
||||
* The status of the delayed processing request.
|
||||
* - 'processing': Indicates the file is still being processed.
|
||||
* - 'error': Indicates there was an error in the processing.
|
||||
*/
|
||||
status: 'processing' | 'error'
|
||||
|
||||
/**
|
||||
* A message provided by the server, which could be a success message or error description.
|
||||
*/
|
||||
message: string
|
||||
|
||||
/**
|
||||
* The percentage of the file that has been processed. This is a number between 0 and 100.
|
||||
*/
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the server configuration.
|
||||
*
|
||||
* @param config - The server configuration object.
|
||||
* @returns True if the configuration is valid, false otherwise.
|
||||
*/
|
||||
export function validateServerConfiguration(config: ServerConfiguration): boolean {
|
||||
if (Boolean(config.api_url) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Boolean(config.delegated_to_url) && Boolean(config.api_url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches, parses, and validates the server configuration from the given URL.
|
||||
*
|
||||
* @param serverUrl The URL of the server.
|
||||
* @returns The server configuration, or an error if the configuration could not be fetched or parsed.
|
||||
*/
|
||||
export async function readServerConfig(serverUrl: string): Promise<ServerConfiguration> {
|
||||
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||
let fetchUrl = ''
|
||||
|
||||
try {
|
||||
const { origin } = new URL(serverUrl)
|
||||
fetchUrl = origin + HTTPROUTE
|
||||
} catch (error) {
|
||||
throw new Error('Invalid URL')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching ${fetchUrl}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: any = await response.json()
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data')
|
||||
}
|
||||
|
||||
if (!validateServerConfiguration(data)) {
|
||||
throw new Error('Invalid configuration data')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (_) {
|
||||
throw new Error(`Error fetching.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given object is a valid FileUploadResponse.
|
||||
*
|
||||
* @param response - The object to validate.
|
||||
* @returns true if the object is a valid FileUploadResponse, otherwise false.
|
||||
*/
|
||||
export function validateFileUploadResponse(response: any): response is FileUploadResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.message !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status === 'processing' && !response.processing_url) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.processing_url) {
|
||||
if (typeof response.processing_url !== 'string') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 'success' && !response.nip94_event) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.nip94_event) {
|
||||
if (
|
||||
!response.nip94_event.tags ||
|
||||
!Array.isArray(response.nip94_event.tags) ||
|
||||
response.nip94_event.tags.length === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const tag of response.nip94_event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length !== 2) return false
|
||||
|
||||
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to a NIP-96 compliant server.
|
||||
*
|
||||
* @param file - The file to be uploaded.
|
||||
* @param serverApiUrl - The API URL of the server, retrieved from the server's configuration.
|
||||
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||
* @param optionalFormDataFields - Optional form data fields.
|
||||
* @returns A promise that resolves to the server's response.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
serverApiUrl: string,
|
||||
nip98AuthorizationHeader: string,
|
||||
optionalFormDataFields?: OptionalFormDataFields,
|
||||
): Promise<FileUploadResponse> {
|
||||
// Create FormData object
|
||||
const formData = new FormData()
|
||||
|
||||
// Append the authorization header to HTML Form Data
|
||||
formData.append('Authorization', nip98AuthorizationHeader)
|
||||
|
||||
// Append optional fields to FormData
|
||||
optionalFormDataFields &&
|
||||
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
formData.append(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
// Append the file to FormData as the last field
|
||||
formData.append('file', file)
|
||||
|
||||
// Make the POST request to the server
|
||||
const response = await fetch(serverApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: nip98AuthorizationHeader,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok === false) {
|
||||
// 413 Payload Too Large
|
||||
if (response.status === 413) {
|
||||
throw new Error('File too large!')
|
||||
}
|
||||
|
||||
// 400 Bad Request
|
||||
if (response.status === 400) {
|
||||
throw new Error('Bad request! Some fields are missing or invalid!')
|
||||
}
|
||||
|
||||
// 403 Forbidden
|
||||
if (response.status === 403) {
|
||||
throw new Error('Forbidden! Payload tag does not match the requested file!')
|
||||
}
|
||||
|
||||
// 402 Payment Required
|
||||
if (response.status === 402) {
|
||||
throw new Error('Payment required!')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the URL for downloading a file from a NIP-96 compliant server.
|
||||
*
|
||||
* @param fileHash - The SHA-256 hash of the original file.
|
||||
* @param serverDownloadUrl - The base URL provided by the server, retrieved from the server's configuration.
|
||||
* @param fileExtension - An optional parameter that specifies the file extension (e.g., '.jpg', '.png').
|
||||
* @returns A string representing the complete URL to download the file.
|
||||
*
|
||||
*/
|
||||
export function generateDownloadUrl(fileHash: string, serverDownloadUrl: string, fileExtension?: string): string {
|
||||
// Construct the base download URL using the file hash
|
||||
let downloadUrl = `${serverDownloadUrl}/${fileHash}`
|
||||
|
||||
// Append the file extension if provided
|
||||
if (fileExtension) {
|
||||
downloadUrl += fileExtension
|
||||
}
|
||||
|
||||
return downloadUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to delete a file from a NIP-96 compliant server.
|
||||
*
|
||||
* @param fileHash - The SHA-256 hash of the original file.
|
||||
* @param serverApiUrl - The base API URL of the server, retrieved from the server's configuration.
|
||||
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||
* @returns A promise that resolves to the server's response to the deletion request.
|
||||
*
|
||||
*/
|
||||
export async function deleteFile(
|
||||
fileHash: string,
|
||||
serverApiUrl: string,
|
||||
nip98AuthorizationHeader: string,
|
||||
): Promise<any> {
|
||||
// make sure the serverApiUrl ends with a slash
|
||||
if (!serverApiUrl.endsWith('/')) {
|
||||
serverApiUrl += '/'
|
||||
}
|
||||
|
||||
// Construct the URL for the delete request
|
||||
const deleteUrl = `${serverApiUrl}${fileHash}`
|
||||
|
||||
// Send the DELETE request
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: nip98AuthorizationHeader,
|
||||
},
|
||||
})
|
||||
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
throw new Error('Error deleting file!')
|
||||
}
|
||||
|
||||
// Return the response from the server
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the server's response to a delayed processing request.
|
||||
*
|
||||
* @param response - The server's response to a delayed processing request.
|
||||
* @returns A boolean indicating whether the response is valid.
|
||||
*/
|
||||
export function validateDelayedProcessingResponse(response: any): response is DelayedProcessingResponse {
|
||||
if (typeof response !== 'object' || response === null) return false
|
||||
|
||||
if (!response.status || !response.message || !response.percentage) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status !== 'processing' && response.status !== 'error') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.message !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof response.percentage !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Number(response.percentage) < 0 || Number(response.percentage) > 100) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the processing status of a file when delayed processing is used.
|
||||
*
|
||||
* @param processingUrl - The URL provided by the server where the processing status can be checked.
|
||||
* @returns A promise that resolves to an object containing the processing status and other relevant information.
|
||||
*/
|
||||
export async function checkFileProcessingStatus(
|
||||
processingUrl: string,
|
||||
): Promise<FileUploadResponse | DelayedProcessingResponse> {
|
||||
// Make the GET request to the processing URL
|
||||
const response = await fetch(processingUrl)
|
||||
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to retrieve processing status. Server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
try {
|
||||
const parsedResponse = await response.json()
|
||||
|
||||
// 201 Created: Indicates the processing is over.
|
||||
if (response.status === 201) {
|
||||
// Validate the response
|
||||
if (!validateFileUploadResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
// 200 OK: Indicates the processing is still ongoing.
|
||||
if (response.status === 200) {
|
||||
// Validate the response
|
||||
if (!validateDelayedProcessingResponse(parsedResponse)) {
|
||||
throw new Error('Invalid response from the server!')
|
||||
}
|
||||
|
||||
return parsedResponse
|
||||
}
|
||||
|
||||
throw new Error('Invalid response from the server!')
|
||||
} catch (error) {
|
||||
throw new Error('Error parsing JSON response!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an event template to indicate a user's File Server Preferences.
|
||||
* This event is of kind 10096 and is used to specify one or more preferred servers for file uploads.
|
||||
*
|
||||
* @param serverUrls - An array of URLs representing the user's preferred file storage servers.
|
||||
* @returns An object representing a Nostr event template for setting file server preferences.
|
||||
*/
|
||||
export function generateFSPEventTemplate(serverUrls: string[]): EventTemplate {
|
||||
serverUrls = serverUrls.filter(serverUrl => {
|
||||
try {
|
||||
new URL(serverUrl)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: FileServerPreference,
|
||||
content: '',
|
||||
tags: serverUrls.map(serverUrl => ['server', serverUrl]),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the SHA-256 hash of a given file. This hash is used in various NIP-96 operations,
|
||||
* such as file upload, download, and deletion, to uniquely identify files.
|
||||
*
|
||||
* @param file - The file for which the SHA-256 hash needs to be calculated.
|
||||
* @returns A promise that resolves to the SHA-256 hash of the file.
|
||||
*/
|
||||
export async function calculateFileHash(file: Blob): Promise<string> {
|
||||
// Read the file as an ArrayBuffer
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
// Calculate the SHA-256 hash of the file
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
|
||||
// Convert the hash to a hexadecimal string
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
return hashHex
|
||||
}
|
||||
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 { utf8Encoder } from './utils.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const sk = generateSecretKey()
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import {
|
||||
getToken,
|
||||
hashPayload,
|
||||
unpackEventFromToken,
|
||||
validateEvent,
|
||||
validateEventKind,
|
||||
validateEventMethodTag,
|
||||
validateEventPayloadTag,
|
||||
validateEventTimestamp,
|
||||
validateEventUrlTag,
|
||||
validateToken,
|
||||
} from './nip98.ts'
|
||||
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { utf8Encoder } from './utils.ts'
|
||||
|
||||
describe('getToken', () => {
|
||||
test('getToken GET returns without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('returns without authorization scheme for GET', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'get'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken POST returns token without authorization scheme', async () => {
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
test('returns token without authorization scheme for POST', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
||||
test('returns token WITH authorization scheme for POST', async () => {
|
||||
const authorizationScheme = 'Nostr '
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||
|
||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
])
|
||||
})
|
||||
|
||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
||||
test('returns token with a valid payload tag when payload is present', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const payload = { test: 'payload' }
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
const payloadHash = hashPayload(payload)
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
|
||||
const decodedResult: Event = await unpackEventFromToken(result)
|
||||
|
||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
||||
expect(decodedResult.content).toBe('')
|
||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
||||
expect(decodedResult.tags).toStrictEqual([
|
||||
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||
expect(unpackedEvent.content).toBe('')
|
||||
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||
expect(unpackedEvent.tags).toStrictEqual([
|
||||
['u', 'http://test.com'],
|
||||
['method', 'post'],
|
||||
['payload', payloadHash],
|
||||
@@ -79,81 +86,265 @@ describe('getToken', () => {
|
||||
})
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('returns true for valid token without authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
expect(isTokenValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
test('returns true for valid token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||
|
||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).toBe(true)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for invalid token', async () => {
|
||||
const result = validateToken('fake', 'http://test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
test('throws an error for invalid token', async () => {
|
||||
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for missing token', async () => {
|
||||
const result = validateToken('', 'http://test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
test('throws an error for missing token', async () => {
|
||||
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.kind = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateToken throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||
e.created_at = 0
|
||||
return finalizeEvent(e, sk)
|
||||
})
|
||||
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||
|
||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
test('throws an error for invalid url', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
||||
expect(result).toBe(true)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong url', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
test('throws an error for invalid method', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent throws an error for a wrong method', async () => {
|
||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
||||
expect(result).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('validateEvent returns true for valid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('validateEvent returns false for invalid payload tag hash', async () => {
|
||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
||||
|
||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
expect(result).rejects.toThrow(Error)
|
||||
expect(isTokenValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEvent', () => {
|
||||
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid event timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('throws an error for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
test('returns true for valid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||
|
||||
expect(isEventValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag hash', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventValid).rejects.toThrow(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventTimestamp', () => {
|
||||
test('returns true for valid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid timestamp', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.created_at = 0
|
||||
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||
|
||||
expect(isEventTimestampValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventKind', () => {
|
||||
test('returns true for valid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid kind', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
unpackedEvent.kind = 0
|
||||
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||
|
||||
expect(isEventKindValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventUrlTag', () => {
|
||||
test('returns true for valid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid url tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||
|
||||
expect(isEventUrlTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventMethodTag', () => {
|
||||
test('returns true for valid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid method tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||
|
||||
expect(isEventMethodTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEventPayloadTag', () => {
|
||||
test('returns true for valid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for missing payload tag', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||
|
||||
expect(isEventPayloadTagValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashPayload', () => {
|
||||
test('returns hash for valid payload', async () => {
|
||||
const payload = { test: 'payload' }
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
|
||||
test('returns hash for empty payload', async () => {
|
||||
const payload = {}
|
||||
const computedPayloadHash = hashPayload(payload)
|
||||
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||
|
||||
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||
})
|
||||
})
|
||||
|
||||
141
nip98.ts
141
nip98.ts
@@ -1,17 +1,13 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { base64 } from '@scure/base'
|
||||
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||
import { HTTPAuth } from './kinds.ts'
|
||||
|
||||
const _authorizationScheme = 'Nostr '
|
||||
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token for NIP-98 flow.
|
||||
*
|
||||
@@ -37,7 +33,7 @@ export async function getToken(
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
||||
event.tags.push(['payload', hashPayload(payload)])
|
||||
}
|
||||
|
||||
const signedEvent = await sign(event)
|
||||
@@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
const event = await unpackEventFromToken(token).catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const valid = await validateEvent(event, url, method).catch(error => {
|
||||
throw error
|
||||
})
|
||||
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks an event from a token.
|
||||
*
|
||||
* @param token - The token to unpack.
|
||||
* @returns A promise that resolves to the unpacked event.
|
||||
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||
*/
|
||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
if (!token) {
|
||||
throw new Error('Missing token')
|
||||
}
|
||||
|
||||
token = token.replace(_authorizationScheme, '')
|
||||
|
||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||
return event
|
||||
}
|
||||
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!event) {
|
||||
throw new Error('Invalid nostr event')
|
||||
/**
|
||||
* Validates the timestamp of an event.
|
||||
* @param event - The event object to validate.
|
||||
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||
*/
|
||||
export function validateEventTimestamp(event: Event): boolean {
|
||||
if (!event.created_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the kind of an event.
|
||||
* @param event The event to validate.
|
||||
* @returns A boolean indicating whether the event kind is valid.
|
||||
*/
|
||||
export function validateEventKind(event: Event): boolean {
|
||||
return event.kind === HTTPAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given URL matches the URL tag of the event.
|
||||
* @param event - The event object.
|
||||
* @param url - The URL to validate.
|
||||
* @returns A boolean indicating whether the URL is valid or not.
|
||||
*/
|
||||
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
|
||||
if (!urlTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return urlTag.length > 0 && urlTag[1] === url
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given event has a method tag that matches the specified method.
|
||||
* @param event - The event to validate.
|
||||
* @param method - The method to match against the method tag.
|
||||
* @returns A boolean indicating whether the event has a matching method tag.
|
||||
*/
|
||||
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
|
||||
if (!methodTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the hash of a payload.
|
||||
* @param payload - The payload to be hashed.
|
||||
* @returns The hash value as a string.
|
||||
*/
|
||||
export function hashPayload(payload: any): string {
|
||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||
return bytesToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the event payload tag against the provided payload.
|
||||
* @param event The event object.
|
||||
* @param payload The payload to validate.
|
||||
* @returns A boolean indicating whether the payload tag is valid.
|
||||
*/
|
||||
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
|
||||
if (!payloadTag) {
|
||||
return false
|
||||
}
|
||||
|
||||
const payloadHash = hashPayload(payload)
|
||||
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Nostr event for the NIP-98 flow.
|
||||
*
|
||||
* @param event - The Nostr event to validate.
|
||||
* @param url - The URL associated with the event.
|
||||
* @param method - The HTTP method associated with the event.
|
||||
* @param body - The request body associated with the event (optional).
|
||||
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||
* @throws An error if the event is invalid.
|
||||
*/
|
||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||
if (!verifyEvent(event)) {
|
||||
throw new Error('Invalid nostr event, signature invalid')
|
||||
}
|
||||
if (event.kind !== HTTPAuth) {
|
||||
|
||||
if (!validateEventKind(event)) {
|
||||
throw new Error('Invalid nostr event, kind invalid')
|
||||
}
|
||||
|
||||
if (!event.created_at) {
|
||||
throw new Error('Invalid nostr event, created_at invalid')
|
||||
if (!validateEventTimestamp(event)) {
|
||||
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||
}
|
||||
|
||||
// Event must be less than 60 seconds old
|
||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
||||
throw new Error('Invalid nostr event, expired')
|
||||
}
|
||||
|
||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
||||
if (!validateEventUrlTag(event, url)) {
|
||||
throw new Error('Invalid nostr event, url tag invalid')
|
||||
}
|
||||
|
||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
||||
if (!validateEventMethodTag(event, method)) {
|
||||
throw new Error('Invalid nostr event, method tag invalid')
|
||||
}
|
||||
|
||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
||||
if (payloadTag?.[1] !== payloadHash) {
|
||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
||||
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||
if (!validateEventPayloadTag(event, body)) {
|
||||
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
506
nip99.test.ts
Normal file
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),
|
||||
}
|
||||
}
|
||||
17
package.json
17
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "nostr-tools",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.5",
|
||||
"description": "Tools for making a Nostr client.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -130,6 +130,11 @@
|
||||
"require": "./lib/cjs/nip28.js",
|
||||
"types": "./lib/types/nip28.d.ts"
|
||||
},
|
||||
"./nip29": {
|
||||
"import": "./lib/esm/nip29.js",
|
||||
"require": "./lib/cjs/nip29.js",
|
||||
"types": "./lib/types/nip29.d.ts"
|
||||
},
|
||||
"./nip30": {
|
||||
"import": "./lib/esm/nip30.js",
|
||||
"require": "./lib/cjs/nip30.js",
|
||||
@@ -173,8 +178,9 @@
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"mitata": "^0.1.6",
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nostr-wasm": "v0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -205,12 +211,15 @@
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"esm-loader-typescript": "^1.0.3",
|
||||
"events": "^3.3.0",
|
||||
"mitata": "^0.1.6",
|
||||
"mock-socket": "^9.3.1",
|
||||
"msw": "^2.1.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublish": "just build && just emit-types"
|
||||
"prepublish": "just build"
|
||||
}
|
||||
}
|
||||
|
||||
84
pool.test.ts
84
pool.test.ts
@@ -1,31 +1,27 @@
|
||||
import { test, expect, afterAll } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { finalizeEvent, type Event } from './pure.ts'
|
||||
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { SimplePool } from './pool.ts'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
|
||||
import { MockRelay } from './test-helpers.ts'
|
||||
|
||||
let pool = new SimplePool()
|
||||
let pool: SimplePool
|
||||
let mockRelays: MockRelay[]
|
||||
let relayURLs: string[]
|
||||
|
||||
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
|
||||
beforeEach(() => {
|
||||
pool = new SimplePool()
|
||||
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
|
||||
relayURLs = mockRelays.map(mr => mr.url)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
||||
afterEach(() => {
|
||||
pool.close(relayURLs)
|
||||
})
|
||||
|
||||
test('removing duplicates when subscribing', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be catched and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
let received: Event[] = []
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
@@ -36,8 +32,17 @@ test('removing duplicates when subscribing', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event: Event) {
|
||||
// this should be called only once even though we're listening
|
||||
// to multiple relays because the events will be caught and
|
||||
// deduplicated efficiently (without even being parsed)
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual(event)
|
||||
@@ -47,12 +52,12 @@ test('same with double subs', async () => {
|
||||
let priv = generateSecretKey()
|
||||
let pub = getPublicKey(priv)
|
||||
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
})
|
||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
||||
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||
onevent(event) {
|
||||
received.push(event)
|
||||
},
|
||||
@@ -70,47 +75,50 @@ test('same with double subs', async () => {
|
||||
priv,
|
||||
)
|
||||
|
||||
await Promise.any(pool.publish(relays, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await Promise.any(pool.publish(relayURLs, event))
|
||||
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('query a bunch of events and cancel on eose', async () => {
|
||||
let events = new Set<string>()
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
pool.subscribeManyEose(
|
||||
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
||||
[{ kinds: [0, 1], limit: 40 }],
|
||||
{
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
onclose: resolve as any,
|
||||
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
|
||||
onevent(event) {
|
||||
events.add(event.id)
|
||||
},
|
||||
)
|
||||
onclose: resolve as any,
|
||||
})
|
||||
})
|
||||
|
||||
expect(events.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
test('querySync()', async () => {
|
||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
||||
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||
|
||||
let events = await pool.querySync(relayURLs, {
|
||||
authors: authors,
|
||||
kinds: [1],
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
|
||||
// the actual received number will be greater than 2, but there will be no duplicates
|
||||
expect(events.length).toBeGreaterThan(2)
|
||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||
expect(events).toHaveLength(uniqueEventCount)
|
||||
})
|
||||
|
||||
test('get()', async () => {
|
||||
let event = await pool.get(relays, {
|
||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
||||
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||
|
||||
let event = await pool.get(relayURLs, {
|
||||
ids: [ids[0]],
|
||||
})
|
||||
|
||||
expect(event).not.toBeNull()
|
||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
||||
expect(event).toHaveProperty('id', ids[0])
|
||||
})
|
||||
|
||||
125
relay.test.ts
125
relay.test.ts
@@ -1,121 +1,92 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { NostrEvent, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||
import { Relay } from './relay.ts'
|
||||
|
||||
let relay = new Relay('wss://relay.nostr.bg')
|
||||
|
||||
afterEach(() => {
|
||||
relay.close()
|
||||
})
|
||||
import { MockRelay } from './test-helpers.ts'
|
||||
|
||||
test('connectivity', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
expect(relay.connected).toBeTrue()
|
||||
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('connectivity, with Relay.connect()', async () => {
|
||||
const relay = await Relay.connect('wss://public.relaying.io')
|
||||
const mockRelay = new MockRelay()
|
||||
const relay = await Relay.connect(mockRelay.url)
|
||||
expect(relay.connected).toBeTrue()
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('querying', async () => {
|
||||
test('querying', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
const kind = 0
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let resolveEvent: () => void
|
||||
let resolveEose: () => void
|
||||
|
||||
const evented = new Promise<void>(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const eosed = new Promise<void>(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
|
||||
kinds: [0],
|
||||
authors: mockRelay.authors,
|
||||
kinds: [kind],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
|
||||
expect(event).toHaveProperty('kind', 0)
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
expect(mockRelay.authors).toContain(event.pubkey)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
await eosed
|
||||
await evented
|
||||
}, 10000)
|
||||
test('listening and publishing and closing', async done => {
|
||||
const mockRelay = new MockRelay()
|
||||
|
||||
test('listening and publishing and closing', async () => {
|
||||
const sk = generateSecretKey()
|
||||
const pk = getPublicKey(sk)
|
||||
const kind = 23571
|
||||
|
||||
const relay = new Relay(mockRelay.url)
|
||||
await relay.connect()
|
||||
|
||||
let sk = generateSecretKey()
|
||||
let pk = getPublicKey(sk)
|
||||
let resolveEose: (_: void) => void
|
||||
let resolveEvent: (_: void) => void
|
||||
let resolveClose: (_: void) => void
|
||||
let eventReceived: NostrEvent | undefined
|
||||
|
||||
const eosed = new Promise(resolve => {
|
||||
resolveEose = resolve
|
||||
})
|
||||
const evented = new Promise(resolve => {
|
||||
resolveEvent = resolve
|
||||
})
|
||||
const closed = new Promise(resolve => {
|
||||
resolveClose = resolve
|
||||
})
|
||||
|
||||
let sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [23571],
|
||||
kinds: [kind],
|
||||
authors: [pk],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(event) {
|
||||
eventReceived = event
|
||||
resolveEvent()
|
||||
},
|
||||
oneose() {
|
||||
resolveEose()
|
||||
expect(event).toHaveProperty('pubkey', pk)
|
||||
expect(event).toHaveProperty('kind', kind)
|
||||
expect(event).toHaveProperty('content', 'content')
|
||||
|
||||
sub.close() // close the subscription and will trigger onclose()
|
||||
},
|
||||
onclose() {
|
||||
resolveClose()
|
||||
relay.close()
|
||||
done()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await eosed
|
||||
|
||||
let event = finalizeEvent(
|
||||
{
|
||||
kind: 23571,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'nostr-tools test suite',
|
||||
},
|
||||
sk,
|
||||
relay.publish(
|
||||
finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
content: 'content',
|
||||
created_at: 0,
|
||||
tags: [],
|
||||
},
|
||||
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')
|
||||
})
|
||||
|
||||
107
test-helpers.ts
107
test-helpers.ts
@@ -1,6 +1,8 @@
|
||||
import type { Event } from './pure.ts'
|
||||
import { Server } from 'mock-socket'
|
||||
|
||||
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
|
||||
import { matchFilters, type Filter } from './filter.ts'
|
||||
|
||||
/** Build an event for testing purposes. */
|
||||
export function buildEvent(params: Partial<Event>): Event {
|
||||
return {
|
||||
id: '',
|
||||
@@ -13,3 +15,104 @@ export function buildEvent(params: Partial<Event>): Event {
|
||||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
let serial = 0
|
||||
|
||||
export class MockRelay {
|
||||
private _server: Server
|
||||
|
||||
public url: string
|
||||
public secretKeys: Uint8Array[]
|
||||
public preloadedEvents: Event[]
|
||||
|
||||
constructor(url?: string | undefined) {
|
||||
serial++
|
||||
this.url = url ?? `wss://random.mock.relay/${serial}`
|
||||
this.secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||
this.preloadedEvents = this.secretKeys.map(sk =>
|
||||
finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
},
|
||||
sk,
|
||||
),
|
||||
)
|
||||
|
||||
this._server = new Server(this.url)
|
||||
this._server.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 }
|
||||
|
||||
this.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 => {
|
||||
this.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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get authors() {
|
||||
return this.secretKeys.map(getPublicKey)
|
||||
}
|
||||
|
||||
get ids() {
|
||||
return this.preloadedEvents.map(evt => evt.id)
|
||||
}
|
||||
}
|
||||
|
||||
1
utils.ts
1
utils.ts
@@ -4,6 +4,7 @@ export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
|
||||
export function normalizeURL(url: string): string {
|
||||
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||
let p = new URL(url)
|
||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||
|
||||
Reference in New Issue
Block a user