Compare commits

...

109 Commits

Author SHA1 Message Date
fiatjaf
9a612e59a2 update nip11 test. 2025-03-14 09:30:35 -03:00
fiatjaf
266dbdf766 nip27: rewrite to support urls and references in a simpler API for rich UIs. 2025-03-14 09:26:40 -03:00
fiatjaf
19ae9837a7 nip19: decodeNostrURI() function that doesn't throw. 2025-03-14 09:26:40 -03:00
António Conselheiro
4188f2c596 Generic repost 2025-03-10 01:58:00 -03:00
fiatjaf
97bded8f5b prevent a relay from eoseing then closing and causing pool handlers to fire twice. 2025-03-02 01:25:39 -03:00
fiatjaf
174d36a440 nip07: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
0177b130c3 nip55: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
05eb62da5b support subscription label, not only an absolute id. 2025-03-02 01:25:39 -03:00
Baris Aydek
3c4019a154 nip54 normalizeIdentifier function 2025-02-25 13:52:40 -03:00
fiatjaf
e7e8db1dbd nip46: take EventTemplate instead of UnsignedEvent. 2025-02-24 14:48:47 -03:00
bitcoinpirate
44a679e642 added support for zapping replaceable events (#424)
* added support for zapping replaceable events

* Update nip57.ts

* Update nip57.ts

Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>

* apply @SnowCait's suggestions.

* fix lint error.

---------

Co-authored-by: AsaiToshiya <to.asai.60@gmail.com>
Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>
2025-02-24 00:46:51 +09:00
Asai Toshiya
c1172caf1d mark getRelays and get_relays as deprecated. 2025-02-21 15:08:55 -03:00
Jon Staab
86f37d6003 Clean up nip96 upload validation and make it less strict 2025-02-11 15:58:20 -03:00
Sandwich
3daade322c export retention details
pain to use without being available as an export.
2025-02-10 09:25:33 -03:00
Asai Toshiya
fcf10541c8 rename "parameterized replaceable" to "addressable". 2025-01-23 14:08:22 -03:00
Asai Toshiya
548abb5d4a nip18: tweak test data. 2025-01-23 20:03:52 +09:00
Asai Toshiya
1e5bfe856b nip18: don't stringify protected event. 2025-01-17 21:30:09 -03:00
Anderson Juhasc
3266b4d4c2 added NIP-55 2025-01-04 14:15:11 -03:00
Asai Toshiya
a0b950ab12 remove unnecessary id from Omit keys. 2025-01-02 15:57:39 -03:00
Asai Toshiya
be741159d7 nip29: update GroupAdminPermission. 2024-12-17 13:33:00 -03:00
im-adithya
9c50b2c655 fix: clear timeout in publish and auth 2024-12-03 11:09:11 -03:00
Egge
bbb09420fe export nip17 2024-11-26 11:59:58 -03:00
Asai Toshiya
2e85f7a5fe Revert "nip19: remove note1."
This reverts commit a8a805fb71.
2024-11-26 11:59:58 -03:00
Asai Toshiya
b22e2465cc nip19: remove note1. 2024-11-26 11:59:58 -03:00
fiatjaf
43ce7f9377 fix reference to nostr-wasm dependency so it can be installed on deno.
fixes https://github.com/nbd-wtf/nostr-tools/issues/459
2024-11-25 21:33:25 -03:00
fiatjaf
5a55c670fb nip10: fix. 2024-11-13 01:21:54 -03:00
fiatjaf
bf0c4d4988 nip10: improve, support quotes, author hints, change the way legacy refs are discovered. 2024-11-04 15:37:39 -03:00
fiatjaf
50fe7c2a8b streamline jsr publishes. 2024-11-02 08:35:52 -03:00
fiatjaf
29270c8c9d nip46: fix legacyDecrypt argument. 2024-11-02 08:13:33 -03:00
fiatjaf
cb29d62033 add links to jsr.io 2024-10-31 16:33:24 -03:00
Asai Toshiya
0d237405d9 fix lint error. 2024-10-31 20:43:03 +09:00
Asai Toshiya
659ad36b62 nip05: use stub to test queryProfile(). 2024-10-31 07:51:53 -03:00
Asai Toshiya
d062ab8afd make publish() timeout. 2024-10-30 11:55:04 -03:00
Fishcake
94f841f347 Fix fetch to work in the edge and node environments, cleanup type issues
- Fix "TypeError: Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code)." that is thrown when trying to use fetch in the edge environment  (e.g., workers)
- Cleanup types and variable definitions.
2024-10-28 14:56:10 -03:00
fiatjaf
c1d03cf00b nip46: only encrypt with nip44 (breaking). 2024-10-27 14:59:27 -03:00
fiatjaf
29ecdfc5ec fix slow types so we can publish to jsr.io 2024-10-26 14:23:28 -03:00
fiatjaf
d3fc4734b4 export missing modules. 2024-10-26 13:10:26 -03:00
fiatjaf
66d0b8a4e1 nip46: export queryBunkerProfile() 2024-10-26 07:24:13 -03:00
fiatjaf
e2ec7a4b55 fix types, imports and other stuff on nip17 and nip59. 2024-10-25 22:10:05 -03:00
fiatjaf
a72e47135a nip46: we have no business checking the pubkey of the sign_event result. 2024-10-25 21:58:03 -03:00
Anderson Juhasc
de7bbfc6a2 organizing and improving nip17 and nip59 2024-10-25 11:49:28 -03:00
fiatjaf
f2d421fa4f nip46: remove "nip44_get_key" method as it was removed from the spec. 2024-10-25 10:22:21 -03:00
Anderson Juhasc
cae06fc4fe Implemented NIP-17 support (#449) 2024-10-24 21:10:09 -03:00
fiatjaf
5c538efa38 nip28: fix naming bug. 2024-10-23 17:11:35 -03:00
fiatjaf
013daae91b nip05: fix test. 2024-10-23 17:09:56 -03:00
fiatjaf
75660e7ff1 nip46: cache the received pubkey. 2024-10-23 17:09:41 -03:00
fiatjaf
4c2d2b5ce6 nip46: fix getPublicKey() by making it actually call "get_public_key". 2024-10-23 16:38:14 -03:00
António Conselheiro
aba266b8e6 Suggestion: export kinds as named types (#447)
* including kinds for nip17 and nip59

* including kinds as types

* solving linter with prettier
2024-10-23 10:39:39 -03:00
Asai Toshiya
d7dcc75ebe nip19: completely remove nrelay. 2024-10-22 13:06:34 -03:00
fiatjaf
b18510b460 nip13: speed improvements. 2024-10-22 13:03:29 -03:00
Egge
b04e0d16c0 test: fixed nip06 assertion 2024-10-20 10:53:08 -03:00
Egge
633696bf46 nip06: return Uint8 instead of string 2024-10-20 10:53:08 -03:00
ciegovolador
bf975c9a87 fix(nip59) formated code 2024-10-18 07:20:37 -03:00
fiatjaf
7aa4f09769 tag v2.8.1 2024-10-17 21:57:38 -03:00
Egge
f646fcd889 export nip59 2024-10-17 21:51:03 -03:00
ciegovolador
1d89038375 add nip59 (#438)
* feat(nip59) add nip59 based on https://nips.nostr.com/59

* fix(nip59) export the code as nip59

* Update nip59.ts

Co-authored-by: Asai Toshiya <to.asai.60@gmail.com>

* fix(nip59) change GiftWrap kind and using kinds from kinds.ts

---------

Co-authored-by: Asai Toshiya <to.asai.60@gmail.com>
2024-10-17 09:38:04 -03:00
Callum Macdonald
0b5b35714c Add subscription id to relay.subscribe().
fix #439
2024-10-17 09:30:49 -03:00
Vinit
e398617fdc chore: use describe block to group NostrTypeGuard tests
this way we don't need to repeat NostrTypeGuard in each tests description
2024-10-11 07:48:46 -03:00
Vinit
1b236faa7b fix: move NostrTypeGuard tests to nip19.test.ts
NostrTypeGuard was moved to nip19.ts in commit 45b25c5bf5
but tests stayed in core.test.ts and started failing because it still imported
NostrTypeGuard from core.ts - which wasn't there.
2024-10-11 07:48:46 -03:00
Asai Toshiya
7064e0b828 make it possible to track relays on publish. 2024-10-10 14:45:37 -03:00
space-shell
4f6976f6f8 fix nip44Decrypt sending "nip44_encrypt" request (#435)
nip44Decrypt sends nip44_decrypt request
2024-09-24 12:56:36 -03:00
António Conselheiro
a61cde77ea Kinds from nip17 nip59 (#434) 2024-09-20 17:05:52 -03:00
fiatjaf
23d95acb26 move Nip05 type to nip05.ts 2024-09-09 14:23:03 -03:00
fiatjaf
13ac04b8f8 nip19: fix ncryptsec type guard.
see https://github.com/nbd-wtf/nostr-tools/pull/409#issuecomment-2338661000
2024-09-09 14:21:29 -03:00
fiatjaf
45b25c5bf5 nip19/nip49: remove nrelay and move bech32 string guard methods from core to nip19. 2024-09-09 14:20:35 -03:00
António Conselheiro
ee76d69b4b including nostr specialized types (#409)
* including nostr types

* including tests for nostr type guard

* fix tests for nostr type guard

* fix linter and add eslint and prettier to devcontainer

* including null in nostr type guard signature

* fix type, ops

* including ncryptsec in nostr type guard

* fix linter for ncryptsec

* including ncryptsec return type for nip49

* fixing names of nostr types and types guards

* fixing names of nostr types and types guards in unit tests descriptions

* fix prettier

* including type guard for nip5
2024-09-09 14:16:23 -03:00
Vinit
21433049b8 fix: eslint no-unused vars violations and setup
@typescript-eslint documents recommends turning off the base
unused-vars rule in eslint explicitly and using '@typescript-eslint/no-unused var'
instead. In this case, the base rule failed to correctly report enums. (reported
unused even though they were used).

Also, fixed two unused variables by adding ignore pattern '_'.
2024-09-06 19:45:50 -03:00
Asai Toshiya
e8ff68f0b3 Update README.md 2024-08-15 11:29:18 -03:00
fiatjaf
1b77d6e080 mention nostrify.dev on readme. 2024-08-08 12:13:41 -03:00
Sam Samskies
76d3a91600 authorization should not be in the form data 2024-08-08 12:00:58 -03:00
Sepehr Safari
6f334f31a7 add and improve helpers for nip29 2024-08-01 18:18:13 -03:00
Alex Gleason
9c009ac543 getFilterLimit: handle parameterized replaceable events 2024-07-20 22:00:28 -03:00
Shusui MOYATANI
a87099fa5c remove Content-Type header from NIP-96 uploadFile 2024-07-20 09:44:45 -03:00
António Conselheiro
475a22a95f methods for abstract pool (#419)
* include method to list current pool relays connections and to close all connections

* fix prettier
2024-07-18 13:33:29 -03:00
fiatjaf
54e352d8e2 tag v2.7.1 2024-07-09 07:59:04 -03:00
António Conselheiro
235a1c50cb making AbstractSimplesPool more extendable 2024-07-08 23:49:46 -03:00
António Conselheiro
dfc2107569 fix typo and include missing attributes for nip11 and they docs 2024-07-07 21:14:52 -03:00
Shusui MOYATANI
986b9d0cce support fallback tag in NIP-94 2024-07-04 15:07:37 -03:00
fiatjaf
753ff323ea specify websocket error as close reason when no message is available.
fixes https://github.com/nbd-wtf/nostr-tools/issues/411
2024-06-06 15:32:27 -03:00
Alex Gleason
f8c3e20f3d getFilterLimit: empty tags return 0 2024-05-30 16:32:55 -03:00
fiatjaf
87a91c2daf fix useWebSocketImplementation so it works with pool on nodejs esm. 2024-05-29 13:39:00 -03:00
Anderson Juhasc
4f1dc9ef1c fixing formatting with Prettier 2024-05-27 10:44:44 -03:00
Anderson Juhasc
faa1a9d556 adding nip06 examples to the README 2024-05-27 10:44:44 -03:00
Anderson Juhasc
97d838f254 white spaces removed 2024-05-27 10:44:44 -03:00
Don
260400b24d fix typo in nip07.ts 2024-05-27 10:42:35 -03:00
fiatjaf
6e5ab34a54 tag v2.6.0 2024-05-26 12:04:48 -03:00
fiatjaf
9562c408b3 never import anything from index.ts in submodules. 2024-05-26 12:04:48 -03:00
fiatjaf
4f4de458e9 rename Nip07 to WindowNostr. 2024-05-26 12:00:42 -03:00
António Conselheiro
88454de628 including interface for nip07 (#403)
* including interface for nip07

* fix types for NIP-07

* including NIP-07 export to jsr

* fix readme about nip07

* including in nip7 interface an output signature compatible with the event returned by the signer
2024-05-26 11:58:12 -03:00
Anderson Juhasc
9f5984d78d added functions accountFromSeedWords, extendedKeysFromSeedWords and accountFromExtendedKey to nip06 2024-05-26 08:21:07 -03:00
António Conselheiro
80df21d47f reviewing just installation in devcontainer 2024-05-25 07:28:49 -03:00
António Conselheiro
296e99d2a4 config devcontainer 2024-05-25 07:28:49 -03:00
fiatjaf
1cd9847ad5 filter: fix tests (remove prefix tests). 2024-05-19 14:58:38 -03:00
fiatjaf
fa31fdca78 nip46: try to decrypt with nip44 if nip04 fails. 2024-05-19 14:51:39 -03:00
fiatjaf
5876acd67a nip44: make the api less classy. 2024-05-19 14:40:23 -03:00
fiatjaf
44efd49bc0 filter: stop matching against id and pubkey prefixes. 2024-05-19 14:26:42 -03:00
fiatjaf
f4f9bece6e tag v2.5.2 2024-05-02 11:38:20 -03:00
hzrd149
e217f751da fix count request in anstract relay 2024-05-02 11:37:50 -03:00
fiatjaf
d0ae8b36a2 tag v2.5.1 2024-04-24 17:47:45 -03:00
fiatjaf
fd945757be accept localSecretKey as a parameter on nip46.createAccount()
fixes https://github.com/nbd-wtf/nostr-tools/issues/401
2024-04-24 10:12:33 -03:00
Daniele Tonon
c12ddd3c53 Add hyphen to BUNKER_REGEX 2024-04-20 16:16:13 -03:00
fiatjaf
1e9f828e3e tag 2.5.0 2024-04-12 21:51:09 -03:00
fiatjaf
0a5eaac088 pool.subscribeManyMap() 2024-04-12 21:50:26 -03:00
Alex Gleason
e858698cb9 Add stable sortEvents function 2024-04-12 21:20:06 -03:00
Alex Gleason
b349ee577d Merge pull request #392 from verbiricha/patch-1
fix typos in README
2024-04-12 17:57:49 -05:00
Alex Gleason
849a2ac3f3 Merge pull request #397 from alexgleason/pure-test
core.test.ts -> pure.test.ts
2024-04-12 17:48:09 -05:00
Alex Gleason
c18b94677c core.test.ts -> pure.test.ts 2024-04-12 17:44:36 -05:00
franzap
f306cec716 querySync does not take an array 2024-04-08 08:38:06 -03:00
Alejandro
88247e56c1 fix typos in README 2024-03-27 18:58:23 +01:00
58 changed files with 3364 additions and 1046 deletions

19
.devcontainer/Dockerfile Executable file
View File

@@ -0,0 +1,19 @@
FROM node:20
RUN npm install typescript eslint prettier -g
# Install bun
RUN curl -fsSL https://bun.sh/install | bash
# Install just
WORKDIR /usr/bin
RUN wget https://github.com/casey/just/releases/download/1.26.0/just-1.26.0-x86_64-unknown-linux-musl.tar.gz
RUN tar -xzf just-1.26.0-x86_64-unknown-linux-musl.tar.gz
RUN chmod +x ./just
RUN rm just-1.26.0-x86_64-unknown-linux-musl.tar.gz
WORKDIR /nostr-tools
ENV LANG C.UTF-8
# The run the start script
CMD [ "/bin/bash" ]

19
.devcontainer/devcontainer.json Executable file
View File

@@ -0,0 +1,19 @@
{
"name": "Nostr Tools",
"dockerComposeFile": [
"docker-compose.yml"
],
"service": "nostr-tools-dev",
"workspaceFolder": "/nostr-tools",
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"eamodio.gitlens",
"dbaeumer.vscode-eslint",
"manishsencha.readme-preview",
"wix.vscode-import-cost"
]
}
}
}

View File

@@ -0,0 +1,13 @@
version: '3.9'
services:
nostr-tools-dev:
image: nostr-tools-dev
container_name: nostr-tools-dev
build:
context: ../.
dockerfile: ./.devcontainer/Dockerfile
working_dir: /nostr-tools
volumes:
- ..:/nostr-tools:cached
tty: true

View File

@@ -116,7 +116,8 @@
"no-unexpected-multiline": 2, "no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }], "no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2, "no-unreachable": 2,
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }], "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
"no-useless-call": 2, "no-useless-call": 2,
"no-useless-constructor": 2, "no-useless-constructor": 2,
"no-with": 2, "no-with": 2,

View File

@@ -1,19 +1,27 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) nostr-tools # ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients. Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages. Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system). This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
## Installation ## Installation
```bash ```bash
npm install nostr-tools # or yarn add nostr-tools # npm
npm install --save nostr-tools
# jsr
npx jsr add @nostr/tools
``` ```
If using TypeScript, this package requires TypeScript >= 5.0. If using TypeScript, this package requires TypeScript >= 5.0.
## Documentation
https://jsr.io/@nostr/tools/doc
## Usage ## Usage
### Generating a private key and a public key ### Generating a private key and a public key
@@ -28,9 +36,9 @@ let pk = getPublicKey(sk) // `pk` is a hex string
To get the secret key in hex format, use To get the secret key in hex format, use
```js ```js
import { bytestohex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency
let skHex = bytestohex(sk) let skHex = bytesToHex(sk)
let backToBytes = hexToBytes(skHex) let backToBytes = hexToBytes(skHex)
``` ```
@@ -76,7 +84,7 @@ const sub = relay.subscribe([
let sk = generateSecretKey() let sk = generateSecretKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)
relay.sub([ relay.subscribe([
{ {
kinds: [1], kinds: [1],
authors: [pk], authors: [pk],
@@ -104,8 +112,11 @@ relay.close()
To use this on Node.js you first must install `ws` and call something like this: To use this on Node.js you first must install `ws` and call something like this:
```js ```js
import { useWebSocketImplementation } from 'nostr-tools/relay' import { useWebSocketImplementation } from 'nostr-tools/pool'
useWebSocketImplementation(require('ws')) // or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly
import WebSocket from 'ws'
useWebSocketImplementation(WebSocket)
``` ```
### Interacting with multiple relays ### Interacting with multiple relays
@@ -138,7 +149,7 @@ let h = pool.subscribeMany(
await Promise.any(pool.publish(relays, newEvent)) await Promise.any(pool.publish(relays, newEvent))
console.log('published to at least one relay!') console.log('published to at least one relay!')
let events = await pool.querySync(relays, [{ kinds: [0, 1] }]) let events = await pool.querySync(relays, { kinds: [0, 1] })
let event = await pool.get(relays, { let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'], ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
}) })
@@ -183,6 +194,43 @@ import { useFetchImplementation } from 'nostr-tools/nip05'
useFetchImplementation(require('node-fetch')) useFetchImplementation(require('node-fetch'))
``` ```
### Including NIP-07 types
```js
import type { WindowNostr } from 'nostr-tools/nip07'
declare global {
interface Window {
nostr?: WindowNostr;
}
}
```
### Generating NIP-06 keys
```js
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey
} from 'nostr-tools/nip06'
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' // optional
const accountIndex = 0
const sk0 = privateKeyFromSeedWords(mnemonic, passphrase, accountIndex)
const { privateKey: sk1, publicKey: pk1 } = accountFromSeedWords(mnemonic, passphrase, accountIndex)
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(mnemonic, passphrase, extendedAccountIndex)
const { privateKey: sk2, publicKey: pk2 } = accountFromExtendedKey(privateExtendedKey)
const { publicKey: pk3 } = accountFromExtendedKey(publicExtendedKey)
```
### Encoding and decoding NIP-19 codes ### Encoding and decoding NIP-19 codes
```js ```js

View File

@@ -1,4 +1,11 @@
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts' /* global WebSocket */
import {
AbstractRelay as AbstractRelay,
SubscriptionParams,
Subscription,
type AbstractRelayConstructorOptions,
} from './abstract-relay.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
import type { Event, Nostr } from './core.ts' import type { Event, Nostr } from './core.ts'
@@ -7,22 +14,28 @@ import { alwaysTrue } from './helpers.ts'
export type SubCloser = { close: () => void } export type SubCloser = { close: () => void }
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & { export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
id?: string id?: string
label?: string
} }
export class AbstractSimplePool { export class AbstractSimplePool {
private relays = new Map<string, AbstractRelay>() protected relays: Map<string, AbstractRelay> = new Map()
public seenOn: Map<string, Set<AbstractRelay>> = new Map() public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent'] public verifyEvent: Nostr['verifyEvent']
public trustedRelayURLs: Set<string> = new Set() public trustedRelayURLs: Set<string> = new Set()
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) { private _WebSocket?: typeof WebSocket
constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation
} }
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> { async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
@@ -32,6 +45,7 @@ export class AbstractSimplePool {
if (!relay) { if (!relay) {
relay = new AbstractRelay(url, { relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket,
}) })
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
this.relays.set(url, relay) this.relays.set(url, relay)
@@ -48,6 +62,10 @@ export class AbstractSimplePool {
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
return this.subscribeManyMap(Object.fromEntries(relays.map(url => [url, filters])), params)
}
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser {
if (this.trackRelays) { if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => { params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id) let set = this.seenOn.get(id)
@@ -61,12 +79,14 @@ export class AbstractSimplePool {
const _knownIds = new Set<string>() const _knownIds = new Set<string>()
const subs: Subscription[] = [] const subs: Subscription[] = []
const relaysLength = Object.keys(requests).length
// batch all EOSEs into a single // batch all EOSEs into a single
const eosesReceived: boolean[] = [] const eosesReceived: boolean[] = []
let handleEose = (i: number) => { let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relays.length) { if (eosesReceived.filter(a => a).length === relaysLength) {
params.oneose?.() params.oneose?.()
handleEose = () => {} handleEose = () => {}
} }
@@ -74,9 +94,10 @@ export class AbstractSimplePool {
// batch all closes into a single // batch all closes into a single
const closesReceived: string[] = [] const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => { let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i) handleEose(i)
closesReceived[i] = reason closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relays.length) { if (closesReceived.filter(a => a).length === relaysLength) {
params.onclose?.(closesReceived) params.onclose?.(closesReceived)
handleClose = () => {} handleClose = () => {}
} }
@@ -93,13 +114,16 @@ export class AbstractSimplePool {
// open a subscription in all given relays // open a subscription in all given relays
const allOpened = Promise.all( const allOpened = Promise.all(
relays.map(normalizeURL).map(async (url, i, arr) => { Object.entries(requests).map(async (req, i, arr) => {
if (arr.indexOf(url) !== i) { if (arr.indexOf(req) !== i) {
// duplicate // duplicate
handleClose(i, 'duplicate url') handleClose(i, 'duplicate url')
return return
} }
let [url, filters] = req
url = normalizeURL(url)
let relay: AbstractRelay let relay: AbstractRelay
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
@@ -135,7 +159,7 @@ export class AbstractSimplePool {
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filters: Filter[],
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribeMany(relays, filters, {
...params, ...params,
@@ -149,7 +173,7 @@ export class AbstractSimplePool {
async querySync( async querySync(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> { ): Promise<Event[]> {
return new Promise(async resolve => { return new Promise(async resolve => {
const events: Event[] = [] const events: Event[] = []
@@ -168,7 +192,7 @@ export class AbstractSimplePool {
async get( async get(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> { ): Promise<Event | null> {
filter.limit = 1 filter.limit = 1
const events = await this.querySync(relays, filter, params) const events = await this.querySync(relays, filter, params)
@@ -184,7 +208,29 @@ export class AbstractSimplePool {
} }
let r = await this.ensureRelay(url) let r = await this.ensureRelay(url)
return r.publish(event) return r.publish(event).then(reason => {
if (this.trackRelays) {
let set = this.seenOn.get(event.id)
if (!set) {
set = new Set()
this.seenOn.set(event.id, set)
}
set.add(r)
}
return reason
})
}) })
} }
listConnectionStatus(): Map<string, boolean> {
const map = new Map<string, boolean>()
this.relays.forEach((relay, url) => map.set(url, relay.connected))
return map
}
destroy(): void {
this.relays.forEach(conn => conn.close())
this.relays = new Map()
}
} }

View File

@@ -7,14 +7,9 @@ import { Queue, normalizeURL } from './utils.ts'
import { makeAuthEvent } from './nip42.ts' import { makeAuthEvent } from './nip42.ts'
import { yieldThread } from './helpers.ts' import { yieldThread } from './helpers.ts'
var _WebSocket: typeof WebSocket export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent']
try { websocketImplementation?: typeof WebSocket
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
} }
export class AbstractRelay { export class AbstractRelay {
@@ -29,6 +24,7 @@ export class AbstractRelay {
public baseEoseTimeout: number = 4400 public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400 public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
@@ -42,12 +38,15 @@ export class AbstractRelay {
private serial: number = 0 private serial: number = 0
private verifyEvent: Nostr['verifyEvent'] private verifyEvent: Nostr['verifyEvent']
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) { private _WebSocket: typeof WebSocket
constructor(url: string, opts: AbstractRelayConstructorOptions) {
this.url = normalizeURL(url) this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket
} }
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }): Promise<AbstractRelay> { static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
const relay = new AbstractRelay(url, opts) const relay = new AbstractRelay(url, opts)
await relay.connect() await relay.connect()
return relay return relay
@@ -87,7 +86,7 @@ export class AbstractRelay {
}, this.connectionTimeout) }, this.connectionTimeout)
try { try {
this.ws = new _WebSocket(this.url) this.ws = new this._WebSocket(this.url)
} catch (err) { } catch (err) {
reject(err) reject(err)
return return
@@ -100,7 +99,7 @@ export class AbstractRelay {
} }
this.ws.onerror = ev => { this.ws.onerror = ev => {
reject((ev as any).message) reject((ev as any).message || 'websocket error')
if (this._connected) { if (this._connected) {
this._connected = false this._connected = false
this.connectionPromise = undefined this.connectionPromise = undefined
@@ -200,9 +199,12 @@ export class AbstractRelay {
const ok: boolean = data[2] const ok: boolean = data[2]
const reason: string = data[3] const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ok) ep.resolve(reason) if (ep) {
else ep.reject(new Error(reason)) clearTimeout(ep.timeout)
this.openEventPublishes.delete(id) if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
}
return return
} }
case 'CLOSED': { case 'CLOSED': {
@@ -239,7 +241,14 @@ export class AbstractRelay {
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 = await signAuthEvent(makeAuthEvent(this.url, this.challenge)) const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
const ret = new Promise<string>((resolve, reject) => { const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(evt.id, { resolve, reject }) const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
}) })
this.send('["AUTH",' + JSON.stringify(evt) + ']') this.send('["AUTH",' + JSON.stringify(evt) + ']')
return ret return ret
@@ -247,7 +256,14 @@ export class AbstractRelay {
public async publish(event: Event): Promise<string> { public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => { const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(event.id, { resolve, reject }) const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('publish timed out'))
this.openEventPublishes.delete(event.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
}) })
this.send('["EVENT",' + JSON.stringify(event) + ']') this.send('["EVENT",' + JSON.stringify(event) + ']')
return ret return ret
@@ -259,19 +275,25 @@ export class AbstractRelay {
const ret = new Promise<number>((resolve, reject) => { const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject }) this.openCountRequests.set(id, { resolve, reject })
}) })
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']') this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1))
return ret return ret
} }
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription { public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
const subscription = this.prepareSubscription(filters, params) const subscription = this.prepareSubscription(filters, params)
subscription.fire() subscription.fire()
return subscription return subscription
} }
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription { public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++ this.serial++
const id = params.id || 'sub:' + this.serial const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const subscription = new Subscription(this, id, filters, params) const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription) this.openSubs.set(id, subscription)
return subscription return subscription
@@ -373,4 +395,5 @@ export type CountResolver = {
export type EventPublishResolver = { export type EventPublishResolver = {
resolve: (reason: string) => void resolve: (reason: string) => void
reject: (err: Error) => void reject: (err: Error) => void
timeout: ReturnType<typeof setTimeout>
} }

View File

@@ -1,293 +1,18 @@
import { describe, test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { sortEvents } from './core.ts'
import { test('sortEvents', () => {
finalizeEvent, const events = [
serializeEvent, { id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
getEventHash, { id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
validateEvent, { id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
verifyEvent, ]
verifiedSymbol,
getPublicKey,
generateSecretKey,
} from './pure.ts'
import { ShortTextNote } from './kinds.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
test('private key generation', () => { const sortedEvents = sortEvents(events)
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
}) expect(sortedEvents).toEqual([
{ id: 'abc124', pubkey: 'key2', created_at: 1620000000, kind: 1, tags: [], content: 'World', sig: 'sig2' },
test('public key generation', () => { { id: 'abc125', pubkey: 'key3', created_at: 1620000000, kind: 1, tags: [], content: '!', sig: 'sig3' },
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/) { id: 'abc123', pubkey: 'key1', created_at: 1610000000, kind: 1, tags: [], content: 'Hello', sig: 'sig1' },
}) ])
test('public key from private key deterministic', () => {
let sk = generateSecretKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})
describe('finalizeEvent', () => {
test('should create a signed event from a template', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const template = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finalizeEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
test('should serialize a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
test('should throw an error for an invalid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115,
pubkey: publicKey, // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
test('should return the correct event hash', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
test('should return true for a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
test('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
test('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid properties', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey',
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid tags', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifyEvent', () => {
test('should return true for a valid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const event = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifyEvent(event)
expect(isValid).toEqual(true)
})
test('should return false for an invalid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
test('should return false when verifying an event with a different private key', () => {
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifyEvent({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
test('should return false for an invalid event id', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
}) })

14
core.ts
View File

@@ -49,3 +49,17 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent {
return true return true
} }
/**
* Sort events in reverse-chronological order by the `created_at` timestamp,
* and then by the event `id` (lexicographically) in case of ties.
* This mutates the array.
*/
export function sortEvents(events: Event[]): Event[] {
return events.sort((a: NostrEvent, b: NostrEvent): number => {
if (a.created_at !== b.created_at) {
return b.created_at - a.created_at
}
return a.id.localeCompare(b.id)
})
}

View File

@@ -13,7 +13,6 @@ describe('Filter', () => {
until: 200, until: 200,
'#tag': ['value'], '#tag': ['value'],
} }
const event = buildEvent({ const event = buildEvent({
id: '123', id: '123',
kind: 1, kind: 1,
@@ -21,39 +20,21 @@ describe('Filter', () => {
created_at: 150, created_at: 150,
tags: [['tag', 'value']], tags: [['tag', 'value']],
}) })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
expect(result).toEqual(true) expect(result).toEqual(true)
}) })
test('should return false when the event id is not in the filter', () => { test('should return false when the event id is not in the filter', () => {
const filter = { ids: ['123', '456'] } const filter = { ids: ['123', '456'] }
const event = buildEvent({ id: '789' }) const event = buildEvent({ id: '789' })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
expect(result).toEqual(false) expect(result).toEqual(false)
}) })
test('should return true when the event id starts with a prefix', () => {
const filter = { ids: ['22', '00'] }
const event = buildEvent({ id: '001' })
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
test('should return false when the event kind is not in the filter', () => { test('should return false when the event kind is not in the filter', () => {
const filter = { kinds: [1, 2, 3] } const filter = { kinds: [1, 2, 3] }
const event = buildEvent({ kind: 4 }) const event = buildEvent({ kind: 4 })
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
expect(result).toEqual(false) expect(result).toEqual(false)
}) })
@@ -154,25 +135,8 @@ describe('Filter', () => {
{ ids: ['456'], kinds: [2], authors: ['def'] }, { ids: ['456'], kinds: [2], authors: ['def'] },
{ ids: ['789'], kinds: [3], authors: ['ghi'] }, { ids: ['789'], kinds: [3], authors: ['ghi'] },
] ]
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' }) const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
test('should return true when at least one prefix matches the event', () => {
const filters = [
{ ids: ['1'], kinds: [1], authors: ['a'] },
{ ids: ['4'], kinds: [2], authors: ['d'] },
{ ids: ['9'], kinds: [3], authors: ['g'] },
]
const event = buildEvent({ id: '987', kind: 3, pubkey: 'ghi' })
const result = matchFilters(filters, event)
expect(result).toEqual(true) expect(result).toEqual(true)
}) })
@@ -201,11 +165,8 @@ describe('Filter', () => {
{ ids: ['456'], kinds: [2], authors: ['def'] }, { ids: ['456'], kinds: [2], authors: ['def'] },
{ ids: ['789'], kinds: [3], authors: ['ghi'] }, { ids: ['789'], kinds: [3], authors: ['ghi'] },
] ]
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' }) const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
expect(result).toEqual(false) expect(result).toEqual(false)
}) })
@@ -221,9 +182,7 @@ describe('Filter', () => {
pubkey: 'def', pubkey: 'def',
created_at: 200, created_at: 200,
}) })
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
expect(result).toEqual(false) expect(result).toEqual(false)
}) })
}) })
@@ -256,6 +215,16 @@ describe('Filter', () => {
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4) expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
}) })
test('should handle parameterized replaceable events', () => {
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
expect(
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
).toEqual(8)
})
test('should return Infinity for authors with regular kinds', () => { test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity) expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
}) })
@@ -263,5 +232,9 @@ describe('Filter', () => {
test('should return Infinity for empty filters', () => { test('should return Infinity for empty filters', () => {
expect(getFilterLimit({})).toEqual(Infinity) expect(getFilterLimit({})).toEqual(Infinity)
}) })
test('empty tags return 0', () => {
expect(getFilterLimit({ '#p': [] })).toEqual(0)
})
}) })
}) })

View File

@@ -1,5 +1,5 @@
import { Event } from './core.ts' import { Event } from './core.ts'
import { isReplaceableKind } from './kinds.ts' import { isAddressableKind, isReplaceableKind } from './kinds.ts'
export type Filter = { export type Filter = {
ids?: string[] ids?: string[]
@@ -14,15 +14,13 @@ export type Filter = {
export function matchFilter(filter: Filter, event: Event): boolean { export function matchFilter(filter: Filter, event: Event): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) { if (filter.ids && filter.ids.indexOf(event.id) === -1) {
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) { return false
return false }
} if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) {
return false
} }
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) { if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) { return false
return false
}
} }
for (let f in filter) { for (let f in filter) {
@@ -41,7 +39,9 @@ export function matchFilter(filter: Filter, event: Event): boolean {
export function matchFilters(filters: Filter[], event: Event): boolean { export function matchFilters(filters: Filter[], event: Event): boolean {
for (let i = 0; i < filters.length; i++) { for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true if (matchFilter(filters[i], event)) {
return true
}
} }
return false return false
} }
@@ -72,17 +72,34 @@ export function mergeFilters(...filters: Filter[]): Filter {
return result return result
} }
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */ /**
* Calculate the intrinsic limit of a filter.
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
*/
export function getFilterLimit(filter: Filter): number { export function getFilterLimit(filter: Filter): number {
if (filter.ids && !filter.ids.length) return 0 if (filter.ids && !filter.ids.length) return 0
if (filter.kinds && !filter.kinds.length) return 0 if (filter.kinds && !filter.kinds.length) return 0
if (filter.authors && !filter.authors.length) return 0 if (filter.authors && !filter.authors.length) return 0
for (const [key, value] of Object.entries(filter)) {
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
}
return Math.min( return Math.min(
// The `limit` property creates an artificial limit.
Math.max(0, filter.limit ?? Infinity), Math.max(0, filter.limit ?? Infinity),
// There can only be one event per `id`.
filter.ids?.length ?? Infinity, filter.ids?.length ?? Infinity,
// Replaceable events are limited by the number of authors and kinds.
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind)) filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length ? filter.authors.length * filter.kinds.length
: Infinity, : Infinity,
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
? filter.authors.length * filter.kinds.length * filter['#d'].length
: Infinity,
) )
} }

View File

@@ -1,7 +1,7 @@
export * from './pure.ts' export * from './pure.ts'
export * from './relay.ts' export { Relay } from './relay.ts'
export * from './filter.ts' export * from './filter.ts'
export * from './pool.ts' export { SimplePool } from './pool.ts'
export * from './references.ts' export * from './references.ts'
export * as nip04 from './nip04.ts' export * as nip04 from './nip04.ts'
@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts' export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts' export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts' export * as nip13 from './nip13.ts'
export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts' export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts' export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts' export * as nip21 from './nip21.ts'
@@ -20,7 +21,9 @@ export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts' export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts' export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts' export * as nip47 from './nip47.ts'
export * as nip54 from './nip54.ts'
export * as nip57 from './nip57.ts' export * as nip57 from './nip57.ts'
export * as nip59 from './nip59.ts'
export * as nip98 from './nip98.ts' export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts' export * as kinds from './kinds.ts'

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.3.2", "version": "2.11.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",
@@ -16,9 +16,11 @@
"./nip04": "./nip04.ts", "./nip04": "./nip04.ts",
"./nip05": "./nip05.ts", "./nip05": "./nip05.ts",
"./nip06": "./nip06.ts", "./nip06": "./nip06.ts",
"./nip07": "./nip07.ts",
"./nip10": "./nip10.ts", "./nip10": "./nip10.ts",
"./nip11": "./nip11.ts", "./nip11": "./nip11.ts",
"./nip13": "./nip13.ts", "./nip13": "./nip13.ts",
"./nip17": "./nip17.ts",
"./nip18": "./nip18.ts", "./nip18": "./nip18.ts",
"./nip19": "./nip19.ts", "./nip19": "./nip19.ts",
"./nip21": "./nip21.ts", "./nip21": "./nip21.ts",
@@ -32,7 +34,10 @@
"./nip44": "./nip44.ts", "./nip44": "./nip44.ts",
"./nip46": "./nip46.ts", "./nip46": "./nip46.ts",
"./nip49": "./nip49.ts", "./nip49": "./nip49.ts",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts", "./nip57": "./nip57.ts",
"./nip58": "./nip58.ts",
"./nip59": "./nip59.ts",
"./nip75": "./nip75.ts", "./nip75": "./nip75.ts",
"./nip94": "./nip94.ts", "./nip94": "./nip94.ts",
"./nip96": "./nip96.ts", "./nip96": "./nip96.ts",
@@ -41,4 +46,4 @@
"./fakejson": "./fakejson.ts", "./fakejson": "./fakejson.ts",
"./utils": "./utils.ts" "./utils": "./utils.ts"
} }
} }

