mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce059d4608 | ||
|
|
b72b0dc1f0 | ||
|
|
29e5b71473 | ||
|
|
b4e54d679f | ||
|
|
9d78c90a79 | ||
|
|
566a2deea3 | ||
|
|
177e673d83 | ||
|
|
cf766cd835 | ||
|
|
7d332605ee | ||
|
|
72f9b482ef | ||
|
|
d14830a8ff | ||
|
|
943cc4fb48 | ||
|
|
04252aaaec | ||
|
|
8c78649d5c | ||
|
|
b9435af708 | ||
|
|
ea5d00beed | ||
|
|
7ec6d127b0 | ||
|
|
7a9d432686 | ||
|
|
744a930ccf | ||
|
|
c6a521e73c | ||
|
|
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"],
|
"curly": [0, "multi-line"],
|
||||||
"dot-location": [2, "property"],
|
"dot-location": [2, "property"],
|
||||||
"eol-last": 2,
|
"eol-last": 2,
|
||||||
"eqeqeq": [2, "allow-null"],
|
|
||||||
"handle-callback-err": [2, "^(err|error)$"],
|
"handle-callback-err": [2, "^(err|error)$"],
|
||||||
"indent": 0,
|
"indent": 0,
|
||||||
"jsx-quotes": [2, "prefer-double"],
|
"jsx-quotes": [2, "prefer-double"],
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -66,18 +66,18 @@ const sub = relay.subscribe([
|
|||||||
let sk = generateSecretKey()
|
let sk = generateSecretKey()
|
||||||
let pk = getPublicKey(sk)
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
let sub = relay.sub([
|
relay.sub([
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
},
|
},
|
||||||
])
|
], {
|
||||||
|
onevent(event) {
|
||||||
sub.on('event', event => {
|
console.log('got event:', event)
|
||||||
console.log('got event:', event)
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let event = {
|
let eventTemplate = {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -85,14 +85,9 @@ let event = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||||
const signedEvent = finalizeEvent(event, sk)
|
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||||
await relay.publish(signedEvent)
|
await relay.publish(signedEvent)
|
||||||
|
|
||||||
let events = await relay.list([{ kinds: [0, 1] }])
|
|
||||||
let event = await relay.get({
|
|
||||||
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
|
||||||
})
|
|
||||||
|
|
||||||
relay.close()
|
relay.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -279,3 +274,7 @@ To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just
|
|||||||
## License
|
## 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.
|
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 */
|
/* global WebSocket */
|
||||||
|
|
||||||
import type { Event, EventTemplate, Nostr } from './core.ts'
|
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||||
import { matchFilters, type Filter } from './filter.ts'
|
import { matchFilters, type Filter } from './filter.ts'
|
||||||
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||||
import { Queue, normalizeURL } from './utils.ts'
|
import { Queue, normalizeURL } from './utils.ts'
|
||||||
@@ -218,11 +218,14 @@ export class AbstractRelay {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public async auth(signAuthEvent: (authEvent: EventTemplate) => Promise<void>) {
|
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||||
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||||
const evt = makeAuthEvent(this.url, this.challenge)
|
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||||
await signAuthEvent(evt)
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||||
|
})
|
||||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||||
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
public async publish(event: Event): Promise<string> {
|
public async publish(event: Event): Promise<string> {
|
||||||
|
|||||||
14
build.js
14
build.js
@@ -28,12 +28,7 @@ esbuild
|
|||||||
format: 'esm',
|
format: 'esm',
|
||||||
packages: 'external',
|
packages: 'external',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => console.log('esm build success.'))
|
||||||
const packageJson = JSON.stringify({ type: 'module' })
|
|
||||||
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
|
|
||||||
|
|
||||||
console.log('esm build success.')
|
|
||||||
})
|
|
||||||
|
|
||||||
esbuild
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
@@ -42,7 +37,12 @@ esbuild
|
|||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
packages: 'external',
|
packages: 'external',
|
||||||
})
|
})
|
||||||
.then(() => console.log('cjs build success.'))
|
.then(() => {
|
||||||
|
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||||
|
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||||
|
|
||||||
|
console.log('cjs build success.')
|
||||||
|
})
|
||||||
|
|
||||||
esbuild
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||||
import { buildEvent } from './test-helpers.ts'
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
describe('Filter', () => {
|
describe('Filter', () => {
|
||||||
@@ -241,4 +241,27 @@ describe('Filter', () => {
|
|||||||
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getFilterLimit', () => {
|
||||||
|
test('should handle ids', () => {
|
||||||
|
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should count the authors times replaceable kinds', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||||
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return Infinity for authors with regular kinds', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return Infinity for empty filters', () => {
|
||||||
|
expect(getFilterLimit({})).toEqual(Infinity)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
16
filter.ts
16
filter.ts
@@ -1,4 +1,5 @@
|
|||||||
import { Event } from './core.ts'
|
import { Event } from './core.ts'
|
||||||
|
import { isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
export type Filter = {
|
export type Filter = {
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@@ -70,3 +71,18 @@ export function mergeFilters(...filters: Filter[]): Filter {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
|
||||||
|
export function getFilterLimit(filter: Filter): number {
|
||||||
|
if (filter.ids && !filter.ids.length) return 0
|
||||||
|
if (filter.kinds && !filter.kinds.length) return 0
|
||||||
|
if (filter.authors && !filter.authors.length) return 0
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.max(0, filter.limit ?? Infinity),
|
||||||
|
filter.ids?.length ?? Infinity,
|
||||||
|
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||||
|
? filter.authors.length * filter.kinds.length
|
||||||
|
: Infinity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||||
|
|
||||||
export async function yieldThread() {
|
export async function yieldThread() {
|
||||||
return new Promise(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
const ch = new MessageChannel()
|
const ch = new MessageChannel()
|
||||||
|
const handler = () => {
|
||||||
|
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
|
||||||
|
ch.port1.removeEventListener('message', handler)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||||
ch.port1.addEventListener('message', resolve)
|
ch.port1.addEventListener('message', handler)
|
||||||
ch.port2.postMessage(0)
|
ch.port2.postMessage(0)
|
||||||
ch.port1.start()
|
ch.port1.start()
|
||||||
})
|
})
|
||||||
|
|||||||
6
justfile
6
justfile
@@ -3,6 +3,7 @@ export PATH := "./node_modules/.bin:" + env_var('PATH')
|
|||||||
build:
|
build:
|
||||||
rm -rf lib
|
rm -rf lib
|
||||||
bun run build.js
|
bun run build.js
|
||||||
|
tsc
|
||||||
|
|
||||||
test:
|
test:
|
||||||
bun test --timeout 20000
|
bun test --timeout 20000
|
||||||
@@ -10,10 +11,7 @@ test:
|
|||||||
test-only file:
|
test-only file:
|
||||||
bun test {{file}}
|
bun test {{file}}
|
||||||
|
|
||||||
emit-types:
|
publish: build
|
||||||
tsc # see tsconfig.json
|
|
||||||
|
|
||||||
publish: build emit-types
|
|
||||||
npm publish
|
npm publish
|
||||||
|
|
||||||
format:
|
format:
|
||||||
|
|||||||
20
kinds.ts
20
kinds.ts
@@ -35,27 +35,22 @@ export const ShortTextNote = 1
|
|||||||
export const RecommendRelay = 2
|
export const RecommendRelay = 2
|
||||||
export const Contacts = 3
|
export const Contacts = 3
|
||||||
export const EncryptedDirectMessage = 4
|
export const EncryptedDirectMessage = 4
|
||||||
|
export const EncryptedDirectMessages = 4
|
||||||
export const EventDeletion = 5
|
export const EventDeletion = 5
|
||||||
export const Repost = 6
|
export const Repost = 6
|
||||||
export const Reaction = 7
|
export const Reaction = 7
|
||||||
export const BadgeAward = 8
|
export const BadgeAward = 8
|
||||||
|
export const GenericRepost = 16
|
||||||
export const ChannelCreation = 40
|
export const ChannelCreation = 40
|
||||||
export const ChannelMetadata = 41
|
export const ChannelMetadata = 41
|
||||||
export const ChannelMessage = 42
|
export const ChannelMessage = 42
|
||||||
export const ChannelHideMessage = 43
|
export const ChannelHideMessage = 43
|
||||||
export const ChannelMuteUser = 44
|
export const ChannelMuteUser = 44
|
||||||
export const Report = 1984
|
|
||||||
export const ZapRequest = 9734
|
|
||||||
export const Zap = 9735
|
|
||||||
export const RelayList = 10002
|
|
||||||
export const ClientAuth = 22242
|
|
||||||
export const BadgeDefinition = 30009
|
|
||||||
export const FileMetadata = 1063
|
|
||||||
export const EncryptedDirectMessages = 4
|
|
||||||
export const GenericRepost = 16
|
|
||||||
export const OpenTimestamps = 1040
|
export const OpenTimestamps = 1040
|
||||||
|
export const FileMetadata = 1063
|
||||||
export const LiveChatMessage = 1311
|
export const LiveChatMessage = 1311
|
||||||
export const ProblemTracker = 1971
|
export const ProblemTracker = 1971
|
||||||
|
export const Report = 1984
|
||||||
export const Reporting = 1984
|
export const Reporting = 1984
|
||||||
export const Label = 1985
|
export const Label = 1985
|
||||||
export const CommunityPostApproval = 4550
|
export const CommunityPostApproval = 4550
|
||||||
@@ -63,9 +58,12 @@ export const JobRequest = 5999
|
|||||||
export const JobResult = 6999
|
export const JobResult = 6999
|
||||||
export const JobFeedback = 7000
|
export const JobFeedback = 7000
|
||||||
export const ZapGoal = 9041
|
export const ZapGoal = 9041
|
||||||
|
export const ZapRequest = 9734
|
||||||
|
export const Zap = 9735
|
||||||
export const Highlights = 9802
|
export const Highlights = 9802
|
||||||
export const Mutelist = 10000
|
export const Mutelist = 10000
|
||||||
export const Pinlist = 10001
|
export const Pinlist = 10001
|
||||||
|
export const RelayList = 10002
|
||||||
export const BookmarkList = 10003
|
export const BookmarkList = 10003
|
||||||
export const CommunitiesList = 10004
|
export const CommunitiesList = 10004
|
||||||
export const PublicChatsList = 10005
|
export const PublicChatsList = 10005
|
||||||
@@ -73,11 +71,14 @@ export const BlockedRelaysList = 10006
|
|||||||
export const SearchRelaysList = 10007
|
export const SearchRelaysList = 10007
|
||||||
export const InterestsList = 10015
|
export const InterestsList = 10015
|
||||||
export const UserEmojiList = 10030
|
export const UserEmojiList = 10030
|
||||||
|
export const FileServerPreference = 10096
|
||||||
export const NWCWalletInfo = 13194
|
export const NWCWalletInfo = 13194
|
||||||
export const LightningPubRPC = 21000
|
export const LightningPubRPC = 21000
|
||||||
|
export const ClientAuth = 22242
|
||||||
export const NWCWalletRequest = 23194
|
export const NWCWalletRequest = 23194
|
||||||
export const NWCWalletResponse = 23195
|
export const NWCWalletResponse = 23195
|
||||||
export const NostrConnect = 24133
|
export const NostrConnect = 24133
|
||||||
|
export const NostrConnectAdmin = 24134
|
||||||
export const HTTPAuth = 27235
|
export const HTTPAuth = 27235
|
||||||
export const Followsets = 30000
|
export const Followsets = 30000
|
||||||
export const Genericlists = 30001
|
export const Genericlists = 30001
|
||||||
@@ -85,6 +86,7 @@ export const Relaysets = 30002
|
|||||||
export const Bookmarksets = 30003
|
export const Bookmarksets = 30003
|
||||||
export const Curationsets = 30004
|
export const Curationsets = 30004
|
||||||
export const ProfileBadges = 30008
|
export const ProfileBadges = 30008
|
||||||
|
export const BadgeDefinition = 30009
|
||||||
export const Interestsets = 30015
|
export const Interestsets = 30015
|
||||||
export const CreateOrUpdateStall = 30017
|
export const CreateOrUpdateStall = 30017
|
||||||
export const CreateOrUpdateProduct = 30018
|
export const CreateOrUpdateProduct = 30018
|
||||||
|
|||||||
52
nip05.ts
52
nip05.ts
@@ -7,7 +7,7 @@ import { ProfilePointer } from './nip19.ts'
|
|||||||
* - 1: name (optional)
|
* - 1: name (optional)
|
||||||
* - 2: domain
|
* - 2: domain
|
||||||
*/
|
*/
|
||||||
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
|
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
||||||
|
|
||||||
var _fetch: any
|
var _fetch: any
|
||||||
|
|
||||||
@@ -21,9 +21,10 @@ export function useFetchImplementation(fetchImplementation: any) {
|
|||||||
|
|
||||||
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||||
try {
|
try {
|
||||||
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
|
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
|
||||||
|
const res = await _fetch(url, { redirect: 'error' })
|
||||||
return res.names
|
const json = await res.json()
|
||||||
|
return json.names
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -36,46 +37,17 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
|
|||||||
const [_, name = '_', domain] = match
|
const [_, name = '_', domain] = match
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||||
const { names, relays } = parseNIP05Result(await res.json())
|
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||||
|
|
||||||
const pubkey = names[name]
|
let pubkey = res.names[name]
|
||||||
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
|
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** nostr.json result. */
|
export async function isValid(pubkey: string, nip05: string): Promise<boolean> {
|
||||||
export interface NIP05Result {
|
let res = await queryProfile(nip05)
|
||||||
names: {
|
return res ? res.pubkey === pubkey : false
|
||||||
[name: string]: string
|
|
||||||
}
|
|
||||||
relays?: {
|
|
||||||
[pubkey: string]: string[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse the nostr.json and throw if it's not valid. */
|
|
||||||
function parseNIP05Result(json: any): NIP05Result {
|
|
||||||
const result: NIP05Result = {
|
|
||||||
names: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, pubkey] of Object.entries(json.names)) {
|
|
||||||
if (typeof name === 'string' && typeof pubkey === 'string') {
|
|
||||||
result.names[name] = pubkey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (json.relays) {
|
|
||||||
result.relays = {}
|
|
||||||
for (const [pubkey, relays] of Object.entries(json.relays)) {
|
|
||||||
if (typeof pubkey === 'string' && Array.isArray(relays)) {
|
|
||||||
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'
|
|||||||
import fetch from 'node-fetch'
|
import fetch from 'node-fetch'
|
||||||
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||||
|
|
||||||
|
// TODO: replace with a mock
|
||||||
describe('requesting relay as for NIP11', () => {
|
describe('requesting relay as for NIP11', () => {
|
||||||
useFetchImplementation(fetch)
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ test('encode and decode naddr', () => {
|
|||||||
test('encode and decode nevent', () => {
|
test('encode and decode nevent', () => {
|
||||||
let pk = getPublicKey(generateSecretKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
let naddr = neventEncode({
|
let nevent = neventEncode({
|
||||||
id: pk,
|
id: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 30023,
|
kind: 30023,
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/nevent1\w+/)
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
let { type, data } = decode(naddr)
|
let { type, data } = decode(nevent)
|
||||||
expect(type).toEqual('nevent')
|
expect(type).toEqual('nevent')
|
||||||
const pointer = data as EventPointer
|
const pointer = data as EventPointer
|
||||||
expect(pointer.id).toEqual(pk)
|
expect(pointer.id).toEqual(pk)
|
||||||
@@ -95,13 +95,13 @@ test('encode and decode nevent', () => {
|
|||||||
test('encode and decode nevent with kind 0', () => {
|
test('encode and decode nevent with kind 0', () => {
|
||||||
let pk = getPublicKey(generateSecretKey())
|
let pk = getPublicKey(generateSecretKey())
|
||||||
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
let naddr = neventEncode({
|
let nevent = neventEncode({
|
||||||
id: pk,
|
id: pk,
|
||||||
relays,
|
relays,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
})
|
})
|
||||||
expect(naddr).toMatch(/nevent1\w+/)
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
let { type, data } = decode(naddr)
|
let { type, data } = decode(nevent)
|
||||||
expect(type).toEqual('nevent')
|
expect(type).toEqual('nevent')
|
||||||
const pointer = data as EventPointer
|
const pointer = data as EventPointer
|
||||||
expect(pointer.id).toEqual(pk)
|
expect(pointer.id).toEqual(pk)
|
||||||
@@ -109,6 +109,25 @@ test('encode and decode nevent with kind 0', () => {
|
|||||||
expect(pointer.kind).toEqual(0)
|
expect(pointer.kind).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('encode and decode naddr with empty "d"', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let naddr = naddrEncode({
|
||||||
|
identifier: '',
|
||||||
|
pubkey: pk,
|
||||||
|
relays,
|
||||||
|
kind: 3,
|
||||||
|
})
|
||||||
|
expect(naddr).toMatch(/naddr\w+/)
|
||||||
|
let { type, data } = decode(naddr)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.identifier).toEqual('')
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(3)
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
})
|
||||||
|
|
||||||
test('decode naddr from habla.news', () => {
|
test('decode naddr from habla.news', () => {
|
||||||
let { type, data } = decode(
|
let { type, data } = decode(
|
||||||
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||||
|
|||||||
23
nip19.ts
23
nip19.ts
@@ -3,7 +3,7 @@ import { bech32 } from '@scure/base'
|
|||||||
|
|
||||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
const Bech32MaxSize = 5000
|
export const Bech32MaxSize = 5000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bech32 regex.
|
* Bech32 regex.
|
||||||
@@ -149,7 +149,6 @@ function parseTLV(data: Uint8Array): TLV {
|
|||||||
while (rest.length > 0) {
|
while (rest.length > 0) {
|
||||||
let t = rest[0]
|
let t = rest[0]
|
||||||
let l = rest[1]
|
let l = rest[1]
|
||||||
if (!l) throw new Error(`malformed TLV ${t}`)
|
|
||||||
let v = rest.slice(2, 2 + l)
|
let v = rest.slice(2, 2 + l)
|
||||||
rest = rest.slice(2 + l)
|
rest = rest.slice(2 + l)
|
||||||
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
@@ -176,7 +175,7 @@ function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array):
|
|||||||
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||||
return encodeBech32(prefix, bytes)
|
return encodeBech32(prefix, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,15 +226,17 @@ export function nrelayEncode(url: string): `nrelay1${string}` {
|
|||||||
function encodeTLV(tlv: TLV): Uint8Array {
|
function encodeTLV(tlv: TLV): Uint8Array {
|
||||||
let entries: Uint8Array[] = []
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
Object.entries(tlv).forEach(([t, vs]) => {
|
Object.entries(tlv)
|
||||||
vs.forEach(v => {
|
.reverse()
|
||||||
let entry = new Uint8Array(v.length + 2)
|
.forEach(([t, vs]) => {
|
||||||
entry.set([parseInt(t)], 0)
|
vs.forEach(v => {
|
||||||
entry.set([v.length], 1)
|
let entry = new Uint8Array(v.length + 2)
|
||||||
entry.set(v, 2)
|
entry.set([parseInt(t)], 0)
|
||||||
entries.push(entry)
|
entry.set([v.length], 1)
|
||||||
|
entry.set(v, 2)
|
||||||
|
entries.push(entry)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
return concatBytes(...entries)
|
return concatBytes(...entries)
|
||||||
}
|
}
|
||||||
|
|||||||
166
nip29.ts
Normal file
166
nip29.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { AbstractSimplePool } from './abstract-pool'
|
||||||
|
import { Subscription } from './abstract-relay'
|
||||||
|
import { decode } from './nip19'
|
||||||
|
import type { Event } from './core'
|
||||||
|
import { fetchRelayInformation } from './nip11'
|
||||||
|
import { normalizeURL } from './utils'
|
||||||
|
import { AddressPointer } from './nip19'
|
||||||
|
|
||||||
|
export function subscribeRelayGroups(
|
||||||
|
pool: AbstractSimplePool,
|
||||||
|
url: string,
|
||||||
|
params: {
|
||||||
|
ongroups: (_: Group[]) => void
|
||||||
|
onerror: (_: Error) => void
|
||||||
|
onconnect?: () => void
|
||||||
|
},
|
||||||
|
): () => void {
|
||||||
|
let normalized = normalizeURL(url)
|
||||||
|
let sub: Subscription
|
||||||
|
let groups: Group[] = []
|
||||||
|
|
||||||
|
fetchRelayInformation(normalized)
|
||||||
|
.then(async info => {
|
||||||
|
let rl = await pool.ensureRelay(normalized)
|
||||||
|
params.onconnect?.()
|
||||||
|
sub = rl.prepareSubscription(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [39000],
|
||||||
|
limit: 50,
|
||||||
|
authors: [info.pubkey],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event: Event) {
|
||||||
|
groups.push(parseGroup(event, normalized))
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
params.ongroups(groups)
|
||||||
|
sub.onevent = (event: Event) => {
|
||||||
|
groups.push(parseGroup(event, normalized))
|
||||||
|
params.ongroups(groups)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sub.fire()
|
||||||
|
})
|
||||||
|
.catch(params.onerror)
|
||||||
|
|
||||||
|
return () => sub.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> {
|
||||||
|
let normalized = normalizeURL(gr.host)
|
||||||
|
|
||||||
|
let info = await fetchRelayInformation(normalized)
|
||||||
|
let event = await pool.get([normalized], {
|
||||||
|
kinds: [39000],
|
||||||
|
authors: [info.pubkey],
|
||||||
|
'#d': [gr.id],
|
||||||
|
})
|
||||||
|
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`)
|
||||||
|
return parseGroup(event, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||||
|
let gr = parseGroupCode(code)
|
||||||
|
if (!gr) throw new Error(`code "${code}" does not identify a group`)
|
||||||
|
return loadGroup(pool, gr)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupReference = {
|
||||||
|
id: string
|
||||||
|
host: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroupCode(code: string): null | GroupReference {
|
||||||
|
if (code.startsWith('naddr1')) {
|
||||||
|
try {
|
||||||
|
let { data } = decode(code)
|
||||||
|
|
||||||
|
let { relays, identifier } = data as AddressPointer
|
||||||
|
if (!relays || relays.length === 0) return null
|
||||||
|
|
||||||
|
let host = relays![0]
|
||||||
|
if (host.startsWith('wss://')) {
|
||||||
|
host = host.slice(6)
|
||||||
|
}
|
||||||
|
return { host, id: identifier }
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else if (code.split("'").length === 2) {
|
||||||
|
let spl = code.split("'")
|
||||||
|
return { host: spl[0], id: spl[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeGroupReference(gr: GroupReference): string {
|
||||||
|
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8)
|
||||||
|
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6)
|
||||||
|
return `${gr.host}'${gr.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string
|
||||||
|
relay: string
|
||||||
|
pubkey: string
|
||||||
|
name?: string
|
||||||
|
picture?: string
|
||||||
|
about?: string
|
||||||
|
public?: boolean
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroup(event: Event, relay: string): Group {
|
||||||
|
const group: Partial<Group> = { relay, pubkey: event.pubkey }
|
||||||
|
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 { makeAuthEvent } from './nip42.ts'
|
||||||
import { Relay } from './relay.ts'
|
import { Relay } from './relay.ts'
|
||||||
|
import { MockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
test('auth flow', async () => {
|
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')
|
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||||
|
|
||||||
expect(auth.tags).toHaveLength(2)
|
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.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||||
expect(auth.kind).toEqual(22242)
|
expect(auth.kind).toEqual(22242)
|
||||||
})
|
})
|
||||||
|
|||||||
331
nip46.ts
Normal file
331
nip46.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
|
||||||
|
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||||
|
import { decrypt, encrypt } from './nip04.ts'
|
||||||
|
import { NIP05_REGEX } from './nip05.ts'
|
||||||
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { Handlerinformation, NostrConnect, NostrConnectAdmin } from './kinds.ts'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%]*)$/
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
export type BunkerPointer = {
|
||||||
|
relays: string[]
|
||||||
|
pubkey: string
|
||||||
|
secret: null | string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
|
||||||
|
and returns a BunkerPointer -- or null in case of error */
|
||||||
|
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
|
||||||
|
let match = input.match(BUNKER_REGEX)
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const pubkey = match[1]
|
||||||
|
const qs = new URLSearchParams(match[2])
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
relays: qs.getAll('relay'),
|
||||||
|
secret: qs.get('secret'),
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
/* just move to the next case */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBunkerProfile(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
|
||||||
|
const match = nip05.match(NIP05_REGEX)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const [_, name = '_', domain] = match
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||||
|
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||||
|
|
||||||
|
let pubkey = res.names[name]
|
||||||
|
let relays = res.nip46[pubkey] || []
|
||||||
|
|
||||||
|
return { pubkey, relays, secret: null }
|
||||||
|
} catch (_err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BunkerSignerParams = {
|
||||||
|
pool?: AbstractSimplePool
|
||||||
|
onauth?: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BunkerSigner {
|
||||||
|
private pool: AbstractSimplePool
|
||||||
|
private subCloser: SubCloser
|
||||||
|
private relays: string[]
|
||||||
|
private isOpen: boolean
|
||||||
|
private serial: number
|
||||||
|
private idPrefix: string
|
||||||
|
private listeners: {
|
||||||
|
[id: string]: {
|
||||||
|
resolve: (_: string) => void
|
||||||
|
reject: (_: string) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private secretKey: Uint8Array
|
||||||
|
private connectionSecret: string
|
||||||
|
public remotePubkey: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance of the Nip46 class.
|
||||||
|
* @param relays - An array of relay addresses.
|
||||||
|
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||||
|
* @param secretKey - An optional key pair.
|
||||||
|
*/
|
||||||
|
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
|
||||||
|
if (bp.relays.length === 0) {
|
||||||
|
throw new Error('no relays are specified for this bunker')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pool = params.pool || new SimplePool()
|
||||||
|
this.secretKey = clientSecretKey
|
||||||
|
this.relays = bp.relays
|
||||||
|
this.remotePubkey = bp.pubkey
|
||||||
|
this.connectionSecret = bp.secret || ''
|
||||||
|
this.isOpen = false
|
||||||
|
this.idPrefix = Math.random().toString(36).substring(7)
|
||||||
|
this.serial = 0
|
||||||
|
this.listeners = {}
|
||||||
|
|
||||||
|
const listeners = this.listeners
|
||||||
|
|
||||||
|
this.subCloser = this.pool.subscribeMany(
|
||||||
|
this.relays,
|
||||||
|
[{ kinds: [NostrConnect, NostrConnectAdmin], '#p': [getPublicKey(this.secretKey)] }],
|
||||||
|
{
|
||||||
|
async onevent(event: NostrEvent) {
|
||||||
|
const { id, result, error } = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
|
||||||
|
|
||||||
|
if (result === 'auth_url') {
|
||||||
|
if (params.onauth) {
|
||||||
|
params.onauth(error)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`nostr-tools/nip46: remote signer ${bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = listeners[id]
|
||||||
|
if (handler) {
|
||||||
|
if (error) handler.reject(error)
|
||||||
|
else if (result) handler.resolve(result)
|
||||||
|
delete listeners[id]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
this.isOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// closes the subscription -- this object can't be used anymore after this
|
||||||
|
async close() {
|
||||||
|
this.isOpen = false
|
||||||
|
this.subCloser.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendRequest(method: string, params: string[]): Promise<string> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
|
||||||
|
this.serial++
|
||||||
|
const id = `${this.idPrefix}-${this.serial}`
|
||||||
|
|
||||||
|
const encryptedContent = await encrypt(
|
||||||
|
this.secretKey,
|
||||||
|
this.remotePubkey,
|
||||||
|
JSON.stringify({ id, method, params }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// the request event
|
||||||
|
const verifiedEvent: VerifiedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: method === 'create_account' ? NostrConnectAdmin : NostrConnect,
|
||||||
|
tags: [['p', this.remotePubkey]],
|
||||||
|
content: encryptedContent,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
this.secretKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup callback listener
|
||||||
|
this.listeners[id] = { resolve, reject }
|
||||||
|
|
||||||
|
// publish the event
|
||||||
|
await Promise.any(this.pool.publish(this.relays, verifiedEvent))
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "connect" method on the bunker.
|
||||||
|
* The promise will be rejected if the response is not "pong".
|
||||||
|
*/
|
||||||
|
async ping(): Promise<void> {
|
||||||
|
let resp = await this.sendRequest('ping', [])
|
||||||
|
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "connect" method on the bunker.
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await this.sendRequest('connect', [getPublicKey(this.secretKey), this.connectionSecret])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This was supposed to call the "get_public_key" method on the bunker,
|
||||||
|
* but instead we just returns the public key we already know.
|
||||||
|
*/
|
||||||
|
async getPublicKey(): Promise<string> {
|
||||||
|
return this.remotePubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "get_relays" method on the bunker.
|
||||||
|
*/
|
||||||
|
async getRelays(): Promise<{ [relay: string]: { read: boolean; write: boolean } }> {
|
||||||
|
return JSON.parse(await this.sendRequest('get_relays', []))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an event using the remote private key.
|
||||||
|
* @param event - The event to sign.
|
||||||
|
* @returns A Promise that resolves to the signed event.
|
||||||
|
*/
|
||||||
|
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
|
||||||
|
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||||
|
let signed: NostrEvent = JSON.parse(resp)
|
||||||
|
if (signed.pubkey === this.remotePubkey && verifyEvent(signed)) {
|
||||||
|
return signed
|
||||||
|
} else {
|
||||||
|
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44GetKey(thirdPartyPubkey: string): Promise<Uint8Array> {
|
||||||
|
let resp = await this.sendRequest('nip44_get_key', [thirdPartyPubkey])
|
||||||
|
return hexToBytes(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an account with the specified username, domain, and optional email.
|
||||||
|
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
|
||||||
|
* @param username - The username for the account.
|
||||||
|
* @param domain - The domain for the account.
|
||||||
|
* @param email - The optional email for the account.
|
||||||
|
* @throws Error if the email is present but invalid.
|
||||||
|
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
|
||||||
|
*/
|
||||||
|
export async function createAccount(
|
||||||
|
bunker: BunkerProfile,
|
||||||
|
params: BunkerSignerParams,
|
||||||
|
username: string,
|
||||||
|
domain: string,
|
||||||
|
email?: string,
|
||||||
|
): Promise<BunkerSigner> {
|
||||||
|
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||||
|
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let rpc = new BunkerSigner(sk, bunker.bunkerPointer, params)
|
||||||
|
|
||||||
|
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
|
||||||
|
|
||||||
|
// once we get the newly created pubkey back, we hijack this signer instance
|
||||||
|
// and turn it into the main instance for this newly created pubkey
|
||||||
|
rpc.remotePubkey = pubkey
|
||||||
|
await rpc.connect()
|
||||||
|
|
||||||
|
return rpc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches info on available providers that announce themselves using NIP-89 events.
|
||||||
|
* @returns A promise that resolves to an array of available bunker objects.
|
||||||
|
*/
|
||||||
|
export async function fetchCustodialBunkers(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
|
||||||
|
const events = await pool.querySync(relays, {
|
||||||
|
kinds: [Handlerinformation],
|
||||||
|
'#k': [NostrConnect.toString()],
|
||||||
|
})
|
||||||
|
|
||||||
|
// validate bunkers by checking their NIP-05 and pubkey
|
||||||
|
// map to a more useful object
|
||||||
|
const validatedBunkers = await Promise.all(
|
||||||
|
events.map(async event => {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content)
|
||||||
|
const bp = await queryBunkerProfile(content.nip05)
|
||||||
|
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
|
||||||
|
return {
|
||||||
|
bunkerPointer: bp,
|
||||||
|
nip05: content.nip05,
|
||||||
|
domain: content.nip05.split('@')[1],
|
||||||
|
name: content.name || content.display_name,
|
||||||
|
picture: content.picture,
|
||||||
|
about: content.about,
|
||||||
|
website: content.website,
|
||||||
|
local: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BunkerProfile = {
|
||||||
|
bunkerPointer: BunkerPointer
|
||||||
|
domain: string
|
||||||
|
nip05: string
|
||||||
|
name: string
|
||||||
|
picture: string
|
||||||
|
about: string
|
||||||
|
website: string
|
||||||
|
local: boolean
|
||||||
|
}
|
||||||
88
nip49.test.ts
Normal file
88
nip49.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { decrypt, encrypt } from './nip49'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
test('encrypt and decrypt', () => {
|
||||||
|
for (let i = 0; i < vectors.length; i++) {
|
||||||
|
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
|
||||||
|
let sec = hexToBytes(secret)
|
||||||
|
let there = encrypt(sec, password, logn, ksb)
|
||||||
|
let back = decrypt(there, password)
|
||||||
|
let again = decrypt(ncryptsec, password)
|
||||||
|
expect(back).toEqual(again)
|
||||||
|
expect(again).toEqual(sec)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
|
||||||
|
[
|
||||||
|
'.ksjabdk.aselqwe',
|
||||||
|
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||||
|
1,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'skjdaklrnçurbç l',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
2,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'777z7z7z7z7z7z7z',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
3,
|
||||||
|
0x02,
|
||||||
|
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'.ksjabdk.aselqwe',
|
||||||
|
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||||
|
7,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'skjdaklrnçurbç l',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
8,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'777z7z7z7z7z7z7z',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
9,
|
||||||
|
0x02,
|
||||||
|
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
4,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
5,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
1,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
9,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgylzyeunu2j85au05ae0g0sly03xu54tgnjemr6g9w0eqwuuczya7k0f4juqve64vzsrlxqxmcekzrpvg2a8qu4q6wtjxe0dvy3xkjh5smmz4uy59jg0jay9vnf28e3rc6jq2kd26h6g3fejyw6cype',
|
||||||
|
],
|
||||||
|
]
|
||||||
45
nip49.ts
Normal file
45
nip49.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { scrypt } from '@noble/hashes/scrypt'
|
||||||
|
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||||
|
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||||
|
import { Bech32MaxSize, encodeBytes } from './nip19'
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {
|
||||||
|
let salt = randomBytes(16)
|
||||||
|
let n = 2 ** logn
|
||||||
|
let key = scrypt(password, salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||||
|
let nonce = randomBytes(24)
|
||||||
|
let aad = Uint8Array.from([ksb])
|
||||||
|
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||||
|
let ciphertext = xc2p1.encrypt(sec)
|
||||||
|
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
|
||||||
|
return encodeBytes('ncryptsec', b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(ncryptsec: string, password: string): Uint8Array {
|
||||||
|
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
|
||||||
|
if (prefix !== 'ncryptsec') {
|
||||||
|
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
|
||||||
|
}
|
||||||
|
let b = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
|
let version = b[0]
|
||||||
|
if (version !== 0x02) {
|
||||||
|
throw new Error(`invalid version ${version}, expected 0x02`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let logn = b[1]
|
||||||
|
let n = 2 ** logn
|
||||||
|
|
||||||
|
let salt = b.slice(2, 2 + 16)
|
||||||
|
let nonce = b.slice(2 + 16, 2 + 16 + 24)
|
||||||
|
let ksb = b[2 + 16 + 24]
|
||||||
|
let aad = Uint8Array.from([ksb])
|
||||||
|
let ciphertext = b.slice(2 + 16 + 24 + 1)
|
||||||
|
|
||||||
|
let key = scrypt(password, salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||||
|
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||||
|
let sec = xc2p1.decrypt(ciphertext)
|
||||||
|
|
||||||
|
return sec
|
||||||
|
}
|
||||||
@@ -242,10 +242,11 @@ describe('validateZapRequest', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('makeZapReceipt', () => {
|
describe('makeZapReceipt', () => {
|
||||||
test('returns a valid Zap receipt with a preimage', () => {
|
const privateKey = generateSecretKey()
|
||||||
const privateKey = generateSecretKey()
|
const publicKey = getPublicKey(privateKey)
|
||||||
const publicKey = getPublicKey(privateKey)
|
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt with a preimage', () => {
|
||||||
const zapRequest = JSON.stringify(
|
const zapRequest = JSON.stringify(
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -253,7 +254,7 @@ describe('makeZapReceipt', () => {
|
|||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
content: 'content',
|
content: 'content',
|
||||||
tags: [
|
tags: [
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
['amount', '100'],
|
['amount', '100'],
|
||||||
['relays', 'relay1', 'relay2'],
|
['relays', 'relay1', 'relay2'],
|
||||||
],
|
],
|
||||||
@@ -274,16 +275,14 @@ describe('makeZapReceipt', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
['bolt11', bolt11],
|
['bolt11', bolt11],
|
||||||
['description', zapRequest],
|
['description', zapRequest],
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
['preimage', preimage],
|
['preimage', preimage],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns a valid Zap receipt without a preimage', () => {
|
test('returns a valid Zap receipt without a preimage', () => {
|
||||||
const privateKey = generateSecretKey()
|
|
||||||
const publicKey = getPublicKey(privateKey)
|
|
||||||
|
|
||||||
const zapRequest = JSON.stringify(
|
const zapRequest = JSON.stringify(
|
||||||
finalizeEvent(
|
finalizeEvent(
|
||||||
{
|
{
|
||||||
@@ -291,7 +290,7 @@ describe('makeZapReceipt', () => {
|
|||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
content: 'content',
|
content: 'content',
|
||||||
tags: [
|
tags: [
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
['amount', '100'],
|
['amount', '100'],
|
||||||
['relays', 'relay1', 'relay2'],
|
['relays', 'relay1', 'relay2'],
|
||||||
],
|
],
|
||||||
@@ -311,7 +310,8 @@ describe('makeZapReceipt', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
['bolt11', bolt11],
|
['bolt11', bolt11],
|
||||||
['description', zapRequest],
|
['description', zapRequest],
|
||||||
['p', publicKey],
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||||
|
|||||||
4
nip57.ts
4
nip57.ts
@@ -23,7 +23,7 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
|||||||
lnurl = utf8Decoder.decode(data)
|
lnurl = utf8Decoder.decode(data)
|
||||||
} else if (lud16) {
|
} else if (lud16) {
|
||||||
let [name, domain] = lud16.split('@')
|
let [name, domain] = lud16.split('@')
|
||||||
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
|
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ export function makeZapReceipt({
|
|||||||
kind: 9735,
|
kind: 9735,
|
||||||
created_at: Math.round(paidAt.getTime() / 1000),
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
tags: [...tagsFromZapRequest, ['bolt11', bolt11], ['description', zapRequest]],
|
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preimage) {
|
if (preimage) {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
654
nip96.test.ts
Normal file
654
nip96.test.ts
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
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 a proper error if response status is 413', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 413 })
|
||||||
|
})
|
||||||
|
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('File too large!')
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 400', 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(
|
||||||
|
'Bad request! Some fields are missing or invalid!',
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 403', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 403 })
|
||||||
|
})
|
||||||
|
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(
|
||||||
|
'Forbidden! Payload tag does not match the requested file!',
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 402', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 402 })
|
||||||
|
})
|
||||||
|
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('Payment required!')
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is not 200, 400, 402, 403, 413', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 500 })
|
||||||
|
})
|
||||||
|
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(
|
||||||
|
'Unknown error in uploading file!',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
590
nip96.ts
Normal file
590
nip96.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
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!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknown error
|
||||||
|
throw new Error('Unknown error in uploading file!')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { sha256 } from '@noble/hashes/sha256'
|
||||||
import { utf8Encoder } from './utils.ts'
|
|
||||||
import { bytesToHex } from '@noble/hashes/utils'
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import { HTTPAuth } from './kinds.ts'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
const sk = generateSecretKey()
|
import { HTTPAuth } from './kinds.ts'
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
hashPayload,
|
||||||
|
unpackEventFromToken,
|
||||||
|
validateEvent,
|
||||||
|
validateEventKind,
|
||||||
|
validateEventMethodTag,
|
||||||
|
validateEventPayloadTag,
|
||||||
|
validateEventTimestamp,
|
||||||
|
validateEventUrlTag,
|
||||||
|
validateToken,
|
||||||
|
} from './nip98.ts'
|
||||||
|
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
describe('getToken', () => {
|
describe('getToken', () => {
|
||||||
test('getToken GET returns without authorization scheme', async () => {
|
test('returns without authorization scheme for GET', async () => {
|
||||||
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'get'],
|
['method', 'get'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken POST returns token without authorization scheme', async () => {
|
test('returns token without authorization scheme for POST', async () => {
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken GET returns token WITH authorization scheme', async () => {
|
test('returns token WITH authorization scheme for POST', async () => {
|
||||||
const authorizationScheme = 'Nostr '
|
const authorizationScheme = 'Nostr '
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
expect(result.startsWith(authorizationScheme)).toBe(true)
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
|
||||||
expect(decodedResult.content).toBe('')
|
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getToken returns token with a valid payload tag when payload is present', async () => {
|
test('returns token with a valid payload tag when payload is present', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
const payload = { test: 'payload' }
|
const payload = { test: 'payload' }
|
||||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
const payloadHash = hashPayload(payload)
|
||||||
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
const decodedResult: Event = await unpackEventFromToken(result)
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
expect(decodedResult.created_at).toBeGreaterThan(0)
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
expect(decodedResult.content).toBe('')
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
expect(decodedResult.kind).toBe(HTTPAuth)
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
|
|
||||||
expect(decodedResult.tags).toStrictEqual([
|
|
||||||
['u', 'http://test.com'],
|
['u', 'http://test.com'],
|
||||||
['method', 'post'],
|
['method', 'post'],
|
||||||
['payload', payloadHash],
|
['payload', payloadHash],
|
||||||
@@ -79,81 +86,265 @@ describe('getToken', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('validateToken', () => {
|
describe('validateToken', () => {
|
||||||
test('validateToken returns true for valid token without authorization scheme', async () => {
|
test('returns true for valid token without authorization scheme', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
|
||||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
expect(result).toBe(true)
|
expect(isTokenValid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken returns true for valid token with authorization scheme', async () => {
|
test('returns true for valid token with authorization scheme', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
|
|
||||||
const result = await validateToken(validToken, 'http://test.com', 'get')
|
expect(isTokenValid).toBe(true)
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for invalid token', async () => {
|
test('throws an error for invalid token', async () => {
|
||||||
const result = validateToken('fake', 'http://test.com', 'get')
|
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for missing token', async () => {
|
test('throws an error for missing token', async () => {
|
||||||
const result = validateToken('', 'http://test.com', 'get')
|
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for a wrong url', async () => {
|
test('throws an error for invalid event kind', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.kind = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateToken throws an error for a wrong method', async () => {
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.created_at = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
const result = validateToken(validToken, 'http://test.com', 'post')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
|
test('throws an error for invalid url', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent throws an error for a wrong url', async () => {
|
test('throws an error for invalid method', async () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
const sk = generateSecretKey()
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||||
|
|
||||||
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
expect(result).rejects.toThrow(Error)
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('validateEvent throws an error for a wrong method', async () => {
|
describe('validateEvent', () => {
|
||||||
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
const result = validateEvent(decodedResult, 'http://test.com', 'post')
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
expect(result).rejects.toThrow(Error)
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
})
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
test('validateEvent returns true for valid payload tag hash', async () => {
|
})
|
||||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
expect(result).toBe(true)
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
})
|
unpackedEvent.kind = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
test('validateEvent returns false for invalid payload tag hash', async () => {
|
|
||||||
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
const decodedResult: Event = await unpackEventFromToken(validToken)
|
})
|
||||||
|
|
||||||
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
expect(result).rejects.toThrow(Error)
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for valid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventTimestamp', () => {
|
||||||
|
test('returns true for valid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventKind', () => {
|
||||||
|
test('returns true for valid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.kind = 0
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventUrlTag', () => {
|
||||||
|
test('returns true for valid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventMethodTag', () => {
|
||||||
|
test('returns true for valid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventPayloadTag', () => {
|
||||||
|
test('returns true for valid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for missing payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashPayload', () => {
|
||||||
|
test('returns hash for valid payload', async () => {
|
||||||
|
const payload = { test: 'payload' }
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns hash for empty payload', async () => {
|
||||||
|
const payload = {}
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
141
nip98.ts
141
nip98.ts
@@ -1,17 +1,13 @@
|
|||||||
import { bytesToHex } from '@noble/hashes/utils'
|
|
||||||
import { sha256 } from '@noble/hashes/sha256'
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import { base64 } from '@scure/base'
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||||
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
import { HTTPAuth } from './kinds.ts'
|
|
||||||
|
|
||||||
const _authorizationScheme = 'Nostr '
|
const _authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
export function hashPayload(payload: any): string {
|
|
||||||
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
|
||||||
return bytesToHex(hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate token for NIP-98 flow.
|
* Generate token for NIP-98 flow.
|
||||||
*
|
*
|
||||||
@@ -37,7 +33,7 @@ export async function getToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
|
event.tags.push(['payload', hashPayload(payload)])
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedEvent = await sign(event)
|
const signedEvent = await sign(event)
|
||||||
@@ -56,6 +52,7 @@ export async function validateToken(token: string, url: string, method: string):
|
|||||||
const event = await unpackEventFromToken(token).catch(error => {
|
const event = await unpackEventFromToken(token).catch(error => {
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
|
||||||
const valid = await validateEvent(event, url, method).catch(error => {
|
const valid = await validateEvent(event, url, method).catch(error => {
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
@@ -63,10 +60,18 @@ export async function validateToken(token: string, url: string, method: string):
|
|||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks an event from a token.
|
||||||
|
*
|
||||||
|
* @param token - The token to unpack.
|
||||||
|
* @returns A promise that resolves to the unpacked event.
|
||||||
|
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||||
|
*/
|
||||||
export async function unpackEventFromToken(token: string): Promise<Event> {
|
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Missing token')
|
throw new Error('Missing token')
|
||||||
}
|
}
|
||||||
|
|
||||||
token = token.replace(_authorizationScheme, '')
|
token = token.replace(_authorizationScheme, '')
|
||||||
|
|
||||||
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||||
@@ -79,41 +84,121 @@ export async function unpackEventFromToken(token: string): Promise<Event> {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
/**
|
||||||
if (!event) {
|
* Validates the timestamp of an event.
|
||||||
throw new Error('Invalid nostr event')
|
* @param event - The event object to validate.
|
||||||
|
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||||
|
*/
|
||||||
|
export function validateEventTimestamp(event: Event): boolean {
|
||||||
|
if (!event.created_at) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the kind of an event.
|
||||||
|
* @param event The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event kind is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventKind(event: Event): boolean {
|
||||||
|
return event.kind === HTTPAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given URL matches the URL tag of the event.
|
||||||
|
* @param event - The event object.
|
||||||
|
* @param url - The URL to validate.
|
||||||
|
* @returns A boolean indicating whether the URL is valid or not.
|
||||||
|
*/
|
||||||
|
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||||
|
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||||
|
|
||||||
|
if (!urlTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlTag.length > 0 && urlTag[1] === url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given event has a method tag that matches the specified method.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @param method - The method to match against the method tag.
|
||||||
|
* @returns A boolean indicating whether the event has a matching method tag.
|
||||||
|
*/
|
||||||
|
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||||
|
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||||
|
|
||||||
|
if (!methodTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the hash of a payload.
|
||||||
|
* @param payload - The payload to be hashed.
|
||||||
|
* @returns The hash value as a string.
|
||||||
|
*/
|
||||||
|
export function hashPayload(payload: any): string {
|
||||||
|
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||||
|
return bytesToHex(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the event payload tag against the provided payload.
|
||||||
|
* @param event The event object.
|
||||||
|
* @param payload The payload to validate.
|
||||||
|
* @returns A boolean indicating whether the payload tag is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||||
|
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||||
|
|
||||||
|
if (!payloadTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadHash = hashPayload(payload)
|
||||||
|
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Nostr event for the NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @param event - The Nostr event to validate.
|
||||||
|
* @param url - The URL associated with the event.
|
||||||
|
* @param method - The HTTP method associated with the event.
|
||||||
|
* @param body - The request body associated with the event (optional).
|
||||||
|
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||||
|
* @throws An error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||||
if (!verifyEvent(event)) {
|
if (!verifyEvent(event)) {
|
||||||
throw new Error('Invalid nostr event, signature invalid')
|
throw new Error('Invalid nostr event, signature invalid')
|
||||||
}
|
}
|
||||||
if (event.kind !== HTTPAuth) {
|
|
||||||
|
if (!validateEventKind(event)) {
|
||||||
throw new Error('Invalid nostr event, kind invalid')
|
throw new Error('Invalid nostr event, kind invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.created_at) {
|
if (!validateEventTimestamp(event)) {
|
||||||
throw new Error('Invalid nostr event, created_at invalid')
|
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event must be less than 60 seconds old
|
if (!validateEventUrlTag(event, url)) {
|
||||||
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
|
|
||||||
throw new Error('Invalid nostr event, expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlTag = event.tags.find(t => t[0] === 'u')
|
|
||||||
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
|
|
||||||
throw new Error('Invalid nostr event, url tag invalid')
|
throw new Error('Invalid nostr event, url tag invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodTag = event.tags.find(t => t[0] === 'method')
|
if (!validateEventMethodTag(event, method)) {
|
||||||
if (methodTag?.length !== 1 && methodTag?.[1].toLowerCase() !== method.toLowerCase()) {
|
|
||||||
throw new Error('Invalid nostr event, method tag invalid')
|
throw new Error('Invalid nostr event, method tag invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean(body) && Object.keys(body).length > 0) {
|
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||||
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
if (!validateEventPayloadTag(event, body)) {
|
||||||
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
|
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||||
if (payloadTag?.[1] !== payloadHash) {
|
|
||||||
throw new Error('Invalid payload tag hash, does not match request body hash')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Event } from './core'
|
||||||
|
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
|
||||||
|
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure'
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
test('should return true for a valid classified listing event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "d" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
// Missing 'd' tag
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "title" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
// Missing 'title' tag
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "summary" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
// Missing 'summary' tag
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "published_at" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
// Missing 'published_at' tag
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "location" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
// Missing 'location' tag
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
// Missing 'price' tag
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "published_at" tag is not a valid timestamp', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', 'not-a-valid-timestamp'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid price', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', 'not-a-valid-price', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid currency', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'not-a-valid-currency'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid number of elements', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event1: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "a" tag has not a valid number of elements', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event1: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['a', 'extra1'],
|
||||||
|
['a', 'extra2', 'value2', 'extra3'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const event2: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['e', 'extra1'],
|
||||||
|
['e', 'extra2', 'value2', 'extra3'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event1)).toBe(false)
|
||||||
|
expect(validateEvent(event2)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseEvent', () => {
|
||||||
|
test('should parse a valid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedListing = {
|
||||||
|
title: 'Sample Title',
|
||||||
|
summary: 'Sample Summary',
|
||||||
|
publishedAt: '1296962229',
|
||||||
|
location: 'NYC',
|
||||||
|
price: {
|
||||||
|
amount: '100',
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image1.jpg',
|
||||||
|
dimensions: '800x600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashtags: ['tag1', 'tag2'],
|
||||||
|
additionalTags: {
|
||||||
|
e: ['value1', 'value2'],
|
||||||
|
a: ['value1', 'value2'],
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parseEvent(event)).toEqual(expectedListing)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw an error for an invalid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
// Missing 'd' tag
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseEvent(event)).toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateEventTemplate', () => {
|
||||||
|
test('should generate the correct event template for a classified listing', () => {
|
||||||
|
const listing: ClassifiedListingObject = {
|
||||||
|
title: 'Sample Title',
|
||||||
|
summary: 'Sample Summary',
|
||||||
|
publishedAt: '1296962229',
|
||||||
|
location: 'NYC',
|
||||||
|
price: {
|
||||||
|
amount: '100',
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image1.jpg',
|
||||||
|
dimensions: '800x600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashtags: ['tag1', 'tag2'],
|
||||||
|
additionalTags: {
|
||||||
|
extra1: 'value1',
|
||||||
|
extra2: 'value2',
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEventTemplate = {
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['extra1', 'value1'],
|
||||||
|
['extra2', 'value2'],
|
||||||
|
],
|
||||||
|
created_at: expect.any(Number),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
228
nip99.ts
Normal file
228
nip99.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the details of a price.
|
||||||
|
* @example { amount: '100', currency: 'USD', frequency: 'month' }
|
||||||
|
* @example { amount: '100', currency: 'EUR' }
|
||||||
|
*/
|
||||||
|
export type PriceDetails = {
|
||||||
|
/**
|
||||||
|
* The amount of the price.
|
||||||
|
*/
|
||||||
|
amount: string
|
||||||
|
/**
|
||||||
|
* The currency of the price in 3-letter ISO 4217 format.
|
||||||
|
* @example 'USD'
|
||||||
|
*/
|
||||||
|
currency: string
|
||||||
|
/**
|
||||||
|
* The optional frequency of payment.
|
||||||
|
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
|
||||||
|
*/
|
||||||
|
frequency?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a classified listing object.
|
||||||
|
*/
|
||||||
|
export type ClassifiedListingObject = {
|
||||||
|
/**
|
||||||
|
* Whether the listing is a draft or not.
|
||||||
|
*/
|
||||||
|
isDraft: boolean
|
||||||
|
/**
|
||||||
|
* A title of the listing.
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* A short summary or tagline.
|
||||||
|
*/
|
||||||
|
summary: string
|
||||||
|
/**
|
||||||
|
* A description in Markdown format.
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
/**
|
||||||
|
* Timestamp in unix seconds of when the listing was published.
|
||||||
|
*/
|
||||||
|
publishedAt: string
|
||||||
|
/**
|
||||||
|
* Location of the listing.
|
||||||
|
* @example 'NYC'
|
||||||
|
*/
|
||||||
|
location: string
|
||||||
|
/**
|
||||||
|
* Price details.
|
||||||
|
*/
|
||||||
|
price: PriceDetails
|
||||||
|
/**
|
||||||
|
* Images of the listing with optional dimensions.
|
||||||
|
*/
|
||||||
|
images: Array<{
|
||||||
|
url: string
|
||||||
|
dimensions?: string
|
||||||
|
}>
|
||||||
|
/**
|
||||||
|
* Tags/Hashtags (i.e. categories, keywords, etc.)
|
||||||
|
*/
|
||||||
|
hashtags: string[]
|
||||||
|
/**
|
||||||
|
* Other standard tags.
|
||||||
|
* @example "g", a geohash for more precise location
|
||||||
|
*/
|
||||||
|
additionalTags: Record<string, string | string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an event to ensure it is a valid classified listing event.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateEvent(event: Event): boolean {
|
||||||
|
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
|
||||||
|
const requiredTagCount = requiredTags.length
|
||||||
|
const tagCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
if (event.tags.length < requiredTagCount) return false
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag.length < 2) return false
|
||||||
|
|
||||||
|
const [tagName, ...tagValues] = tag
|
||||||
|
|
||||||
|
if (tagName == 'published_at') {
|
||||||
|
const timestamp = parseInt(tagValues[0])
|
||||||
|
if (isNaN(timestamp)) return false
|
||||||
|
} else if (tagName == 'price') {
|
||||||
|
if (tagValues.length < 2) return false
|
||||||
|
|
||||||
|
const price = parseInt(tagValues[0])
|
||||||
|
if (isNaN(price) || tagValues[1].length != 3) return false
|
||||||
|
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredTags.includes(tagName)) {
|
||||||
|
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an event and returns a classified listing object.
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns The classified listing object.
|
||||||
|
* @throws Error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseEvent(event: Event): ClassifiedListingObject {
|
||||||
|
if (!validateEvent(event)) {
|
||||||
|
throw new Error('Invalid event')
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing: ClassifiedListingObject = {
|
||||||
|
isDraft: event.kind === DraftClassifiedListing,
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: event.content,
|
||||||
|
publishedAt: '',
|
||||||
|
location: '',
|
||||||
|
price: {
|
||||||
|
amount: '',
|
||||||
|
currency: '',
|
||||||
|
},
|
||||||
|
images: [],
|
||||||
|
hashtags: [],
|
||||||
|
additionalTags: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
const [tagName, ...tagValues] = tag
|
||||||
|
|
||||||
|
if (tagName == 'title') {
|
||||||
|
listing.title = tagValues[0]
|
||||||
|
} else if (tagName == 'summary') {
|
||||||
|
listing.summary = tagValues[0]
|
||||||
|
} else if (tagName == 'published_at') {
|
||||||
|
listing.publishedAt = tagValues[0]
|
||||||
|
} else if (tagName == 'location') {
|
||||||
|
listing.location = tagValues[0]
|
||||||
|
} else if (tagName == 'price') {
|
||||||
|
listing.price.amount = tagValues[0]
|
||||||
|
listing.price.currency = tagValues[1]
|
||||||
|
|
||||||
|
if (tagValues.length == 3) {
|
||||||
|
listing.price.frequency = tagValues[2]
|
||||||
|
}
|
||||||
|
} else if (tagName == 'image') {
|
||||||
|
listing.images.push({
|
||||||
|
url: tagValues[0],
|
||||||
|
dimensions: tagValues?.[1] ?? undefined,
|
||||||
|
})
|
||||||
|
} else if (tagName == 't') {
|
||||||
|
listing.hashtags.push(tagValues[0])
|
||||||
|
} else if (tagName == 'e' || tagName == 'a') {
|
||||||
|
listing.additionalTags[tagName] = [...tagValues]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template based on a classified listing object.
|
||||||
|
*
|
||||||
|
* @param listing - The classified listing object.
|
||||||
|
* @returns The event template.
|
||||||
|
*/
|
||||||
|
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
|
||||||
|
const priceTag = ['price', listing.price.amount, listing.price.currency]
|
||||||
|
if (listing.price.frequency) priceTag.push(listing.price.frequency)
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
|
||||||
|
['title', listing.title],
|
||||||
|
['published_at', listing.publishedAt],
|
||||||
|
['summary', listing.summary],
|
||||||
|
['location', listing.location],
|
||||||
|
priceTag,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < listing.images.length; i++) {
|
||||||
|
const image = listing.images[i]
|
||||||
|
const imageTag = ['image', image.url]
|
||||||
|
if (image.dimensions) imageTag.push(image.dimensions)
|
||||||
|
|
||||||
|
tags.push(imageTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < listing.hashtags.length; i++) {
|
||||||
|
const t = listing.hashtags[i]
|
||||||
|
|
||||||
|
tags.push(['t', t])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(listing.additionalTags)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const val = value[i]
|
||||||
|
|
||||||
|
tags.push([key, val])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tags.push([key, value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
|
||||||
|
content: listing.content,
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
58
package.json
58
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "2.1.0",
|
"version": "2.1.8",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -20,6 +20,11 @@
|
|||||||
"require": "./lib/cjs/index.js",
|
"require": "./lib/cjs/index.js",
|
||||||
"types": "./lib/types/index.d.ts"
|
"types": "./lib/types/index.d.ts"
|
||||||
},
|
},
|
||||||
|
"./core": {
|
||||||
|
"import": "./lib/esm/core.js",
|
||||||
|
"require": "./lib/cjs/core.js",
|
||||||
|
"types": "./lib/types/core.d.ts"
|
||||||
|
},
|
||||||
"./pure": {
|
"./pure": {
|
||||||
"import": "./lib/esm/pure.js",
|
"import": "./lib/esm/pure.js",
|
||||||
"require": "./lib/cjs/pure.js",
|
"require": "./lib/cjs/pure.js",
|
||||||
@@ -70,11 +75,6 @@
|
|||||||
"require": "./lib/cjs/nip04.js",
|
"require": "./lib/cjs/nip04.js",
|
||||||
"types": "./lib/types/nip04.d.ts"
|
"types": "./lib/types/nip04.d.ts"
|
||||||
},
|
},
|
||||||
"./nip44": {
|
|
||||||
"import": "./lib/esm/nip44.js",
|
|
||||||
"require": "./lib/cjs/nip44.js",
|
|
||||||
"types": "./lib/types/nip44.d.ts"
|
|
||||||
},
|
|
||||||
"./nip05": {
|
"./nip05": {
|
||||||
"import": "./lib/esm/nip05.js",
|
"import": "./lib/esm/nip05.js",
|
||||||
"require": "./lib/cjs/nip05.js",
|
"require": "./lib/cjs/nip05.js",
|
||||||
@@ -130,6 +130,11 @@
|
|||||||
"require": "./lib/cjs/nip28.js",
|
"require": "./lib/cjs/nip28.js",
|
||||||
"types": "./lib/types/nip28.d.ts"
|
"types": "./lib/types/nip28.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip29": {
|
||||||
|
"import": "./lib/esm/nip29.js",
|
||||||
|
"require": "./lib/cjs/nip29.js",
|
||||||
|
"types": "./lib/types/nip29.d.ts"
|
||||||
|
},
|
||||||
"./nip30": {
|
"./nip30": {
|
||||||
"import": "./lib/esm/nip30.js",
|
"import": "./lib/esm/nip30.js",
|
||||||
"require": "./lib/cjs/nip30.js",
|
"require": "./lib/cjs/nip30.js",
|
||||||
@@ -145,16 +150,46 @@
|
|||||||
"require": "./lib/cjs/nip42.js",
|
"require": "./lib/cjs/nip42.js",
|
||||||
"types": "./lib/types/nip42.d.ts"
|
"types": "./lib/types/nip42.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip44": {
|
||||||
|
"import": "./lib/esm/nip44.js",
|
||||||
|
"require": "./lib/cjs/nip44.js",
|
||||||
|
"types": "./lib/types/nip44.d.ts"
|
||||||
|
},
|
||||||
|
"./nip46": {
|
||||||
|
"import": "./lib/esm/nip46.js",
|
||||||
|
"require": "./lib/cjs/nip46.js",
|
||||||
|
"types": "./lib/types/nip46.d.ts"
|
||||||
|
},
|
||||||
|
"./nip49": {
|
||||||
|
"import": "./lib/esm/nip49.js",
|
||||||
|
"require": "./lib/cjs/nip49.js",
|
||||||
|
"types": "./lib/types/nip49.d.ts"
|
||||||
|
},
|
||||||
"./nip57": {
|
"./nip57": {
|
||||||
"import": "./lib/esm/nip57.js",
|
"import": "./lib/esm/nip57.js",
|
||||||
"require": "./lib/cjs/nip57.js",
|
"require": "./lib/cjs/nip57.js",
|
||||||
"types": "./lib/types/nip57.d.ts"
|
"types": "./lib/types/nip57.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip94": {
|
||||||
|
"import": "./lib/esm/nip94.js",
|
||||||
|
"require": "./lib/cjs/nip94.js",
|
||||||
|
"types": "./lib/types/nip94.d.ts"
|
||||||
|
},
|
||||||
|
"./nip96": {
|
||||||
|
"import": "./lib/esm/nip96.js",
|
||||||
|
"require": "./lib/cjs/nip96.js",
|
||||||
|
"types": "./lib/types/nip96.d.ts"
|
||||||
|
},
|
||||||
"./nip98": {
|
"./nip98": {
|
||||||
"import": "./lib/esm/nip98.js",
|
"import": "./lib/esm/nip98.js",
|
||||||
"require": "./lib/cjs/nip98.js",
|
"require": "./lib/cjs/nip98.js",
|
||||||
"types": "./lib/types/nip98.d.ts"
|
"types": "./lib/types/nip98.d.ts"
|
||||||
},
|
},
|
||||||
|
"./nip99": {
|
||||||
|
"import": "./lib/esm/nip99.js",
|
||||||
|
"require": "./lib/cjs/nip99.js",
|
||||||
|
"types": "./lib/types/nip99.d.ts"
|
||||||
|
},
|
||||||
"./fakejson": {
|
"./fakejson": {
|
||||||
"import": "./lib/esm/fakejson.js",
|
"import": "./lib/esm/fakejson.js",
|
||||||
"require": "./lib/cjs/fakejson.js",
|
"require": "./lib/cjs/fakejson.js",
|
||||||
@@ -173,8 +208,9 @@
|
|||||||
"@noble/hashes": "1.3.1",
|
"@noble/hashes": "1.3.1",
|
||||||
"@scure/base": "1.1.1",
|
"@scure/base": "1.1.1",
|
||||||
"@scure/bip32": "1.3.1",
|
"@scure/bip32": "1.3.1",
|
||||||
"@scure/bip39": "1.2.1",
|
"@scure/bip39": "1.2.1"
|
||||||
"mitata": "^0.1.6",
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
"nostr-wasm": "v0.1.0"
|
"nostr-wasm": "v0.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -205,12 +241,14 @@
|
|||||||
"eslint-plugin-babel": "^5.3.1",
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
"esm-loader-typescript": "^1.0.3",
|
"esm-loader-typescript": "^1.0.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
|
"mitata": "^0.1.6",
|
||||||
|
"mock-socket": "^9.3.1",
|
||||||
|
"msw": "^2.1.4",
|
||||||
"node-fetch": "^2.6.9",
|
"node-fetch": "^2.6.9",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"tsd": "^0.22.0",
|
|
||||||
"typescript": "^5.0.4"
|
"typescript": "^5.0.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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 { 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(() => {
|
afterEach(() => {
|
||||||
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
|
pool.close(relayURLs)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('removing duplicates when subscribing', async () => {
|
test('removing duplicates when subscribing', async () => {
|
||||||
let priv = generateSecretKey()
|
let priv = generateSecretKey()
|
||||||
let pub = getPublicKey(priv)
|
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 received: Event[] = []
|
||||||
|
|
||||||
let event = finalizeEvent(
|
let event = finalizeEvent(
|
||||||
{
|
{
|
||||||
created_at: Math.round(Date.now() / 1000),
|
created_at: Math.round(Date.now() / 1000),
|
||||||
@@ -36,8 +32,17 @@ test('removing duplicates when subscribing', async () => {
|
|||||||
priv,
|
priv,
|
||||||
)
|
)
|
||||||
|
|
||||||
await Promise.any(pool.publish(relays, event))
|
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
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).toHaveLength(1)
|
||||||
expect(received[0]).toEqual(event)
|
expect(received[0]).toEqual(event)
|
||||||
@@ -47,12 +52,12 @@ test('same with double subs', async () => {
|
|||||||
let priv = generateSecretKey()
|
let priv = generateSecretKey()
|
||||||
let pub = getPublicKey(priv)
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
received.push(event)
|
received.push(event)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
pool.subscribeMany(relays, [{ authors: [pub] }], {
|
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
received.push(event)
|
received.push(event)
|
||||||
},
|
},
|
||||||
@@ -70,47 +75,50 @@ test('same with double subs', async () => {
|
|||||||
priv,
|
priv,
|
||||||
)
|
)
|
||||||
|
|
||||||
await Promise.any(pool.publish(relays, event))
|
await Promise.any(pool.publish(relayURLs, event))
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||||
|
|
||||||
expect(received).toHaveLength(2)
|
expect(received).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('query a bunch of events and cancel on eose', async () => {
|
test('query a bunch of events and cancel on eose', async () => {
|
||||||
let events = new Set<string>()
|
let events = new Set<string>()
|
||||||
|
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
pool.subscribeManyEose(
|
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
|
||||||
[...relays, 'wss://relayable.org', 'wss://relay.noswhere.com', 'wss://nothing.com'],
|
onevent(event) {
|
||||||
[{ kinds: [0, 1], limit: 40 }],
|
events.add(event.id)
|
||||||
{
|
|
||||||
onevent(event) {
|
|
||||||
events.add(event.id)
|
|
||||||
},
|
|
||||||
onclose: resolve as any,
|
|
||||||
},
|
},
|
||||||
)
|
onclose: resolve as any,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(events.size).toBeGreaterThan(50)
|
expect(events.size).toBeGreaterThan(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('querySync()', async () => {
|
test('querySync()', async () => {
|
||||||
let events = await pool.querySync([...relays.slice(2), 'wss://offchain.pub', 'wss://eden.nostr.land'], {
|
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||||
authors: ['3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'],
|
|
||||||
|
let events = await pool.querySync(relayURLs, {
|
||||||
|
authors: authors,
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
limit: 2,
|
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
|
// the actual received number will be greater than 2, but there will be no duplicates
|
||||||
expect(events.length).toBeGreaterThan(2)
|
expect(events.length).toBeGreaterThan(2)
|
||||||
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
|
||||||
expect(events).toHaveLength(uniqueEventCount)
|
expect(events).toHaveLength(uniqueEventCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('get()', async () => {
|
test('get()', async () => {
|
||||||
let event = await pool.get(relays, {
|
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||||
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
|
|
||||||
|
let event = await pool.get(relayURLs, {
|
||||||
|
ids: [ids[0]],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(event).not.toBeNull()
|
expect(event).not.toBeNull()
|
||||||
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
|
expect(event).toHaveProperty('id', ids[0])
|
||||||
})
|
})
|
||||||
|
|||||||
2
pure.ts
2
pure.ts
@@ -1,6 +1,6 @@
|
|||||||
import { schnorr } from '@noble/curves/secp256k1'
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
import { bytesToHex } from '@noble/hashes/utils'
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
|
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core.ts'
|
||||||
import { sha256 } from '@noble/hashes/sha256'
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
|
||||||
import { utf8Encoder } from './utils.ts'
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|||||||
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'
|
import { Relay } from './relay.ts'
|
||||||
|
import { MockRelay } from './test-helpers.ts'
|
||||||
let relay = new Relay('wss://relay.nostr.bg')
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
relay.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('connectivity', async () => {
|
test('connectivity', async () => {
|
||||||
|
const mockRelay = new MockRelay()
|
||||||
|
|
||||||
|
const relay = new Relay(mockRelay.url)
|
||||||
await relay.connect()
|
await relay.connect()
|
||||||
|
|
||||||
expect(relay.connected).toBeTrue()
|
expect(relay.connected).toBeTrue()
|
||||||
|
|
||||||
|
relay.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('connectivity, with Relay.connect()', async () => {
|
test('connectivity, with Relay.connect()', async () => {
|
||||||
const relay = await Relay.connect('wss://public.relaying.io')
|
const mockRelay = new MockRelay()
|
||||||
|
const relay = await Relay.connect(mockRelay.url)
|
||||||
expect(relay.connected).toBeTrue()
|
expect(relay.connected).toBeTrue()
|
||||||
relay.close()
|
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()
|
await relay.connect()
|
||||||
|
|
||||||
let resolveEvent: () => void
|
|
||||||
let resolveEose: () => void
|
|
||||||
|
|
||||||
const evented = new Promise<void>(resolve => {
|
|
||||||
resolveEvent = resolve
|
|
||||||
})
|
|
||||||
const eosed = new Promise<void>(resolve => {
|
|
||||||
resolveEose = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
relay.subscribe(
|
relay.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
authors: ['9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc'],
|
authors: mockRelay.authors,
|
||||||
kinds: [0],
|
kinds: [kind],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
expect(event).toHaveProperty('pubkey', '9bbe185a20f50607b6e021c68a2c7275649770d3f8277c120d2b801a2b9a64fc')
|
expect(mockRelay.authors).toContain(event.pubkey)
|
||||||
expect(event).toHaveProperty('kind', 0)
|
expect(event).toHaveProperty('kind', kind)
|
||||||
resolveEvent()
|
|
||||||
},
|
relay.close()
|
||||||
oneose() {
|
done()
|
||||||
resolveEose()
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
await eosed
|
test('listening and publishing and closing', async done => {
|
||||||
await evented
|
const mockRelay = new MockRelay()
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
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()
|
await relay.connect()
|
||||||
|
|
||||||
let sk = generateSecretKey()
|
|
||||||
let pk = getPublicKey(sk)
|
|
||||||
let resolveEose: (_: void) => void
|
|
||||||
let resolveEvent: (_: void) => void
|
|
||||||
let resolveClose: (_: void) => void
|
|
||||||
let eventReceived: NostrEvent | undefined
|
|
||||||
|
|
||||||
const eosed = new Promise(resolve => {
|
|
||||||
resolveEose = resolve
|
|
||||||
})
|
|
||||||
const evented = new Promise(resolve => {
|
|
||||||
resolveEvent = resolve
|
|
||||||
})
|
|
||||||
const closed = new Promise(resolve => {
|
|
||||||
resolveClose = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
let sub = relay.subscribe(
|
let sub = relay.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [23571],
|
kinds: [kind],
|
||||||
authors: [pk],
|
authors: [pk],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onevent(event) {
|
onevent(event) {
|
||||||
eventReceived = event
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
resolveEvent()
|
expect(event).toHaveProperty('kind', kind)
|
||||||
},
|
expect(event).toHaveProperty('content', 'content')
|
||||||
oneose() {
|
|
||||||
resolveEose()
|
sub.close() // close the subscription and will trigger onclose()
|
||||||
},
|
},
|
||||||
onclose() {
|
onclose() {
|
||||||
resolveClose()
|
relay.close()
|
||||||
|
done()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await eosed
|
relay.publish(
|
||||||
|
finalizeEvent(
|
||||||
let event = finalizeEvent(
|
{
|
||||||
{
|
kind,
|
||||||
kind: 23571,
|
content: 'content',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: 'nostr-tools test suite',
|
},
|
||||||
},
|
sk,
|
||||||
sk,
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await relay.publish(event)
|
|
||||||
await evented
|
|
||||||
sub.close()
|
|
||||||
await closed
|
|
||||||
|
|
||||||
expect(eventReceived).toBeDefined()
|
|
||||||
expect(eventReceived).toHaveProperty('pubkey', pk)
|
|
||||||
expect(eventReceived).toHaveProperty('kind', 23571)
|
|
||||||
expect(eventReceived).toHaveProperty('content', 'nostr-tools test suite')
|
|
||||||
})
|
})
|
||||||
|
|||||||
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 {
|
export function buildEvent(params: Partial<Event>): Event {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
@@ -13,3 +15,104 @@ export function buildEvent(params: Partial<Event>): Event {
|
|||||||
...params,
|
...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 const utf8Encoder = new TextEncoder()
|
||||||
|
|
||||||
export function normalizeURL(url: string): string {
|
export function normalizeURL(url: string): string {
|
||||||
|
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||||
let p = new URL(url)
|
let p = new URL(url)
|
||||||
p.pathname = p.pathname.replace(/\/+/g, '/')
|
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||||
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||||
|
|||||||
Reference in New Issue
Block a user