View File

@@ -13,6 +13,9 @@ test-only file:
publish: build publish: build
npm publish npm publish
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
jsr publish --allow-dirty
git checkout -- package.json
format: format:
eslint --ext .ts --fix *.ts eslint --ext .ts --fix *.ts

View File

@@ -1,5 +1,6 @@
import { test, expect } from 'bun:test' import { expect, test } from 'bun:test'
import { classifyKind } from './kinds.ts' import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
test('kind classification', () => { test('kind classification', () => {
expect(classifyKind(1)).toBe('regular') expect(classifyKind(1)).toBe('regular')
@@ -19,3 +20,22 @@ test('kind classification', () => {
expect(classifyKind(40000)).toBe('unknown') expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown') expect(classifyKind(255)).toBe('unknown')
}) })
test('kind type guard', () => {
const privateKey = generateSecretKey()
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
expect(isKind(repostedEvent, Repost)).toBeFalse()
})

View File

@@ -1,3 +1,5 @@
import { NostrEvent, validateEvent } from './pure.ts'
/** Events are **regular**, which means they're all expected to be stored by relays. */ /** Events are **regular**, which means they're all expected to be stored by relays. */
export function isRegularKind(kind: number): boolean { export function isRegularKind(kind: number): boolean {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind) return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
@@ -13,11 +15,14 @@ export function isEphemeralKind(kind: number): boolean {
return 20000 <= kind && kind < 30000 return 20000 <= kind && kind < 30000
} }
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */ /** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
export function isParameterizedReplaceableKind(kind: number): boolean { export function isAddressableKind(kind: number): boolean {
return 30000 <= kind && kind < 40000 return 30000 <= kind && kind < 40000
} }
/** @deprecated use isAddressableKind instead */
export const isParameterizedReplaceableKind = isAddressableKind
/** Classification of the event kind. */ /** Classification of the event kind. */
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown' export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
@@ -26,81 +31,166 @@ export function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular' if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable' if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral' if (isEphemeralKind(kind)) return 'ephemeral'
if (isParameterizedReplaceableKind(kind)) return 'parameterized' if (isAddressableKind(kind)) return 'parameterized'
return 'unknown' return 'unknown'
} }
export function isKind<T extends number>(event: unknown, kind: T | Array<T>): event is NostrEvent & { kind: T } {
const kindAsArray: number[] = kind instanceof Array ? kind : [kind]
return (validateEvent(event) && kindAsArray.includes(event.kind)) || false
}
export const Metadata = 0 export const Metadata = 0
export type Metadata = typeof Metadata
export const ShortTextNote = 1 export const ShortTextNote = 1
export type ShortTextNote = typeof ShortTextNote
export const RecommendRelay = 2 export const RecommendRelay = 2
export type RecommendRelay = typeof RecommendRelay
export const Contacts = 3 export const Contacts = 3
export type Contacts = typeof Contacts
export const EncryptedDirectMessage = 4 export const EncryptedDirectMessage = 4
export const EncryptedDirectMessages = 4 export type EncryptedDirectMessage = typeof EncryptedDirectMessage
export const EventDeletion = 5 export const EventDeletion = 5
export type EventDeletion = typeof EventDeletion
export const Repost = 6 export const Repost = 6
export type Repost = typeof Repost
export const Reaction = 7 export const Reaction = 7
export type Reaction = typeof Reaction
export const BadgeAward = 8 export const BadgeAward = 8
export type BadgeAward = typeof BadgeAward
export const Seal = 13
export type Seal = typeof Seal
export const PrivateDirectMessage = 14
export type PrivateDirectMessage = typeof PrivateDirectMessage
export const GenericRepost = 16 export const GenericRepost = 16
export type GenericRepost = typeof GenericRepost
export const ChannelCreation = 40 export const ChannelCreation = 40
export type ChannelCreation = typeof ChannelCreation
export const ChannelMetadata = 41 export const ChannelMetadata = 41
export type ChannelMetadata = typeof ChannelMetadata
export const ChannelMessage = 42 export const ChannelMessage = 42
export type ChannelMessage = typeof ChannelMessage
export const ChannelHideMessage = 43 export const ChannelHideMessage = 43
export type ChannelHideMessage = typeof ChannelHideMessage
export const ChannelMuteUser = 44 export const ChannelMuteUser = 44
export type ChannelMuteUser = typeof ChannelMuteUser
export const OpenTimestamps = 1040 export const OpenTimestamps = 1040
export type OpenTimestamps = typeof OpenTimestamps
export const GiftWrap = 1059
export type GiftWrap = typeof GiftWrap
export const FileMetadata = 1063 export const FileMetadata = 1063
export type FileMetadata = typeof FileMetadata
export const LiveChatMessage = 1311 export const LiveChatMessage = 1311
export type LiveChatMessage = typeof LiveChatMessage
export const ProblemTracker = 1971 export const ProblemTracker = 1971
export type ProblemTracker = typeof ProblemTracker
export const Report = 1984 export const Report = 1984
export type Report = typeof Report
export const Reporting = 1984 export const Reporting = 1984
export type Reporting = typeof Reporting
export const Label = 1985 export const Label = 1985
export type Label = typeof Label
export const CommunityPostApproval = 4550 export const CommunityPostApproval = 4550
export type CommunityPostApproval = typeof CommunityPostApproval
export const JobRequest = 5999 export const JobRequest = 5999
export type JobRequest = typeof JobRequest
export const JobResult = 6999 export const JobResult = 6999
export type JobResult = typeof JobResult
export const JobFeedback = 7000 export const JobFeedback = 7000
export type JobFeedback = typeof JobFeedback
export const ZapGoal = 9041 export const ZapGoal = 9041
export type ZapGoal = typeof ZapGoal
export const ZapRequest = 9734 export const ZapRequest = 9734
export type ZapRequest = typeof ZapRequest
export const Zap = 9735 export const Zap = 9735
export type Zap = typeof Zap
export const Highlights = 9802 export const Highlights = 9802
export type Highlights = typeof Highlights
export const Mutelist = 10000 export const Mutelist = 10000
export type Mutelist = typeof Mutelist
export const Pinlist = 10001 export const Pinlist = 10001
export type Pinlist = typeof Pinlist
export const RelayList = 10002 export const RelayList = 10002
export type RelayList = typeof RelayList
export const BookmarkList = 10003 export const BookmarkList = 10003
export type BookmarkList = typeof BookmarkList
export const CommunitiesList = 10004 export const CommunitiesList = 10004
export type CommunitiesList = typeof CommunitiesList
export const PublicChatsList = 10005 export const PublicChatsList = 10005
export type PublicChatsList = typeof PublicChatsList
export const BlockedRelaysList = 10006 export const BlockedRelaysList = 10006
export type BlockedRelaysList = typeof BlockedRelaysList
export const SearchRelaysList = 10007 export const SearchRelaysList = 10007
export type SearchRelaysList = typeof SearchRelaysList
export const InterestsList = 10015 export const InterestsList = 10015
export type InterestsList = typeof InterestsList
export const UserEmojiList = 10030 export const UserEmojiList = 10030
export type UserEmojiList = typeof UserEmojiList
export const DirectMessageRelaysList = 10050
export type DirectMessageRelaysList = typeof DirectMessageRelaysList
export const FileServerPreference = 10096 export const FileServerPreference = 10096
export type FileServerPreference = typeof FileServerPreference
export const NWCWalletInfo = 13194 export const NWCWalletInfo = 13194
export type NWCWalletInfo = typeof NWCWalletInfo
export const LightningPubRPC = 21000 export const LightningPubRPC = 21000
export type LightningPubRPC = typeof LightningPubRPC
export const ClientAuth = 22242 export const ClientAuth = 22242
export type ClientAuth = typeof ClientAuth
export const NWCWalletRequest = 23194 export const NWCWalletRequest = 23194
export type NWCWalletRequest = typeof NWCWalletRequest
export const NWCWalletResponse = 23195 export const NWCWalletResponse = 23195
export type NWCWalletResponse = typeof NWCWalletResponse
export const NostrConnect = 24133 export const NostrConnect = 24133
export type NostrConnect = typeof NostrConnect
export const HTTPAuth = 27235 export const HTTPAuth = 27235
export type HTTPAuth = typeof HTTPAuth
export const Followsets = 30000 export const Followsets = 30000
export type Followsets = typeof Followsets
export const Genericlists = 30001 export const Genericlists = 30001
export type Genericlists = typeof Genericlists
export const Relaysets = 30002 export const Relaysets = 30002
export type Relaysets = typeof Relaysets
export const Bookmarksets = 30003 export const Bookmarksets = 30003
export type Bookmarksets = typeof Bookmarksets
export const Curationsets = 30004 export const Curationsets = 30004
export type Curationsets = typeof Curationsets
export const ProfileBadges = 30008 export const ProfileBadges = 30008
export type ProfileBadges = typeof ProfileBadges
export const BadgeDefinition = 30009 export const BadgeDefinition = 30009
export type BadgeDefinition = typeof BadgeDefinition
export const Interestsets = 30015 export const Interestsets = 30015
export type Interestsets = typeof Interestsets
export const CreateOrUpdateStall = 30017 export const CreateOrUpdateStall = 30017
export type CreateOrUpdateStall = typeof CreateOrUpdateStall
export const CreateOrUpdateProduct = 30018 export const CreateOrUpdateProduct = 30018
export type CreateOrUpdateProduct = typeof CreateOrUpdateProduct
export const LongFormArticle = 30023 export const LongFormArticle = 30023
export type LongFormArticle = typeof LongFormArticle
export const DraftLong = 30024 export const DraftLong = 30024
export type DraftLong = typeof DraftLong
export const Emojisets = 30030 export const Emojisets = 30030
export type Emojisets = typeof Emojisets
export const Application = 30078 export const Application = 30078
export type Application = typeof Application
export const LiveEvent = 30311 export const LiveEvent = 30311
export type LiveEvent = typeof LiveEvent
export const UserStatuses = 30315 export const UserStatuses = 30315
export type UserStatuses = typeof UserStatuses
export const ClassifiedListing = 30402 export const ClassifiedListing = 30402
export type ClassifiedListing = typeof ClassifiedListing
export const DraftClassifiedListing = 30403 export const DraftClassifiedListing = 30403
export type DraftClassifiedListing = typeof DraftClassifiedListing
export const Date = 31922 export const Date = 31922
export type Date = typeof Date
export const Time = 31923 export const Time = 31923
export type Time = typeof Time
export const Calendar = 31924 export const Calendar = 31924
export type Calendar = typeof Calendar
export const CalendarEventRSVP = 31925 export const CalendarEventRSVP = 31925
export type CalendarEventRSVP = typeof CalendarEventRSVP
export const Handlerrecommendation = 31989 export const Handlerrecommendation = 31989
export type Handlerrecommendation = typeof Handlerrecommendation
export const Handlerinformation = 31990 export const Handlerinformation = 31990
export type Handlerinformation = typeof Handlerinformation
export const CommunityDefinition = 34550 export const CommunityDefinition = 34550
export type CommunityDefinition = typeof CommunityDefinition

View File

@@ -1,18 +1,44 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, queryProfile } from './nip05.ts' import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
test('validate NIP05_REGEX', () => {
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
expect(isNip05('bob@bob.com.br')).toBeTrue()
expect(isNip05('b&b@bob.com.br')).toBeFalse()
})
test('fetch nip05 profiles', async () => { test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch) const fetchStub = async (url: string) => ({
status: 200,
async json() {
return {
'https://compile-error.net/.well-known/nostr.json?name=_': {
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
},
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
relays: {
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
'wss://pyramid.fiatjaf.com',
'wss://nos.lol',
],
},
},
}[url]
},
})
let p1 = await queryProfile('jb55.com') useFetchImplementation(fetchStub)
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com') let p2 = await queryProfile('compile-error.net')
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245') expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com') let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d') expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')

View File

@@ -1,5 +1,7 @@
import { ProfilePointer } from './nip19.ts' import { ProfilePointer } from './nip19.ts'
export type Nip05 = `${string}@${string}`
/** /**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise. * NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
* *
@@ -8,21 +10,28 @@ import { ProfilePointer } from './nip19.ts'
* - 2: domain * - 2: domain
*/ */
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/ export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
var _fetch: any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let _fetch: any
try { try {
_fetch = fetch _fetch = fetch
} catch {} } catch (_) {
null
}
export function useFetchImplementation(fetchImplementation: any) { export function useFetchImplementation(fetchImplementation: unknown) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> { export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
try { try {
const url = `https://${domain}/.well-known/nostr.json?name=${query}` const url = `https://${domain}/.well-known/nostr.json?name=${query}`
const res = await _fetch(url, { redirect: 'error' }) const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json() const json = await res.json()
return json.names return json.names
} catch (_) { } catch (_) {
@@ -34,20 +43,24 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
const match = fullname.match(NIP05_REGEX) const match = fullname.match(NIP05_REGEX)
if (!match) return null if (!match) return null
const [_, name = '_', domain] = match const [, name = '_', domain] = match
try { try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}` const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json() const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
let pubkey = res.names[name] const pubkey = json.names[name]
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
} catch (_e) { } catch (_e) {
return null return null
} }
} }
export async function isValid(pubkey: string, nip05: string): Promise<boolean> { export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
let res = await queryProfile(nip05) const res = await queryProfile(nip05)
return res ? res.pubkey === pubkey : false return res ? res.pubkey === pubkey : false
} }

View File

@@ -1,28 +1,77 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { privateKeyFromSeedWords } from './nip06.ts' import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey,
} from './nip06.ts'
import { hexToBytes } from '@noble/hashes/utils'
test('generate private key from a mnemonic', async () => { test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic) const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2') expect(privateKey).toEqual(hexToBytes('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'))
}) })
test('generate private key for account 1 from a mnemonic', async () => { test('generate private key for account 1 from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1) const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b') expect(privateKey).toEqual(hexToBytes('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b'))
}) })
test('generate private key from a mnemonic and passphrase', async () => { test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase) const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4') expect(privateKey).toEqual(hexToBytes('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'))
}) })
test('generate private key for account 1 from a mnemonic and passphrase', async () => { test('generate private key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1) const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135') expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
})
test('generate private and public key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const { privateKey, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
})
test('generate extended keys from mnemonic', () => {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const passphrase = ''
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(
mnemonic,
passphrase,
extendedAccountIndex,
)
expect(privateExtendedKey).toBe(
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH',
)
expect(publicExtendedKey).toBe(
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN',
)
})
test('generate account from extended private key', () => {
const xprv =
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
const { privateKey, publicKey } = accountFromExtendedKey(xprv)
expect(privateKey).toEqual(hexToBytes('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731'))
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
})
test('generate account from extended public key', () => {
const xpub =
'xpub6D6V5EX8HTe95getx2tTH2QApmrA1nPJFEnneAK813RjcDdSc3WaAF7BRNpTF7o7zXjVm3DD3VMX66jhQ7wLaZ9sS6NzyfiwfzqDZbxvpDN'
const { publicKey } = accountFromExtendedKey(xpub)
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
}) })

View File

@@ -3,11 +3,67 @@ import { wordlist } from '@scure/bip39/wordlists/english'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39' import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { HDKey } from '@scure/bip32' import { HDKey } from '@scure/bip32'
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string { const DERIVATION_PATH = `m/44'/1237'`
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)) let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key') if (!privateKey) throw new Error('could not derive private key')
return bytesToHex(privateKey) return privateKey
}
export function accountFromSeedWords(
mnemonic: string,
passphrase?: string,
accountIndex = 0,
): {
privateKey: Uint8Array
publicKey: string
} {
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
const publicKey = bytesToHex(seed.publicKey!.slice(1))
const privateKey = seed.privateKey
if (!privateKey || !publicKey) {
throw new Error('could not derive key pair')
}
return { privateKey, publicKey }
}
export function extendedKeysFromSeedWords(
mnemonic: string,
passphrase?: string,
extendedAccountIndex = 0,
): {
privateExtendedKey: string
publicExtendedKey: string
} {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let seed = root.derive(`${DERIVATION_PATH}/${extendedAccountIndex}'`)
let privateExtendedKey = seed.privateExtendedKey
let publicExtendedKey = seed.publicExtendedKey
if (!privateExtendedKey && !publicExtendedKey) throw new Error('could not derive extended key pair')
return { privateExtendedKey, publicExtendedKey }
}
export function accountFromExtendedKey(
base58key: string,
accountIndex = 0,
): {
privateKey?: Uint8Array
publicKey: string
} {
let extendedKey = HDKey.fromExtendedKey(base58key)
let version = base58key.slice(0, 4)
let child = extendedKey.deriveChild(0).deriveChild(accountIndex)
let publicKey = bytesToHex(child.publicKey!.slice(1))
if (!publicKey) throw new Error('could not derive public key')
if (version === 'xprv') {
let privateKey = child.privateKey!
if (!privateKey) throw new Error('could not derive private key')
return { privateKey, publicKey }
}
return { publicKey }
} }
export function generateSeedWords(): string { export function generateSeedWords(): string {

14
nip07.ts Normal file
View File

@@ -0,0 +1,14 @@
import { EventTemplate, NostrEvent } from './core.ts'
export interface WindowNostr {
getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<NostrEvent>
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
}

View File

@@ -5,20 +5,21 @@ describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => { test('legacy + a lot of events', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -55,33 +56,80 @@ describe('parse NIP10-referenced events', () => {
relays: [], relays: [],
}, },
], ],
reply: { root: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d', id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [], relays: [],
}, },
root: { reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [], relays: [],
}, },
}) })
}) })
test('legacy + 3 events', () => { test('modern', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern, inverted, author hint', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
'wss://banana.com',
'root',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [], relays: [],
}, },
], ],
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
}, },
{ {
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [], relays: ['wss://banana.com', 'wss://goiaba.com'],
}, },
], ],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: ['wss://banana.com', 'wss://goiaba.com'],
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
},
reply: { reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [], relays: [],
}, },
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
}) })
}) })
test('legacy + 2 events', () => { test('1 event, relay hint from author', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'], ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'], [
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'], 'e',
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'], '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], '',
'root',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [], mentions: [],
profiles: [ profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{ {
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [], relays: ['wss://banana.com'],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
}, },
], ],
reply: { reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590', id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: [], relays: ['wss://banana.com'],
},
root: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
}, },
}) })
}) })
test('recommended + 1 event', () => { test('many p 1 reply', () => {
let event = { let event = {
tags: [ tags: [
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'], ['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'], ['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'], ['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
reply: { reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'], relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
}, },
root: undefined,
}) })
}) })
}) })

152
nip10.ts
View File

@@ -1,7 +1,7 @@
import type { Event } from './core.ts' import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts' import type { EventPointer, ProfilePointer } from './nip19.ts'
export type NIP10Result = { export function parse(event: Pick<Event, 'tags'>): {
/** /**
* Pointer to the root of the thread. * Pointer to the root of the thread.
*/ */
@@ -13,29 +13,80 @@ export type NIP10Result = {
reply: EventPointer | undefined reply: EventPointer | undefined
/** /**
* Pointers to events which may or may not be in the reply chain. * Pointers to events that may or may not be in the reply chain.
*/ */
mentions: EventPointer[] mentions: EventPointer[]
/**
* Pointers to events that were directly quoted.
*/
quotes: EventPointer[]
/** /**
* List of pubkeys that are involved in the thread in no particular order. * List of pubkeys that are involved in the thread in no particular order.
*/ */
profiles: ProfilePointer[] profiles: ProfilePointer[]
} } {
const result: ReturnType<typeof parse> = {
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined, reply: undefined,
root: undefined, root: undefined,
mentions: [], mentions: [],
profiles: [], profiles: [],
quotes: [],
} }
const eTags: string[][] = [] let maybeParent: EventPointer | undefined
let maybeRoot: EventPointer | undefined
for (let i = event.tags.length - 1; i >= 0; i--) {
const tag = event.tags[i]
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) { if (tag[0] === 'e' && tag[1]) {
eTags.push(tag) const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
string,
string,
undefined | string,
undefined | string,
undefined | string,
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
author: eTagAuthor,
}
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (!maybeParent) {
maybeParent = eventPointer
} else {
maybeRoot = eventPointer
}
result.mentions.push(eventPointer)
continue
}
if (tag[0] === 'q' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
result.quotes.push({
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
})
} }
if (tag[0] === 'p' && tag[1]) { if (tag[0] === 'p' && tag[1]) {
@@ -43,49 +94,54 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
pubkey: tag[1], pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [], relays: tag[2] ? [tag[2]] : [],
}) })
continue
} }
} }
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) { // get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
const eTag = eTags[eTagIndex] if (!result.root) {
result.root = maybeRoot || maybeParent || result.reply
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
} }
if (!result.reply) {
result.reply = maybeParent || result.root
}
// remove root and reply from mentions, inherit relay hints from authors if any
;[result.reply, result.root].forEach(ref => {
if (!ref) return
let idx = result.mentions.indexOf(ref)
if (idx !== -1) {
result.mentions.splice(idx, 1)
}
if (ref.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
result.mentions.forEach(ref => {
if (ref!.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
return result return result
} }

View File

@@ -10,7 +10,9 @@ describe('requesting relay as for NIP11', () => {
const info = await fetchRelayInformation('wss://nos.lol') const info = await fetchRelayInformation('wss://nos.lol')
expect(info.name).toEqual('nos.lol') expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.') expect(info.description).toContain('Generally accepts notes, except spammy ones.')
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40]) expect(info.supported_nips).toContain(1)
expect(info.supported_nips).toContain(11)
expect(info.supported_nips).toContain(70)
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git') expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
}) })
}) })

View File

@@ -68,7 +68,7 @@ export interface BasicRelayInformation {
* from `[` to `]` and is after UTF-8 serialization (so some * from `[` to `]` and is after UTF-8 serialization (so some
* unicode characters will cost 2-3 bytes). It is equal to * unicode characters will cost 2-3 bytes). It is equal to
* the maximum size of the WebSocket message frame. * the maximum size of the WebSocket message frame.
* @param max_subscription total number of subscriptions * @param max_subscriptions total number of subscriptions
* that may be active on a single websocket connection to * that may be active on a single websocket connection to
* this relay. It's possible that authenticated clients with * this relay. It's possible that authenticated clients with
* a (paid) relationship to the relay may have higher limits. * a (paid) relationship to the relay may have higher limits.
@@ -101,12 +101,17 @@ export interface BasicRelayInformation {
* authentication to happen before a new connection may * authentication to happen before a new connection may
* perform any other action. Even if set to False, * perform any other action. Even if set to False,
* authentication may be required for specific actions. * authentication may be required for specific actions.
* @param restricted_writes: this relay requires some kind
* of condition to be fulfilled in order to accept events
* (not necessarily, but including
* @param payment_required this relay requires payment * @param payment_required this relay requires payment
* before a new connection may perform any action. * before a new connection may perform any action.
* @param created_at_lower_limit: 'created_at' lower limit
* @param created_at_upper_limit: 'created_at' upper limit
*/ */
export interface Limitations { export interface Limitations {
max_message_length: number max_message_length: number
max_subscription: number max_subscriptions: number
max_filters: number max_filters: number
max_limit: number max_limit: number
max_subid_length: number max_subid_length: number
@@ -116,9 +121,12 @@ export interface Limitations {
min_pow_difficulty: number min_pow_difficulty: number
auth_required: boolean auth_required: boolean
payment_required: boolean payment_required: boolean
created_at_lower_limit: number
created_at_upper_limit: number
restricted_writes: boolean
} }
interface RetentionDetails { export interface RetentionDetails {
kinds: (number | number[])[] kinds: (number | number[])[]
time?: number | null time?: number | null
count?: number | null count?: number | null

View File

@@ -2,9 +2,14 @@ import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts' import { getPow, minePow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => { test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358' ;[
const difficulty = getPow(id) ['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
expect(difficulty).toEqual(21) ['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
}) })
test('mines POW for an event', async () => { test('mines POW for an event', async () => {

View File

@@ -1,15 +1,19 @@
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts' import { bytesToHex } from '@noble/hashes/utils'
import { type UnsignedEvent, type Event } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
/** Get POW difficulty from a Nostr hex ID. */ /** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number { export function getPow(hex: string): number {
let count = 0 let count = 0
for (let i = 0; i < hex.length; i++) { for (let i = 0; i < 64; i += 8) {
const nibble = parseInt(hex[i], 16) const nibble = parseInt(hex.substring(i, i + 8), 16)
if (nibble === 0) { if (nibble === 0) {
count += 4 count += 32
} else { } else {
count += Math.clz32(nibble) - 28 count += Math.clz32(nibble)
break break
} }
} }
@@ -20,8 +24,6 @@ export function getPow(hex: string): number {
/** /**
* Mine an event with the desired POW. This function mutates the event. * Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread. * Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
*
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
*/ */
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> { export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
let count = 0 let count = 0
@@ -41,7 +43,7 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
tag[1] = (++count).toString() tag[1] = (++count).toString()
event.id = getEventHash(event) event.id = fastEventHash(event)
if (getPow(event.id) >= difficulty) { if (getPow(event.id) >= difficulty) {
break break
@@ -50,3 +52,9 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
return event return event
} }
export function fastEventHash(evt: UnsignedEvent): string {
return bytesToHex(
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
)
}

97
nip17.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import { test, expect } from 'bun:test'
import { getPublicKey } from './pure.ts'
import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
const recipients = [
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
]
const message = 'Hello, this is a direct message!'
const conversationTitle = 'Private Group Conversation' // Optional
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
[Symbol('verified')]: true,
}
expect(wrappedEvent.kind).toEqual(expected.kind)
expect(wrappedEvent.tags).toEqual(expected.tags)
})
test('wrapManyEvents', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729560014,
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 14,
content: 'Hello, this is a direct message!',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
['e', 'previousEventId123', '', 'reply'],
['subject', 'Private Group Conversation'],
],
}
const result = unwrapEvent(wrappedEvent, sk1)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})

77
nip17.ts Normal file
View File

@@ -0,0 +1,77 @@
import { PrivateDirectMessage } from './kinds.ts'
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
import * as nip59 from './nip59.ts'
type Recipient = {
publicKey: string
relayUrl?: string
}
type ReplyTo = {
eventId: string
relayUrl?: string
}
function createEvent(
recipients: Recipient | Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): EventTemplate {
const baseEvent: EventTemplate = {
created_at: Math.ceil(Date.now() / 1000),
kind: PrivateDirectMessage,
tags: [],
content: message,
}
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
recipientsArray.forEach(({ publicKey, relayUrl }) => {
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
})
if (replyTo) {
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
}
if (conversationTitle) {
baseEvent.tags.push(['subject', conversationTitle])
}
return baseEvent
}
export function wrapEvent(
senderPrivateKey: Uint8Array,
recipient: Recipient,
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent {
const event = createEvent(recipient, message, conversationTitle, replyTo)
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
}
export function wrapManyEvents(
senderPrivateKey: Uint8Array,
recipients: Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent[] {
if (!recipients || recipients.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
// wrap the event for the sender and then for each recipient
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
)
}
export const unwrapEvent = nip59.unwrapEvent
export const unwrapManyEvents = nip59.unwrapManyEvents

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts' import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { Repost, ShortTextNote } from './kinds.ts' import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts' import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
}) })
}) })
describe('GenericRepost', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const eventTemplate: EventTemplate = {
content: '',
created_at: 1617932114,
kind: BadgeDefinitionKind,
tags: [
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
],
}
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
test('should create a generic reposted event', () => {
const template = { created_at: 1617932115 }
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(GenericRepost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
['k', '30009'],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
})
describe('getRepostedEventPointer', () => { describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => { test('should parse an event with only an `e` tag', () => {
const event = buildEvent({ const event = buildEvent({
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
expect(repostedEventPointer!.relays).toEqual([relayUrl]) expect(repostedEventPointer!.relays).toEqual([relayUrl])
}) })
}) })
describe('finishRepostEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
test('should create an event with empty content if the reposted event is protected', () => {
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [['-']],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.content).toBe('')
})
})

View File

@@ -1,6 +1,6 @@
import { Event, finalizeEvent, verifyEvent } from './pure.ts' import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
import { Repost } from './kinds.ts'
import { EventPointer } from './nip19.ts' import { EventPointer } from './nip19.ts'
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
export type RepostEventTemplate = { export type RepostEventTemplate = {
/** /**
@@ -25,11 +25,20 @@ export function finishRepostEvent(
relayUrl: string, relayUrl: string,
privateKey: Uint8Array, privateKey: Uint8Array,
): Event { ): Event {
let kind: Repost | GenericRepost
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
if (reposted.kind === ShortTextNote) {
kind = Repost
} else {
kind = GenericRepost
tags.push(['k', String(reposted.kind)])
}
return finalizeEvent( return finalizeEvent(
{ {
kind: Repost, kind,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]], tags,
content: t.content === '' ? '' : JSON.stringify(reposted), content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
created_at: t.created_at, created_at: t.created_at,
}, },
privateKey, privateKey,
@@ -37,7 +46,7 @@ export function finishRepostEvent(
} }
export function getRepostedEventPointer(event: Event): undefined | EventPointer { export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Repost) { if (![Repost, GenericRepost].includes(event.kind)) {
return undefined return undefined
} }

View File

@@ -1,16 +1,16 @@
import { test, expect } from 'bun:test' import { test, expect, describe } from 'bun:test'
import { generateSecretKey, getPublicKey } from './pure.ts' import { generateSecretKey, getPublicKey } from './pure.ts'
import { import {
decode, decode,
naddrEncode, naddrEncode,
nprofileEncode, nprofileEncode,
npubEncode, npubEncode,
nrelayEncode,
nsecEncode, nsecEncode,
neventEncode, neventEncode,
type AddressPointer, type AddressPointer,
type ProfilePointer, type ProfilePointer,
EventPointer, EventPointer,
NostrTypeGuard,
} from './nip19.ts' } from './nip19.ts'
test('encode and decode nsec', () => { test('encode and decode nsec', () => {
@@ -153,11 +153,134 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
expect(pointer.identifier).toEqual('banana') expect(pointer.identifier).toEqual('banana')
}) })
test('encode and decode nrelay', () => { describe('NostrTypeGuard', () => {
let url = 'wss://relay.nostr.example' test('isNProfile', () => {
let nrelay = nrelayEncode(url) const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(nrelay).toMatch(/nrelay1\w+/)
let { type, data } = decode(nrelay) expect(is).toBeTrue()
expect(type).toEqual('nrelay') })
expect(data).toEqual(url)
test('isNProfile invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
expect(is).toBeFalse()
})
test('isNProfile with invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNEvent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
)
expect(is).toBeTrue()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
)
expect(is).toBeFalse()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNAddr', () => {
const is = NostrTypeGuard.isNAddr(
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
)
expect(is).toBeTrue()
})
test('isNAddr with invalid nadress', () => {
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNSec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeTrue()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
expect(is).toBeFalse()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNPub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeTrue()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNote', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
expect(is).toBeTrue()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNcryptsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeTrue()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeFalse()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
}) })

View File

@@ -3,6 +3,24 @@ import { bech32 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts' import { utf8Decoder, utf8Encoder } from './utils.ts'
export type NProfile = `nprofile1${string}`
export type NEvent = `nevent1${string}`
export type NAddr = `naddr1${string}`
export type NSec = `nsec1${string}`
export type NPub = `npub1${string}`
export type Note = `note1${string}`
export type Ncryptsec = `ncryptsec1${string}`
export const NostrTypeGuard = {
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
}
export const Bech32MaxSize = 5000 export const Bech32MaxSize = 5000
/** /**
@@ -45,7 +63,6 @@ export type AddressPointer = {
type Prefixes = { type Prefixes = {
nprofile: ProfilePointer nprofile: ProfilePointer
nrelay: string
nevent: EventPointer nevent: EventPointer
naddr: AddressPointer naddr: AddressPointer
nsec: Uint8Array nsec: Uint8Array
@@ -62,6 +79,15 @@ export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P> [P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes] }[keyof Prefixes]
export function decodeNostrURI(nip19code: string): DecodeResult | { type: 'invalid'; data: null } {
try {
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
return decode(nip19code)
} catch (_err) {
return { type: 'invalid', data: null }
}
}
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix> export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult { export function decode(nip19: string): DecodeResult {
@@ -119,16 +145,6 @@ export function decode(nip19: string): DecodeResult {
} }
} }
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0]),
}
}
case 'nsec': case 'nsec':
return { type: prefix, data } return { type: prefix, data }
@@ -158,15 +174,15 @@ function parseTLV(data: Uint8Array): TLV {
return result return result
} }
export function nsecEncode(key: Uint8Array): `nsec1${string}` { export function nsecEncode(key: Uint8Array): NSec {
return encodeBytes('nsec', key) return encodeBytes('nsec', key)
} }
export function npubEncode(hex: string): `npub1${string}` { export function npubEncode(hex: string): NPub {
return encodeBytes('npub', hexToBytes(hex)) return encodeBytes('npub', hexToBytes(hex))
} }
export function noteEncode(hex: string): `note1${string}` { export function noteEncode(hex: string): Note {
return encodeBytes('note', hexToBytes(hex)) return encodeBytes('note', hexToBytes(hex))
} }
@@ -179,7 +195,7 @@ export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8A
return encodeBech32(prefix, bytes) return encodeBech32(prefix, bytes)
} }
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` { export function nprofileEncode(profile: ProfilePointer): NProfile {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(profile.pubkey)], 0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)), 1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
@@ -187,7 +203,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
return encodeBech32('nprofile', data) return encodeBech32('nprofile', data)
} }
export function neventEncode(event: EventPointer): `nevent1${string}` { export function neventEncode(event: EventPointer): NEvent {
let kindArray let kindArray
if (event.kind !== undefined) { if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind) kindArray = integerToUint8Array(event.kind)
@@ -203,7 +219,7 @@ export function neventEncode(event: EventPointer): `nevent1${string}` {
return encodeBech32('nevent', data) return encodeBech32('nevent', data)
} }
export function naddrEncode(addr: AddressPointer): `naddr1${string}` { export function naddrEncode(addr: AddressPointer): NAddr {
let kind = new ArrayBuffer(4) let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false) new DataView(kind).setUint32(0, addr.kind, false)
@@ -216,13 +232,6 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
return encodeBech32('naddr', data) return encodeBech32('naddr', data)
} }
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)],
})
return encodeBech32('nrelay', data)
}
function encodeTLV(tlv: TLV): Uint8Array { function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = [] let entries: Uint8Array[] = []

View File

@@ -1,68 +1,77 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip27.ts' import { parse } from './nip27.ts'
test('matchAll', () => { test('first: parse simple content with 1 url and 1 nostr uri', () => {
const result = matchAll( const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', const blocks = Array.from(parse(content))
)
expect([...result]).toEqual([ expect(blocks).toEqual([
{ { type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'text', text: ' check out my profile:' },
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
decoded: { { type: 'text', text: '; and this cool image ' },
type: 'npub', { type: 'image', url: 'https://images.com/image.jpg' },
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
start: 6,
end: 75,
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
start: 78,
end: 147,
},
]) ])
}) })
test('matchAll with an invalid nip19', () => { test('second: parse content with 3 urls of different types', () => {
const result = matchAll( const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', http://music.com/song.mp3
) and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
const blocks = Array.from(parse(content))
expect([...result]).toEqual([ expect(blocks).toEqual([
{ type: 'text', text: ':' },
{ type: 'relay', url: 'wss://oa.ao/' },
{ type: 'text', text: "; this was a relay and now here's a video -> " },
{ type: 'video', url: 'https://videos.com/video.mp4' },
{ type: 'text', text: '! and some music:\n' },
{ type: 'audio', url: 'http://music.com/song.mp3' },
{ type: 'text', text: '\nand a regular link: ' },
{ type: 'url', url: 'https://regular.com/page?ok=true' },
{ {
decoded: { type: 'text',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
type: 'note',
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
}, },
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
]) ])
}) })
test('replaceAll', () => { test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const content = const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
with a video https://example.com/vid.webm and finally https://example.com/docs`
const blocks = Array.from(parse(content))
const result = replaceAll(content, ({ decoded, value }) => { expect(blocks).toEqual([
switch (decoded.type) { { type: 'text', text: 'Look at these profiles ' },
case 'npub': { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
return '@alex' { type: 'text', text: ' ' },
case 'note': {
return '!1234' type: 'reference',
default: pointer: {
return value pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
} relays: ['wss://qwieu.com'],
}) },
},
expect(result).toEqual('Hello @alex!\n\n!1234') { type: 'text', text: ' check this event ' },
{
type: 'reference',
pointer: {
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
relays: ['wss://zjbdksa.aswjdkn'],
author: undefined,
kind: undefined,
},
},
{ type: 'text', text: "\n here's an image " },
{ type: 'image', url: 'https://example.com/pic.png' },
{ type: 'text', text: ' and another profile ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '\n with a video ' },
{ type: 'video', url: 'https://example.com/vid.webm' },
{ type: 'text', text: ' and finally ' },
{ type: 'url', url: 'https://example.com/docs' },
])
}) })

212
nip27.ts
View File

@@ -1,63 +1,169 @@
import { decode } from './nip19.ts' import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */ export type Block =
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') | {
type: 'text'
text: string
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
}
| {
type: 'url'
url: string
}
| {
type: 'relay'
url: string
}
| {
type: 'image'
url: string
}
| {
type: 'video'
url: string
}
| {
type: 'audio'
url: string
}
/** Match result for a Nostr URI in event content. */ const noCharacter = /\W/m
export interface NostrURIMatch extends NostrURI { const noURLCharacter = /\W |\W$|$|,| /m
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */ export function* parse(content: string): Iterable<Block> {
export function* matchAll(content: string): Iterable<NostrURIMatch> { const max = content.length
const matches = content.matchAll(regex()) let prevIndex = 0
let index = 0
while (index < max) {
let u = content.indexOf(':', index)
if (u === -1) {
// reached end
break
}
for (const match of matches) { if (content.substring(u - 5, u) === 'nostr') {
try { const m = content.substring(u + 60).match(noCharacter)
const [uri, value] = match const end = m ? u + 60 + m.index! : max
try {
let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.substring(u + 1, end))
yield { switch (type) {
uri: uri as `nostr:${string}`, case 'npub':
value, pointer = { pubkey: data } as ProfilePointer
decoded: decode(value), break
start: match.index!, case 'nsec':
end: match.index! + uri.length, case 'note':
// ignore this, treat it as not a valid uri
index = end + 1
continue
default:
pointer = data as any
}
if (prevIndex !== u - 5) {
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
}
yield { type: 'reference', pointer }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid nostr uri
index = u + 1
continue
} }
} catch (_e) { } else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
// do nothing const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 5 : 4
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index
continue
}
yield { type: 'url', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 3 : 2
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid ws url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
yield { type: 'relay', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else {
// ignore this, it is nothing
index = u + 1
continue
} }
} }
}
/** if (prevIndex !== max) {
* Replace all occurrences of Nostr URIs in the text. yield { type: 'text', text: content.substring(prevIndex) }
* }
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
})
})
} }

View File

@@ -1,5 +1,11 @@
import { Event, finalizeEvent } from './pure.ts' import { Event, finalizeEvent } from './pure.ts'
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts' import {
ChannelCreation,
ChannelHideMessage,
ChannelMessage,
ChannelMetadata as KindChannelMetadata,
ChannelMuteUser,
} from './kinds.ts'
export interface ChannelMetadata { export interface ChannelMetadata {
name: string name: string
@@ -78,7 +84,7 @@ export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey
return finalizeEvent( return finalizeEvent(
{ {
kind: ChannelMetadata, kind: KindChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])], tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content, content: content,
created_at: t.created_at, created_at: t.created_at,

692
nip29.ts
View File

@@ -1,80 +1,522 @@
import { AbstractSimplePool } from './abstract-pool.ts' import { AbstractSimplePool } from './abstract-pool.ts'
import { Subscription } from './abstract-relay.ts' import { Subscription } from './abstract-relay.ts'
import { decode } from './nip19.ts' import type { Event, EventTemplate } from './core.ts'
import type { Event } from './core.ts' import { fetchRelayInformation, RelayInformation } from './nip11.ts'
import { fetchRelayInformation } from './nip11.ts' import { AddressPointer, decode } from './nip19.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
import { AddressPointer } from './nip19.ts'
export function subscribeRelayGroups( /**
pool: AbstractSimplePool, * Represents a NIP29 group.
url: string, */
params: { export type Group = {
ongroups: (_: Group[]) => void relay: string
onerror: (_: Error) => void metadata: GroupMetadata
onconnect?: () => void admins?: GroupAdmin[]
}, members?: GroupMember[]
): () => void { reference: GroupReference
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) * Represents the metadata for a NIP29 group.
*/
let info = await fetchRelayInformation(normalized) export type GroupMetadata = {
let event = await pool.get([normalized], { id: string
kinds: [39000], pubkey: string
authors: [info.pubkey], name?: string
'#d': [gr.id], picture?: string
}) about?: string
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`) isPublic?: boolean
return parseGroup(event, normalized) isOpen?: boolean
}
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)
} }
/**
* Represents a NIP29 group reference.
*/
export type GroupReference = { export type GroupReference = {
id: string id: string
host: string host: string
} }
/**
* Represents a NIP29 group member.
*/
export type GroupMember = {
pubkey: string
label?: string
}
/**
* Represents a NIP29 group admin.
*/
export type GroupAdmin = {
pubkey: string
label?: string
permissions: GroupAdminPermission[]
}
/**
* Represents the permissions that a NIP29 group admin can have.
*/
export enum GroupAdminPermission {
/** @deprecated use PutUser instead */
AddUser = 'add-user',
EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event',
RemoveUser = 'remove-user',
/** @deprecated removed from NIP */
AddPermission = 'add-permission',
/** @deprecated removed from NIP */
RemovePermission = 'remove-permission',
/** @deprecated removed from NIP */
EditGroupStatus = 'edit-group-status',
PutUser = 'put-user',
CreateGroup = 'create-group',
DeleteGroup = 'delete-group',
CreateInvite = 'create-invite',
}
/**
* Generates a group metadata event template.
*
* @param group - The group object.
* @returns An event template with the generated group metadata that can be signed later.
*/
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
group.metadata.name && tags.push(['name', group.metadata.name])
group.metadata.picture && tags.push(['picture', group.metadata.picture])
group.metadata.about && tags.push(['about', group.metadata.about])
group.metadata.isPublic && tags.push(['public'])
group.metadata.isOpen && tags.push(['open'])
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39000,
tags,
}
}
/**
* Validates a group metadata event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is valid.
*/
export function validateGroupMetadataEvent(event: Event): boolean {
if (event.kind !== 39000) return false
if (!event.pubkey) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an event template for group admins.
*
* @param group - The group object.
* @param admins - An array of group admins.
* @returns The generated event template with the group admins that can be signed later.
*/
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const admin of admins) {
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39001,
tags,
}
}
/**
* Validates a group admins event.
*
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateGroupAdminsEvent(event: Event): boolean {
if (event.kind !== 39001) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate permissions
for (const [tag, _value, _label, ...permissions] of event.tags) {
if (tag !== 'p') continue
for (let i = 0; i < permissions.length; i += 1) {
if (typeof permissions[i] !== 'string') return false
// validate permission name from the GroupAdminPermission enum
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
}
}
return true
}
/**
* Generates an event template for a group with its members.
*
* @param group - The group object.
* @param members - An array of group members.
* @returns The generated event template with the group members that can be signed later.
*/
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const member of members) {
tags.push(['p', member.pubkey, member.label || ''])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39002,
tags,
}
}
/**
* Validates a group members event.
*
* @param event - The event to validate.
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
*/
export function validateGroupMembersEvent(event: Event): boolean {
if (event.kind !== 39002) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Returns the normalized relay URL based on the provided group reference.
*
* @param groupReference - The group reference object containing the host.
* @returns The normalized relay URL.
*/
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
return normalizeURL(groupReference.host)
}
/**
* Fetches relay information by group reference.
*
* @param groupReference The group reference.
* @returns A promise that resolves to the relay information.
*/
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
return fetchRelayInformation(normalizedRelayURL)
}
/**
* Fetches the group metadata event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
* @throws {Error} If the group is not found on the specified relay.
*/
export async function fetchGroupMetadataEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
kinds: [39000],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMetadataEvent
}
/**
* Parses a group metadata event and returns the corresponding GroupMetadata object.
*
* @param event - The event to parse.
* @returns The parsed GroupMetadata object.
* @throws An error if the group metadata event is invalid.
*/
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
const metadata: GroupMetadata = {
id: '',
pubkey: event.pubkey,
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'd':
metadata.id = value
break
case 'name':
metadata.name = value
break
case 'picture':
metadata.picture = value
break
case 'about':
metadata.about = value
break
case 'public':
metadata.isPublic = true
break
case 'open':
metadata.isOpen = true
break
}
}
return metadata
}
/**
* Fetches the group admins event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information.
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
* @throws {Error} If the group admins event is not found on the specified relay.
*/
export async function fetchGroupAdminsEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
kinds: [39001],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupAdminsEvent
}
/**
* Parses a group admins event and returns an array of GroupAdmin objects.
*
* @param event - The event to parse.
* @returns An array of GroupAdmin objects.
* @throws Throws an error if the group admins event is invalid.
*/
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
const admins: GroupAdmin[] = []
for (const [tag, value, label, ...permissions] of event.tags) {
if (tag !== 'p') continue
admins.push({
pubkey: value,
label,
permissions: permissions as GroupAdminPermission[],
})
}
return admins
}
/**
* Fetches the group members event from the specified relay.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool object.
* @param {GroupReference} options.groupReference - The group reference object.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
* @throws {Error} If the group members event is not found.
*/
export async function fetchGroupMembersEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMembersEvent = await pool.get([normalizedRelayURL], {
kinds: [39002],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMembersEvent
}
/**
* Parses a group members event and returns an array of GroupMember objects.
* @param event - The event to parse.
* @returns An array of GroupMember objects.
* @throws Throws an error if the group members event is invalid.
*/
export function parseGroupMembersEvent(event: Event): GroupMember[] {
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
const members: GroupMember[] = []
for (const [tag, value, label] of event.tags) {
if (tag !== 'p') continue
members.push({
pubkey: value,
label,
})
}
return members
}
/**
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
* If the normalized relay URL is not provided, it will be obtained using the group reference.
* If the relay information is not provided, it will be fetched using the normalized relay URL.
*
* @param {Object} options - The options for loading the group.
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
* @param {GroupReference} options.groupReference - The reference of the group to load.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
* @returns {Promise<Group>} A promise that resolves to the loaded group.
*/
export async function loadGroup({
pool,
groupReference,
normalizedRelayURL,
relayInformation,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Group> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const metadata = parseGroupMetadataEvent(metadataEvent)
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const admins = parseGroupAdminsEvent(adminsEvent)
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const members = parseGroupMembersEvent(membersEvent)
const group: Group = {
relay: normalizedRelayURL,
metadata,
admins,
members,
reference: groupReference,
}
return group
}
/**
* Loads a group from the specified pool using the provided group code.
*
* @param {AbstractSimplePool} pool - The pool to load the group from.
* @param {string} code - The code representing the group.
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
* @throws {Error} - If the group code is invalid.
*/
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
const groupReference = parseGroupCode(code)
if (!groupReference) throw new Error('invalid group code')
return loadGroup({ pool, groupReference })
}
/**
* Parses a group code and returns a GroupReference object.
*
* @param code The group code to parse.
* @returns A GroupReference object if the code is valid, otherwise null.
*/
export function parseGroupCode(code: string): null | GroupReference { export function parseGroupCode(code: string): null | GroupReference {
if (code.startsWith('naddr1')) { if (code.startsWith('naddr1')) {
try { try {
@@ -99,68 +541,74 @@ export function parseGroupCode(code: string): null | GroupReference {
return null return null
} }
/**
* Encodes a group reference into a string.
*
* @param gr - The group reference to encode.
* @returns The encoded group reference as a string.
*/
export function encodeGroupReference(gr: GroupReference): string { export function encodeGroupReference(gr: GroupReference): string {
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8) const { host, id } = gr
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6) const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
return `${gr.host}'${gr.id}`
return `${normalizedHost}'${id}`
} }
export type Group = { /**
id: string * Subscribes to relay groups metadata events and calls the provided event handler function
relay: string * when an event is received.
pubkey: string *
name?: string * @param {Object} options - The options for subscribing to relay groups metadata events.
picture?: string * @param {AbstractSimplePool} options.pool - The pool to subscribe to.
about?: string * @param {string} options.relayURL - The URL of the relay.
public?: boolean * @param {Function} options.onError - The error handler function.
open?: boolean * @param {Function} options.onEvent - The event handler function.
} * @param {Function} [options.onConnect] - The connect handler function.
* @returns {Function} - A function to close the subscription
*/
export function subscribeRelayGroupsMetadataEvents({
pool,
relayURL,
onError,
onEvent,
onConnect,
}: {
pool: AbstractSimplePool
relayURL: string
onError: (err: Error) => void
onEvent: (event: Event) => void
onConnect?: () => void
}): () => void {
let sub: Subscription
export function parseGroup(event: Event, relay: string): Group { const normalizedRelayURL = normalizeURL(relayURL)
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 = { fetchRelayInformation(normalizedRelayURL)
pubkey: string .then(async info => {
label?: string const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
permissions: string[]
}
export function parseMembers(event: Event): Member[] { onConnect?.()
const members = []
for (let i = 0; i < event.tags.length; i++) { sub = abstractedRelay.prepareSubscription(
const tag = event.tags[i] [
if (tag.length < 2) continue {
if (tag[0] !== 'p') continue kinds: [39000],
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue limit: 50,
const member: Member = { pubkey: tag[1], permissions: [] } authors: [info.pubkey],
if (tag.length > 2) member.label = tag[2] },
if (tag.length > 3) member.permissions = tag.slice(3) ],
members.push(member) {
} onevent(event: Event) {
return members onEvent(event)
},
},
)
})
.catch(err => {
sub.close()
onError(err)
})
return () => sub.close()
} }

View File

@@ -7,7 +7,7 @@ const v2vec = vec.v2
test('get_conversation_key', () => { test('get_conversation_key', () => {
for (const v of v2vec.valid.get_conversation_key) { for (const v of v2vec.valid.get_conversation_key) {
const key = v2.utils.getConversationKey(v.sec1, v.pub2) const key = v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key) expect(bytesToHex(key)).toEqual(v.conversation_key)
} }
}) })
@@ -15,7 +15,7 @@ test('get_conversation_key', () => {
test('encrypt_decrypt', () => { test('encrypt_decrypt', () => {
for (const v of v2vec.valid.encrypt_decrypt) { for (const v of v2vec.valid.encrypt_decrypt) {
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2)) const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
const key = v2.utils.getConversationKey(v.sec1, pub2) const key = v2.utils.getConversationKey(hexToBytes(v.sec1), pub2)
expect(bytesToHex(key)).toEqual(v.conversation_key) expect(bytesToHex(key)).toEqual(v.conversation_key)
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce)) const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
expect(ciphertext).toEqual(v.payload) expect(ciphertext).toEqual(v.payload)
@@ -39,6 +39,8 @@ test('decrypt', async () => {
test('get_conversation_key', async () => { test('get_conversation_key', async () => {
for (const v of v2vec.invalid.get_conversation_key) { for (const v of v2vec.invalid.get_conversation_key) {
expect(() => v2.utils.getConversationKey(v.sec1, v.pub2)).toThrow(/(Point is not on curve|Cannot find square root)/) expect(() => v2.utils.getConversationKey(hexToBytes(v.sec1), v.pub2)).toThrow(
/(Point is not on curve|Cannot find square root)/,
)
} }
}) })

221
nip44.ts
View File

@@ -4,129 +4,124 @@ import { secp256k1 } from '@noble/curves/secp256k1'
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf' import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac' import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256' import { sha256 } from '@noble/hashes/sha256'
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils' import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { base64 } from '@scure/base' import { base64 } from '@scure/base'
const decoder = new TextDecoder() import { utf8Decoder, utf8Encoder } from './utils.ts'
class u { const minPlaintextSize = 0x0001 // 1b msg => padded to 32b
static minPlaintextSize = 0x0001 // 1b msg => padded to 32b const maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
static maxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
static utf8Encode = utf8ToBytes export function getConversationKey(privkeyA: Uint8Array, pubkeyB: string): Uint8Array {
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
return hkdf_extract(sha256, sharedX, 'nip44-v2')
}
static utf8Decode(bytes: Uint8Array): string { function getMessageKeys(
return decoder.decode(bytes) conversationKey: Uint8Array,
} nonce: Uint8Array,
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } {
static getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array { const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33) return {
return hkdf_extract(sha256, sharedX, 'nip44-v2') chacha_key: keys.subarray(0, 32),
} chacha_nonce: keys.subarray(32, 44),
hmac_key: keys.subarray(44, 76),
static getMessageKeys(
conversationKey: Uint8Array,
nonce: Uint8Array,
): { chacha_key: Uint8Array; chacha_nonce: Uint8Array; hmac_key: Uint8Array } {
const keys = hkdf_expand(sha256, conversationKey, nonce, 76)
return {
chacha_key: keys.subarray(0, 32),
chacha_nonce: keys.subarray(32, 44),
hmac_key: keys.subarray(44, 76),
}
}
static calcPaddedLen(len: number): number {
if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
}
static writeU16BE(num: number): Uint8Array {
if (!Number.isSafeInteger(num) || num < u.minPlaintextSize || num > u.maxPlaintextSize)
throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
const arr = new Uint8Array(2)
new DataView(arr.buffer).setUint16(0, num, false)
return arr
}
static pad(plaintext: string): Uint8Array {
const unpadded = u.utf8Encode(plaintext)
const unpaddedLen = unpadded.length
const prefix = u.writeU16BE(unpaddedLen)
const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen)
return concatBytes(prefix, unpadded, suffix)
}
static unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen < u.minPlaintextSize ||
unpaddedLen > u.maxPlaintextSize ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + u.calcPaddedLen(unpaddedLen)
)
throw new Error('invalid padding')
return u.utf8Decode(unpadded)
}
static hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
const combined = concatBytes(aad, message)
return hmac(sha256, key, combined)
}
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
// plaintext: 1b to 0xffff
// padded plaintext: 32b to 0xffff
// ciphertext: 32b+2 to 0xffff+2
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
// compressed payload (base64): 132b to 87472b
static decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
const plen = payload.length
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
if (payload[0] === '#') throw new Error('unknown encryption version')
let data: Uint8Array
try {
data = base64.decode(payload)
} catch (error) {
throw new Error('invalid base64: ' + (error as any).message)
}
const dlen = data.length
if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
const vers = data[0]
if (vers !== 2) throw new Error('unknown encryption version ' + vers)
return {
nonce: data.subarray(1, 33),
ciphertext: data.subarray(33, -32),
mac: data.subarray(-32),
}
} }
} }
export class v2 { function calcPaddedLen(len: number): number {
static utils = u if (!Number.isSafeInteger(len) || len < 1) throw new Error('expected positive integer')
if (len <= 32) return 32
const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1)
const chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * (Math.floor((len - 1) / chunk) + 1)
}
static encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = randomBytes(32)): string { function writeU16BE(num: number): Uint8Array {
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce) if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > maxPlaintextSize)
const padded = u.pad(plaintext) throw new Error('invalid plaintext size: must be between 1 and 65535 bytes')
const ciphertext = chacha20(chacha_key, chacha_nonce, padded) const arr = new Uint8Array(2)
const mac = u.hmacAad(hmac_key, ciphertext, nonce) new DataView(arr.buffer).setUint16(0, num, false)
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac)) return arr
}
function pad(plaintext: string): Uint8Array {
const unpadded = utf8Encoder.encode(plaintext)
const unpaddedLen = unpadded.length
const prefix = writeU16BE(unpaddedLen)
const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen)
return concatBytes(prefix, unpadded, suffix)
}
function unpad(padded: Uint8Array): string {
const unpaddedLen = new DataView(padded.buffer).getUint16(0)
const unpadded = padded.subarray(2, 2 + unpaddedLen)
if (
unpaddedLen < minPlaintextSize ||
unpaddedLen > maxPlaintextSize ||
unpadded.length !== unpaddedLen ||
padded.length !== 2 + calcPaddedLen(unpaddedLen)
)
throw new Error('invalid padding')
return utf8Decoder.decode(unpadded)
}
function hmacAad(key: Uint8Array, message: Uint8Array, aad: Uint8Array): Uint8Array {
if (aad.length !== 32) throw new Error('AAD associated data must be 32 bytes')
const combined = concatBytes(aad, message)
return hmac(sha256, key, combined)
}
// metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
// plaintext: 1b to 0xffff
// padded plaintext: 32b to 0xffff
// ciphertext: 32b+2 to 0xffff+2
// raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
// compressed payload (base64): 132b to 87472b
function decodePayload(payload: string): { nonce: Uint8Array; ciphertext: Uint8Array; mac: Uint8Array } {
if (typeof payload !== 'string') throw new Error('payload must be a valid string')
const plen = payload.length
if (plen < 132 || plen > 87472) throw new Error('invalid payload length: ' + plen)
if (payload[0] === '#') throw new Error('unknown encryption version')
let data: Uint8Array
try {
data = base64.decode(payload)
} catch (error) {
throw new Error('invalid base64: ' + (error as any).message)
} }
const dlen = data.length
static decrypt(payload: string, conversationKey: Uint8Array): string { if (dlen < 99 || dlen > 65603) throw new Error('invalid data length: ' + dlen)
const { nonce, ciphertext, mac } = u.decodePayload(payload) const vers = data[0]
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce) if (vers !== 2) throw new Error('unknown encryption version ' + vers)
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce) return {
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC') nonce: data.subarray(1, 33),
const padded = chacha20(chacha_key, chacha_nonce, ciphertext) ciphertext: data.subarray(33, -32),
return u.unpad(padded) mac: data.subarray(-32),
} }
} }
export default { v2 } export function encrypt(plaintext: string, conversationKey: Uint8Array, nonce: Uint8Array = randomBytes(32)): string {
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
const padded = pad(plaintext)
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
const mac = hmacAad(hmac_key, ciphertext, nonce)
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
}
export function decrypt(payload: string, conversationKey: Uint8Array): string {
const { nonce, ciphertext, mac } = decodePayload(payload)
const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce)
const calculatedMac = hmacAad(hmac_key, ciphertext, nonce)
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
return unpad(padded)
}
export const v2 = {
utils: {
getConversationKey,
calcPaddedLen,
},
encrypt,
decrypt,
}

View File

@@ -1,11 +1,12 @@
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts' import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts' import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts' import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt, encrypt } from './nip04.ts' import { decrypt as legacyDecrypt } from './nip04.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts' import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts' import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts' import { Handlerinformation, NostrConnect } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils' import type { RelayRecord } from './relay.ts'
var _fetch: any var _fetch: any
@@ -17,7 +18,7 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%]*)$/ export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%-]*)$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export type BunkerPointer = { export type BunkerPointer = {
@@ -47,7 +48,7 @@ export async function parseBunkerInput(input: string): Promise<BunkerPointer | n
return queryBunkerProfile(input) return queryBunkerProfile(input)
} }
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> { export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX) const match = nip05.match(NIP05_REGEX)
if (!match) return null if (!match) return null
@@ -85,8 +86,11 @@ export class BunkerSigner {
} }
private waitingForAuth: { [id: string]: boolean } private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array private secretKey: Uint8Array
private conversationKey: Uint8Array
public bp: BunkerPointer public bp: BunkerPointer
private cachedPubKey: string | undefined
/** /**
* Creates a new instance of the Nip46 class. * Creates a new instance of the Nip46 class.
* @param relays - An array of relay addresses. * @param relays - An array of relay addresses.
@@ -100,6 +104,7 @@ export class BunkerSigner {
this.pool = params.pool || new SimplePool() this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp this.bp = bp
this.isOpen = false this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7) this.idPrefix = Math.random().toString(36).substring(7)
@@ -109,13 +114,21 @@ export class BunkerSigner {
const listeners = this.listeners const listeners = this.listeners
const waitingForAuth = this.waitingForAuth const waitingForAuth = this.waitingForAuth
const convKey = this.conversationKey
this.subCloser = this.pool.subscribeMany( this.subCloser = this.pool.subscribeMany(
this.bp.relays, this.bp.relays,
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }], [{ kinds: [NostrConnect], authors: [bp.pubkey], '#p': [getPublicKey(this.secretKey)] }],
{ {
async onevent(event: NostrEvent) { async onevent(event: NostrEvent) {
const { id, result, error } = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content)) let o
try {
o = JSON.parse(decrypt(event.content, convKey))
} catch (err) {
o = JSON.parse(await legacyDecrypt(clientSecretKey, event.pubkey, event.content))
}
const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) { if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id] delete waitingForAuth[id]
@@ -155,7 +168,7 @@ export class BunkerSigner {
this.serial++ this.serial++
const id = `${this.idPrefix}-${this.serial}` const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = await encrypt(this.secretKey, this.bp.pubkey, JSON.stringify({ id, method, params })) const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
// the request event // the request event
const verifiedEvent: VerifiedEvent = finalizeEvent( const verifiedEvent: VerifiedEvent = finalizeEvent(
@@ -197,17 +210,22 @@ export class BunkerSigner {
} }
/** /**
* This was supposed to call the "get_public_key" method on the bunker, * Calls the "get_public_key" method on the bunker.
* but instead we just returns the public key we already know. * (before we would return the public key hardcoded in the bunker parameters, but
* that is not correct as that may be the bunker pubkey and the actual signer
* pubkey may be different.)
*/ */
async getPublicKey(): Promise<string> { async getPublicKey(): Promise<string> {
return this.bp.pubkey if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', [])
}
return this.cachedPubKey
} }
/** /**
* Calls the "get_relays" method on the bunker. * @deprecated removed from NIP
*/ */
async getRelays(): Promise<{ [relay: string]: { read: boolean; write: boolean } }> { async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', [])) return JSON.parse(await this.sendRequest('get_relays', []))
} }
@@ -216,10 +234,10 @@ export class BunkerSigner {
* @param event - The event to sign. * @param event - The event to sign.
* @returns A Promise that resolves to the signed event. * @returns A Promise that resolves to the signed event.
*/ */
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> { async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]) let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp) let signed: NostrEvent = JSON.parse(resp)
if (signed.pubkey === this.bp.pubkey && verifyEvent(signed)) { if (verifyEvent(signed)) {
return signed return signed
} else { } else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`) throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
@@ -234,17 +252,12 @@ export class BunkerSigner {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]) 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> { async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]) return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
} }
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> { async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext]) return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
} }
} }
@@ -254,6 +267,8 @@ export class BunkerSigner {
* @param username - The username for the account. * @param username - The username for the account.
* @param domain - The domain for the account. * @param domain - The domain for the account.
* @param email - The optional email for the account. * @param email - The optional email for the account.
* @param localSecretKey - Optionally pass a local secret key that will be used to communicate with the bunker,
this will default to generating a random key.
* @throws Error if the email is present but invalid. * @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. * @returns A Promise that resolves to the auth_url that the client should follow to create an account.
*/ */
@@ -263,11 +278,11 @@ export async function createAccount(
username: string, username: string,
domain: string, domain: string,
email?: string, email?: string,
localSecretKey: Uint8Array = generateSecretKey(),
): Promise<BunkerSigner> { ): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email') if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let sk = generateSecretKey() let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
let rpc = new BunkerSigner(sk, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']) let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
@@ -279,9 +294,6 @@ export async function createAccount(
return rpc return rpc
} }
// @deprecated use fetchBunkerProviders instead
export const fetchCustodialBunkers = fetchBunkerProviders
/** /**
* Fetches info on available providers that announce themselves using NIP-89 events. * Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects. * @returns A promise that resolves to an array of available bunker objects.

View File

@@ -1,10 +1,15 @@
import { scrypt } from '@noble/hashes/scrypt' import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha' import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils' import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, encodeBytes } from './nip19.ts' import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
import { bech32 } from '@scure/base' import { bech32 } from '@scure/base'
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string { export function encrypt(
sec: Uint8Array,
password: string,
logn: number = 16,
ksb: 0x00 | 0x01 | 0x02 = 0x02,
): Ncryptsec {
let salt = randomBytes(16) let salt = randomBytes(16)
let n = 2 ** logn let n = 2 ** logn
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 }) let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })

42
nip54.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, test, expect } from 'bun:test'
import { normalizeIdentifier } from './nip54.ts'
describe('normalizeIdentifier', () => {
test('converts to lowercase', () => {
expect(normalizeIdentifier('HELLO')).toBe('hello')
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
})
test('trims whitespace', () => {
expect(normalizeIdentifier(' hello ')).toBe('hello')
expect(normalizeIdentifier('\thello\n')).toBe('hello')
})
test('normalizes Unicode to NFKC form', () => {
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
expect(normalizeIdentifier('café')).toBe('café')
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
})
test('replaces non-alphanumeric characters with hyphens', () => {
expect(normalizeIdentifier('hello world')).toBe('hello-world')
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
})
test('preserves numbers', () => {
expect(normalizeIdentifier('user123')).toBe('user123')
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
})
test('handles multiple consecutive special characters', () => {
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
})
test('handles Unicode letters from different scripts', () => {
expect(normalizeIdentifier('привет')).toBe('привет')
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
})
})

19
nip54.ts Normal file
View File

@@ -0,0 +1,19 @@
export function normalizeIdentifier(name: string): string {
// Trim and lowercase
name = name.trim().toLowerCase()
// Normalize Unicode to NFKC form
name = name.normalize('NFKC')
// Convert to array of characters and map each one
return Array.from(name)
.map(char => {
// Check if character is letter or number using Unicode ranges
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
return char
}
return '-'
})
.join('')
}

166
nip55.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { test, expect } from 'bun:test'
import * as nip55 from './nip55.js'
// Function to parse the NostrSigner URI
function parseNostrSignerUri(uri: string) {
const [base, query] = uri.split('?')
const basePart = base.replace('nostrsigner:', '')
let jsonObject = null
if (basePart) {
try {
jsonObject = JSON.parse(decodeURIComponent(basePart))
} catch (e) {
console.warn('Failed to parse base JSON:', e)
}
}
const urlSearchParams = new URLSearchParams(query)
const queryParams = Object.fromEntries(urlSearchParams.entries())
if (queryParams.permissions) {
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
}
return {
base: jsonObject,
...queryParams,
}
}
// Test cases
test('Get Public Key URI', () => {
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
const callbackUrl = 'https://example.com/?event='
const uri = nip55.getPublicKeyUri({
permissions,
callbackUrl,
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'get_public_key')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
})
test('Sign Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.signEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('base.content', 'test')
expect(jsonObject).toHaveProperty('type', 'sign_event')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})
test('Encrypt NIP-04 URI', () => {
const callbackUrl = 'https://example.com/?event='
const uri = nip55.encryptNip04Uri({
callbackUrl,
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-04 URI', () => {
const uri = nip55.decryptNip04Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Encrypt NIP-44 URI', () => {
const uri = nip55.encryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-44 URI', () => {
const uri = nip55.decryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Decrypt Zap Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.decryptZapEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
returnType: 'event',
compressionType: 'gzip',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
expect(jsonObject).toHaveProperty('returnType', 'event')
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})

123
nip55.ts Normal file
View File

@@ -0,0 +1,123 @@
type BaseParams = {
callbackUrl?: string
returnType?: 'signature' | 'event'
compressionType?: 'none' | 'gzip'
}
type PermissionsParams = BaseParams & {
permissions?: { type: string; kind?: number }[]
}
type EventUriParams = BaseParams & {
eventJson: Record<string, unknown>
id?: string
currentUser?: string
}
type EncryptDecryptParams = BaseParams & {
pubKey: string
content: string
id?: string
currentUser?: string
}
type UriParams = BaseParams & {
base: string
type: string
id?: string
currentUser?: string
permissions?: { type: string; kind?: number }[]
pubKey?: string
plainText?: string
encryptedText?: string
appName?: string
}
function encodeParams(params: Record<string, unknown>): string {
return new URLSearchParams(params as Record<string, string>).toString()
}
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
}
function buildUri({
base,
type,
callbackUrl,
returnType = 'signature',
compressionType = 'none',
...params
}: UriParams): string {
const baseParams = {
type,
compressionType,
returnType,
callbackUrl,
id: params.id,
current_user: params.currentUser,
permissions:
params.permissions && params.permissions.length > 0
? encodeURIComponent(JSON.stringify(params.permissions))
: undefined,
pubKey: params.pubKey,
plainText: params.plainText,
encryptedText: params.encryptedText,
appName: params.appName,
}
const filteredParams = filterUndefined(baseParams)
return `${base}?${encodeParams(filteredParams)}`
}
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
return buildUri({
base: 'nostrsigner:',
type,
...params,
})
}
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
return buildDefaultUri('get_public_key', { permissions, ...params })
}
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'sign_event',
...params,
})
}
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, plainText: params.content })
}
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, encryptedText: params.content })
}
export function encryptNip04Uri(params: EncryptDecryptParams): string {
return encryptUri('nip04_encrypt', params)
}
export function decryptNip04Uri(params: EncryptDecryptParams): string {
return decryptUri('nip04_decrypt', params)
}
export function encryptNip44Uri(params: EncryptDecryptParams): string {
return encryptUri('nip44_encrypt', params)
}
export function decryptNip44Uri(params: EncryptDecryptParams): string {
return decryptUri('nip44_decrypt', params)
}
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'decrypt_zap_event',
...params,
})
}

View File

@@ -2,6 +2,7 @@ import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts' import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts' import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
var _fetch: any var _fetch: any
@@ -49,7 +50,7 @@ export function makeZapRequest({
comment = '', comment = '',
}: { }: {
profile: string profile: string
event: string | null event: string | Event | null
amount: number amount: number
comment: string comment: string
relays: string[] relays: string[]
@@ -68,9 +69,22 @@ export function makeZapRequest({
], ],
} }
if (event) { if (event && typeof event === 'string') {
zr.tags.push(['e', event]) zr.tags.push(['e', event])
} }
if (event && typeof event === 'object') {
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
zr.tags.push(a)
// addressable event
} else if (isAddressableKind(event.kind)) {
let d = event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
zr.tags.push(a)
}
}
return zr return zr
} }

113
nip59.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { test, expect } from 'bun:test'
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
import { decode } from './nip19.ts'
import { NostrEvent, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data
const recipientPublicKey = getPublicKey(recipientPrivateKey)
const event = {
kind: 1,
content: 'Are you going to the party tonight?',
}
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
[Symbol('verified')]: true,
}
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
expect(result.kind).toEqual(expected.kind)
expect(result.tags).toEqual(expected.tags)
})
test('wrapManyEvent', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 1,
content: 'Are you going to the party tonight?',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [],
}
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})
test('getWrappedEvents and unwrapManyEvents', async () => {
const expected = [
{
created_at: 1729721879,
content: 'Hello!',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
},
{
created_at: 1729722025,
content: 'How are you?',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
},
]
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
const pool = new SimplePool()
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
unwrappedEvents.forEach((event, index) => {
expect(event).toEqual(expected[index])
})
})

107
nip59.ts Normal file
View File

@@ -0,0 +1,107 @@
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
import { Seal, GiftWrap } from './kinds.ts'
type Rumor = UnsignedEvent & { id: string }
const TWO_DAYS = 2 * 24 * 60 * 60
const now = () => Math.round(Date.now() / 1000)
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
const rumor = {
created_at: now(),
content: '',
tags: [],
...event,
pubkey: getPublicKey(privateKey),
} as any
rumor.id = getEventHash(rumor)
return rumor as Rumor
}
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
return finalizeEvent(
{
kind: Seal,
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
created_at: randomNow(),
tags: [],
},
privateKey,
)
}
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
const randomKey = generateSecretKey()
return finalizeEvent(
{
kind: GiftWrap,
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
created_at: randomNow(),
tags: [['p', recipientPublicKey]],
},
randomKey,
) as NostrEvent
}
export function wrapEvent(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientPublicKey: string,
): NostrEvent {
const rumor = createRumor(event, senderPrivateKey)
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
return createWrap(seal, recipientPublicKey)
}
export function wrapManyEvents(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientsPublicKeys: string[],
): NostrEvent[] {
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
recipientsPublicKeys.forEach(recipientPublicKey => {
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
})
return wrappeds
}
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
}
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
let unwrappedEvents: Rumor[] = []
wrappedEvents.forEach(e => {
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
})
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
return unwrappedEvents
}

View File

@@ -21,6 +21,7 @@ describe('generateEventTemplate', () => {
image: 'https://example.com/image.jpg', image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum', summary: 'Lorem ipsum',
alt: 'Image alt text', alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
} }
const expectedEventTemplate: EventTemplate = { const expectedEventTemplate: EventTemplate = {
@@ -40,6 +41,8 @@ describe('generateEventTemplate', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
], ],
} }
@@ -71,6 +74,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -100,6 +104,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -129,6 +134,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -158,6 +164,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -181,6 +188,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -204,6 +212,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -227,6 +236,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -259,6 +269,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -288,6 +299,7 @@ describe('validateEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -319,6 +331,8 @@ describe('parseEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback1.example.com/image.jpg'],
['fallback', 'https://fallback2.example.com/image.jpg'],
], ],
}, },
sk, sk,
@@ -340,6 +354,7 @@ describe('parseEvent', () => {
image: 'https://example.com/image.jpg', image: 'https://example.com/image.jpg',
summary: 'Lorem ipsum', summary: 'Lorem ipsum',
alt: 'Image alt text', alt: 'Image alt text',
fallback: ['https://fallback1.example.com/image.jpg', 'https://fallback2.example.com/image.jpg'],
}) })
}) })
@@ -364,6 +379,7 @@ describe('parseEvent', () => {
['image', 'https://example.com/image.jpg'], ['image', 'https://example.com/image.jpg'],
['summary', 'Lorem ipsum'], ['summary', 'Lorem ipsum'],
['alt', 'Image alt text'], ['alt', 'Image alt text'],
['fallback', 'https://fallback.example.com/image.jpg'],
], ],
}, },
sk, sk,

View File

@@ -75,6 +75,11 @@ export type FileMetadataObject = {
* Optional: A description for accessibility, providing context or a brief description of the file. * Optional: A description for accessibility, providing context or a brief description of the file.
*/ */
alt?: string alt?: string
/**
* Optional: fallback URLs in case url fails.
*/
fallback?: string[]
} }
/** /**
@@ -104,6 +109,7 @@ export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTe
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image]) if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary]) if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt]) if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
if (fileMetadata.fallback) fileMetadata.fallback.forEach(url => eventTemplate.tags.push(['fallback', url]))
return eventTemplate return eventTemplate
} }
@@ -194,6 +200,10 @@ export function parseEvent(event: Event): FileMetadataObject {
case 'alt': case 'alt':
fileMetadata.alt = value fileMetadata.alt = value
break break
case 'fallback':
fileMetadata.fallback ??= []
fileMetadata.fallback.push(value)
break
} }
} }

View File

@@ -267,13 +267,11 @@ export async function readServerConfig(serverUrl: string): Promise<ServerConfigu
* @returns true if the object is a valid FileUploadResponse, otherwise false. * @returns true if the object is a valid FileUploadResponse, otherwise false.
*/ */
export function validateFileUploadResponse(response: any): response is FileUploadResponse { export function validateFileUploadResponse(response: any): response is FileUploadResponse {
if (typeof response !== 'object' || response === null) return false if (typeof response !== 'object' || response === null) {
if (!response.status || !response.message) {
return false return false
} }
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') { if (!['success', 'error', 'processing'].includes(response.status)) {
return false return false
} }
@@ -285,10 +283,8 @@ export function validateFileUploadResponse(response: any): response is FileUploa
return false return false
} }
if (response.processing_url) { if (response.processing_url && typeof response.processing_url !== 'string') {
if (typeof response.processing_url !== 'string') { return false
return false
}
} }
if (response.status === 'success' && !response.nip94_event) { if (response.status === 'success' && !response.nip94_event) {
@@ -296,25 +292,21 @@ export function validateFileUploadResponse(response: any): response is FileUploa
} }
if (response.nip94_event) { if (response.nip94_event) {
if ( const tags = response.nip94_event.tags as string[][]
!response.nip94_event.tags ||
!Array.isArray(response.nip94_event.tags) || if (!Array.isArray(tags)) {
response.nip94_event.tags.length === 0
) {
return false return false
} }
for (const tag of response.nip94_event.tags) { if (tags.some(t => t.length < 2 || t.some(x => typeof x !== 'string'))) {
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 return false
} }
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) { if (!tags.some(t => t[0] === 'url')) {
return false
}
if (!tags.some(t => t[0] === 'ox')) {
return false return false
} }
} }
@@ -340,9 +332,6 @@ export async function uploadFile(
// Create FormData object // Create FormData object
const formData = new FormData() const formData = new FormData()
// Append the authorization header to HTML Form Data
formData.append('Authorization', nip98AuthorizationHeader)
// Append optional fields to FormData // Append optional fields to FormData
optionalFormDataFields && optionalFormDataFields &&
Object.entries(optionalFormDataFields).forEach(([key, value]) => { Object.entries(optionalFormDataFields).forEach(([key, value]) => {
@@ -359,7 +348,6 @@ export async function uploadFile(
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: nip98AuthorizationHeader, Authorization: nip98AuthorizationHeader,
'Content-Type': 'multipart/form-data',
}, },
body: formData, body: formData,
}) })
@@ -389,17 +377,13 @@ export async function uploadFile(
throw new Error('Unknown error in uploading file!') throw new Error('Unknown error in uploading file!')
} }
try { const parsedResponse = await response.json()
const parsedResponse = await response.json()
if (!validateFileUploadResponse(parsedResponse)) { if (!validateFileUploadResponse(parsedResponse)) {
throw new Error('Invalid response from the server!') throw new Error('Failed to validate upload response!')
}
return parsedResponse
} catch (error) {
throw new Error('Error parsing JSON response!')
} }
return parsedResponse
} }
/** /**
@@ -516,33 +500,28 @@ export async function checkFileProcessingStatus(
} }
// Parse the response // Parse the response
try { const parsedResponse = await response.json()
const parsedResponse = await response.json()
// 201 Created: Indicates the processing is over. // 201 Created: Indicates the processing is over.
if (response.status === 201) { if (response.status === 201) {
// Validate the response if (!validateFileUploadResponse(parsedResponse)) {
if (!validateFileUploadResponse(parsedResponse)) { throw new Error('Failed to validate upload response!')
throw new Error('Invalid response from the server!')
}
return parsedResponse
} }
// 200 OK: Indicates the processing is still ongoing. return parsedResponse as FileUploadResponse
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!')
} }
// 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!')
} }
/** /**

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.4.0", "version": "2.11.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -85,6 +85,9 @@
"require": "./lib/cjs/nip06.js", "require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts" "types": "./lib/types/nip06.d.ts"
}, },
"./nip07": {
"types": "./lib/types/nip07.d.ts"
},
"./nip10": { "./nip10": {
"import": "./lib/esm/nip10.js", "import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js", "require": "./lib/cjs/nip10.js",
@@ -100,6 +103,11 @@
"require": "./lib/cjs/nip13.js", "require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts" "types": "./lib/types/nip13.d.ts"
}, },
"./nip17": {
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": { "./nip18": {
"import": "./lib/esm/nip18.js", "import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js", "require": "./lib/cjs/nip18.js",
@@ -165,11 +173,21 @@
"require": "./lib/cjs/nip49.js", "require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts" "types": "./lib/types/nip49.d.ts"
}, },
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.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"
}, },
"./nip59": {
"import": "./lib/esm/nip59.js",
"require": "./lib/cjs/nip59.js",
"types": "./lib/types/nip59.d.ts"
},
"./nip58": { "./nip58": {
"import": "./lib/esm/nip58.js", "import": "./lib/esm/nip58.js",
"require": "./lib/cjs/nip58.js", "require": "./lib/cjs/nip58.js",
@@ -221,7 +239,7 @@
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"nostr-wasm": "v0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"

View File

@@ -1,9 +1,9 @@
import { afterEach, beforeEach, expect, test } from 'bun:test' import { afterEach, beforeEach, expect, test } from 'bun:test'
import { SimplePool } from './pool.ts' import { SimplePool, useWebSocketImplementation } from './pool.ts'
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts' import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
import { useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts' import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
import { hexToBytes } from '@noble/hashes/utils'
useWebSocketImplementation(MockWebSocketClient) useWebSocketImplementation(MockWebSocketClient)
@@ -84,6 +84,86 @@ test('same with double subs', async () => {
expect(received).toHaveLength(2) expect(received).toHaveLength(2)
}) })
test('subscribe many map', async () => {
let priv = hexToBytes('8ea002840d413ccdd5be98df5dd89d799eaa566355ede83ca0bbdbb4b145e0d3')
let pub = getPublicKey(priv)
let received: Event[] = []
let event1 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test1',
kind: 20001,
tags: [],
},
priv,
)
let event2 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 20002,
tags: [['t', 'biloba']],
},
priv,
)
let event3 = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test3',
kind: 20003,
tags: [['t', 'biloba']],
},
priv,
)
const [relayA, relayB, relayC] = relayURLs
pool.subscribeManyMap(
{
[relayA]: [{ authors: [pub], kinds: [20001] }],
[relayB]: [{ authors: [pub], kinds: [20002] }],
[relayC]: [{ kinds: [20003], '#t': ['biloba'] }],
},
{
onevent(event: Event) {
received.push(event)
},
},
)
// publish the first
await Promise.all(pool.publish([relayA, relayB], event1))
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event1)
// publish the second
await pool.publish([relayB], event2)[0]
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(2)
expect(received[1]).toEqual(event2)
// publish a events that shouldn't match our filters
await Promise.all([
...pool.publish([relayA, relayB], event3),
...pool.publish([relayA, relayB, relayC], event1),
pool.publish([relayA, relayB, relayC], event2),
])
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(2)
// publsih the third
await pool.publish([relayC], event3)[0]
await new Promise(resolve => setTimeout(resolve, 100))
expect(received).toHaveLength(3)
expect(received[2]).toEqual(event3)
})
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>()
@@ -125,3 +205,33 @@ test('get()', async () => {
expect(event).not.toBeNull() expect(event).not.toBeNull()
expect(event).toHaveProperty('id', ids[0]) expect(event).toHaveProperty('id', ids[0])
}) })
test('track relays when publishing', async () => {
let event1 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
let event2 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
pool.trackRelays = true
await Promise.all(pool.publish(relayURLs, event1))
expect(pool.seenOn.get(event1.id)).toBeDefined()
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
pool.trackRelays = false
await Promise.all(pool.publish(relayURLs, event2))
expect(pool.seenOn.get(event2.id)).toBeUndefined()
})

14
pool.ts
View File

@@ -1,9 +1,21 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts' import { verifyEvent } from './pure.ts'
import { AbstractSimplePool } from './abstract-pool.ts' import { AbstractSimplePool } from './abstract-pool.ts'
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
}
export class SimplePool extends AbstractSimplePool { export class SimplePool extends AbstractSimplePool {
constructor() { constructor() {
super({ verifyEvent }) super({ verifyEvent, websocketImplementation: _WebSocket })
} }
} }

293
pure.test.ts Normal file
View File

@@ -0,0 +1,293 @@
import { describe, test, expect } from 'bun:test'
import {
finalizeEvent,
serializeEvent,
getEventHash,
validateEvent,
verifyEvent,
verifiedSymbol,
getPublicKey,
generateSecretKey,
} from './pure.ts'
import { ShortTextNote } from './kinds.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
test('private key generation', () => {
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generateSecretKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})
describe('finalizeEvent', () => {
test('should create a signed event from a template', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const template = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finalizeEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
test('should serialize a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
test('should throw an error for an invalid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115,
pubkey: publicKey, // missing content
}
expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
test('should return the correct event hash', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
test('should return true for a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
test('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
test('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid properties', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey',
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid tags', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifyEvent', () => {
test('should return true for a valid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const event = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifyEvent(event)
expect(isValid).toEqual(true)
})
test('should return false for an invalid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
test('should return false when verifying an event with a different private key', () => {
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifyEvent({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
test('should return false for an invalid event id', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
})

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test' import { expect, test } from 'bun:test'
import { Server } from 'mock-socket'
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts' import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay, useWebSocketImplementation } from './relay.ts' import { Relay, useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts' import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
@@ -92,3 +92,28 @@ test('listening and publishing and closing', async done => {
), ),
) )
}) })
test('publish timeout', async () => {
const url = 'wss://relay.example.com'
new Server(url)
const relay = new Relay(url)
relay.publishTimeout = 100
await relay.connect()
setTimeout(() => relay.close(), 20000) // close the relay to fail the test on timeout
expect(
relay.publish(
finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
),
),
).rejects.toThrow('publish timed out')
})

View File

@@ -1,3 +1,5 @@
/* global WebSocket */
import { verifyEvent } from './pure.ts' import { verifyEvent } from './pure.ts'
import { AbstractRelay } from './abstract-relay.ts' import { AbstractRelay } from './abstract-relay.ts'
@@ -8,9 +10,19 @@ export function relayConnect(url: string): Promise<Relay> {
return Relay.connect(url) return Relay.connect(url)
} }
var _WebSocket: typeof WebSocket
try {
_WebSocket = WebSocket
} catch {}
export function useWebSocketImplementation(websocketImplementation: any) {
_WebSocket = websocketImplementation
}
export class Relay extends AbstractRelay { export class Relay extends AbstractRelay {
constructor(url: string) { constructor(url: string) {
super(url, { verifyEvent }) super(url, { verifyEvent, websocketImplementation: _WebSocket })
} }
static async connect(url: string): Promise<Relay> { static async connect(url: string): Promise<Relay> {
@@ -20,4 +32,6 @@ export class Relay extends AbstractRelay {
} }
} }
export type RelayRecord = Record<string, { read: boolean; write: boolean }>
export * from './abstract-relay.ts' export * from './abstract-relay.ts'

View File

@@ -35,9 +35,9 @@ export class MockRelay {
finalizeEvent( finalizeEvent(
{ {
kind: 1, kind: 1,
content: '', content: 'autogenerated by relay',
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [], tags: [['t', 'auto']],
}, },
sk, sk,
), ),
@@ -68,9 +68,9 @@ export class MockRelay {
const event = finalizeEvent( const event = finalizeEvent(
{ {
kind, kind,
content: '', content: 'kind-aware autogenerated by relay',
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [], tags: [['t', 'auto']],
}, },
sk, sk,
) )