mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2025-12-08 16:28:49 +00:00
Compare commits
495 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6baf2e6b | ||
|
|
bb1e6f4356 | ||
|
|
5626d3048b | ||
|
|
058d0276e2 | ||
|
|
37b046c047 | ||
|
|
846654b449 | ||
|
|
b676dc0987 | ||
|
|
b1ce901555 | ||
|
|
62e5730965 | ||
|
|
01f13292bb | ||
|
|
7b0458db72 | ||
|
|
3aab7121f7 | ||
|
|
ce059d4608 | ||
|
|
b72b0dc1f0 | ||
|
|
29e5b71473 | ||
|
|
b4e54d679f | ||
|
|
9d78c90a79 | ||
|
|
566a2deea3 | ||
|
|
177e673d83 | ||
|
|
cf766cd835 | ||
|
|
7d332605ee | ||
|
|
72f9b482ef | ||
|
|
d14830a8ff | ||
|
|
943cc4fb48 | ||
|
|
04252aaaec | ||
|
|
8c78649d5c | ||
|
|
b9435af708 | ||
|
|
ea5d00beed | ||
|
|
7ec6d127b0 | ||
|
|
7a9d432686 | ||
|
|
744a930ccf | ||
|
|
c6a521e73c | ||
|
|
6aebe0d38c | ||
|
|
16cdf40112 | ||
|
|
e36ea11f41 | ||
|
|
31a35a8008 | ||
|
|
0f5b3f397c | ||
|
|
d156f3c0ac | ||
|
|
d656c84ab5 | ||
|
|
2f0ef90bd5 | ||
|
|
967d7fe63a | ||
|
|
12147d4fee | ||
|
|
c453bc5ec3 | ||
|
|
2017b3cabd | ||
|
|
fbcfccda01 | ||
|
|
0357e035f4 | ||
|
|
dd0014aee3 | ||
|
|
2e9798b8ab | ||
|
|
10b800db3a | ||
|
|
dbad25b2fa | ||
|
|
829633b0d6 | ||
|
|
b1bbcd6c46 | ||
|
|
6a9940c850 | ||
|
|
9b08550885 | ||
|
|
3b81e5e762 | ||
|
|
8b2b050c0d | ||
|
|
d4090dae2b | ||
|
|
49596d24c3 | ||
|
|
ac83eeff1c | ||
|
|
85b741b39a | ||
|
|
c69c528ab0 | ||
|
|
1aad9ad0bd | ||
|
|
f6ed374f2f | ||
|
|
6d7ad22677 | ||
|
|
340a4a6799 | ||
|
|
5ec136a365 | ||
|
|
75eb08b170 | ||
|
|
677b679c2c | ||
|
|
7b79d6a899 | ||
|
|
c1efbbd919 | ||
|
|
7d58705e9a | ||
|
|
f1d315632c | ||
|
|
348d118ce4 | ||
|
|
498c1603b0 | ||
|
|
4cfc67e294 | ||
|
|
da51418f04 | ||
|
|
75df47421f | ||
|
|
1cfe705baf | ||
|
|
566437fe2e | ||
|
|
5d6c2b9e5d | ||
|
|
a43f2a708c | ||
|
|
f727058a3a | ||
|
|
1de54838d3 | ||
|
|
703c29a311 | ||
|
|
ddf1064da9 | ||
|
|
f719d99a11 | ||
|
|
6152238d65 | ||
|
|
9ac1b63994 | ||
|
|
1890c91ae3 | ||
|
|
7067b47cd4 | ||
|
|
397931f847 | ||
|
|
5d795c291f | ||
|
|
7adbd30799 | ||
|
|
83b6dd7ec3 | ||
|
|
d61cc6c9bf | ||
|
|
d7dad8e204 | ||
|
|
daaa2ef0a1 | ||
|
|
7f11c0c618 | ||
|
|
a4ae964ee6 | ||
|
|
1f7378ca49 | ||
|
|
d155bcdcda | ||
|
|
919d29363e | ||
|
|
ef12a451be | ||
|
|
a9acdada19 | ||
|
|
bf3818e434 | ||
|
|
b7389be5c7 | ||
|
|
7552a36ff2 | ||
|
|
1b31a27d89 | ||
|
|
0cc3c02d84 | ||
|
|
8625d45152 | ||
|
|
8f03116687 | ||
|
|
e6d1808fda | ||
|
|
9648de3470 | ||
|
|
fe87529646 | ||
|
|
1908e1ee0d | ||
|
|
2571db9afc | ||
|
|
f77b9eab10 | ||
|
|
71b412657f | ||
|
|
8840c4d8e2 | ||
|
|
804403f574 | ||
|
|
965ebdb6d1 | ||
|
|
c54fd95b3e | ||
|
|
7a6c0754ad | ||
|
|
9e4911160a | ||
|
|
73c6630cf7 | ||
|
|
88703e9ea2 | ||
|
|
07d208308f | ||
|
|
f56f2ae709 | ||
|
|
a0cb2eecae | ||
|
|
2a7fd83be8 | ||
|
|
1ebe098805 | ||
|
|
3bfb50e267 | ||
|
|
420a6910e9 | ||
|
|
7a640092d0 | ||
|
|
3d541e537e | ||
|
|
1357642575 | ||
|
|
d16f3f77c3 | ||
|
|
0108e3b605 | ||
|
|
2ac69278ce | ||
|
|
bf31f2eba3 | ||
|
|
39cfc5c09e | ||
|
|
3d767beeb9 | ||
|
|
36e0de2a68 | ||
|
|
9cd4f16e45 | ||
|
|
6a07e7c1cc | ||
|
|
1939c46eaa | ||
|
|
93538d2373 | ||
|
|
19b3faea17 | ||
|
|
867aa11d12 | ||
|
|
4fcf925387 | ||
|
|
40c5337ef0 | ||
|
|
350d8ec3b6 | ||
|
|
c5f3c8052e | ||
|
|
dc04d1eb85 | ||
|
|
a2a15567b7 | ||
|
|
318e3f8c88 | ||
|
|
894ffff1f0 | ||
|
|
ce11a5fc89 | ||
|
|
5e85bbc2ed | ||
|
|
eb0a9093f2 | ||
|
|
c73268c4e2 | ||
|
|
6874f58c0a | ||
|
|
e899cc32b7 | ||
|
|
de72172583 | ||
|
|
073dcaafd6 | ||
|
|
8e932f0c5a | ||
|
|
f9a048679f | ||
|
|
6db8b94275 | ||
|
|
13bc2ad5a8 | ||
|
|
55f032d0a4 | ||
|
|
c890e29290 | ||
|
|
c18f050468 | ||
|
|
401b9c7864 | ||
|
|
c175f6c804 | ||
|
|
41265a19f5 | ||
|
|
d88761907a | ||
|
|
8325d4351e | ||
|
|
62bf592d72 | ||
|
|
54f3bedf38 | ||
|
|
34e0ad8c41 | ||
|
|
e9eac28bab | ||
|
|
85035d61f2 | ||
|
|
cf46560619 | ||
|
|
e7aa23cb1d | ||
|
|
5977d68ec2 | ||
|
|
48767d382d | ||
|
|
718032022c | ||
|
|
2a70bb18ff | ||
|
|
9effe807d1 | ||
|
|
899c2bd0dc | ||
|
|
918d514a25 | ||
|
|
48cb9046c4 | ||
|
|
864dd28b26 | ||
|
|
fa085367c9 | ||
|
|
350951b88e | ||
|
|
c6133f7160 | ||
|
|
470512bbeb | ||
|
|
c3acb82464 | ||
|
|
fc23d05764 | ||
|
|
8296ce897c | ||
|
|
3ca78c0e13 | ||
|
|
837a05e54d | ||
|
|
32fd25556b | ||
|
|
0925f5db81 | ||
|
|
bce976fecd | ||
|
|
45e479d7aa | ||
|
|
b92407b156 | ||
|
|
2431896921 | ||
|
|
d13eecad4a | ||
|
|
df6f887d7e | ||
|
|
e00362e7c9 | ||
|
|
9efdd16e26 | ||
|
|
de7e128818 | ||
|
|
4978c858e7 | ||
|
|
16c7ae2a70 | ||
|
|
3368e8c00e | ||
|
|
e5a3ad9855 | ||
|
|
03185c654b | ||
|
|
9d690814ca | ||
|
|
17590cce91 | ||
|
|
ee9f37e192 | ||
|
|
c1848d78a0 | ||
|
|
81776ba811 | ||
|
|
915d6d729b | ||
|
|
1a23f5ee01 | ||
|
|
fec40490a2 | ||
|
|
bb3e41bb89 | ||
|
|
27b971eef3 | ||
|
|
0041008b22 | ||
|
|
ae5bf4c72c | ||
|
|
75fc836cf6 | ||
|
|
70b025b8da | ||
|
|
c9bc702d90 | ||
|
|
7652318185 | ||
|
|
d81a2444b3 | ||
|
|
7507943253 | ||
|
|
b9a7f814aa | ||
|
|
0e364701da | ||
|
|
a55fb8465f | ||
|
|
472a01af6a | ||
|
|
bb5acfc197 | ||
|
|
1c6f39e4ae | ||
|
|
5b15237b95 | ||
|
|
4184609a00 | ||
|
|
97287cad74 | ||
|
|
fa21f71ab5 | ||
|
|
08885ab8da | ||
|
|
9f896479d0 | ||
|
|
82caa2aad9 | ||
|
|
67a8ee23ce | ||
|
|
18e8227123 | ||
|
|
64caef9cda | ||
|
|
6a07d2d9d3 | ||
|
|
341ccc5ac5 | ||
|
|
d2a9af2586 | ||
|
|
5d92be05bb | ||
|
|
03cc18d53b | ||
|
|
ac7598b5e3 | ||
|
|
424449c773 | ||
|
|
ab6abe6815 | ||
|
|
30fd6b6215 | ||
|
|
8a53b3b8b3 | ||
|
|
d0bd599ce8 | ||
|
|
1cbb62e6b9 | ||
|
|
977316915b | ||
|
|
dd8f555094 | ||
|
|
87f5ea4291 | ||
|
|
595ae21baf | ||
|
|
9fa554ca8e | ||
|
|
1647601727 | ||
|
|
b66ca1787a | ||
|
|
278cdda9c2 | ||
|
|
552530fa3f | ||
|
|
13e9b4aa3e | ||
|
|
9a3e05ce5f | ||
|
|
55ff796b9f | ||
|
|
3ef2ad5bc4 | ||
|
|
45c07a5f45 | ||
|
|
6a037d1658 | ||
|
|
dcf101c6c2 | ||
|
|
eb97dbd9ef | ||
|
|
92988051c6 | ||
|
|
bf7e00d32a | ||
|
|
9241089997 | ||
|
|
32c47e9bd8 | ||
|
|
6e58fe371c | ||
|
|
26e35d50e0 | ||
|
|
ef3184a6e0 | ||
|
|
56fe3dd5dd | ||
|
|
f1bb5030c8 | ||
|
|
ac212cb5c8 | ||
|
|
204ae0eff1 | ||
|
|
f17ab41d72 | ||
|
|
f6f5ee8223 | ||
|
|
a05506468d | ||
|
|
674ff66b6f | ||
|
|
731705047a | ||
|
|
94b382a49f | ||
|
|
199411a971 | ||
|
|
a1dc6f41b9 | ||
|
|
5b59b93d86 | ||
|
|
12acd7bdca | ||
|
|
3bdb68020d | ||
|
|
b0a58e2ca4 | ||
|
|
b063be76ae | ||
|
|
e3cea5db16 | ||
|
|
9ee58bd6c7 | ||
|
|
f1eb9a3bc7 | ||
|
|
ce081bb4cb | ||
|
|
7413072e9f | ||
|
|
4c464b39cf | ||
|
|
11ef43abdc | ||
|
|
3e67f9b014 | ||
|
|
0933fba6d5 | ||
|
|
51b8f42529 | ||
|
|
24d885aaeb | ||
|
|
74c77a2e9f | ||
|
|
ce73b96565 | ||
|
|
8818e4f88a | ||
|
|
5a63c75f24 | ||
|
|
60e01a9006 | ||
|
|
687f387385 | ||
|
|
6d116a2f7f | ||
|
|
51c3aec788 | ||
|
|
613b2c177f | ||
|
|
24f5068fdb | ||
|
|
5733f9c4e4 | ||
|
|
6b73bbf8a3 | ||
|
|
d244b62c7a | ||
|
|
b00af9a30a | ||
|
|
be7c981c14 | ||
|
|
5539e5cf89 | ||
|
|
73decbc8e0 | ||
|
|
b3d95cecdd | ||
|
|
82228036ef | ||
|
|
01435ab9f5 | ||
|
|
63cbc4133a | ||
|
|
049f183d27 | ||
|
|
f9e3119ab4 | ||
|
|
f992c9c967 | ||
|
|
dbf625d6ac | ||
|
|
8622bd11dd | ||
|
|
0970eee70f | ||
|
|
086f8830e3 | ||
|
|
e48d722227 | ||
|
|
0d77013aab | ||
|
|
4c415280aa | ||
|
|
4188aaf7c8 | ||
|
|
673f4abab8 | ||
|
|
bcefaa0757 | ||
|
|
649af36a86 | ||
|
|
96a6f7af87 | ||
|
|
a4c713efcb | ||
|
|
9d345a8f01 | ||
|
|
c362212778 | ||
|
|
a8938a3a0f | ||
|
|
a21329da3f | ||
|
|
63f4a49a69 | ||
|
|
27749d91b8 | ||
|
|
9530849f0a | ||
|
|
b8aa75b6e1 | ||
|
|
344762820c | ||
|
|
f43d23d344 | ||
|
|
bf55ad6b5a | ||
|
|
04a46b815c | ||
|
|
165ff44dff | ||
|
|
7bfd23af3c | ||
|
|
3d93ec8446 | ||
|
|
0f841138cd | ||
|
|
336948b1d1 | ||
|
|
d46794c681 | ||
|
|
93cef5d886 | ||
|
|
2324f9548e | ||
|
|
f9748d9cc3 | ||
|
|
3a22dd3da6 | ||
|
|
d13039dc11 | ||
|
|
95b03902cc | ||
|
|
ab5ea8de36 | ||
|
|
a330b97590 | ||
|
|
24406b5679 | ||
|
|
6dbcc87d93 | ||
|
|
0ddcfdce68 | ||
|
|
87bf349ce8 | ||
|
|
54dfc7b972 | ||
|
|
32793146a4 | ||
|
|
c42cd925ce | ||
|
|
43ccb72476 | ||
|
|
b2b7999517 | ||
|
|
a568afc295 | ||
|
|
9bcaed6e60 | ||
|
|
5a9cbbb557 | ||
|
|
e9acc59809 | ||
|
|
18fe9637b9 | ||
|
|
ff3bf4a51c | ||
|
|
7ff97b5488 | ||
|
|
df169ea42b | ||
|
|
341f2bcb8d | ||
|
|
b2d1dd2110 | ||
|
|
75d7be5a54 | ||
|
|
b5c8255b2f | ||
|
|
4485c8ed5e | ||
|
|
3710866430 | ||
|
|
da59e3ce90 | ||
|
|
cc8e34163d | ||
|
|
9082953ede | ||
|
|
61f397463d | ||
|
|
312b6fd035 | ||
|
|
7f1bd4f4a8 | ||
|
|
26089ef958 | ||
|
|
2e305b7cd4 | ||
|
|
51c1a54ddf | ||
|
|
cb05ee188f | ||
|
|
fa9e169c46 | ||
|
|
bb1e3f2fa6 | ||
|
|
160987472f | ||
|
|
8b18341ebb | ||
|
|
901445dea1 | ||
|
|
91b67cd0d5 | ||
|
|
1e696e0f3b | ||
|
|
4b36848b2d | ||
|
|
3cb351a5f4 | ||
|
|
5db1934fa4 | ||
|
|
50c3f24b25 | ||
|
|
39ea47660d | ||
|
|
8071e2f4fa | ||
|
|
cc2250da1f | ||
|
|
c37d10bb9d | ||
|
|
97e28fdf9a | ||
|
|
87c0f0d061 | ||
|
|
83c397b839 | ||
|
|
cd7d1cec48 | ||
|
|
613a843838 | ||
|
|
74a0d5454a | ||
|
|
c0d1e41424 | ||
|
|
f7e510e1c8 | ||
|
|
c08bdac7a7 | ||
|
|
c5b64404f6 | ||
|
|
c7b26fdba2 | ||
|
|
ac698ef67d | ||
|
|
8262a81cb2 | ||
|
|
26e6da6ba3 | ||
|
|
8aa31bb437 | ||
|
|
4bd4469357 | ||
|
|
89ae21f796 | ||
|
|
41a1614d89 | ||
|
|
0500415a4e | ||
|
|
cee4357cab | ||
|
|
d5cf5930d1 | ||
|
|
a78e2036aa | ||
|
|
adc1854ac6 | ||
|
|
83148e8bdf | ||
|
|
364c37cac5 | ||
|
|
385cdb4ac6 | ||
|
|
3f1025f551 | ||
|
|
482c5affd4 | ||
|
|
679ac0c133 | ||
|
|
b96159ad36 | ||
|
|
6dede4a688 | ||
|
|
50c8bb72f9 | ||
|
|
72781e0eab | ||
|
|
bf120c1348 | ||
|
|
3630d377e5 | ||
|
|
53b0091bf4 | ||
|
|
1a7cc5f21f | ||
|
|
1162935f58 | ||
|
|
a49d971f6a | ||
|
|
897919be3b | ||
|
|
39aca167fb | ||
|
|
de8bdd8370 | ||
|
|
46a0a342db | ||
|
|
4fe2a9c91a | ||
|
|
e62b833464 | ||
|
|
100c77d2aa | ||
|
|
12be5a5338 | ||
|
|
b955ba2a09 | ||
|
|
ec805be4ab | ||
|
|
92fb339afb | ||
|
|
f8f125270a | ||
|
|
1b798b2eee | ||
|
|
ae717a1a4a | ||
|
|
b2015c8fe5 | ||
|
|
c5d2e3b037 | ||
|
|
0ef5d1e19c | ||
|
|
7d9d10fdb1 | ||
|
|
a1e1ce131a | ||
|
|
cdb07bb175 | ||
|
|
1f1bcff803 | ||
|
|
896af30619 | ||
|
|
a8542c4b56 | ||
|
|
9f9e822c6d | ||
|
|
821a8f7895 | ||
|
|
2f7e3f8473 | ||
|
|
536dbcbffe | ||
|
|
ed52d2a8d4 | ||
|
|
faf8e62120 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
|
"extends": ["prettier"],
|
||||||
|
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint", "babel"],
|
||||||
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 9,
|
"ecmaVersion": 9,
|
||||||
"ecmaFeatures": {
|
"ecmaFeatures": {
|
||||||
@@ -14,14 +19,11 @@
|
|||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"plugins": [
|
|
||||||
"babel"
|
|
||||||
],
|
|
||||||
|
|
||||||
"globals": {
|
"globals": {
|
||||||
"document": false,
|
"document": false,
|
||||||
"navigator": false,
|
"navigator": false,
|
||||||
"window": false,
|
"window": false,
|
||||||
|
"crypto": false,
|
||||||
"location": false,
|
"location": false,
|
||||||
"URL": false,
|
"URL": false,
|
||||||
"URLSearchParams": false,
|
"URLSearchParams": false,
|
||||||
@@ -43,9 +45,7 @@
|
|||||||
"curly": [0, "multi-line"],
|
"curly": [0, "multi-line"],
|
||||||
"dot-location": [2, "property"],
|
"dot-location": [2, "property"],
|
||||||
"eol-last": 2,
|
"eol-last": 2,
|
||||||
"eqeqeq": [2, "allow-null"],
|
"handle-callback-err": [2, "^(err|error)$"],
|
||||||
"generator-star-spacing": [2, { "before": true, "after": true }],
|
|
||||||
"handle-callback-err": [2, "^(err|error)$" ],
|
|
||||||
"indent": 0,
|
"indent": 0,
|
||||||
"jsx-quotes": [2, "prefer-double"],
|
"jsx-quotes": [2, "prefer-double"],
|
||||||
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
||||||
@@ -100,7 +100,6 @@
|
|||||||
"no-octal-escape": 2,
|
"no-octal-escape": 2,
|
||||||
"no-path-concat": 0,
|
"no-path-concat": 0,
|
||||||
"no-proto": 2,
|
"no-proto": 2,
|
||||||
"no-redeclare": 2,
|
|
||||||
"no-regex-spaces": 2,
|
"no-regex-spaces": 2,
|
||||||
"no-return-assign": 0,
|
"no-return-assign": 0,
|
||||||
"no-self-assign": 2,
|
"no-self-assign": 2,
|
||||||
@@ -117,7 +116,7 @@
|
|||||||
"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": [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,
|
||||||
|
|||||||
24
.github/workflows/test.yml
vendored
Normal file
24
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: test every commit
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- uses: extractions/setup-just@v1
|
||||||
|
- run: bun i
|
||||||
|
- run: just test
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: oven-sh/setup-bun@v1
|
||||||
|
- uses: extractions/setup-just@v1
|
||||||
|
- run: bun i
|
||||||
|
- run: just lint
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,4 +2,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
yarn.lock
|
yarn.lock
|
||||||
package-lock.json
|
package-lock.json
|
||||||
nostr.js
|
.envrc
|
||||||
|
lib
|
||||||
|
test.html
|
||||||
|
bench.js
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
semi: false
|
|
||||||
arrowParens: avoid
|
arrowParens: avoid
|
||||||
|
bracketSpacing: true
|
||||||
insertPragma: false
|
insertPragma: false
|
||||||
printWidth: 80
|
printWidth: 120
|
||||||
proseWrap: preserve
|
proseWrap: preserve
|
||||||
|
semi: false
|
||||||
singleQuote: true
|
singleQuote: true
|
||||||
trailingComma: none
|
trailingComma: all
|
||||||
useTabs: false
|
useTabs: false
|
||||||
jsxBracketSameLine: false
|
|
||||||
bracketSpacing: false
|
|
||||||
|
|||||||
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org>
|
||||||
320
README.md
320
README.md
@@ -1,89 +1,269 @@
|
|||||||
# nostr-tools
|
#  nostr-tools
|
||||||
|
|
||||||
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
|
||||||
|
|
||||||
## Usage
|
Only depends on _@scure_ and _@noble_ packages.
|
||||||
|
|
||||||
```js
|
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).
|
||||||
import {relayPool} from 'nostr-tools'
|
|
||||||
|
|
||||||
const pool = relayPool()
|
## Installation
|
||||||
|
|
||||||
pool.setPrivateKey('<hex>') // optional
|
```bash
|
||||||
|
npm install nostr-tools # or yarn add nostr-tools
|
||||||
pool.addRelay('ws://some.relay.com', {read: true, write: true})
|
|
||||||
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
|
|
||||||
|
|
||||||
// example callback function for a subscription
|
|
||||||
function onEvent(event, relay) {
|
|
||||||
console.log(`got an event from ${relay.url} which is already validated.`, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribing to a single user
|
|
||||||
// author is the user's public key
|
|
||||||
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
|
|
||||||
|
|
||||||
// or bulk follow
|
|
||||||
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
|
|
||||||
|
|
||||||
// reuse a subscription channel
|
|
||||||
const mySubscription = pool.sub({cb: ..., filter: ....})
|
|
||||||
mySubscription.sub({filter: ....})
|
|
||||||
mySubscription.sub({cb: ...})
|
|
||||||
mySubscription.unsub()
|
|
||||||
|
|
||||||
// get specific event
|
|
||||||
const specificChannel = pool.sub({
|
|
||||||
cb: (event, relay) => {
|
|
||||||
console.log('got specific event from relay', event, relay)
|
|
||||||
specificChannel.unsub()
|
|
||||||
},
|
|
||||||
filter: {id: '<hex>'}
|
|
||||||
})
|
|
||||||
|
|
||||||
// or get a specific event plus all the events that reference it in the 'e' tag
|
|
||||||
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
|
|
||||||
|
|
||||||
// get all events
|
|
||||||
pool.sub({cb: (event, relay) => {...}, filter: {}})
|
|
||||||
|
|
||||||
// get recent events
|
|
||||||
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
|
|
||||||
|
|
||||||
// publishing events(inside an async function):
|
|
||||||
const ev = await pool.publish(eventObject, (status, url) => {
|
|
||||||
if (status === 0) {
|
|
||||||
console.log(`publish request sent to ${url}`)
|
|
||||||
}
|
|
||||||
if (status === 1) {
|
|
||||||
console.log(`event published by ${url}`, ev)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// it will be signed automatically with the key supplied above
|
|
||||||
// or pass an already signed event to bypass this
|
|
||||||
|
|
||||||
// subscribing to a new relay
|
|
||||||
pool.addRelay('<url>')
|
|
||||||
// will automatically subscribe to the all the events called with .sub above
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
|
If using TypeScript, this package requires TypeScript >= 5.0.
|
||||||
|
|
||||||
For other utils please read the source (for now).
|
## Usage
|
||||||
|
|
||||||
|
### Generating a private key and a public key
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||||
|
|
||||||
|
let sk = generateSecretKey() // `sk` is a Uint8Array
|
||||||
|
let pk = getPublicKey(sk) // `pk` is a hex string
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating, signing and verifying events
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
|
||||||
|
|
||||||
|
let event = finalizeEvent({
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'hello',
|
||||||
|
}, sk)
|
||||||
|
|
||||||
|
let isGood = verifyEvent(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interacting with a relay
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||||
|
import { Relay } from 'nostr-tools/relay'
|
||||||
|
|
||||||
|
const relay = await Relay.connect('wss://relay.example.com')
|
||||||
|
console.log(`connected to ${relay.url}`)
|
||||||
|
|
||||||
|
// let's query for an event that exists
|
||||||
|
const sub = relay.subscribe([
|
||||||
|
{
|
||||||
|
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
onevent(event) {
|
||||||
|
console.log('we got the event we wanted:', event)
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// let's publish a new event while simultaneously monitoring the relay for it
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let pk = getPublicKey(sk)
|
||||||
|
|
||||||
|
relay.sub([
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
authors: [pk],
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
onevent(event) {
|
||||||
|
console.log('got event:', event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let eventTemplate = {
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: 'hello world',
|
||||||
|
}
|
||||||
|
|
||||||
|
// this assigns the pubkey, calculates the event id and signs the event in a single step
|
||||||
|
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||||
|
await relay.publish(signedEvent)
|
||||||
|
|
||||||
|
relay.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
To use this on Node.js you first must install `ws` and call something like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useWebSocketImplementation } from 'nostr-tools/relay'
|
||||||
|
useWebSocketImplementation(require('ws'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interacting with multiple relays
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { SimplePool } from 'nostr-tools/pool'
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
|
||||||
|
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
|
||||||
|
|
||||||
|
let h = pool.subscribeMany(
|
||||||
|
[...relays, 'wss://relay.example3.com'],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
// this will only be called once the first time the event is received
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
h.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.any(pool.publish(relays, newEvent))
|
||||||
|
console.log('published to at least one relay!')
|
||||||
|
|
||||||
|
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
|
||||||
|
let event = await pool.get(relays, {
|
||||||
|
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parsing references (mentions) from a content using NIP-10 and NIP-27
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { parseReferences } from 'nostr-tools/references'
|
||||||
|
|
||||||
|
let references = parseReferences(event)
|
||||||
|
let simpleAugmentedContent = event.content
|
||||||
|
for (let i = 0; i < references.length; i++) {
|
||||||
|
let { text, profile, event, address } = references[i]
|
||||||
|
let augmentedReference = profile
|
||||||
|
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
|
||||||
|
: event
|
||||||
|
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
|
||||||
|
: address
|
||||||
|
? `<a href="${text}">[link]</a>`
|
||||||
|
: text
|
||||||
|
simpleAugmentedContent.replaceAll(text, augmentedReference)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying profile data from a NIP-05 address
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { queryProfile } from 'nostr-tools/nip05'
|
||||||
|
|
||||||
|
let profile = await queryProfile('jb55.com')
|
||||||
|
console.log(profile.pubkey)
|
||||||
|
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
|
||||||
|
console.log(profile.relays)
|
||||||
|
// prints: [wss://relay.damus.io]
|
||||||
|
```
|
||||||
|
|
||||||
|
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useFetchImplementation } from 'nostr-tools/nip05'
|
||||||
|
useFetchImplementation(require('node-fetch'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encoding and decoding NIP-19 codes
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||||
|
import * as nip19 from 'nostr-tools/nip19'
|
||||||
|
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let nsec = nip19.nsecEncode(sk)
|
||||||
|
let { type, data } = nip19.decode(nsec)
|
||||||
|
assert(type === 'nsec')
|
||||||
|
assert(data === sk)
|
||||||
|
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let npub = nip19.npubEncode(pk)
|
||||||
|
let { type, data } = nip19.decode(npub)
|
||||||
|
assert(type === 'npub')
|
||||||
|
assert(data === pk)
|
||||||
|
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nprofile = nip19.nprofileEncode({ pubkey: pk, relays })
|
||||||
|
let { type, data } = nip19.decode(nprofile)
|
||||||
|
assert(type === 'nprofile')
|
||||||
|
assert(data.pubkey === pk)
|
||||||
|
assert(data.relays.length === 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using it with `nostr-wasm`
|
||||||
|
|
||||||
|
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
|
||||||
|
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
|
||||||
|
initNostrWasm().then(setNostrWasm)
|
||||||
|
|
||||||
|
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
|
||||||
|
// see https://www.npmjs.com/package/nostr-wasm for options
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm'
|
||||||
|
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||||
|
import { AbstractSimplePool } from 'nostr-tools/abstract-pool'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
|
||||||
|
initNostrWasm().then(setNostrWasm)
|
||||||
|
|
||||||
|
const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent })
|
||||||
|
const pool = new AbstractSimplePool({ verifyEvent })
|
||||||
|
```
|
||||||
|
|
||||||
|
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks:
|
||||||
|
|
||||||
|
```
|
||||||
|
benchmark time (avg) (min … max) p75 p99 p995
|
||||||
|
------------------------------------------------- -----------------------------
|
||||||
|
• relay read message and verify event (many events)
|
||||||
|
------------------------------------------------- -----------------------------
|
||||||
|
wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms
|
||||||
|
pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms
|
||||||
|
trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs
|
||||||
|
|
||||||
|
summary for relay read message and verify event
|
||||||
|
wasm
|
||||||
|
86.77x slower than trusted
|
||||||
|
6.86x faster than pure js
|
||||||
|
```
|
||||||
|
|
||||||
### Using from the browser (if you don't want to use a bundler)
|
### Using from the browser (if you don't want to use a bundler)
|
||||||
|
|
||||||
You can import nostr-tools as an ES module. Just add a script tag like this:
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="module">
|
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||||
import {generatePrivateKey} from 'https://unpkg.com/nostr-tools/nostr.js'
|
<script>
|
||||||
console.log(generatePrivateKey())
|
window.NostrTools.generateSecretKey('...') // and so on
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
And import whatever function you would import from `"nostr-tools"` in a bundler.
|
## Plumbing
|
||||||
|
|
||||||
|
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Public domain.
|
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.
|
||||||
|
|
||||||
|
## Contributing to this repository
|
||||||
|
|
||||||
|
Use NIP-34 to send your patches to `naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq`.
|
||||||
|
|||||||
190
abstract-pool.ts
Normal file
190
abstract-pool.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { AbstractRelay as AbstractRelay, SubscriptionParams, Subscription } from './abstract-relay.ts'
|
||||||
|
import { normalizeURL } from './utils.ts'
|
||||||
|
|
||||||
|
import type { Event, Nostr } from './core.ts'
|
||||||
|
import { type Filter } from './filter.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
export type SubCloser = { close: () => void }
|
||||||
|
|
||||||
|
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
|
||||||
|
maxWait?: number
|
||||||
|
onclose?: (reasons: string[]) => void
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbstractSimplePool {
|
||||||
|
private relays = new Map<string, AbstractRelay>()
|
||||||
|
public seenOn = new Map<string, Set<AbstractRelay>>()
|
||||||
|
public trackRelays: boolean = false
|
||||||
|
|
||||||
|
public verifyEvent: Nostr['verifyEvent']
|
||||||
|
public trustedRelayURLs = new Set<string>()
|
||||||
|
|
||||||
|
constructor(opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
|
||||||
|
url = normalizeURL(url)
|
||||||
|
|
||||||
|
let relay = this.relays.get(url)
|
||||||
|
if (!relay) {
|
||||||
|
relay = new AbstractRelay(url, {
|
||||||
|
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||||
|
})
|
||||||
|
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
|
||||||
|
this.relays.set(url, relay)
|
||||||
|
}
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
|
||||||
|
close(relays: string[]) {
|
||||||
|
relays.map(normalizeURL).forEach(url => {
|
||||||
|
this.relays.get(url)?.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
|
||||||
|
if (this.trackRelays) {
|
||||||
|
params.receivedEvent = (relay: AbstractRelay, id: string) => {
|
||||||
|
let set = this.seenOn.get(id)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.seenOn.set(id, set)
|
||||||
|
}
|
||||||
|
set.add(relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _knownIds = new Set<string>()
|
||||||
|
const subs: Subscription[] = []
|
||||||
|
|
||||||
|
// batch all EOSEs into a single
|
||||||
|
const eosesReceived: boolean[] = []
|
||||||
|
let handleEose = (i: number) => {
|
||||||
|
eosesReceived[i] = true
|
||||||
|
if (eosesReceived.filter(a => a).length === relays.length) {
|
||||||
|
params.oneose?.()
|
||||||
|
handleEose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// batch all closes into a single
|
||||||
|
const closesReceived: string[] = []
|
||||||
|
let handleClose = (i: number, reason: string) => {
|
||||||
|
handleEose(i)
|
||||||
|
closesReceived[i] = reason
|
||||||
|
if (closesReceived.filter(a => a).length === relays.length) {
|
||||||
|
params.onclose?.(closesReceived)
|
||||||
|
handleClose = () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAlreadyHaveEventHandler = (id: string) => {
|
||||||
|
if (params.alreadyHaveEvent?.(id)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const have = _knownIds.has(id)
|
||||||
|
_knownIds.add(id)
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
// open a subscription in all given relays
|
||||||
|
const allOpened = Promise.all(
|
||||||
|
relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||||
|
if (arr.indexOf(url) !== i) {
|
||||||
|
// duplicate
|
||||||
|
handleClose(i, 'duplicate url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let relay: AbstractRelay
|
||||||
|
try {
|
||||||
|
relay = await this.ensureRelay(url, {
|
||||||
|
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
handleClose(i, (err as any)?.message || String(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = relay.subscribe(filters, {
|
||||||
|
...params,
|
||||||
|
oneose: () => handleEose(i),
|
||||||
|
onclose: reason => handleClose(i, reason),
|
||||||
|
alreadyHaveEvent: localAlreadyHaveEventHandler,
|
||||||
|
eoseTimeout: params.maxWait,
|
||||||
|
})
|
||||||
|
|
||||||
|
subs.push(subscription)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
async close() {
|
||||||
|
await allOpened
|
||||||
|
subs.forEach(sub => {
|
||||||
|
sub.close()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeManyEose(
|
||||||
|
relays: string[],
|
||||||
|
filters: Filter[],
|
||||||
|
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
|
||||||
|
): SubCloser {
|
||||||
|
const subcloser = this.subscribeMany(relays, filters, {
|
||||||
|
...params,
|
||||||
|
oneose() {
|
||||||
|
subcloser.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return subcloser
|
||||||
|
}
|
||||||
|
|
||||||
|
async querySync(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event[]> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
const events: Event[] = []
|
||||||
|
this.subscribeManyEose(relays, [filter], {
|
||||||
|
...params,
|
||||||
|
onevent(event: Event) {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
onclose(_: string[]) {
|
||||||
|
resolve(events)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
relays: string[],
|
||||||
|
filter: Filter,
|
||||||
|
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
|
||||||
|
): Promise<Event | null> {
|
||||||
|
filter.limit = 1
|
||||||
|
const events = await this.querySync(relays, filter, params)
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
return events[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(relays: string[], event: Event): Promise<string>[] {
|
||||||
|
return relays.map(normalizeURL).map(async (url, i, arr) => {
|
||||||
|
if (arr.indexOf(url) !== i) {
|
||||||
|
// duplicate
|
||||||
|
return Promise.reject('duplicate url')
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = await this.ensureRelay(url)
|
||||||
|
return r.publish(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
369
abstract-relay.ts
Normal file
369
abstract-relay.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/* global WebSocket */
|
||||||
|
|
||||||
|
import type { Event, EventTemplate, VerifiedEvent, Nostr } from './core.ts'
|
||||||
|
import { matchFilters, type Filter } from './filter.ts'
|
||||||
|
import { getHex64, getSubscriptionId } from './fakejson.ts'
|
||||||
|
import { Queue, normalizeURL } from './utils.ts'
|
||||||
|
import { makeAuthEvent } from './nip42.ts'
|
||||||
|
import { yieldThread } from './helpers.ts'
|
||||||
|
|
||||||
|
var _WebSocket: typeof WebSocket
|
||||||
|
|
||||||
|
try {
|
||||||
|
_WebSocket = WebSocket
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useWebSocketImplementation(websocketImplementation: any) {
|
||||||
|
_WebSocket = websocketImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbstractRelay {
|
||||||
|
public readonly url: string
|
||||||
|
private _connected: boolean = false
|
||||||
|
|
||||||
|
public onclose: (() => void) | null = null
|
||||||
|
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
|
||||||
|
|
||||||
|
public baseEoseTimeout: number = 4400
|
||||||
|
public connectionTimeout: number = 4400
|
||||||
|
public openSubs = new Map<string, Subscription>()
|
||||||
|
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
private connectionPromise: Promise<void> | undefined
|
||||||
|
private openCountRequests = new Map<string, CountResolver>()
|
||||||
|
private openEventPublishes = new Map<string, EventPublishResolver>()
|
||||||
|
private ws: WebSocket | undefined
|
||||||
|
private incomingMessageQueue = new Queue<string>()
|
||||||
|
private queueRunning = false
|
||||||
|
private challenge: string | undefined
|
||||||
|
private serial: number = 0
|
||||||
|
private verifyEvent: Nostr['verifyEvent']
|
||||||
|
|
||||||
|
constructor(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
this.url = normalizeURL(url)
|
||||||
|
this.verifyEvent = opts.verifyEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string, opts: { verifyEvent: Nostr['verifyEvent'] }) {
|
||||||
|
const relay = new AbstractRelay(url, opts)
|
||||||
|
await relay.connect()
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeAllSubscriptions(reason: string) {
|
||||||
|
for (let [_, sub] of this.openSubs) {
|
||||||
|
sub.close(reason)
|
||||||
|
}
|
||||||
|
this.openSubs.clear()
|
||||||
|
|
||||||
|
for (let [_, ep] of this.openEventPublishes) {
|
||||||
|
ep.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this.openEventPublishes.clear()
|
||||||
|
|
||||||
|
for (let [_, cr] of this.openCountRequests) {
|
||||||
|
cr.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this.openCountRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
public get connected(): boolean {
|
||||||
|
return this._connected
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
if (this.connectionPromise) return this.connectionPromise
|
||||||
|
|
||||||
|
this.challenge = undefined
|
||||||
|
this.connectionPromise = new Promise((resolve, reject) => {
|
||||||
|
this.connectionTimeoutHandle = setTimeout(() => {
|
||||||
|
reject('connection timed out')
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection timed out')
|
||||||
|
}, this.connectionTimeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new _WebSocket(this.url)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
clearTimeout(this.connectionTimeoutHandle)
|
||||||
|
this._connected = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = ev => {
|
||||||
|
reject((ev as any).message)
|
||||||
|
if (this._connected) {
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection errored')
|
||||||
|
this._connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = async () => {
|
||||||
|
this.connectionPromise = undefined
|
||||||
|
this.onclose?.()
|
||||||
|
this.closeAllSubscriptions('relay connection closed')
|
||||||
|
this._connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = this._onmessage.bind(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.connectionPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runQueue() {
|
||||||
|
this.queueRunning = true
|
||||||
|
while (true) {
|
||||||
|
if (false === this.handleNext()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await yieldThread()
|
||||||
|
}
|
||||||
|
this.queueRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNext(): undefined | false {
|
||||||
|
const json = this.incomingMessageQueue.dequeue()
|
||||||
|
if (!json) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const subid = getSubscriptionId(json)
|
||||||
|
if (subid) {
|
||||||
|
const so = this.openSubs.get(subid as string)
|
||||||
|
if (!so) {
|
||||||
|
// this is an EVENT message, but for a subscription we don't have, so just stop here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// this will be called only when this message is a EVENT message for a subscription we have
|
||||||
|
// we do this before parsing the JSON to not have to do that for duplicate events
|
||||||
|
// since JSON parsing is slow
|
||||||
|
const id = getHex64(json, 'id')
|
||||||
|
const alreadyHave = so.alreadyHaveEvent?.(id)
|
||||||
|
|
||||||
|
// notify any interested client that the relay has this event
|
||||||
|
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
|
||||||
|
so.receivedEvent?.(this, id)
|
||||||
|
|
||||||
|
if (alreadyHave) {
|
||||||
|
// if we had already seen this event we can just stop here
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(json)
|
||||||
|
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
|
||||||
|
// will naturally be caught by the encompassing try..catch block
|
||||||
|
|
||||||
|
switch (data[0]) {
|
||||||
|
case 'EVENT': {
|
||||||
|
const so = this.openSubs.get(data[1] as string) as Subscription
|
||||||
|
const event = data[2] as Event
|
||||||
|
if (this.verifyEvent(event) && matchFilters(so.filters, event)) {
|
||||||
|
so.onevent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'COUNT': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const payload = data[2] as { count: number }
|
||||||
|
const cr = this.openCountRequests.get(id) as CountResolver
|
||||||
|
if (cr) {
|
||||||
|
cr.resolve(payload.count)
|
||||||
|
this.openCountRequests.delete(id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'EOSE': {
|
||||||
|
const so = this.openSubs.get(data[1] as string)
|
||||||
|
if (!so) return
|
||||||
|
so.receivedEose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'OK': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const ok: boolean = data[2]
|
||||||
|
const reason: string = data[3]
|
||||||
|
const ep = this.openEventPublishes.get(id) as EventPublishResolver
|
||||||
|
if (ok) ep.resolve(reason)
|
||||||
|
else ep.reject(new Error(reason))
|
||||||
|
this.openEventPublishes.delete(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'CLOSED': {
|
||||||
|
const id: string = data[1]
|
||||||
|
const so = this.openSubs.get(id)
|
||||||
|
if (!so) return
|
||||||
|
so.closed = true
|
||||||
|
so.close(data[2] as string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'NOTICE':
|
||||||
|
this.onnotice(data[1] as string)
|
||||||
|
return
|
||||||
|
case 'AUTH': {
|
||||||
|
this.challenge = data[1] as string
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(message: string) {
|
||||||
|
if (!this.connectionPromise) throw new Error('sending on closed connection')
|
||||||
|
|
||||||
|
this.connectionPromise.then(() => {
|
||||||
|
this.ws?.send(message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>) {
|
||||||
|
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
|
||||||
|
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(evt.id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public async publish(event: Event): Promise<string> {
|
||||||
|
const ret = new Promise<string>((resolve, reject) => {
|
||||||
|
this.openEventPublishes.set(event.id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["EVENT",' + JSON.stringify(event) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
|
||||||
|
this.serial++
|
||||||
|
const id = params?.id || 'count:' + this.serial
|
||||||
|
const ret = new Promise<number>((resolve, reject) => {
|
||||||
|
this.openCountRequests.set(id, { resolve, reject })
|
||||||
|
})
|
||||||
|
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
|
||||||
|
const subscription = this.prepareSubscription(filters, params)
|
||||||
|
subscription.fire()
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
|
||||||
|
this.serial++
|
||||||
|
const id = params.id || 'sub:' + this.serial
|
||||||
|
const subscription = new Subscription(this, id, filters, params)
|
||||||
|
this.openSubs.set(id, subscription)
|
||||||
|
return subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.closeAllSubscriptions('relay connection closed by us')
|
||||||
|
this._connected = false
|
||||||
|
this.ws?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is the function assigned to this.ws.onmessage
|
||||||
|
// it's exposed for testing and debugging purposes
|
||||||
|
public _onmessage(ev: MessageEvent<any>) {
|
||||||
|
this.incomingMessageQueue.enqueue(ev.data as string)
|
||||||
|
if (!this.queueRunning) {
|
||||||
|
this.runQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Subscription {
|
||||||
|
public readonly relay: AbstractRelay
|
||||||
|
public readonly id: string
|
||||||
|
|
||||||
|
public closed: boolean = false
|
||||||
|
public eosed: boolean = false
|
||||||
|
public filters: Filter[]
|
||||||
|
public alreadyHaveEvent: ((id: string) => boolean) | undefined
|
||||||
|
public receivedEvent: ((relay: AbstractRelay, id: string) => void) | undefined
|
||||||
|
|
||||||
|
public onevent: (evt: Event) => void
|
||||||
|
public oneose: (() => void) | undefined
|
||||||
|
public onclose: ((reason: string) => void) | undefined
|
||||||
|
|
||||||
|
public eoseTimeout: number
|
||||||
|
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
constructor(relay: AbstractRelay, id: string, filters: Filter[], params: SubscriptionParams) {
|
||||||
|
this.relay = relay
|
||||||
|
this.filters = filters
|
||||||
|
this.id = id
|
||||||
|
this.alreadyHaveEvent = params.alreadyHaveEvent
|
||||||
|
this.receivedEvent = params.receivedEvent
|
||||||
|
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
|
||||||
|
|
||||||
|
this.oneose = params.oneose
|
||||||
|
this.onclose = params.onclose
|
||||||
|
this.onevent =
|
||||||
|
params.onevent ||
|
||||||
|
(event => {
|
||||||
|
console.warn(
|
||||||
|
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public fire() {
|
||||||
|
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
|
||||||
|
|
||||||
|
// only now we start counting the eoseTimeout
|
||||||
|
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
public receivedEose() {
|
||||||
|
if (this.eosed) return
|
||||||
|
clearTimeout(this.eoseTimeoutHandle)
|
||||||
|
this.eosed = true
|
||||||
|
this.oneose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(reason: string = 'closed by caller') {
|
||||||
|
if (!this.closed) {
|
||||||
|
// if the connection was closed by the user calling .close() we will send a CLOSE message
|
||||||
|
// otherwise this._open will be already set to false so we will skip this
|
||||||
|
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
|
||||||
|
this.closed = true
|
||||||
|
}
|
||||||
|
this.relay.openSubs.delete(this.id)
|
||||||
|
this.onclose?.(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionParams = {
|
||||||
|
onevent?: (evt: Event) => void
|
||||||
|
oneose?: () => void
|
||||||
|
onclose?: (reason: string) => void
|
||||||
|
alreadyHaveEvent?: (id: string) => boolean
|
||||||
|
receivedEvent?: (relay: AbstractRelay, id: string) => void
|
||||||
|
eoseTimeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountResolver = {
|
||||||
|
resolve: (count: number) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventPublishResolver = {
|
||||||
|
resolve: (reason: string) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
61
benchmarks.ts
Normal file
61
benchmarks.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { run, bench, group, baseline } from 'mitata'
|
||||||
|
import { initNostrWasm } from 'nostr-wasm'
|
||||||
|
import { NostrEvent } from './core'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure'
|
||||||
|
import { setNostrWasm, verifyEvent } from './wasm'
|
||||||
|
import { AbstractRelay } from './abstract-relay.ts'
|
||||||
|
import { Relay as PureRelay } from './relay.ts'
|
||||||
|
import { alwaysTrue } from './helpers.ts'
|
||||||
|
|
||||||
|
// benchmarking relay reads with verifyEvent
|
||||||
|
const EVENTS = 200
|
||||||
|
let messages: string[] = []
|
||||||
|
let baseContent = ''
|
||||||
|
for (let i = 0; i < EVENTS; i++) {
|
||||||
|
baseContent += 'a'
|
||||||
|
}
|
||||||
|
const secretKey = generateSecretKey()
|
||||||
|
for (let i = 0; i < EVENTS; i++) {
|
||||||
|
const tags = []
|
||||||
|
for (let t = 0; t < i; t++) {
|
||||||
|
tags.push(['t', 'nada'])
|
||||||
|
}
|
||||||
|
const event = { created_at: Math.round(Date.now()) / 1000, kind: 1, content: baseContent.slice(0, EVENTS - i), tags }
|
||||||
|
const signed = finalizeEvent(event, secretKey)
|
||||||
|
messages.push(JSON.stringify(['EVENT', '_', signed]))
|
||||||
|
}
|
||||||
|
|
||||||
|
setNostrWasm(await initNostrWasm())
|
||||||
|
|
||||||
|
const pureRelay = new PureRelay('wss://pure.com/')
|
||||||
|
const trustedRelay = new AbstractRelay('wss://trusted.com/', { verifyEvent: alwaysTrue })
|
||||||
|
const wasmRelay = new AbstractRelay('wss://wasm.com/', { verifyEvent })
|
||||||
|
|
||||||
|
const runWith = (relay: AbstractRelay) => async () => {
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
let received = 0
|
||||||
|
let sub = relay.prepareSubscription([{}], {
|
||||||
|
id: '_',
|
||||||
|
onevent(_: NostrEvent) {
|
||||||
|
received++
|
||||||
|
if (received === messages.length - 1) {
|
||||||
|
resolve()
|
||||||
|
sub.closed = true
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (let e = 0; e < messages.length; e++) {
|
||||||
|
relay._onmessage({ data: messages[e] } as any)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
group(`relay read ${EVENTS} messages and verify its events`, () => {
|
||||||
|
baseline('wasm', runWith(wasmRelay))
|
||||||
|
bench('pure js', runWith(pureRelay))
|
||||||
|
bench('trusted', runWith(trustedRelay))
|
||||||
|
})
|
||||||
|
|
||||||
|
// actually running the thing
|
||||||
|
await run()
|
||||||
75
build.js
75
build.js
@@ -1,25 +1,60 @@
|
|||||||
#!/usr/bin/env node
|
const fs = require('node:fs')
|
||||||
|
|
||||||
const esbuild = require('esbuild')
|
const esbuild = require('esbuild')
|
||||||
const alias = require('esbuild-plugin-alias')
|
const { join } = require('path')
|
||||||
const nodeGlobals = require('@esbuild-plugins/node-globals-polyfill').default
|
|
||||||
|
|
||||||
const buildOptions = {
|
const entryPoints = fs
|
||||||
entryPoints: ['index.js'],
|
.readdirSync(process.cwd())
|
||||||
outfile: 'nostr.js',
|
.filter(
|
||||||
|
file =>
|
||||||
|
file.endsWith('.ts') &&
|
||||||
|
file !== 'core.ts' &&
|
||||||
|
file !== 'test-helpers.ts' &&
|
||||||
|
file !== 'helpers.ts' &&
|
||||||
|
file !== 'benchmarks.ts' &&
|
||||||
|
!file.endsWith('.test.ts') &&
|
||||||
|
fs.statSync(join(process.cwd(), file)).isFile(),
|
||||||
|
)
|
||||||
|
|
||||||
|
let common = {
|
||||||
|
entryPoints,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'esm',
|
sourcemap: 'external',
|
||||||
plugins: [
|
|
||||||
alias({
|
|
||||||
stream: require.resolve('readable-stream')
|
|
||||||
}),
|
|
||||||
nodeGlobals({buffer: true})
|
|
||||||
],
|
|
||||||
define: {
|
|
||||||
window: 'self',
|
|
||||||
global: 'self'
|
|
||||||
},
|
|
||||||
loader: {'.js': 'jsx'}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
esbuild.build(buildOptions).then(() => console.log('build success.'))
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
outdir: 'lib/esm',
|
||||||
|
format: 'esm',
|
||||||
|
packages: 'external',
|
||||||
|
})
|
||||||
|
.then(() => console.log('esm build success.'))
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
outdir: 'lib/cjs',
|
||||||
|
format: 'cjs',
|
||||||
|
packages: 'external',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const packageJson = JSON.stringify({ type: 'commonjs' })
|
||||||
|
fs.writeFileSync(`${__dirname}/lib/cjs/package.json`, packageJson, 'utf8')
|
||||||
|
|
||||||
|
console.log('cjs build success.')
|
||||||
|
})
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
...common,
|
||||||
|
entryPoints: ['index.ts'],
|
||||||
|
outfile: 'lib/nostr.bundle.js',
|
||||||
|
format: 'iife',
|
||||||
|
globalName: 'NostrTools',
|
||||||
|
define: {
|
||||||
|
window: 'self',
|
||||||
|
global: 'self',
|
||||||
|
process: '{"env": {}}',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => console.log('standalone build success.'))
|
||||||
|
|||||||
293
core.test.ts
Normal file
293
core.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
51
core.ts
Normal file
51
core.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface Nostr {
|
||||||
|
generateSecretKey(): Uint8Array
|
||||||
|
getPublicKey(secretKey: Uint8Array): string
|
||||||
|
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
|
||||||
|
verifyEvent(event: Event): event is VerifiedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Designates a verified event signature. */
|
||||||
|
export const verifiedSymbol = Symbol('verified')
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
pubkey: string
|
||||||
|
id: string
|
||||||
|
sig: string
|
||||||
|
[verifiedSymbol]?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrEvent = Event
|
||||||
|
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
|
||||||
|
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
|
||||||
|
|
||||||
|
/** An event whose signature has been verified. */
|
||||||
|
export interface VerifiedEvent extends Event {
|
||||||
|
[verifiedSymbol]: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
|
||||||
|
|
||||||
|
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
|
||||||
|
if (!isRecord(event)) return false
|
||||||
|
if (typeof event.kind !== 'number') return false
|
||||||
|
if (typeof event.content !== 'string') return false
|
||||||
|
if (typeof event.created_at !== 'number') return false
|
||||||
|
if (typeof event.pubkey !== 'string') return false
|
||||||
|
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
|
||||||
|
|
||||||
|
if (!Array.isArray(event.tags)) return false
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
let tag = event.tags[i]
|
||||||
|
if (!Array.isArray(tag)) return false
|
||||||
|
for (let j = 0; j < tag.length; j++) {
|
||||||
|
if (typeof tag[j] === 'object') return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
58
event.js
58
event.js
@@ -1,58 +0,0 @@
|
|||||||
import {Buffer} from 'buffer'
|
|
||||||
import createHash from 'create-hash'
|
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
|
|
||||||
export function getBlankEvent() {
|
|
||||||
return {
|
|
||||||
kind: 255,
|
|
||||||
pubkey: null,
|
|
||||||
content: '',
|
|
||||||
tags: [],
|
|
||||||
created_at: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeEvent(evt) {
|
|
||||||
return JSON.stringify([
|
|
||||||
0,
|
|
||||||
evt.pubkey,
|
|
||||||
evt.created_at,
|
|
||||||
evt.kind,
|
|
||||||
evt.tags,
|
|
||||||
evt.content
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEventHash(event) {
|
|
||||||
let eventHash = createHash('sha256')
|
|
||||||
.update(Buffer.from(serializeEvent(event)))
|
|
||||||
.digest()
|
|
||||||
return Buffer.from(eventHash).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateEvent(event) {
|
|
||||||
if (event.id !== getEventHash(event)) return false
|
|
||||||
if (typeof event.content !== 'string') return false
|
|
||||||
if (typeof event.created_at !== 'number') return false
|
|
||||||
|
|
||||||
if (!Array.isArray(event.tags)) return false
|
|
||||||
for (let i = 0; i < event.tags.length; i++) {
|
|
||||||
let tag = event.tags[i]
|
|
||||||
if (!Array.isArray(tag)) return false
|
|
||||||
for (let j = 0; j < tag.length; j++) {
|
|
||||||
if (typeof tag[j] === 'object') return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifySignature(event) {
|
|
||||||
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signEvent(event, key) {
|
|
||||||
return Buffer.from(
|
|
||||||
await secp256k1.schnorr.sign(getEventHash(event), key)
|
|
||||||
).toString('hex')
|
|
||||||
}
|
|
||||||
44
fakejson.test.ts
Normal file
44
fakejson.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { matchEventId, matchEventKind, getSubscriptionId } from './fakejson.ts'
|
||||||
|
|
||||||
|
test('match id', () => {
|
||||||
|
expect(
|
||||||
|
matchEventId(
|
||||||
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
|
),
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchEventId(
|
||||||
|
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
|
||||||
|
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
|
),
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('match kind', () => {
|
||||||
|
expect(
|
||||||
|
matchEventKind(
|
||||||
|
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchEventKind(
|
||||||
|
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
|
||||||
|
12720,
|
||||||
|
),
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('match subscription id', () => {
|
||||||
|
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
|
||||||
|
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
|
||||||
|
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
|
||||||
|
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual('kasjbdjkav')
|
||||||
|
expect(
|
||||||
|
getSubscriptionId(' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'),
|
||||||
|
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
|
||||||
|
})
|
||||||
41
fakejson.ts
Normal file
41
fakejson.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export function getHex64(json: string, field: string): string {
|
||||||
|
let len = field.length + 3
|
||||||
|
let idx = json.indexOf(`"${field}":`) + len
|
||||||
|
let s = json.slice(idx).indexOf(`"`) + idx + 1
|
||||||
|
return json.slice(s, s + 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInt(json: string, field: string): number {
|
||||||
|
let len = field.length
|
||||||
|
let idx = json.indexOf(`"${field}":`) + len + 3
|
||||||
|
let sliced = json.slice(idx)
|
||||||
|
let end = Math.min(sliced.indexOf(','), sliced.indexOf('}'))
|
||||||
|
return parseInt(sliced.slice(0, end), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubscriptionId(json: string): string | null {
|
||||||
|
let idx = json.slice(0, 22).indexOf(`"EVENT"`)
|
||||||
|
if (idx === -1) return null
|
||||||
|
|
||||||
|
let pstart = json.slice(idx + 7 + 1).indexOf(`"`)
|
||||||
|
if (pstart === -1) return null
|
||||||
|
let start = idx + 7 + 1 + pstart
|
||||||
|
|
||||||
|
let pend = json.slice(start + 1, 80).indexOf(`"`)
|
||||||
|
if (pend === -1) return null
|
||||||
|
let end = start + 1 + pend
|
||||||
|
|
||||||
|
return json.slice(start + 1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchEventId(json: string, id: string): boolean {
|
||||||
|
return id === getHex64(json, 'id')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchEventPubkey(json: string, pubkey: string): boolean {
|
||||||
|
return pubkey === getHex64(json, 'pubkey')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchEventKind(json: string, kind: number): boolean {
|
||||||
|
return kind === getInt(json, 'kind')
|
||||||
|
}
|
||||||
30
filter.js
30
filter.js
@@ -1,30 +0,0 @@
|
|||||||
export function matchFilter(filter, event) {
|
|
||||||
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
|
|
||||||
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
|
|
||||||
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
|
|
||||||
return false
|
|
||||||
|
|
||||||
for (let f in filter) {
|
|
||||||
if (f[0] === '#') {
|
|
||||||
if (
|
|
||||||
filter[f] &&
|
|
||||||
!event.tags.find(
|
|
||||||
([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.since && event.created_at < filter.since) return false
|
|
||||||
if (filter.until && event.created_at >= filter.until) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchFilters(filters, event) {
|
|
||||||
for (let i = 0; i < filters.length; i++) {
|
|
||||||
if (matchFilter(filters[i], event)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
267
filter.test.ts
Normal file
267
filter.test.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { getFilterLimit, matchFilter, matchFilters, mergeFilters } from './filter.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
describe('Filter', () => {
|
||||||
|
describe('matchFilter', () => {
|
||||||
|
test('should return true when all filter conditions are met', () => {
|
||||||
|
const filter = {
|
||||||
|
ids: ['123', '456'],
|
||||||
|
kinds: [1, 2, 3],
|
||||||
|
authors: ['abc'],
|
||||||
|
since: 100,
|
||||||
|
until: 200,
|
||||||
|
'#tag': ['value'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150,
|
||||||
|
tags: [['tag', 'value']],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event id is not in the filter', () => {
|
||||||
|
const filter = { ids: ['123', '456'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '789' })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const filter = { kinds: [1, 2, 3] }
|
||||||
|
|
||||||
|
const event = buildEvent({ kind: 4 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event author is not in the filter', () => {
|
||||||
|
const filter = { authors: ['abc', 'def'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ pubkey: 'ghi' })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when a tag is not present in the event', () => {
|
||||||
|
const filter = { '#tag': ['value1', 'value2'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ tags: [['not_tag', 'value1']] })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when a tag value is not present in the event', () => {
|
||||||
|
const filter = { '#tag': ['value1', 'value2'] }
|
||||||
|
|
||||||
|
const event = buildEvent({ tags: [['tag', 'value3']] })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when filter has tags that is present in the event', () => {
|
||||||
|
const filter = { '#tag1': ['foo'] }
|
||||||
|
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150,
|
||||||
|
tags: [
|
||||||
|
['tag1', 'foo'],
|
||||||
|
['tag2', 'bar'],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event is before the filter since value', () => {
|
||||||
|
const filter = { since: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 50 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when the timestamp of event is equal to the filter since value', () => {
|
||||||
|
const filter = { since: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 100 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the event is after the filter until value', () => {
|
||||||
|
const filter = { until: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 150 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when the timestamp of event is equal to the filter until value', () => {
|
||||||
|
const filter = { until: 100 }
|
||||||
|
|
||||||
|
const event = buildEvent({ created_at: 100 })
|
||||||
|
|
||||||
|
const result = matchFilter(filter, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchFilters', () => {
|
||||||
|
test('should return true when at least one filter matches the event', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||||
|
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||||
|
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '789', kind: 3, pubkey: 'ghi' })
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return true when event matches one or more filters and some have limit set', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], limit: 1 },
|
||||||
|
{ kinds: [1], limit: 2 },
|
||||||
|
{ authors: ['abc'], limit: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '123',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: 'abc',
|
||||||
|
created_at: 150,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when no filters match the event', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], kinds: [1], authors: ['abc'] },
|
||||||
|
{ ids: ['456'], kinds: [2], authors: ['def'] },
|
||||||
|
{ ids: ['789'], kinds: [3], authors: ['ghi'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const event = buildEvent({ id: '100', kind: 4, pubkey: 'jkl' })
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when event matches none of the filters and some have limit set', () => {
|
||||||
|
const filters = [
|
||||||
|
{ ids: ['123'], limit: 1 },
|
||||||
|
{ kinds: [1], limit: 2 },
|
||||||
|
{ authors: ['abc'], limit: 3 },
|
||||||
|
]
|
||||||
|
const event = buildEvent({
|
||||||
|
id: '456',
|
||||||
|
kind: 2,
|
||||||
|
pubkey: 'def',
|
||||||
|
created_at: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = matchFilters(filters, event)
|
||||||
|
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeFilters', () => {
|
||||||
|
test('should merge filters', () => {
|
||||||
|
expect(mergeFilters({ ids: ['a', 'b'], limit: 3 }, { authors: ['x'], ids: ['b', 'c'] })).toEqual({
|
||||||
|
ids: ['a', 'b', 'c'],
|
||||||
|
limit: 3,
|
||||||
|
authors: ['x'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mergeFilters({ kinds: [1], since: 15, until: 30 }, { since: 10, kinds: [7], until: 15 }, { kinds: [9, 10] }),
|
||||||
|
).toEqual({ kinds: [1, 7, 9, 10], since: 10, until: 30 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getFilterLimit', () => {
|
||||||
|
test('should handle ids', () => {
|
||||||
|
expect(getFilterLimit({ ids: ['123'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: 2 })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: 0 })).toEqual(0)
|
||||||
|
expect(getFilterLimit({ ids: ['123'], limit: -1 })).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should count the authors times replaceable kinds', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [0], authors: ['alex'] })).toEqual(1)
|
||||||
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex'] })).toEqual(2)
|
||||||
|
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return Infinity for authors with regular kinds', () => {
|
||||||
|
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return Infinity for empty filters', () => {
|
||||||
|
expect(getFilterLimit({})).toEqual(Infinity)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
88
filter.ts
Normal file
88
filter.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Event } from './core.ts'
|
||||||
|
import { isReplaceableKind } from './kinds.ts'
|
||||||
|
|
||||||
|
export type Filter = {
|
||||||
|
ids?: string[]
|
||||||
|
kinds?: number[]
|
||||||
|
authors?: string[]
|
||||||
|
since?: number
|
||||||
|
until?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
[key: `#${string}`]: string[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchFilter(filter: Filter, event: Event): boolean {
|
||||||
|
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
|
||||||
|
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
|
||||||
|
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.some(prefix => event.pubkey.startsWith(prefix))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let f in filter) {
|
||||||
|
if (f[0] === '#') {
|
||||||
|
let tagName = f.slice(1)
|
||||||
|
let values = filter[`#${tagName}`]
|
||||||
|
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.since && event.created_at < filter.since) return false
|
||||||
|
if (filter.until && event.created_at > filter.until) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchFilters(filters: Filter[], event: Event): boolean {
|
||||||
|
for (let i = 0; i < filters.length; i++) {
|
||||||
|
if (matchFilter(filters[i], event)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFilters(...filters: Filter[]): Filter {
|
||||||
|
let result: Filter = {}
|
||||||
|
for (let i = 0; i < filters.length; i++) {
|
||||||
|
let filter = filters[i]
|
||||||
|
Object.entries(filter).forEach(([property, values]) => {
|
||||||
|
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
|
||||||
|
// @ts-ignore
|
||||||
|
result[property] = result[property] || []
|
||||||
|
// @ts-ignore
|
||||||
|
for (let v = 0; v < values.length; v++) {
|
||||||
|
// @ts-ignore
|
||||||
|
let value = values[v]
|
||||||
|
// @ts-ignore
|
||||||
|
if (!result[property].includes(value)) result[property].push(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filter.limit && (!result.limit || filter.limit > result.limit)) result.limit = filter.limit
|
||||||
|
if (filter.until && (!result.until || filter.until > result.until)) result.until = filter.until
|
||||||
|
if (filter.since && (!result.since || filter.since < result.since)) result.since = filter.since
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */
|
||||||
|
export function getFilterLimit(filter: Filter): number {
|
||||||
|
if (filter.ids && !filter.ids.length) return 0
|
||||||
|
if (filter.kinds && !filter.kinds.length) return 0
|
||||||
|
if (filter.authors && !filter.authors.length) return 0
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.max(0, filter.limit ?? Infinity),
|
||||||
|
filter.ids?.length ?? Infinity,
|
||||||
|
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
|
||||||
|
? filter.authors.length * filter.kinds.length
|
||||||
|
: Infinity,
|
||||||
|
)
|
||||||
|
}
|
||||||
21
helpers.ts
Normal file
21
helpers.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { verifiedSymbol, type Event, type Nostr, VerifiedEvent } from './core.ts'
|
||||||
|
|
||||||
|
export async function yieldThread() {
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
const ch = new MessageChannel()
|
||||||
|
const handler = () => {
|
||||||
|
// @ts-ignore (typescript thinks this property should be called `removeListener`, but in fact it's `removeEventListener`)
|
||||||
|
ch.port1.removeEventListener('message', handler)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
|
||||||
|
ch.port1.addEventListener('message', handler)
|
||||||
|
ch.port2.postMessage(0)
|
||||||
|
ch.port1.start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alwaysTrue: Nostr['verifyEvent'] = (t: Event): t is VerifiedEvent => {
|
||||||
|
t[verifiedSymbol] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
27
index.js
27
index.js
@@ -1,27 +0,0 @@
|
|||||||
import {generatePrivateKey, getPublicKey} from './keys'
|
|
||||||
import {relayConnect} from './relay'
|
|
||||||
import {relayPool} from './pool'
|
|
||||||
import {
|
|
||||||
getBlankEvent,
|
|
||||||
signEvent,
|
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
serializeEvent,
|
|
||||||
getEventHash
|
|
||||||
} from './event'
|
|
||||||
import {matchFilter, matchFilters} from './filter'
|
|
||||||
|
|
||||||
export {
|
|
||||||
generatePrivateKey,
|
|
||||||
relayConnect,
|
|
||||||
relayPool,
|
|
||||||
signEvent,
|
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
serializeEvent,
|
|
||||||
getEventHash,
|
|
||||||
getPublicKey,
|
|
||||||
getBlankEvent,
|
|
||||||
matchFilter,
|
|
||||||
matchFilters
|
|
||||||
}
|
|
||||||
28
index.ts
Normal file
28
index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export * from './pure.ts'
|
||||||
|
export * from './relay.ts'
|
||||||
|
export * from './filter.ts'
|
||||||
|
export * from './pool.ts'
|
||||||
|
export * from './references.ts'
|
||||||
|
|
||||||
|
export * as nip04 from './nip04.ts'
|
||||||
|
export * as nip05 from './nip05.ts'
|
||||||
|
export * as nip10 from './nip10.ts'
|
||||||
|
export * as nip11 from './nip11.ts'
|
||||||
|
export * as nip13 from './nip13.ts'
|
||||||
|
export * as nip18 from './nip18.ts'
|
||||||
|
export * as nip19 from './nip19.ts'
|
||||||
|
export * as nip21 from './nip21.ts'
|
||||||
|
export * as nip25 from './nip25.ts'
|
||||||
|
export * as nip27 from './nip27.ts'
|
||||||
|
export * as nip28 from './nip28.ts'
|
||||||
|
export * as nip30 from './nip30.ts'
|
||||||
|
export * as nip39 from './nip39.ts'
|
||||||
|
export * as nip42 from './nip42.ts'
|
||||||
|
export * as nip44 from './nip44.ts'
|
||||||
|
export * as nip47 from './nip47.ts'
|
||||||
|
export * as nip57 from './nip57.ts'
|
||||||
|
export * as nip98 from './nip98.ts'
|
||||||
|
|
||||||
|
export * as kinds from './kinds.ts'
|
||||||
|
export * as fj from './fakejson.ts'
|
||||||
|
export * as utils from './utils.ts'
|
||||||
30
justfile
Normal file
30
justfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export PATH := "./node_modules/.bin:" + env_var('PATH')
|
||||||
|
|
||||||
|
build:
|
||||||
|
rm -rf lib
|
||||||
|
bun run build.js
|
||||||
|
tsc
|
||||||
|
|
||||||
|
test:
|
||||||
|
bun test --timeout 20000
|
||||||
|
|
||||||
|
test-only file:
|
||||||
|
bun test {{file}}
|
||||||
|
|
||||||
|
publish: build
|
||||||
|
npm publish
|
||||||
|
|
||||||
|
format:
|
||||||
|
eslint --ext .ts --fix *.ts
|
||||||
|
prettier --write *.ts
|
||||||
|
|
||||||
|
lint:
|
||||||
|
eslint --ext .ts *.ts
|
||||||
|
prettier --check *.ts
|
||||||
|
|
||||||
|
benchmark:
|
||||||
|
bun build --target=node --outfile=bench.js benchmarks.ts
|
||||||
|
timeout 60s deno run --allow-read bench.js || true
|
||||||
|
timeout 60s node bench.js || true
|
||||||
|
timeout 60s bun run benchmarks.ts || true
|
||||||
|
rm bench.js
|
||||||
10
keys.js
10
keys.js
@@ -1,10 +0,0 @@
|
|||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
import {Buffer} from 'buffer'
|
|
||||||
|
|
||||||
export function generatePrivateKey() {
|
|
||||||
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicKey(privateKey) {
|
|
||||||
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex')
|
|
||||||
}
|
|
||||||
21
kinds.test.ts
Normal file
21
kinds.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { classifyKind } from './kinds.ts'
|
||||||
|
|
||||||
|
test('kind classification', () => {
|
||||||
|
expect(classifyKind(1)).toBe('regular')
|
||||||
|
expect(classifyKind(5)).toBe('regular')
|
||||||
|
expect(classifyKind(6)).toBe('regular')
|
||||||
|
expect(classifyKind(7)).toBe('regular')
|
||||||
|
expect(classifyKind(1000)).toBe('regular')
|
||||||
|
expect(classifyKind(9999)).toBe('regular')
|
||||||
|
expect(classifyKind(0)).toBe('replaceable')
|
||||||
|
expect(classifyKind(3)).toBe('replaceable')
|
||||||
|
expect(classifyKind(10000)).toBe('replaceable')
|
||||||
|
expect(classifyKind(19999)).toBe('replaceable')
|
||||||
|
expect(classifyKind(20000)).toBe('ephemeral')
|
||||||
|
expect(classifyKind(29999)).toBe('ephemeral')
|
||||||
|
expect(classifyKind(30000)).toBe('parameterized')
|
||||||
|
expect(classifyKind(39999)).toBe('parameterized')
|
||||||
|
expect(classifyKind(40000)).toBe('unknown')
|
||||||
|
expect(classifyKind(255)).toBe('unknown')
|
||||||
|
})
|
||||||
106
kinds.ts
Normal file
106
kinds.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/** Events are **regular**, which means they're all expected to be stored by relays. */
|
||||||
|
export function isRegularKind(kind: number) {
|
||||||
|
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
|
||||||
|
export function isReplaceableKind(kind: number) {
|
||||||
|
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
|
||||||
|
export function isEphemeralKind(kind: number) {
|
||||||
|
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. */
|
||||||
|
export function isParameterizedReplaceableKind(kind: number) {
|
||||||
|
return 30000 <= kind && kind < 40000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classification of the event kind. */
|
||||||
|
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
|
||||||
|
|
||||||
|
/** Determine the classification of this kind of event if known, or `unknown`. */
|
||||||
|
export function classifyKind(kind: number): KindClassification {
|
||||||
|
if (isRegularKind(kind)) return 'regular'
|
||||||
|
if (isReplaceableKind(kind)) return 'replaceable'
|
||||||
|
if (isEphemeralKind(kind)) return 'ephemeral'
|
||||||
|
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Metadata = 0
|
||||||
|
export const ShortTextNote = 1
|
||||||
|
export const RecommendRelay = 2
|
||||||
|
export const Contacts = 3
|
||||||
|
export const EncryptedDirectMessage = 4
|
||||||
|
export const EncryptedDirectMessages = 4
|
||||||
|
export const EventDeletion = 5
|
||||||
|
export const Repost = 6
|
||||||
|
export const Reaction = 7
|
||||||
|
export const BadgeAward = 8
|
||||||
|
export const GenericRepost = 16
|
||||||
|
export const ChannelCreation = 40
|
||||||
|
export const ChannelMetadata = 41
|
||||||
|
export const ChannelMessage = 42
|
||||||
|
export const ChannelHideMessage = 43
|
||||||
|
export const ChannelMuteUser = 44
|
||||||
|
export const OpenTimestamps = 1040
|
||||||
|
export const FileMetadata = 1063
|
||||||
|
export const LiveChatMessage = 1311
|
||||||
|
export const ProblemTracker = 1971
|
||||||
|
export const Report = 1984
|
||||||
|
export const Reporting = 1984
|
||||||
|
export const Label = 1985
|
||||||
|
export const CommunityPostApproval = 4550
|
||||||
|
export const JobRequest = 5999
|
||||||
|
export const JobResult = 6999
|
||||||
|
export const JobFeedback = 7000
|
||||||
|
export const ZapGoal = 9041
|
||||||
|
export const ZapRequest = 9734
|
||||||
|
export const Zap = 9735
|
||||||
|
export const Highlights = 9802
|
||||||
|
export const Mutelist = 10000
|
||||||
|
export const Pinlist = 10001
|
||||||
|
export const RelayList = 10002
|
||||||
|
export const BookmarkList = 10003
|
||||||
|
export const CommunitiesList = 10004
|
||||||
|
export const PublicChatsList = 10005
|
||||||
|
export const BlockedRelaysList = 10006
|
||||||
|
export const SearchRelaysList = 10007
|
||||||
|
export const InterestsList = 10015
|
||||||
|
export const UserEmojiList = 10030
|
||||||
|
export const FileServerPreference = 10096
|
||||||
|
export const NWCWalletInfo = 13194
|
||||||
|
export const LightningPubRPC = 21000
|
||||||
|
export const ClientAuth = 22242
|
||||||
|
export const NWCWalletRequest = 23194
|
||||||
|
export const NWCWalletResponse = 23195
|
||||||
|
export const NostrConnect = 24133
|
||||||
|
export const HTTPAuth = 27235
|
||||||
|
export const Followsets = 30000
|
||||||
|
export const Genericlists = 30001
|
||||||
|
export const Relaysets = 30002
|
||||||
|
export const Bookmarksets = 30003
|
||||||
|
export const Curationsets = 30004
|
||||||
|
export const ProfileBadges = 30008
|
||||||
|
export const BadgeDefinition = 30009
|
||||||
|
export const Interestsets = 30015
|
||||||
|
export const CreateOrUpdateStall = 30017
|
||||||
|
export const CreateOrUpdateProduct = 30018
|
||||||
|
export const LongFormArticle = 30023
|
||||||
|
export const DraftLong = 30024
|
||||||
|
export const Emojisets = 30030
|
||||||
|
export const Application = 30078
|
||||||
|
export const LiveEvent = 30311
|
||||||
|
export const UserStatuses = 30315
|
||||||
|
export const ClassifiedListing = 30402
|
||||||
|
export const DraftClassifiedListing = 30403
|
||||||
|
export const Date = 31922
|
||||||
|
export const Time = 31923
|
||||||
|
export const Calendar = 31924
|
||||||
|
export const CalendarEventRSVP = 31925
|
||||||
|
export const Handlerrecommendation = 31989
|
||||||
|
export const Handlerinformation = 31990
|
||||||
|
export const CommunityDefinition = 34550
|
||||||
42
nip04.js
42
nip04.js
@@ -1,42 +0,0 @@
|
|||||||
import aes from 'browserify-cipher'
|
|
||||||
import {Buffer} from 'buffer'
|
|
||||||
import {randomBytes} from '@noble/hashes/utils'
|
|
||||||
import * as secp256k1 from '@noble/secp256k1'
|
|
||||||
|
|
||||||
export function encrypt(privkey, pubkey, text) {
|
|
||||||
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
|
||||||
const normalizedKey = getNormalizedX(key)
|
|
||||||
|
|
||||||
let iv = Uint8Array.from(randomBytes(16))
|
|
||||||
var cipher = aes.createCipheriv(
|
|
||||||
'aes-256-cbc',
|
|
||||||
Buffer.from(normalizedKey, 'hex'),
|
|
||||||
iv
|
|
||||||
)
|
|
||||||
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
|
|
||||||
encryptedMessage += cipher.final('base64')
|
|
||||||
|
|
||||||
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decrypt(privkey, pubkey, ciphertext) {
|
|
||||||
let [cip, iv] = ciphertext.split('?iv=')
|
|
||||||
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
|
||||||
let normalizedKey = getNormalizedX(key)
|
|
||||||
|
|
||||||
var decipher = aes.createDecipheriv(
|
|
||||||
'aes-256-cbc',
|
|
||||||
Buffer.from(normalizedKey, 'hex'),
|
|
||||||
Buffer.from(iv, 'base64')
|
|
||||||
)
|
|
||||||
let decryptedMessage = decipher.update(cip, 'base64')
|
|
||||||
decryptedMessage += decipher.final('utf8')
|
|
||||||
|
|
||||||
return decryptedMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNormalizedX(key) {
|
|
||||||
return typeof key === 'string'
|
|
||||||
? key.substr(2, 64)
|
|
||||||
: Buffer.from(key.slice(1, 33)).toString('hex')
|
|
||||||
}
|
|
||||||
50
nip04.test.ts
Normal file
50
nip04.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { encrypt, decrypt } from './nip04.ts'
|
||||||
|
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
globalThis.crypto = crypto
|
||||||
|
} catch (err) {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
|
||||||
|
test('encrypt and decrypt message', async () => {
|
||||||
|
let sk1 = generateSecretKey()
|
||||||
|
let sk2 = generateSecretKey()
|
||||||
|
let pk1 = getPublicKey(sk1)
|
||||||
|
let pk2 = getPublicKey(sk2)
|
||||||
|
|
||||||
|
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
|
||||||
|
|
||||||
|
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt message from go-nostr', async () => {
|
||||||
|
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||||
|
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||||
|
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||||
|
|
||||||
|
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
|
||||||
|
|
||||||
|
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt big payload from go-nostr', async () => {
|
||||||
|
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
|
||||||
|
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
|
||||||
|
let pk1 = getPublicKey(hexToBytes(sk1))
|
||||||
|
|
||||||
|
let ciphertext =
|
||||||
|
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
|
||||||
|
let plaintext = ''
|
||||||
|
for (let i = 0; i < 800; i++) {
|
||||||
|
plaintext += 'z'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
|
||||||
|
})
|
||||||
46
nip04.ts
Normal file
46
nip04.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
|
||||||
|
// @ts-ignore
|
||||||
|
crypto.subtle = crypto.webcrypto.subtle
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
|
||||||
|
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||||
|
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||||
|
const normalizedKey = getNormalizedX(key)
|
||||||
|
|
||||||
|
let iv = Uint8Array.from(randomBytes(16))
|
||||||
|
let plaintext = utf8Encoder.encode(text)
|
||||||
|
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||||
|
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||||
|
let ctb64 = base64.encode(new Uint8Array(ciphertext))
|
||||||
|
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
|
||||||
|
|
||||||
|
return `${ctb64}?iv=${ivb64}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
|
||||||
|
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
|
||||||
|
let [ctb64, ivb64] = data.split('?iv=')
|
||||||
|
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
|
||||||
|
let normalizedKey = getNormalizedX(key)
|
||||||
|
|
||||||
|
let cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||||
|
let ciphertext = base64.decode(ctb64)
|
||||||
|
let iv = base64.decode(ivb64)
|
||||||
|
|
||||||
|
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||||
|
|
||||||
|
let text = utf8Decoder.decode(plaintext)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||||
|
return key.slice(1, 33)
|
||||||
|
}
|
||||||
28
nip05.js
28
nip05.js
@@ -1,28 +0,0 @@
|
|||||||
import fetch from 'cross-fetch'
|
|
||||||
|
|
||||||
export async function searchDomain(domain, query = '') {
|
|
||||||
try {
|
|
||||||
let res = await (
|
|
||||||
await fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return res.names
|
|
||||||
} catch (_) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryName(fullname) {
|
|
||||||
try {
|
|
||||||
let [name, domain] = fullname.split('@')
|
|
||||||
if (!domain) return null
|
|
||||||
|
|
||||||
let res = await (
|
|
||||||
await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return res.names && res.names[name]
|
|
||||||
} catch (_) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
nip05.test.ts
Normal file
20
nip05.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
|
import { useFetchImplementation, queryProfile } from './nip05.ts'
|
||||||
|
|
||||||
|
test('fetch nip05 profiles', async () => {
|
||||||
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
let p1 = await queryProfile('jb55.com')
|
||||||
|
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||||
|
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
|
||||||
|
|
||||||
|
let p2 = await queryProfile('jb55@jb55.com')
|
||||||
|
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
|
||||||
|
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
|
||||||
|
|
||||||
|
let p3 = await queryProfile('_@fiatjaf.com')
|
||||||
|
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||||
|
expect(p3!.relays).toEqual(['wss://pyramid.fiatjaf.com', 'wss://nos.lol'])
|
||||||
|
})
|
||||||
53
nip05.ts
Normal file
53
nip05.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
|
||||||
|
*
|
||||||
|
* - 0: full match
|
||||||
|
* - 1: name (optional)
|
||||||
|
* - 2: domain
|
||||||
|
*/
|
||||||
|
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
|
||||||
|
|
||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
|
||||||
|
try {
|
||||||
|
const url = `https://${domain}/.well-known/nostr.json?name=${query}`
|
||||||
|
const res = await _fetch(url, { redirect: 'error' })
|
||||||
|
const json = await res.json()
|
||||||
|
return json.names
|
||||||
|
} catch (_) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
|
||||||
|
const match = fullname.match(NIP05_REGEX)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const [_, name = '_', domain] = match
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||||
|
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||||
|
|
||||||
|
let pubkey = res.names[name]
|
||||||
|
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null
|
||||||
|
} catch (_e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isValid(pubkey: string, nip05: string): Promise<boolean> {
|
||||||
|
let res = await queryProfile(nip05)
|
||||||
|
return res ? res.pubkey === pubkey : false
|
||||||
|
}
|
||||||
26
nip06.js
26
nip06.js
@@ -1,26 +0,0 @@
|
|||||||
import {wordlist} from 'micro-bip39/wordlists/english'
|
|
||||||
import {
|
|
||||||
generateMnemonic,
|
|
||||||
mnemonicToSeedSync,
|
|
||||||
validateMnemonic
|
|
||||||
} from 'micro-bip39'
|
|
||||||
import {HDKey} from 'micro-bip32'
|
|
||||||
|
|
||||||
export function privateKeyFromSeed(seed) {
|
|
||||||
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
|
|
||||||
return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString(
|
|
||||||
'hex'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seedFromWords(mnemonic) {
|
|
||||||
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateSeedWords() {
|
|
||||||
return generateMnemonic(wordlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateWords(words) {
|
|
||||||
return validateMnemonic(words, wordlist)
|
|
||||||
}
|
|
||||||
28
nip06.test.ts
Normal file
28
nip06.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { privateKeyFromSeedWords } from './nip06.ts'
|
||||||
|
|
||||||
|
test('generate private key from a mnemonic', async () => {
|
||||||
|
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic)
|
||||||
|
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
|
||||||
|
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 passphrase = '123'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
|
||||||
|
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 passphrase = '123'
|
||||||
|
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
|
||||||
|
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
|
||||||
|
})
|
||||||
19
nip06.ts
Normal file
19
nip06.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { wordlist } from '@scure/bip39/wordlists/english'
|
||||||
|
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
|
||||||
|
import { HDKey } from '@scure/bip32'
|
||||||
|
|
||||||
|
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
|
||||||
|
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
|
||||||
|
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
|
||||||
|
if (!privateKey) throw new Error('could not derive private key')
|
||||||
|
return bytesToHex(privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSeedWords(): string {
|
||||||
|
return generateMnemonic(wordlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWords(words: string): boolean {
|
||||||
|
return validateMnemonic(words, wordlist)
|
||||||
|
}
|
||||||
229
nip10.test.ts
Normal file
229
nip10.test.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { parse } from './nip10.ts'
|
||||||
|
|
||||||
|
describe('parse NIP10-referenced events', () => {
|
||||||
|
test('legacy + a lot of events', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||||
|
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
|
||||||
|
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
|
||||||
|
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
|
||||||
|
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy + 3 events', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy + 2 events', () => {
|
||||||
|
let event = {
|
||||||
|
tags: [
|
||||||
|
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
|
||||||
|
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
|
||||||
|
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
|
||||||
|
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
|
||||||
|
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
|
||||||
|
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',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recommended + 1 event', () => {
|
||||||
|
let event = {
|
||||||
|
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'],
|
||||||
|
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'],
|
||||||
|
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parse(event)).toEqual({
|
||||||
|
mentions: [],
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
pubkey: 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey: 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reply: {
|
||||||
|
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
|
||||||
|
relays: ['wss://relay.mostr.pub'],
|
||||||
|
},
|
||||||
|
root: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
91
nip10.ts
Normal file
91
nip10.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Event } from './core.ts'
|
||||||
|
import type { EventPointer, ProfilePointer } from './nip19.ts'
|
||||||
|
|
||||||
|
export type NIP10Result = {
|
||||||
|
/**
|
||||||
|
* Pointer to the root of the thread.
|
||||||
|
*/
|
||||||
|
root: EventPointer | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer to a "parent" event that parsed event replies to (responded to).
|
||||||
|
*/
|
||||||
|
reply: EventPointer | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointers to events which may or may not be in the reply chain.
|
||||||
|
*/
|
||||||
|
mentions: EventPointer[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of pubkeys that are involved in the thread in no particular order.
|
||||||
|
*/
|
||||||
|
profiles: ProfilePointer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
|
||||||
|
const result: NIP10Result = {
|
||||||
|
reply: undefined,
|
||||||
|
root: undefined,
|
||||||
|
mentions: [],
|
||||||
|
profiles: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const eTags: string[][] = []
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'e' && tag[1]) {
|
||||||
|
eTags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
|
result.profiles.push({
|
||||||
|
pubkey: tag[1],
|
||||||
|
relays: tag[2] ? [tag[2]] : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
|
||||||
|
const eTag = eTags[eTagIndex]
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
17
nip11.test.ts
Normal file
17
nip11.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import { useFetchImplementation, fetchRelayInformation } from './nip11'
|
||||||
|
|
||||||
|
// TODO: replace with a mock
|
||||||
|
describe('requesting relay as for NIP11', () => {
|
||||||
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
test('testing a relay', async () => {
|
||||||
|
const info = await fetchRelayInformation('wss://atlas.nostr.land')
|
||||||
|
expect(info.name).toEqual('nostr.land')
|
||||||
|
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
|
||||||
|
expect(info.fees).toBeTruthy()
|
||||||
|
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
|
||||||
|
expect(info.software).toEqual('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
286
nip11.ts
Normal file
286
nip11.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelayInformation(url: string) {
|
||||||
|
return (await (
|
||||||
|
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
|
||||||
|
headers: { Accept: 'application/nostr+json' },
|
||||||
|
})
|
||||||
|
).json()) as RelayInformation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Relay Information Document
|
||||||
|
|
||||||
|
* Relays may provide server metadata to clients to inform
|
||||||
|
* them of capabilities, administrative contacts, and
|
||||||
|
* various server attributes. This is made available as a
|
||||||
|
* JSON document over HTTP, on the same URI as the relay's
|
||||||
|
* websocket.
|
||||||
|
|
||||||
|
* Any field may be omitted, and clients MUST ignore any
|
||||||
|
* additional fields they do not understand. Relays MUST
|
||||||
|
* accept CORS requests by sending
|
||||||
|
* `Access-Control-Allow-Origin`,
|
||||||
|
* `Access-Control-Allow-Headers`, and
|
||||||
|
* `Access-Control-Allow-Methods` headers.
|
||||||
|
* @param name string identifying relay
|
||||||
|
* @param description string with detailed information
|
||||||
|
* @param pubkey administrative contact pubkey
|
||||||
|
* @param contact: administrative alternate contact
|
||||||
|
* @param supported_nips a list of NIP numbers supported by
|
||||||
|
* the relay
|
||||||
|
* @param software identifying relay software URL
|
||||||
|
* @param version string version identifier
|
||||||
|
*/
|
||||||
|
export interface BasicRelayInformation {
|
||||||
|
// string identifying relay
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
pubkey: string
|
||||||
|
contact: string
|
||||||
|
supported_nips: number[]
|
||||||
|
software: string
|
||||||
|
version: string
|
||||||
|
// limitation?: Limitations<A, P>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * ## Extra Fields
|
||||||
|
|
||||||
|
* * ### Server Limitations
|
||||||
|
|
||||||
|
* These are limitations imposed by the relay on clients.
|
||||||
|
* Your client should expect that requests which exceed
|
||||||
|
* these practical_ limitations are rejected or fail immediately.
|
||||||
|
* @param max_message_length this is the maximum number of
|
||||||
|
* bytes for incoming JSON that the relay will attempt to
|
||||||
|
* decode and act upon. When you send large subscriptions,
|
||||||
|
* you will be limited by this value. It also effectively
|
||||||
|
* limits the maximum size of any event. Value is calculated
|
||||||
|
* from `[` to `]` and is after UTF-8 serialization (so some
|
||||||
|
* unicode characters will cost 2-3 bytes). It is equal to
|
||||||
|
* the maximum size of the WebSocket message frame.
|
||||||
|
* @param max_subscription total number of subscriptions
|
||||||
|
* that may be active on a single websocket connection to
|
||||||
|
* this relay. It's possible that authenticated clients with
|
||||||
|
* a (paid) relationship to the relay may have higher limits.
|
||||||
|
* @param max_filters maximum number of filter values in
|
||||||
|
* each subscription. Must be one or higher.
|
||||||
|
* @param max_limit the relay server will clamp each
|
||||||
|
* filter's `limit` value to this number.
|
||||||
|
* This means the client won't be able to get more than this
|
||||||
|
* number of events from a single subscription filter. This
|
||||||
|
* clamping is typically done silently by the relay, but
|
||||||
|
* with this number, you can know that there are additional
|
||||||
|
* results if you narrowed your filter's time range or other
|
||||||
|
* parameters.
|
||||||
|
* @param max_subid_length maximum length of subscription id as a
|
||||||
|
* string.
|
||||||
|
* @param min_prefix for `authors` and `ids` filters which
|
||||||
|
* are to match against a hex prefix, you must provide at
|
||||||
|
* least this many hex digits in the prefix.
|
||||||
|
* @param max_event_tags in any event, this is the maximum
|
||||||
|
* number of elements in the `tags` list.
|
||||||
|
* @param max_content_length maximum number of characters in
|
||||||
|
* the `content` field of any event. This is a count of
|
||||||
|
* unicode characters. After serializing into JSON it may be
|
||||||
|
* larger (in bytes), and is still subject to the
|
||||||
|
* max_message_length`, if defined.
|
||||||
|
* @param min_pow_difficulty new events will require at
|
||||||
|
* least this difficulty of PoW, based on [NIP-13](13.md),
|
||||||
|
* or they will be rejected by this server.
|
||||||
|
* @param auth_required this relay requires [NIP-42](42.md)
|
||||||
|
* authentication to happen before a new connection may
|
||||||
|
* perform any other action. Even if set to False,
|
||||||
|
* authentication may be required for specific actions.
|
||||||
|
* @param payment_required this relay requires payment
|
||||||
|
* before a new connection may perform any action.
|
||||||
|
*/
|
||||||
|
export interface Limitations {
|
||||||
|
max_message_length: number
|
||||||
|
max_subscription: number
|
||||||
|
max_filters: number
|
||||||
|
max_limit: number
|
||||||
|
max_subid_length: number
|
||||||
|
min_prefix: number
|
||||||
|
max_event_tags: number
|
||||||
|
max_content_length: number
|
||||||
|
min_pow_difficulty: number
|
||||||
|
auth_required: boolean
|
||||||
|
payment_required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetentionDetails {
|
||||||
|
kinds: (number | number[])[]
|
||||||
|
time?: number | null
|
||||||
|
count?: number | null
|
||||||
|
}
|
||||||
|
type AnyRetentionDetails = RetentionDetails
|
||||||
|
/**
|
||||||
|
* ### Event Retention
|
||||||
|
|
||||||
|
* There may be a cost associated with storing data forever,
|
||||||
|
* so relays may wish to state retention times. The values
|
||||||
|
* stated here are defaults for unauthenticated users and
|
||||||
|
* visitors. Paid users would likely have other policies.
|
||||||
|
|
||||||
|
* Retention times are given in seconds, with `null`
|
||||||
|
* indicating infinity. If zero is provided, this means the
|
||||||
|
* event will not be stored at all, and preferably an error
|
||||||
|
* will be provided when those are received.
|
||||||
|
* ```json
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"retention": [
|
||||||
|
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
|
||||||
|
{ "kinds": [[40000, 49999]], "time": 100 },
|
||||||
|
{ "kinds": [[30000, 39999]], "count": 1000 },
|
||||||
|
{ "time": 3600, "count": 10000 }
|
||||||
|
]
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* @param retention is a list of specifications: each will
|
||||||
|
* apply to either all kinds, or a subset of kinds. Ranges
|
||||||
|
* may be specified for the kind field as a tuple of
|
||||||
|
* inclusive start and end values. Events of indicated kind
|
||||||
|
* (or all) are then limited to a `count` and/or time
|
||||||
|
* period.
|
||||||
|
|
||||||
|
* It is possible to effectively blacklist Nostr-based
|
||||||
|
* protocols that rely on a specific `kind` number, by
|
||||||
|
* giving a retention time of zero for those `kind` values.
|
||||||
|
* While that is unfortunate, it does allow clients to
|
||||||
|
* discover servers that will support their protocol quickly
|
||||||
|
* via a single HTTP fetch.
|
||||||
|
|
||||||
|
* There is no need to specify retention times for
|
||||||
|
* _ephemeral events_ as defined in [NIP-16](16.md) since
|
||||||
|
* they are not retained.
|
||||||
|
*/
|
||||||
|
export interface Retention {
|
||||||
|
retention: AnyRetentionDetails[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some relays may be governed by the arbitrary laws of a
|
||||||
|
* nation state. This may limit what content can be stored
|
||||||
|
* in cleartext on those relays. All clients are encouraged
|
||||||
|
* to use encryption to work around this limitation.
|
||||||
|
|
||||||
|
* It is not possible to describe the limitations of each
|
||||||
|
* country's laws and policies which themselves are
|
||||||
|
* typically vague and constantly shifting.
|
||||||
|
|
||||||
|
* Therefore, this field allows the relay operator to
|
||||||
|
* indicate which countries' laws might end up being
|
||||||
|
* enforced on them, and then indirectly on their users'
|
||||||
|
* content.
|
||||||
|
|
||||||
|
* Users should be able to avoid relays in countries they
|
||||||
|
* don't like, and/or select relays in more favourable
|
||||||
|
* zones. Exposing this flexibility is up to the client
|
||||||
|
* software.
|
||||||
|
|
||||||
|
* @param relay_countries a list of two-level ISO country
|
||||||
|
* codes (ISO 3166-1 alpha-2) whose laws and policies may
|
||||||
|
* affect this relay. `EU` may be used for European Union
|
||||||
|
* countries.
|
||||||
|
|
||||||
|
* Remember that a relay may be hosted in a country which is
|
||||||
|
* not the country of the legal entities who own the relay,
|
||||||
|
* so it's very likely a number of countries are involved.
|
||||||
|
*/
|
||||||
|
export interface ContentLimitations {
|
||||||
|
relay_countries: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ### Community Preferences
|
||||||
|
|
||||||
|
* For public text notes at least, a relay may try to foster
|
||||||
|
* a local community. This would encourage users to follow
|
||||||
|
* the global feed on that relay, in addition to their usual
|
||||||
|
* individual follows. To support this goal, relays MAY
|
||||||
|
* specify some of the following values.
|
||||||
|
|
||||||
|
* @param language_tags is an ordered list of [IETF
|
||||||
|
* language
|
||||||
|
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
|
||||||
|
* indicating the major languages spoken on the relay.
|
||||||
|
* @param tags is a list of limitations on the topics to be
|
||||||
|
* discussed. For example `sfw-only` indicates that only
|
||||||
|
* "Safe For Work" content is encouraged on this relay. This
|
||||||
|
* relies on assumptions of what the "work" "community"
|
||||||
|
* feels "safe" talking about. In time, a common set of tags
|
||||||
|
* may emerge that allow users to find relays that suit
|
||||||
|
* their needs, and client software will be able to parse
|
||||||
|
* these tags easily. The `bitcoin-only` tag indicates that
|
||||||
|
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
|
||||||
|
* be ridiculed without mercy.
|
||||||
|
* @param posting_policy is a link to a human-readable page
|
||||||
|
* which specifies the community policies for the relay. In
|
||||||
|
* cases where `sfw-only` is True, it's important to link to
|
||||||
|
* a page which gets into the specifics of your posting
|
||||||
|
* policy.
|
||||||
|
|
||||||
|
* The `description` field should be used to describe your
|
||||||
|
* community goals and values, in brief. The
|
||||||
|
* `posting_policy` is for additional detail and legal
|
||||||
|
* terms. Use the `tags` field to signify limitations on
|
||||||
|
* content, or topics to be discussed, which could be
|
||||||
|
* machine processed by appropriate client software.
|
||||||
|
*/
|
||||||
|
export interface CommunityPreferences {
|
||||||
|
language_tags: string[]
|
||||||
|
tags: string[]
|
||||||
|
posting_policy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Amount {
|
||||||
|
amount: number
|
||||||
|
unit: 'msat'
|
||||||
|
}
|
||||||
|
export interface PublicationAmount extends Amount {
|
||||||
|
kinds: number[]
|
||||||
|
}
|
||||||
|
export interface Subscription extends Amount {
|
||||||
|
period: number
|
||||||
|
}
|
||||||
|
export interface Fees {
|
||||||
|
admission: Amount[]
|
||||||
|
subscription: Subscription[]
|
||||||
|
publication: PublicationAmount[]
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Relays that require payments may want to expose their fee
|
||||||
|
* schedules.
|
||||||
|
*/
|
||||||
|
export interface PayToRelay {
|
||||||
|
payments_url: string
|
||||||
|
fees: Fees
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL pointing to an image to be used as an icon for the
|
||||||
|
* relay. Recommended to be squared in shape.
|
||||||
|
*/
|
||||||
|
export interface Icon {
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayInformation = BasicRelayInformation &
|
||||||
|
Partial<Retention> & {
|
||||||
|
limitation?: Partial<Limitations>
|
||||||
|
} & Partial<ContentLimitations> &
|
||||||
|
Partial<CommunityPreferences> &
|
||||||
|
Partial<PayToRelay> &
|
||||||
|
Partial<Icon>
|
||||||
25
nip13.test.ts
Normal file
25
nip13.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { getPow, minePow } from './nip13.ts'
|
||||||
|
|
||||||
|
test('identifies proof-of-work difficulty', async () => {
|
||||||
|
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
|
||||||
|
const difficulty = getPow(id)
|
||||||
|
expect(difficulty).toEqual(21)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mines POW for an event', async () => {
|
||||||
|
const difficulty = 10
|
||||||
|
|
||||||
|
const event = minePow(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 0,
|
||||||
|
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
|
||||||
|
},
|
||||||
|
difficulty,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
|
||||||
|
})
|
||||||
52
nip13.ts
Normal file
52
nip13.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
|
||||||
|
|
||||||
|
/** Get POW difficulty from a Nostr hex ID. */
|
||||||
|
export function getPow(hex: string): number {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < hex.length; i++) {
|
||||||
|
const nibble = parseInt(hex[i], 16)
|
||||||
|
if (nibble === 0) {
|
||||||
|
count += 4
|
||||||
|
} else {
|
||||||
|
count += Math.clz32(nibble) - 28
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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'> {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
const event = unsigned as Omit<Event, 'sig'>
|
||||||
|
const tag = ['nonce', count.toString(), difficulty.toString()]
|
||||||
|
|
||||||
|
event.tags.push(tag)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const now = Math.floor(new Date().getTime() / 1000)
|
||||||
|
|
||||||
|
if (now !== event.created_at) {
|
||||||
|
count = 0
|
||||||
|
event.created_at = now
|
||||||
|
}
|
||||||
|
|
||||||
|
tag[1] = (++count).toString()
|
||||||
|
|
||||||
|
event.id = getEventHash(event)
|
||||||
|
|
||||||
|
if (getPow(event.id) >= difficulty) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
102
nip18.test.ts
Normal file
102
nip18.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||||
|
import { Repost, ShortTextNote } from './kinds.ts'
|
||||||
|
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
const relayUrl = 'https://relay.example.com'
|
||||||
|
|
||||||
|
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const repostedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [
|
||||||
|
['e', 'replied event id'],
|
||||||
|
['p', 'replied event pubkey'],
|
||||||
|
],
|
||||||
|
content: 'Replied to a post',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('should create a signed event from a minimal template', () => {
|
||||||
|
const template = {
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(Repost)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['e', repostedEvent.id, relayUrl],
|
||||||
|
['p', repostedEvent.pubkey],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual(JSON.stringify(repostedEvent))
|
||||||
|
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')
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should create a signed event from a filled template', () => {
|
||||||
|
const template = {
|
||||||
|
tags: [['nonstandard', 'tag']],
|
||||||
|
content: '' as const,
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(Repost)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['nonstandard', 'tag'],
|
||||||
|
['e', repostedEvent.id, relayUrl],
|
||||||
|
['p', repostedEvent.pubkey],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual('')
|
||||||
|
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')
|
||||||
|
|
||||||
|
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).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRepostedEventPointer', () => {
|
||||||
|
test('should parse an event with only an `e` tag', () => {
|
||||||
|
const event = buildEvent({
|
||||||
|
kind: Repost,
|
||||||
|
tags: [['e', 'reposted event id', relayUrl]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const repostedEventPointer = getRepostedEventPointer(event)
|
||||||
|
|
||||||
|
expect(repostedEventPointer!.id).toEqual('reposted event id')
|
||||||
|
expect(repostedEventPointer!.author).toBeUndefined()
|
||||||
|
expect(repostedEventPointer!.relays).toEqual([relayUrl])
|
||||||
|
})
|
||||||
|
})
|
||||||
97
nip18.ts
Normal file
97
nip18.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
|
||||||
|
import { Repost } from './kinds.ts'
|
||||||
|
import { EventPointer } from './nip19.ts'
|
||||||
|
|
||||||
|
export type RepostEventTemplate = {
|
||||||
|
/**
|
||||||
|
* Pass only non-nip18 tags if you have to.
|
||||||
|
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
|
||||||
|
*/
|
||||||
|
tags?: string[][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass an empty string to NOT include the stringified JSON of the reposted event.
|
||||||
|
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
|
||||||
|
* @default Stringified JSON of the reposted event
|
||||||
|
*/
|
||||||
|
content?: ''
|
||||||
|
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishRepostEvent(
|
||||||
|
t: RepostEventTemplate,
|
||||||
|
reposted: Event,
|
||||||
|
relayUrl: string,
|
||||||
|
privateKey: Uint8Array,
|
||||||
|
): Event {
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: Repost,
|
||||||
|
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
|
||||||
|
content: t.content === '' ? '' : JSON.stringify(reposted),
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepostedEventPointer(event: Event): undefined | EventPointer {
|
||||||
|
if (event.kind !== Repost) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastETag: undefined | string[]
|
||||||
|
let lastPTag: undefined | string[]
|
||||||
|
|
||||||
|
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
if (tag.length >= 2) {
|
||||||
|
if (tag[0] === 'e' && lastETag === undefined) {
|
||||||
|
lastETag = tag
|
||||||
|
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||||
|
lastPTag = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastETag === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lastETag[1],
|
||||||
|
relays: [lastETag[2], lastPTag?.[2]].filter((x): x is string => typeof x === 'string'),
|
||||||
|
author: lastPTag?.[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetRepostedEventOptions = {
|
||||||
|
skipVerification?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
|
||||||
|
const pointer = getRepostedEventPointer(event)
|
||||||
|
|
||||||
|
if (pointer === undefined || event.content === '') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let repostedEvent: undefined | Event
|
||||||
|
|
||||||
|
try {
|
||||||
|
repostedEvent = JSON.parse(event.content) as Event
|
||||||
|
} catch (error) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repostedEvent.id !== pointer.id) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipVerification && !verifyEvent(repostedEvent)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return repostedEvent
|
||||||
|
}
|
||||||
163
nip19.test.ts
Normal file
163
nip19.test.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import {
|
||||||
|
decode,
|
||||||
|
naddrEncode,
|
||||||
|
nprofileEncode,
|
||||||
|
npubEncode,
|
||||||
|
nrelayEncode,
|
||||||
|
nsecEncode,
|
||||||
|
neventEncode,
|
||||||
|
type AddressPointer,
|
||||||
|
type ProfilePointer,
|
||||||
|
EventPointer,
|
||||||
|
} from './nip19.ts'
|
||||||
|
|
||||||
|
test('encode and decode nsec', () => {
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let nsec = nsecEncode(sk)
|
||||||
|
expect(nsec).toMatch(/nsec1\w+/)
|
||||||
|
let { type, data } = decode(nsec)
|
||||||
|
expect(type).toEqual('nsec')
|
||||||
|
expect(data).toEqual(sk)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode npub', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let npub = npubEncode(pk)
|
||||||
|
expect(npub).toMatch(/npub1\w+/)
|
||||||
|
let { type, data } = decode(npub)
|
||||||
|
expect(type).toEqual('npub')
|
||||||
|
expect(data).toEqual(pk)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nprofile', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nprofile = nprofileEncode({ pubkey: pk, relays })
|
||||||
|
expect(nprofile).toMatch(/nprofile1\w+/)
|
||||||
|
let { type, data } = decode(nprofile)
|
||||||
|
expect(type).toEqual('nprofile')
|
||||||
|
const pointer = data as ProfilePointer
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.relays).toContain(relays[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decode nprofile without relays', () => {
|
||||||
|
expect(
|
||||||
|
decode(
|
||||||
|
nprofileEncode({
|
||||||
|
pubkey: '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
|
||||||
|
relays: [],
|
||||||
|
}),
|
||||||
|
).data,
|
||||||
|
).toHaveProperty('pubkey', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode naddr', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let naddr = naddrEncode({
|
||||||
|
pubkey: pk,
|
||||||
|
relays,
|
||||||
|
kind: 30023,
|
||||||
|
identifier: 'banana',
|
||||||
|
})
|
||||||
|
expect(naddr).toMatch(/naddr1\w+/)
|
||||||
|
let { type, data } = decode(naddr)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.relays).toContain(relays[1])
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('banana')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nevent', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nevent = neventEncode({
|
||||||
|
id: pk,
|
||||||
|
relays,
|
||||||
|
kind: 30023,
|
||||||
|
})
|
||||||
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
|
let { type, data } = decode(nevent)
|
||||||
|
expect(type).toEqual('nevent')
|
||||||
|
const pointer = data as EventPointer
|
||||||
|
expect(pointer.id).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nevent with kind 0', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let nevent = neventEncode({
|
||||||
|
id: pk,
|
||||||
|
relays,
|
||||||
|
kind: 0,
|
||||||
|
})
|
||||||
|
expect(nevent).toMatch(/nevent1\w+/)
|
||||||
|
let { type, data } = decode(nevent)
|
||||||
|
expect(type).toEqual('nevent')
|
||||||
|
const pointer = data as EventPointer
|
||||||
|
expect(pointer.id).toEqual(pk)
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode naddr with empty "d"', () => {
|
||||||
|
let pk = getPublicKey(generateSecretKey())
|
||||||
|
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
|
||||||
|
let naddr = naddrEncode({
|
||||||
|
identifier: '',
|
||||||
|
pubkey: pk,
|
||||||
|
relays,
|
||||||
|
kind: 3,
|
||||||
|
})
|
||||||
|
expect(naddr).toMatch(/naddr\w+/)
|
||||||
|
let { type, data } = decode(naddr)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.identifier).toEqual('')
|
||||||
|
expect(pointer.relays).toContain(relays[0])
|
||||||
|
expect(pointer.kind).toEqual(3)
|
||||||
|
expect(pointer.pubkey).toEqual(pk)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decode naddr from habla.news', () => {
|
||||||
|
let { type, data } = decode(
|
||||||
|
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5',
|
||||||
|
)
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual('7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194')
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('references')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decode naddr from go-nostr with different TLV ordering', () => {
|
||||||
|
let { type, data } = decode(
|
||||||
|
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(type).toEqual('naddr')
|
||||||
|
const pointer = data as AddressPointer
|
||||||
|
expect(pointer.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')
|
||||||
|
expect(pointer.relays).toContain('wss://relay.nostr.example.mydomain.example.com')
|
||||||
|
expect(pointer.relays).toContain('wss://nostr.banana.com')
|
||||||
|
expect(pointer.kind).toEqual(30023)
|
||||||
|
expect(pointer.identifier).toEqual('banana')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encode and decode nrelay', () => {
|
||||||
|
let url = 'wss://relay.nostr.example'
|
||||||
|
let nrelay = nrelayEncode(url)
|
||||||
|
expect(nrelay).toMatch(/nrelay1\w+/)
|
||||||
|
let { type, data } = decode(nrelay)
|
||||||
|
expect(type).toEqual('nrelay')
|
||||||
|
expect(data).toEqual(url)
|
||||||
|
})
|
||||||
242
nip19.ts
Normal file
242
nip19.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
export const Bech32MaxSize = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bech32 regex.
|
||||||
|
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
|
||||||
|
*/
|
||||||
|
export const BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
|
||||||
|
|
||||||
|
function integerToUint8Array(number: number) {
|
||||||
|
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
|
||||||
|
const uint8Array = new Uint8Array(4)
|
||||||
|
|
||||||
|
// Use bitwise operations to extract the bytes.
|
||||||
|
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
|
||||||
|
uint8Array[1] = (number >> 16) & 0xff
|
||||||
|
uint8Array[2] = (number >> 8) & 0xff
|
||||||
|
uint8Array[3] = number & 0xff // Least significant byte (LSB)
|
||||||
|
|
||||||
|
return uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfilePointer = {
|
||||||
|
pubkey: string // hex
|
||||||
|
relays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventPointer = {
|
||||||
|
id: string // hex
|
||||||
|
relays?: string[]
|
||||||
|
author?: string
|
||||||
|
kind?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddressPointer = {
|
||||||
|
identifier: string
|
||||||
|
pubkey: string
|
||||||
|
kind: number
|
||||||
|
relays?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Prefixes = {
|
||||||
|
nprofile: ProfilePointer
|
||||||
|
nrelay: string
|
||||||
|
nevent: EventPointer
|
||||||
|
naddr: AddressPointer
|
||||||
|
nsec: Uint8Array
|
||||||
|
npub: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecodeValue<Prefix extends keyof Prefixes> = {
|
||||||
|
type: Prefix
|
||||||
|
data: Prefixes[Prefix]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecodeResult = {
|
||||||
|
[P in keyof Prefixes]: DecodeValue<P>
|
||||||
|
}[keyof Prefixes]
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let { prefix, words } = bech32.decode(nip19, Bech32MaxSize)
|
||||||
|
let data = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
|
switch (prefix) {
|
||||||
|
case 'nprofile': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
|
||||||
|
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'nprofile',
|
||||||
|
data: {
|
||||||
|
pubkey: bytesToHex(tlv[0][0]),
|
||||||
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
|
||||||
|
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
|
||||||
|
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||||
|
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'nevent',
|
||||||
|
data: {
|
||||||
|
id: bytesToHex(tlv[0][0]),
|
||||||
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
|
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
|
||||||
|
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'naddr': {
|
||||||
|
let tlv = parseTLV(data)
|
||||||
|
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
|
||||||
|
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
|
||||||
|
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
|
||||||
|
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
|
||||||
|
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'naddr',
|
||||||
|
data: {
|
||||||
|
identifier: utf8Decoder.decode(tlv[0][0]),
|
||||||
|
pubkey: bytesToHex(tlv[2][0]),
|
||||||
|
kind: parseInt(bytesToHex(tlv[3][0]), 16),
|
||||||
|
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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':
|
||||||
|
return { type: prefix, data }
|
||||||
|
|
||||||
|
case 'npub':
|
||||||
|
case 'note':
|
||||||
|
return { type: prefix, data: bytesToHex(data) }
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown prefix ${prefix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLV = { [t: number]: Uint8Array[] }
|
||||||
|
|
||||||
|
function parseTLV(data: Uint8Array): TLV {
|
||||||
|
let result: TLV = {}
|
||||||
|
let rest = data
|
||||||
|
while (rest.length > 0) {
|
||||||
|
let t = rest[0]
|
||||||
|
let l = rest[1]
|
||||||
|
let v = rest.slice(2, 2 + l)
|
||||||
|
rest = rest.slice(2 + l)
|
||||||
|
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
|
||||||
|
result[t] = result[t] || []
|
||||||
|
result[t].push(v)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
|
||||||
|
return encodeBytes('nsec', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function npubEncode(hex: string): `npub1${string}` {
|
||||||
|
return encodeBytes('npub', hexToBytes(hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteEncode(hex: string): `note1${string}` {
|
||||||
|
return encodeBytes('note', hexToBytes(hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
|
||||||
|
let words = bech32.toWords(data)
|
||||||
|
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
|
||||||
|
return encodeBech32(prefix, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [hexToBytes(profile.pubkey)],
|
||||||
|
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
|
})
|
||||||
|
return encodeBech32('nprofile', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neventEncode(event: EventPointer): `nevent1${string}` {
|
||||||
|
let kindArray
|
||||||
|
if (event.kind !== undefined) {
|
||||||
|
kindArray = integerToUint8Array(event.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [hexToBytes(event.id)],
|
||||||
|
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
|
2: event.author ? [hexToBytes(event.author)] : [],
|
||||||
|
3: kindArray ? [new Uint8Array(kindArray)] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return encodeBech32('nevent', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
|
||||||
|
let kind = new ArrayBuffer(4)
|
||||||
|
new DataView(kind).setUint32(0, addr.kind, false)
|
||||||
|
|
||||||
|
let data = encodeTLV({
|
||||||
|
0: [utf8Encoder.encode(addr.identifier)],
|
||||||
|
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
|
||||||
|
2: [hexToBytes(addr.pubkey)],
|
||||||
|
3: [new Uint8Array(kind)],
|
||||||
|
})
|
||||||
|
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 {
|
||||||
|
let entries: Uint8Array[] = []
|
||||||
|
|
||||||
|
Object.entries(tlv)
|
||||||
|
.reverse()
|
||||||
|
.forEach(([t, vs]) => {
|
||||||
|
vs.forEach(v => {
|
||||||
|
let entry = new Uint8Array(v.length + 2)
|
||||||
|
entry.set([parseInt(t)], 0)
|
||||||
|
entry.set([v.length], 1)
|
||||||
|
entry.set(v, 2)
|
||||||
|
entries.push(entry)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return concatBytes(...entries)
|
||||||
|
}
|
||||||
24
nip21.test.ts
Normal file
24
nip21.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { test as testRegex, parse } from './nip21.ts'
|
||||||
|
|
||||||
|
test('test()', () => {
|
||||||
|
expect(testRegex('nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(true)
|
||||||
|
expect(testRegex('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')).toBe(true)
|
||||||
|
expect(testRegex(' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||||
|
expect(testRegex('nostr:')).toBe(false)
|
||||||
|
expect(testRegex('nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6')).toBe(false)
|
||||||
|
expect(testRegex('gggggg')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parse', () => {
|
||||||
|
const result = parse('nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
decoded: {
|
||||||
|
type: 'note',
|
||||||
|
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
30
nip21.ts
Normal file
30
nip21.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { BECH32_REGEX, decode, type DecodeResult } from './nip19.ts'
|
||||||
|
|
||||||
|
/** Nostr URI regex, eg `nostr:npub1...` */
|
||||||
|
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
|
||||||
|
|
||||||
|
/** Test whether the value is a Nostr URI. */
|
||||||
|
export function test(value: unknown): value is `nostr:${string}` {
|
||||||
|
return typeof value === 'string' && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsed Nostr URI data. */
|
||||||
|
export interface NostrURI {
|
||||||
|
/** Full URI including the `nostr:` protocol. */
|
||||||
|
uri: `nostr:${string}`
|
||||||
|
/** The bech32-encoded data (eg `npub1...`). */
|
||||||
|
value: string
|
||||||
|
/** Decoded bech32 string, according to NIP-19. */
|
||||||
|
decoded: DecodeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse and decode a Nostr URI. */
|
||||||
|
export function parse(uri: string): NostrURI {
|
||||||
|
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
|
||||||
|
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
|
||||||
|
return {
|
||||||
|
uri: match[0] as `nostr:${string}`,
|
||||||
|
value: match[1],
|
||||||
|
decoded: decode(match[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
78
nip25.test.ts
Normal file
78
nip25.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { finalizeEvent, getPublicKey } from './pure.ts'
|
||||||
|
import { Reaction, ShortTextNote } from './kinds.ts'
|
||||||
|
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
|
||||||
|
|
||||||
|
describe('finishReactionEvent + getReactedEventPointer', () => {
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const reactedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ShortTextNote,
|
||||||
|
tags: [
|
||||||
|
['e', 'replied event id'],
|
||||||
|
['p', 'replied event pubkey'],
|
||||||
|
],
|
||||||
|
content: 'Replied to a post',
|
||||||
|
created_at: 1617932115,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('should create a signed event from a minimal template', () => {
|
||||||
|
const template = {
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(Reaction)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['e', 'replied event id'],
|
||||||
|
['p', 'replied event pubkey'],
|
||||||
|
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||||
|
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual('+')
|
||||||
|
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')
|
||||||
|
|
||||||
|
const reactedEventPointer = getReactedEventPointer(event)
|
||||||
|
|
||||||
|
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||||
|
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should create a signed event from a filled template', () => {
|
||||||
|
const template = {
|
||||||
|
tags: [['nonstandard', 'tag']],
|
||||||
|
content: '👍',
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finishReactionEvent(template, reactedEvent, privateKey)
|
||||||
|
|
||||||
|
expect(event.kind).toEqual(Reaction)
|
||||||
|
expect(event.tags).toEqual([
|
||||||
|
['nonstandard', 'tag'],
|
||||||
|
['e', 'replied event id'],
|
||||||
|
['p', 'replied event pubkey'],
|
||||||
|
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
|
||||||
|
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f'],
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual('👍')
|
||||||
|
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')
|
||||||
|
|
||||||
|
const reactedEventPointer = getReactedEventPointer(event)
|
||||||
|
|
||||||
|
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
|
||||||
|
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
|
||||||
|
})
|
||||||
|
})
|
||||||
62
nip25.ts
Normal file
62
nip25.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Event, finalizeEvent } from './pure.ts'
|
||||||
|
import { Reaction } from './kinds.ts'
|
||||||
|
|
||||||
|
import type { EventPointer } from './nip19.ts'
|
||||||
|
|
||||||
|
export type ReactionEventTemplate = {
|
||||||
|
/**
|
||||||
|
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
|
||||||
|
*/
|
||||||
|
tags?: string[][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default '+'
|
||||||
|
*/
|
||||||
|
content?: string
|
||||||
|
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishReactionEvent(t: ReactionEventTemplate, reacted: Event, privateKey: Uint8Array): Event {
|
||||||
|
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
...t,
|
||||||
|
kind: Reaction,
|
||||||
|
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
|
||||||
|
content: t.content ?? '+',
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReactedEventPointer(event: Event): undefined | EventPointer {
|
||||||
|
if (event.kind !== Reaction) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastETag: undefined | string[]
|
||||||
|
let lastPTag: undefined | string[]
|
||||||
|
|
||||||
|
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
if (tag.length >= 2) {
|
||||||
|
if (tag[0] === 'e' && lastETag === undefined) {
|
||||||
|
lastETag = tag
|
||||||
|
} else if (tag[0] === 'p' && lastPTag === undefined) {
|
||||||
|
lastPTag = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastETag === undefined || lastPTag === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lastETag[1],
|
||||||
|
relays: [lastETag[2], lastPTag[2]].filter(x => x !== undefined),
|
||||||
|
author: lastPTag[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
68
nip27.test.ts
Normal file
68
nip27.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { matchAll, replaceAll } from './nip27.ts'
|
||||||
|
|
||||||
|
test('matchAll', () => {
|
||||||
|
const result = matchAll(
|
||||||
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect([...result]).toEqual([
|
||||||
|
{
|
||||||
|
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||||
|
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
|
||||||
|
decoded: {
|
||||||
|
type: 'npub',
|
||||||
|
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', () => {
|
||||||
|
const result = matchAll(
|
||||||
|
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect([...result]).toEqual([
|
||||||
|
{
|
||||||
|
decoded: {
|
||||||
|
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
|
||||||
|
type: 'note',
|
||||||
|
},
|
||||||
|
end: 193,
|
||||||
|
start: 124,
|
||||||
|
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replaceAll', () => {
|
||||||
|
const content =
|
||||||
|
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
|
||||||
|
|
||||||
|
const result = replaceAll(content, ({ decoded, value }) => {
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub':
|
||||||
|
return '@alex'
|
||||||
|
case 'note':
|
||||||
|
return '!1234'
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual('Hello @alex!\n\n!1234')
|
||||||
|
})
|
||||||
63
nip27.ts
Normal file
63
nip27.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { decode } from './nip19.ts'
|
||||||
|
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
|
||||||
|
|
||||||
|
/** Regex to find NIP-21 URIs inside event content. */
|
||||||
|
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
|
||||||
|
|
||||||
|
/** Match result for a Nostr URI in event content. */
|
||||||
|
export interface NostrURIMatch extends NostrURI {
|
||||||
|
/** 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* matchAll(content: string): Iterable<NostrURIMatch> {
|
||||||
|
const matches = content.matchAll(regex())
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
|
const [uri, value] = match
|
||||||
|
|
||||||
|
yield {
|
||||||
|
uri: uri as `nostr:${string}`,
|
||||||
|
value,
|
||||||
|
decoded: decode(value),
|
||||||
|
start: match.index!,
|
||||||
|
end: match.index! + uri.length,
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all occurrences of Nostr URIs in the text.
|
||||||
|
*
|
||||||
|
* 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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
130
nip28.test.ts
Normal file
130
nip28.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { getPublicKey } from './pure.ts'
|
||||||
|
import * as Kind from './kinds.ts'
|
||||||
|
import {
|
||||||
|
channelCreateEvent,
|
||||||
|
channelMetadataEvent,
|
||||||
|
channelMessageEvent,
|
||||||
|
channelHideMessageEvent,
|
||||||
|
channelMuteUserEvent,
|
||||||
|
ChannelMetadata,
|
||||||
|
ChannelMessageEventTemplate,
|
||||||
|
} from './nip28.ts'
|
||||||
|
|
||||||
|
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
describe('NIP-28 Functions', () => {
|
||||||
|
const channelMetadata: ChannelMetadata = {
|
||||||
|
name: 'Test Channel',
|
||||||
|
about: 'This is a test channel',
|
||||||
|
picture: 'https://example.com/picture.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
test('channelCreateEvent should create an event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
content: channelMetadata,
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelCreateEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelCreation)
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('channelMetadataEvent should create a signed event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
channel_create_event_id: 'channel creation event id',
|
||||||
|
content: channelMetadata,
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMetadataEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelMetadata)
|
||||||
|
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event!.id).toEqual('string')
|
||||||
|
expect(typeof event!.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('channelMessageEvent should create a signed message event with given template', () => {
|
||||||
|
const template: ChannelMessageEventTemplate = {
|
||||||
|
channel_create_event_id: 'channel creation event id',
|
||||||
|
relay_url: 'https://relay.example.com',
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMessageEvent(template, privateKey)
|
||||||
|
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||||
|
expect(event.tags[0]).toEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
|
||||||
|
expect(event.content).toEqual(template.content)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('channelMessageEvent should create a signed message reply event with given template', () => {
|
||||||
|
const template: ChannelMessageEventTemplate = {
|
||||||
|
channel_create_event_id: 'channel creation event id',
|
||||||
|
reply_to_channel_message_event_id: 'channel message event id',
|
||||||
|
relay_url: 'https://relay.example.com',
|
||||||
|
content: 'Hello, world!',
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMessageEvent(template, privateKey)
|
||||||
|
expect(event.kind).toEqual(Kind.ChannelMessage)
|
||||||
|
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
|
||||||
|
'e',
|
||||||
|
template.channel_create_event_id,
|
||||||
|
template.relay_url,
|
||||||
|
'root',
|
||||||
|
])
|
||||||
|
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
|
||||||
|
'e',
|
||||||
|
template.reply_to_channel_message_event_id as string,
|
||||||
|
template.relay_url,
|
||||||
|
'reply',
|
||||||
|
])
|
||||||
|
expect(event.content).toEqual(template.content)
|
||||||
|
expect(event.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event.id).toEqual('string')
|
||||||
|
expect(typeof event.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('channelHideMessageEvent should create a signed event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
channel_message_event_id: 'channel message event id',
|
||||||
|
content: { reason: 'Inappropriate content' },
|
||||||
|
created_at: 1617932115,
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelHideMessageEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
|
||||||
|
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event!.id).toEqual('string')
|
||||||
|
expect(typeof event!.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('channelMuteUserEvent should create a signed event with given template', () => {
|
||||||
|
const template = {
|
||||||
|
content: { reason: 'Spamming' },
|
||||||
|
created_at: 1617932115,
|
||||||
|
pubkey_to_mute: 'pubkey to mute',
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = channelMuteUserEvent(template, privateKey)
|
||||||
|
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
|
||||||
|
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
|
||||||
|
expect(event!.content).toEqual(JSON.stringify(template.content))
|
||||||
|
expect(event!.pubkey).toEqual(publicKey)
|
||||||
|
expect(typeof event!.id).toEqual('string')
|
||||||
|
expect(typeof event!.sig).toEqual('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
152
nip28.ts
Normal file
152
nip28.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Event, finalizeEvent } from './pure.ts'
|
||||||
|
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
|
||||||
|
|
||||||
|
export interface ChannelMetadata {
|
||||||
|
name: string
|
||||||
|
about: string
|
||||||
|
picture: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelCreateEventTemplate {
|
||||||
|
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||||
|
content: string | ChannelMetadata
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMetadataEventTemplate {
|
||||||
|
channel_create_event_id: string
|
||||||
|
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
|
||||||
|
content: string | ChannelMetadata
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMessageEventTemplate {
|
||||||
|
channel_create_event_id: string
|
||||||
|
reply_to_channel_message_event_id?: string
|
||||||
|
relay_url: string
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelHideMessageEventTemplate {
|
||||||
|
channel_message_event_id: string
|
||||||
|
content: string | { reason: string }
|
||||||
|
created_at: number
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelMuteUserEventTemplate {
|
||||||
|
content: string | { reason: string }
|
||||||
|
created_at: number
|
||||||
|
pubkey_to_mute: string
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelCreation,
|
||||||
|
tags: [...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelMetadata,
|
||||||
|
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
|
||||||
|
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
|
||||||
|
|
||||||
|
if (t.reply_to_channel_message_event_id) {
|
||||||
|
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelMessage,
|
||||||
|
tags: [...tags, ...(t.tags ?? [])],
|
||||||
|
content: t.content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "e" tag should be the kind 42 event to hide */
|
||||||
|
export const channelHideMessageEvent = (
|
||||||
|
t: ChannelHideMessageEventTemplate,
|
||||||
|
privateKey: Uint8Array,
|
||||||
|
): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelHideMessage,
|
||||||
|
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
|
||||||
|
let content: string
|
||||||
|
if (typeof t.content === 'object') {
|
||||||
|
content = JSON.stringify(t.content)
|
||||||
|
} else if (typeof t.content === 'string') {
|
||||||
|
content = t.content
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: ChannelMuteUser,
|
||||||
|
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
|
||||||
|
content: content,
|
||||||
|
created_at: t.created_at,
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
166
nip29.ts
Normal file
166
nip29.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { AbstractSimplePool } from './abstract-pool'
|
||||||
|
import { Subscription } from './abstract-relay'
|
||||||
|
import { decode } from './nip19'
|
||||||
|
import type { Event } from './core'
|
||||||
|
import { fetchRelayInformation } from './nip11'
|
||||||
|
import { normalizeURL } from './utils'
|
||||||
|
import { AddressPointer } from './nip19'
|
||||||
|
|
||||||
|
export function subscribeRelayGroups(
|
||||||
|
pool: AbstractSimplePool,
|
||||||
|
url: string,
|
||||||
|
params: {
|
||||||
|
ongroups: (_: Group[]) => void
|
||||||
|
onerror: (_: Error) => void
|
||||||
|
onconnect?: () => void
|
||||||
|
},
|
||||||
|
): () => void {
|
||||||
|
let normalized = normalizeURL(url)
|
||||||
|
let sub: Subscription
|
||||||
|
let groups: Group[] = []
|
||||||
|
|
||||||
|
fetchRelayInformation(normalized)
|
||||||
|
.then(async info => {
|
||||||
|
let rl = await pool.ensureRelay(normalized)
|
||||||
|
params.onconnect?.()
|
||||||
|
sub = rl.prepareSubscription(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [39000],
|
||||||
|
limit: 50,
|
||||||
|
authors: [info.pubkey],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event: Event) {
|
||||||
|
groups.push(parseGroup(event, normalized))
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
params.ongroups(groups)
|
||||||
|
sub.onevent = (event: Event) => {
|
||||||
|
groups.push(parseGroup(event, normalized))
|
||||||
|
params.ongroups(groups)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sub.fire()
|
||||||
|
})
|
||||||
|
.catch(params.onerror)
|
||||||
|
|
||||||
|
return () => sub.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> {
|
||||||
|
let normalized = normalizeURL(gr.host)
|
||||||
|
|
||||||
|
let info = await fetchRelayInformation(normalized)
|
||||||
|
let event = await pool.get([normalized], {
|
||||||
|
kinds: [39000],
|
||||||
|
authors: [info.pubkey],
|
||||||
|
'#d': [gr.id],
|
||||||
|
})
|
||||||
|
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`)
|
||||||
|
return parseGroup(event, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
|
||||||
|
let gr = parseGroupCode(code)
|
||||||
|
if (!gr) throw new Error(`code "${code}" does not identify a group`)
|
||||||
|
return loadGroup(pool, gr)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupReference = {
|
||||||
|
id: string
|
||||||
|
host: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroupCode(code: string): null | GroupReference {
|
||||||
|
if (code.startsWith('naddr1')) {
|
||||||
|
try {
|
||||||
|
let { data } = decode(code)
|
||||||
|
|
||||||
|
let { relays, identifier } = data as AddressPointer
|
||||||
|
if (!relays || relays.length === 0) return null
|
||||||
|
|
||||||
|
let host = relays![0]
|
||||||
|
if (host.startsWith('wss://')) {
|
||||||
|
host = host.slice(6)
|
||||||
|
}
|
||||||
|
return { host, id: identifier }
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else if (code.split("'").length === 2) {
|
||||||
|
let spl = code.split("'")
|
||||||
|
return { host: spl[0], id: spl[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeGroupReference(gr: GroupReference): string {
|
||||||
|
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8)
|
||||||
|
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6)
|
||||||
|
return `${gr.host}'${gr.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string
|
||||||
|
relay: string
|
||||||
|
pubkey: string
|
||||||
|
name?: string
|
||||||
|
picture?: string
|
||||||
|
about?: string
|
||||||
|
public?: boolean
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroup(event: Event, relay: string): Group {
|
||||||
|
const group: Partial<Group> = { relay, pubkey: event.pubkey }
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
switch (tag[0]) {
|
||||||
|
case 'd':
|
||||||
|
group.id = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'name':
|
||||||
|
group.name = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'about':
|
||||||
|
group.about = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'picture':
|
||||||
|
group.picture = tag[1] || ''
|
||||||
|
break
|
||||||
|
case 'open':
|
||||||
|
group.open = true
|
||||||
|
break
|
||||||
|
case 'public':
|
||||||
|
group.public = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return group as Group
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Member = {
|
||||||
|
pubkey: string
|
||||||
|
label?: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMembers(event: Event): Member[] {
|
||||||
|
const members = []
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
if (tag.length < 2) continue
|
||||||
|
if (tag[0] !== 'p') continue
|
||||||
|
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue
|
||||||
|
const member: Member = { pubkey: tag[1], permissions: [] }
|
||||||
|
if (tag.length > 2) member.label = tag[2]
|
||||||
|
if (tag.length > 3) member.permissions = tag.slice(3)
|
||||||
|
members.push(member)
|
||||||
|
}
|
||||||
|
return members
|
||||||
|
}
|
||||||
33
nip30.test.ts
Normal file
33
nip30.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { matchAll, replaceAll } from './nip30.ts'
|
||||||
|
|
||||||
|
test('matchAll', () => {
|
||||||
|
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
|
||||||
|
|
||||||
|
expect([...result]).toEqual([
|
||||||
|
{
|
||||||
|
name: 'blobcat',
|
||||||
|
shortcode: ':blobcat:',
|
||||||
|
start: 6,
|
||||||
|
end: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disputed',
|
||||||
|
shortcode: ':disputed:',
|
||||||
|
start: 16,
|
||||||
|
end: 26,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replaceAll', () => {
|
||||||
|
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
|
||||||
|
|
||||||
|
const result = replaceAll(content, ({ name }) => {
|
||||||
|
return `<img src="https://ditto.pub/emoji/${name}.png" />`
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
|
||||||
|
)
|
||||||
|
})
|
||||||
51
nip30.ts
Normal file
51
nip30.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/** Regex for a single emoji shortcode. */
|
||||||
|
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
|
||||||
|
|
||||||
|
/** Regex to find emoji shortcodes in content. */
|
||||||
|
export const regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
|
||||||
|
|
||||||
|
/** Represents a Nostr custom emoji. */
|
||||||
|
export interface CustomEmoji {
|
||||||
|
/** The matched emoji name with colons. */
|
||||||
|
shortcode: `:${string}:`
|
||||||
|
/** The matched emoji name without colons. */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match result for a custom emoji in text content. */
|
||||||
|
export interface CustomEmojiMatch extends CustomEmoji {
|
||||||
|
/** Index where the emoji begins in the text content. */
|
||||||
|
start: number
|
||||||
|
/** Index where the emoji ends in the text content. */
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find all custom emoji shortcodes. */
|
||||||
|
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
|
||||||
|
const matches = content.matchAll(regex())
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
try {
|
||||||
|
const [shortcode, name] = match
|
||||||
|
|
||||||
|
yield {
|
||||||
|
shortcode: shortcode as `:${string}:`,
|
||||||
|
name,
|
||||||
|
start: match.index!,
|
||||||
|
end: match.index! + shortcode.length,
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace all emoji shortcodes in the content. */
|
||||||
|
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
|
||||||
|
return content.replaceAll(regex(), (shortcode, name) => {
|
||||||
|
return replacer({
|
||||||
|
shortcode: shortcode as `:${string}:`,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
15
nip39.test.ts
Normal file
15
nip39.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
|
import { useFetchImplementation, validateGithub } from './nip39.ts'
|
||||||
|
|
||||||
|
test('validate github claim', async () => {
|
||||||
|
useFetchImplementation(fetch)
|
||||||
|
|
||||||
|
let result = await validateGithub(
|
||||||
|
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
|
||||||
|
'vitorpamplona',
|
||||||
|
'cf19e2d1d7f8dac6348ad37b35ec8421',
|
||||||
|
)
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
18
nip39.ts
Normal file
18
nip39.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateGithub(pubkey: string, username: string, proof: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let res = await (await _fetch(`https://gist.github.com/${username}/${proof}/raw`)).text()
|
||||||
|
return res === `Verifying that I control the following Nostr public key: ${pubkey}`
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
44
nip40.test.ts
Normal file
44
nip40.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, test, expect, jest } from 'bun:test'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
import { getExpiration, isEventExpired, waitForExpire, onExpire } from './nip40.ts'
|
||||||
|
|
||||||
|
describe('getExpiration', () => {
|
||||||
|
test('returns the expiration as a Date object', () => {
|
||||||
|
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||||
|
const result = getExpiration(event)
|
||||||
|
expect(result).toEqual(new Date(123000))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isEventExpired', () => {
|
||||||
|
test('returns true when the event has expired', () => {
|
||||||
|
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||||
|
const result = isEventExpired(event)
|
||||||
|
expect(result).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when the event has not expired', () => {
|
||||||
|
const future = Math.floor(Date.now() / 1000) + 10
|
||||||
|
const event = buildEvent({ tags: [['expiration', future.toString()]] })
|
||||||
|
const result = isEventExpired(event)
|
||||||
|
expect(result).toEqual(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('waitForExpire', () => {
|
||||||
|
test('returns a promise that resolves when the event expires', async () => {
|
||||||
|
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||||
|
const result = await waitForExpire(event)
|
||||||
|
expect(result).toEqual(event)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onExpire', () => {
|
||||||
|
test('calls the callback when the event expires', async () => {
|
||||||
|
const event = buildEvent({ tags: [['expiration', '123']] })
|
||||||
|
const callback = jest.fn()
|
||||||
|
onExpire(event, callback)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
expect(callback).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
49
nip40.ts
Normal file
49
nip40.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Event } from './core.ts'
|
||||||
|
|
||||||
|
/** Get the expiration of the event as a `Date` object, if any. */
|
||||||
|
function getExpiration(event: Event): Date | undefined {
|
||||||
|
const tag = event.tags.find(([name]) => name === 'expiration')
|
||||||
|
if (tag) {
|
||||||
|
return new Date(parseInt(tag[1]) * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the event has expired. */
|
||||||
|
function isEventExpired(event: Event): boolean {
|
||||||
|
const expiration = getExpiration(event)
|
||||||
|
if (expiration) {
|
||||||
|
return Date.now() > expiration.getTime()
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a promise that resolves when the event expires. */
|
||||||
|
async function waitForExpire(event: Event): Promise<Event> {
|
||||||
|
const expiration = getExpiration(event)
|
||||||
|
if (expiration) {
|
||||||
|
const diff = expiration.getTime() - Date.now()
|
||||||
|
if (diff > 0) {
|
||||||
|
await sleep(diff)
|
||||||
|
return event
|
||||||
|
} else {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Event has no expiration')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls the callback when the event expires. */
|
||||||
|
function onExpire(event: Event, callback: (event: Event) => void): void {
|
||||||
|
waitForExpire(event)
|
||||||
|
.then(callback)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves when the given number of milliseconds have elapsed. */
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getExpiration, isEventExpired, waitForExpire, onExpire }
|
||||||
16
nip42.test.ts
Normal file
16
nip42.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { makeAuthEvent } from './nip42.ts'
|
||||||
|
import { Relay } from './relay.ts'
|
||||||
|
import { MockRelay } from './test-helpers.ts'
|
||||||
|
|
||||||
|
test('auth flow', async () => {
|
||||||
|
const mockRelay = new MockRelay()
|
||||||
|
const relay = await Relay.connect(mockRelay.url)
|
||||||
|
const auth = makeAuthEvent(relay.url, 'chachacha')
|
||||||
|
|
||||||
|
expect(auth.tags).toHaveLength(2)
|
||||||
|
expect(auth.tags[0]).toEqual(['relay', mockRelay.url])
|
||||||
|
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||||
|
expect(auth.kind).toEqual(22242)
|
||||||
|
})
|
||||||
17
nip42.ts
Normal file
17
nip42.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { EventTemplate } from './core.ts'
|
||||||
|
import { ClientAuth } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates an EventTemplate for an AUTH event to be signed.
|
||||||
|
*/
|
||||||
|
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
|
||||||
|
return {
|
||||||
|
kind: ClientAuth,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['relay', relayURL],
|
||||||
|
['challenge', challenge],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
44
nip44.test.ts
Normal file
44
nip44.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { v2 } from './nip44.js'
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { default as vec } from './nip44.vectors.json' assert { type: 'json' }
|
||||||
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
|
const v2vec = vec.v2
|
||||||
|
|
||||||
|
test('get_conversation_key', () => {
|
||||||
|
for (const v of v2vec.valid.get_conversation_key) {
|
||||||
|
const key = v2.utils.getConversationKey(v.sec1, v.pub2)
|
||||||
|
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('encrypt_decrypt', () => {
|
||||||
|
for (const v of v2vec.valid.encrypt_decrypt) {
|
||||||
|
const pub2 = bytesToHex(schnorr.getPublicKey(v.sec2))
|
||||||
|
const key = v2.utils.getConversationKey(v.sec1, pub2)
|
||||||
|
expect(bytesToHex(key)).toEqual(v.conversation_key)
|
||||||
|
const ciphertext = v2.encrypt(v.plaintext, key, hexToBytes(v.nonce))
|
||||||
|
expect(ciphertext).toEqual(v.payload)
|
||||||
|
const decrypted = v2.decrypt(ciphertext, key)
|
||||||
|
expect(decrypted).toEqual(v.plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calc_padded_len', () => {
|
||||||
|
for (const [len, shouldBePaddedTo] of v2vec.valid.calc_padded_len) {
|
||||||
|
const actual = v2.utils.calcPaddedLen(len)
|
||||||
|
expect(actual).toEqual(shouldBePaddedTo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decrypt', async () => {
|
||||||
|
for (const v of v2vec.invalid.decrypt) {
|
||||||
|
expect(() => v2.decrypt(v.payload, hexToBytes(v.conversation_key))).toThrow(new RegExp(v.note))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get_conversation_key', async () => {
|
||||||
|
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)/)
|
||||||
|
}
|
||||||
|
})
|
||||||
131
nip44.ts
Normal file
131
nip44.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { chacha20 } from '@noble/ciphers/chacha'
|
||||||
|
import { ensureBytes, equalBytes } from '@noble/ciphers/utils'
|
||||||
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
|
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf'
|
||||||
|
import { hmac } from '@noble/hashes/hmac'
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const u = {
|
||||||
|
minPlaintextSize: 0x0001, // 1b msg => padded to 32b
|
||||||
|
maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb
|
||||||
|
|
||||||
|
utf8Encode: utf8ToBytes,
|
||||||
|
utf8Decode(bytes: Uint8Array) {
|
||||||
|
return decoder.decode(bytes)
|
||||||
|
},
|
||||||
|
|
||||||
|
getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
|
||||||
|
const sharedX = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB).subarray(1, 33)
|
||||||
|
return hkdf_extract(sha256, sharedX, 'nip44-v2')
|
||||||
|
},
|
||||||
|
|
||||||
|
getMessageKeys(conversationKey: Uint8Array, nonce: Uint8Array) {
|
||||||
|
ensureBytes(conversationKey, 32)
|
||||||
|
ensureBytes(nonce, 32)
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
writeU16BE(num: number) {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
hmacAad(key: Uint8Array, message: Uint8Array, aad: 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
|
||||||
|
decodePayload(payload: string) {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(plaintext: string, conversationKey: Uint8Array, nonce = randomBytes(32)): string {
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||||
|
const padded = u.pad(plaintext)
|
||||||
|
const ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
||||||
|
const mac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
return base64.encode(concatBytes(new Uint8Array([2]), nonce, ciphertext, mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(payload: string, conversationKey: Uint8Array): string {
|
||||||
|
const { nonce, ciphertext, mac } = u.decodePayload(payload)
|
||||||
|
const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys(conversationKey, nonce)
|
||||||
|
const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce)
|
||||||
|
if (!equalBytes(calculatedMac, mac)) throw new Error('invalid MAC')
|
||||||
|
const padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
||||||
|
return u.unpad(padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const v2 = {
|
||||||
|
utils: u,
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { v2 }
|
||||||
605
nip44.vectors.json
Normal file
605
nip44.vectors.json
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
{
|
||||||
|
"v2": {
|
||||||
|
"valid": {
|
||||||
|
"get_conversation_key": [
|
||||||
|
{
|
||||||
|
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
|
||||||
|
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
|
||||||
|
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
|
||||||
|
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
|
||||||
|
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
|
||||||
|
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
|
||||||
|
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
|
||||||
|
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
|
||||||
|
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
|
||||||
|
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
|
||||||
|
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
|
||||||
|
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
|
||||||
|
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
|
||||||
|
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
|
||||||
|
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
|
||||||
|
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
|
||||||
|
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
|
||||||
|
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
|
||||||
|
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
|
||||||
|
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
|
||||||
|
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
|
||||||
|
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
|
||||||
|
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
|
||||||
|
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
|
||||||
|
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
|
||||||
|
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
|
||||||
|
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
|
||||||
|
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
|
||||||
|
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
|
||||||
|
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
|
||||||
|
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
|
||||||
|
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
|
||||||
|
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
|
||||||
|
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
|
||||||
|
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
|
||||||
|
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
|
||||||
|
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
|
||||||
|
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
|
||||||
|
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
|
||||||
|
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
|
||||||
|
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
|
||||||
|
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
|
||||||
|
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
|
||||||
|
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
|
||||||
|
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
|
||||||
|
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
|
||||||
|
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
|
||||||
|
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
|
||||||
|
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
|
||||||
|
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
|
||||||
|
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
|
||||||
|
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
|
||||||
|
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
|
||||||
|
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
|
||||||
|
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
|
||||||
|
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
|
||||||
|
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
|
||||||
|
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
|
||||||
|
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
|
||||||
|
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
|
||||||
|
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
|
||||||
|
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
|
||||||
|
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
|
||||||
|
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
|
||||||
|
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||||
|
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
|
||||||
|
"note": "sec1 = n-2, pub2: random, 0x02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
|
||||||
|
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
|
||||||
|
"note": "sec1 = 2, pub2: rand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||||
|
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
|
||||||
|
"note": "sec1 == pub2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"get_message_keys": {
|
||||||
|
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
|
||||||
|
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
|
||||||
|
"chacha_nonce": "c4ad129bb01180c0933a160c",
|
||||||
|
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
|
||||||
|
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
|
||||||
|
"chacha_nonce": "22925e920cee4a50a478be90",
|
||||||
|
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
|
||||||
|
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
|
||||||
|
"chacha_nonce": "d3594987af769a52904656ac",
|
||||||
|
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
|
||||||
|
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
|
||||||
|
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
|
||||||
|
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
|
||||||
|
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
|
||||||
|
"chacha_nonce": "400224ba67fc2f1b76736916",
|
||||||
|
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
|
||||||
|
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
|
||||||
|
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
|
||||||
|
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
|
||||||
|
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
|
||||||
|
"chacha_nonce": "72f69a5a5f795465cee59da8",
|
||||||
|
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
|
||||||
|
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
|
||||||
|
"chacha_nonce": "6e69be92d61c04a276021565",
|
||||||
|
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
|
||||||
|
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
|
||||||
|
"chacha_nonce": "3dda53569cfcb7fac1805c35",
|
||||||
|
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
|
||||||
|
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
|
||||||
|
"chacha_nonce": "65064239186e50304cc0f156",
|
||||||
|
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
|
||||||
|
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
|
||||||
|
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
|
||||||
|
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
|
||||||
|
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
|
||||||
|
"chacha_nonce": "cbb2530ea653766e5a37a83a",
|
||||||
|
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
|
||||||
|
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
|
||||||
|
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
|
||||||
|
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
|
||||||
|
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
|
||||||
|
"chacha_nonce": "67803605a7e5010d0f63f8c8",
|
||||||
|
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
|
||||||
|
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
|
||||||
|
"chacha_nonce": "4e62a0073087ed808be62469",
|
||||||
|
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
|
||||||
|
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
|
||||||
|
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
|
||||||
|
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
|
||||||
|
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
|
||||||
|
"chacha_nonce": "329bb3024461e84b2e1c489b",
|
||||||
|
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
|
||||||
|
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
|
||||||
|
"chacha_nonce": "653d759042b85194d4d8c0a7",
|
||||||
|
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
|
||||||
|
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
|
||||||
|
"chacha_nonce": "b822e2c959df32b3cb772a7c",
|
||||||
|
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
|
||||||
|
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
|
||||||
|
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
|
||||||
|
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
|
||||||
|
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
|
||||||
|
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
|
||||||
|
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
|
||||||
|
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
|
||||||
|
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
|
||||||
|
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
|
||||||
|
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
|
||||||
|
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
|
||||||
|
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
|
||||||
|
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
|
||||||
|
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
|
||||||
|
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
|
||||||
|
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
|
||||||
|
"chacha_nonce": "263830a065af33d9c6c5aa1f",
|
||||||
|
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
|
||||||
|
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
|
||||||
|
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
|
||||||
|
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
|
||||||
|
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
|
||||||
|
"chacha_nonce": "ccdaad5b3b7645be430992eb",
|
||||||
|
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
|
||||||
|
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
|
||||||
|
"chacha_nonce": "ed02dece5fc3a186f123420b",
|
||||||
|
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
|
||||||
|
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
|
||||||
|
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
|
||||||
|
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
|
||||||
|
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
|
||||||
|
"chacha_nonce": "26b450612ca5e905b937e147",
|
||||||
|
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
|
||||||
|
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
|
||||||
|
"chacha_nonce": "4a296a1fb0048e5020d3b129",
|
||||||
|
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
|
||||||
|
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
|
||||||
|
"chacha_nonce": "a8188daff807a1182200b39d",
|
||||||
|
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"calc_padded_len": [
|
||||||
|
[16, 32],
|
||||||
|
[32, 32],
|
||||||
|
[33, 64],
|
||||||
|
[37, 64],
|
||||||
|
[45, 64],
|
||||||
|
[49, 64],
|
||||||
|
[64, 64],
|
||||||
|
[65, 96],
|
||||||
|
[100, 128],
|
||||||
|
[111, 128],
|
||||||
|
[200, 224],
|
||||||
|
[250, 256],
|
||||||
|
[320, 320],
|
||||||
|
[383, 384],
|
||||||
|
[384, 384],
|
||||||
|
[400, 448],
|
||||||
|
[500, 512],
|
||||||
|
[512, 512],
|
||||||
|
[515, 640],
|
||||||
|
[700, 768],
|
||||||
|
[800, 896],
|
||||||
|
[900, 1024],
|
||||||
|
[1020, 1024],
|
||||||
|
[65536, 65536]
|
||||||
|
],
|
||||||
|
"encrypt_decrypt": [
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||||
|
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"plaintext": "a",
|
||||||
|
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||||
|
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
|
||||||
|
"plaintext": "🍕🫃",
|
||||||
|
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
|
||||||
|
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
|
||||||
|
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
|
||||||
|
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
|
||||||
|
"plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
|
||||||
|
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
|
||||||
|
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
|
||||||
|
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
|
||||||
|
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
|
||||||
|
"plaintext": "ability🤝的 ȺȾ",
|
||||||
|
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
|
||||||
|
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
|
||||||
|
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
|
||||||
|
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
|
||||||
|
"plaintext": "pepper👀їжак",
|
||||||
|
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
|
||||||
|
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
|
||||||
|
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
|
||||||
|
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
|
||||||
|
"plaintext": "( ͡° ͜ʖ ͡°)",
|
||||||
|
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
|
||||||
|
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
|
||||||
|
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
|
||||||
|
"plaintext": "الكل في المجمو عة (5)",
|
||||||
|
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
|
||||||
|
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
|
||||||
|
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
|
||||||
|
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
|
||||||
|
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
|
||||||
|
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
|
||||||
|
"plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
|
||||||
|
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"encrypt_decrypt_long_msg": [
|
||||||
|
{
|
||||||
|
"conversation_key": "7a1ccf5ce5a08e380f590de0c02776623b85a61ae67cfb6a017317e505b7cb51",
|
||||||
|
"nonce": "a000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
"letter": "ф",
|
||||||
|
"repeat": 65535,
|
||||||
|
"payload_checksum_sha256": "",
|
||||||
|
"note": "фффф... (65535 times)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"invalid": {
|
||||||
|
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
|
||||||
|
"decrypt_msg_lengths": [0, 1, 2, 5, 10, 20, 32, 48, 64],
|
||||||
|
"get_conversation_key": [
|
||||||
|
{
|
||||||
|
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "sec1 higher than curve.n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "sec1 is 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
|
||||||
|
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
"note": "pub2 is invalid, no sqrt, all-ff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "sec1 == curve.n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
"note": "pub2 is invalid, no sqrt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||||
|
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"note": "pub2 is point of order 3 on twist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||||
|
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
|
||||||
|
"note": "pub2 is point of order 13 on twist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
|
||||||
|
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
|
||||||
|
"note": "pub2 is point of order 3319 on twist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decrypt": [
|
||||||
|
{
|
||||||
|
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||||
|
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||||
|
"plaintext": "n o b l e",
|
||||||
|
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
|
||||||
|
"note": "unknown encryption version"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
|
||||||
|
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
|
||||||
|
"plaintext": "⚠️",
|
||||||
|
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
|
||||||
|
"note": "unknown encryption version 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
|
||||||
|
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
|
||||||
|
"plaintext": "n o s t r",
|
||||||
|
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
|
||||||
|
"note": "invalid base64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
|
||||||
|
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
|
||||||
|
"plaintext": "¯\\_(ツ)_/¯",
|
||||||
|
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"note": "invalid MAC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
|
||||||
|
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
|
||||||
|
"plaintext": "🥎",
|
||||||
|
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
|
||||||
|
"note": "invalid MAC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
|
||||||
|
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
|
||||||
|
"plaintext": "elliptic-curve cryptography",
|
||||||
|
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
|
||||||
|
"note": "invalid padding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
|
||||||
|
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
|
||||||
|
"plaintext": "noble",
|
||||||
|
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
|
||||||
|
"note": "invalid padding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
|
||||||
|
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
|
||||||
|
"plaintext": "censorship-resistant and global social network",
|
||||||
|
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
|
||||||
|
"note": "invalid padding"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
329
nip46.ts
Normal file
329
nip46.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
|
||||||
|
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
|
||||||
|
import { decrypt, encrypt } from './nip04.ts'
|
||||||
|
import { NIP05_REGEX } from './nip05.ts'
|
||||||
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { Handlerinformation, NostrConnect } from './kinds.ts'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUNKER_REGEX = /^bunker:\/\/([0-9a-f]{64})\??([?\/\w:.=&%]*)$/
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
export type BunkerPointer = {
|
||||||
|
relays: string[]
|
||||||
|
pubkey: string
|
||||||
|
secret: null | string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This takes either a bunker:// URL or a name@domain.com NIP-05 identifier
|
||||||
|
and returns a BunkerPointer -- or null in case of error */
|
||||||
|
export async function parseBunkerInput(input: string): Promise<BunkerPointer | null> {
|
||||||
|
let match = input.match(BUNKER_REGEX)
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const pubkey = match[1]
|
||||||
|
const qs = new URLSearchParams(match[2])
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
relays: qs.getAll('relay'),
|
||||||
|
secret: qs.get('secret'),
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
/* just move to the next case */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBunkerProfile(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
|
||||||
|
const match = nip05.match(NIP05_REGEX)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const [_, name = '_', domain] = match
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||||
|
const res = await (await _fetch(url, { redirect: 'error' })).json()
|
||||||
|
|
||||||
|
let pubkey = res.names[name]
|
||||||
|
let relays = res.nip46[pubkey] || []
|
||||||
|
|
||||||
|
return { pubkey, relays, secret: null }
|
||||||
|
} catch (_err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BunkerSignerParams = {
|
||||||
|
pool?: AbstractSimplePool
|
||||||
|
onauth?: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BunkerSigner {
|
||||||
|
private pool: AbstractSimplePool
|
||||||
|
private subCloser: SubCloser
|
||||||
|
private isOpen: boolean
|
||||||
|
private serial: number
|
||||||
|
private idPrefix: string
|
||||||
|
private listeners: {
|
||||||
|
[id: string]: {
|
||||||
|
resolve: (_: string) => void
|
||||||
|
reject: (_: string) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private waitingForAuth: { [id: string]: boolean }
|
||||||
|
private secretKey: Uint8Array
|
||||||
|
public bp: BunkerPointer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance of the Nip46 class.
|
||||||
|
* @param relays - An array of relay addresses.
|
||||||
|
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||||
|
* @param secretKey - An optional key pair.
|
||||||
|
*/
|
||||||
|
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
|
||||||
|
if (bp.relays.length === 0) {
|
||||||
|
throw new Error('no relays are specified for this bunker')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pool = params.pool || new SimplePool()
|
||||||
|
this.secretKey = clientSecretKey
|
||||||
|
this.bp = bp
|
||||||
|
this.isOpen = false
|
||||||
|
this.idPrefix = Math.random().toString(36).substring(7)
|
||||||
|
this.serial = 0
|
||||||
|
this.listeners = {}
|
||||||
|
this.waitingForAuth = {}
|
||||||
|
|
||||||
|
const listeners = this.listeners
|
||||||
|
const waitingForAuth = this.waitingForAuth
|
||||||
|
|
||||||
|
this.subCloser = this.pool.subscribeMany(
|
||||||
|
this.bp.relays,
|
||||||
|
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }],
|
||||||
|
{
|
||||||
|
async onevent(event: NostrEvent) {
|
||||||
|
const { id, result, error } = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
|
||||||
|
|
||||||
|
if (result === 'auth_url' && waitingForAuth[id]) {
|
||||||
|
delete listeners[id]
|
||||||
|
|
||||||
|
if (params.onauth) {
|
||||||
|
params.onauth(error)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`nostr-tools/nip46: remote signer ${bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = listeners[id]
|
||||||
|
if (handler) {
|
||||||
|
if (error) handler.reject(error)
|
||||||
|
else if (result) handler.resolve(result)
|
||||||
|
delete listeners[id]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
this.isOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// closes the subscription -- this object can't be used anymore after this
|
||||||
|
async close() {
|
||||||
|
this.isOpen = false
|
||||||
|
this.subCloser.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendRequest(method: string, params: string[]): Promise<string> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (!this.isOpen) throw new Error('this signer is not open anymore, create a new one')
|
||||||
|
this.serial++
|
||||||
|
const id = `${this.idPrefix}-${this.serial}`
|
||||||
|
|
||||||
|
const encryptedContent = await encrypt(this.secretKey, this.bp.pubkey, JSON.stringify({ id, method, params }))
|
||||||
|
|
||||||
|
// the request event
|
||||||
|
const verifiedEvent: VerifiedEvent = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: NostrConnect,
|
||||||
|
tags: [['p', this.bp.pubkey]],
|
||||||
|
content: encryptedContent,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
this.secretKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup callback listener
|
||||||
|
this.listeners[id] = { resolve, reject }
|
||||||
|
this.waitingForAuth[id] = true
|
||||||
|
|
||||||
|
// publish the event
|
||||||
|
await Promise.any(this.pool.publish(this.bp.relays, verifiedEvent))
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "connect" method on the bunker.
|
||||||
|
* The promise will be rejected if the response is not "pong".
|
||||||
|
*/
|
||||||
|
async ping(): Promise<void> {
|
||||||
|
let resp = await this.sendRequest('ping', [])
|
||||||
|
if (resp !== 'pong') throw new Error(`result is not pong: ${resp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "connect" method on the bunker.
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await this.sendRequest('connect', [getPublicKey(this.secretKey), this.bp.secret || ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This was supposed to call the "get_public_key" method on the bunker,
|
||||||
|
* but instead we just returns the public key we already know.
|
||||||
|
*/
|
||||||
|
async getPublicKey(): Promise<string> {
|
||||||
|
return this.bp.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the "get_relays" method on the bunker.
|
||||||
|
*/
|
||||||
|
async getRelays(): Promise<{ [relay: string]: { read: boolean; write: boolean } }> {
|
||||||
|
return JSON.parse(await this.sendRequest('get_relays', []))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an event using the remote private key.
|
||||||
|
* @param event - The event to sign.
|
||||||
|
* @returns A Promise that resolves to the signed event.
|
||||||
|
*/
|
||||||
|
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> {
|
||||||
|
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
|
||||||
|
let signed: NostrEvent = JSON.parse(resp)
|
||||||
|
if (signed.pubkey === this.bp.pubkey && verifyEvent(signed)) {
|
||||||
|
return signed
|
||||||
|
} else {
|
||||||
|
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip04Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext])
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip04Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44GetKey(thirdPartyPubkey: string): Promise<Uint8Array> {
|
||||||
|
let resp = await this.sendRequest('nip44_get_key', [thirdPartyPubkey])
|
||||||
|
return hexToBytes(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
|
||||||
|
}
|
||||||
|
|
||||||
|
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
|
||||||
|
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an account with the specified username, domain, and optional email.
|
||||||
|
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
|
||||||
|
* @param username - The username for the account.
|
||||||
|
* @param domain - The domain for the account.
|
||||||
|
* @param email - The optional email for the account.
|
||||||
|
* @throws Error if the email is present but invalid.
|
||||||
|
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
|
||||||
|
*/
|
||||||
|
export async function createAccount(
|
||||||
|
bunker: BunkerProfile,
|
||||||
|
params: BunkerSignerParams,
|
||||||
|
username: string,
|
||||||
|
domain: string,
|
||||||
|
email?: string,
|
||||||
|
): Promise<BunkerSigner> {
|
||||||
|
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
|
||||||
|
|
||||||
|
let sk = generateSecretKey()
|
||||||
|
let rpc = new BunkerSigner(sk, bunker.bunkerPointer, params)
|
||||||
|
|
||||||
|
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
|
||||||
|
|
||||||
|
// once we get the newly created pubkey back, we hijack this signer instance
|
||||||
|
// and turn it into the main instance for this newly created pubkey
|
||||||
|
rpc.bp.pubkey = pubkey
|
||||||
|
await rpc.connect()
|
||||||
|
|
||||||
|
return rpc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches info on available providers that announce themselves using NIP-89 events.
|
||||||
|
* @returns A promise that resolves to an array of available bunker objects.
|
||||||
|
*/
|
||||||
|
export async function fetchCustodialBunkers(pool: AbstractSimplePool, relays: string[]): Promise<BunkerProfile[]> {
|
||||||
|
const events = await pool.querySync(relays, {
|
||||||
|
kinds: [Handlerinformation],
|
||||||
|
'#k': [NostrConnect.toString()],
|
||||||
|
})
|
||||||
|
|
||||||
|
// validate bunkers by checking their NIP-05 and pubkey
|
||||||
|
// map to a more useful object
|
||||||
|
const validatedBunkers = await Promise.all(
|
||||||
|
events.map(async event => {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content)
|
||||||
|
const bp = await queryBunkerProfile(content.nip05)
|
||||||
|
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
|
||||||
|
return {
|
||||||
|
bunkerPointer: bp,
|
||||||
|
nip05: content.nip05,
|
||||||
|
domain: content.nip05.split('@')[1],
|
||||||
|
name: content.name || content.display_name,
|
||||||
|
picture: content.picture,
|
||||||
|
about: content.about,
|
||||||
|
website: content.website,
|
||||||
|
local: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return validatedBunkers.filter(b => b !== undefined) as BunkerProfile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BunkerProfile = {
|
||||||
|
bunkerPointer: BunkerPointer
|
||||||
|
domain: string
|
||||||
|
nip05: string
|
||||||
|
name: string
|
||||||
|
picture: string
|
||||||
|
about: string
|
||||||
|
website: string
|
||||||
|
local: boolean
|
||||||
|
}
|
||||||
65
nip47.test.ts
Normal file
65
nip47.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
|
||||||
|
import { decrypt } from './nip04.ts'
|
||||||
|
import { NWCWalletRequest } from './kinds.ts'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
globalThis.crypto = crypto
|
||||||
|
|
||||||
|
describe('parseConnectionString', () => {
|
||||||
|
test('returns pubkey, relay, and secret if connection string is valid', () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||||
|
const { pubkey, relay, secret } = parseConnectionString(connectionString)
|
||||||
|
|
||||||
|
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
|
||||||
|
expect(relay).toBe('wss://relay.damus.io')
|
||||||
|
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if no pubkey in connection string', async () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||||
|
|
||||||
|
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if no relay in connection string', async () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
|
||||||
|
|
||||||
|
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if no secret in connection string', async () => {
|
||||||
|
const connectionString =
|
||||||
|
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
|
||||||
|
|
||||||
|
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makeNwcRequestEvent', () => {
|
||||||
|
test('returns a valid NWC request event', async () => {
|
||||||
|
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
|
||||||
|
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
|
||||||
|
const invoice =
|
||||||
|
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
|
||||||
|
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
|
||||||
|
expect(result.kind).toBe(NWCWalletRequest)
|
||||||
|
expect(await decrypt(secret, pubkey, result.content)).toEqual(
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: {
|
||||||
|
invoice,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(result.tags).toEqual([['p', pubkey]])
|
||||||
|
expect(result.id).toEqual(expect.any(String))
|
||||||
|
expect(result.sig).toEqual(expect.any(String))
|
||||||
|
})
|
||||||
|
})
|
||||||
34
nip47.ts
Normal file
34
nip47.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { finalizeEvent } from './pure.ts'
|
||||||
|
import { NWCWalletRequest } from './kinds.ts'
|
||||||
|
import { encrypt } from './nip04.ts'
|
||||||
|
|
||||||
|
export function parseConnectionString(connectionString: string) {
|
||||||
|
const { pathname, searchParams } = new URL(connectionString)
|
||||||
|
const pubkey = pathname
|
||||||
|
const relay = searchParams.get('relay')
|
||||||
|
const secret = searchParams.get('secret')
|
||||||
|
|
||||||
|
if (!pubkey || !relay || !secret) {
|
||||||
|
throw new Error('invalid connection string')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pubkey, relay, secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
|
||||||
|
const content = {
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: {
|
||||||
|
invoice,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
|
||||||
|
const eventTemplate = {
|
||||||
|
kind: NWCWalletRequest,
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: encryptedContent,
|
||||||
|
tags: [['p', pubkey]],
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalizeEvent(eventTemplate, secretKey)
|
||||||
|
}
|
||||||
95
nip49.test.ts
Normal file
95
nip49.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { decrypt, encrypt } from './nip49'
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
test('encrypt and decrypt', () => {
|
||||||
|
for (let i = 0; i < vectors.length; i++) {
|
||||||
|
let [password, secret, logn, ksb, ncryptsec] = vectors[i]
|
||||||
|
let sec = hexToBytes(secret)
|
||||||
|
let there = encrypt(sec, password, logn, ksb)
|
||||||
|
let back = decrypt(there, password)
|
||||||
|
let again = decrypt(ncryptsec, password)
|
||||||
|
expect(back).toEqual(again)
|
||||||
|
expect(again).toEqual(sec)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const vectors: [string, string, number, 0x00 | 0x01 | 0x02, string][] = [
|
||||||
|
[
|
||||||
|
'.ksjabdk.aselqwe',
|
||||||
|
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||||
|
1,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgqeya6cggg2chdaf48s9evsr0czq3dw059t2khf5nvmq03yeckywqmspcc037l9ajjsq2p08480afuc5hq2zq3rtt454c2epjqxcxll0eff3u7ln2t349t7rc04029q63u28mkeuj4tdazsqqk6p5ky',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'skjdaklrnçurbç l',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
2,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgp86t7az0u5w0wp8nrjnxu9xhullqt39wvfsljz8289gyxg0thrlzv3k40dsqu32vcqza3m7srzm27mkg929gmv6hv5ctay59jf0h8vsj5pjmylvupkdtvy7fy88et3fhe6m3d84t9m8j2umq0j75lw',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'777z7z7z7z7z7z7z',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
3,
|
||||||
|
0x02,
|
||||||
|
'ncryptsec1qgpc7jmmzmds376r8slazywlagrm5eerlrx7njnjenweggq2atjl0h9vmpk8f9gad0tqy3pwch8e49kyj5qtehp4mjwpzlshx5f5cce8feukst08w52zf4a7gssdqvt3eselup7x4zzezlme3ydxpjaf',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'.ksjabdk.aselqwe',
|
||||||
|
'14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a',
|
||||||
|
7,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgrss6ycqptee05e5anq33x2vz6ljr0rqunsy9xj5gypkp0lucatdf8yhexrztqcy76sqweuzk8yqzep9mugp988vznz5df8urnyrmaa7l7fvvskp4t0ydjtz0zeajtumul8cnsjcksp68xhxggmy4dz',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'skjdaklrnçurbç l',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
8,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgy0gg98z4wvl35eqlraxf7cyxhfs4968teq59vm97e94gpycmcy6znsc8z82dy5rk8sz0r499ue7xfmd0yuyvzxagtfyxtnwcrcsjavkch8lfseejukwdq7mdcpm43znffngw7texdc5pdujywszhrr',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'777z7z7z7z7z7z7z',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
9,
|
||||||
|
0x02,
|
||||||
|
'ncryptsec1qgyskhh7mpr0zspg95kv4eefm8233hyz46xyr6s52s6qvan906c2u24gl3dc5f7wytzq9njx7sqksd7snagce3kqth7tv4ug4avlxd5su4vthsh54vk62m88whkazavyc6yefnegf4tx473afssxw4p9',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
4,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgzv73a9ktnwmgyvv24x2xtr6grup2v6an96xgs64z3pmh5etg2k4yryachtlu3tpqwqphhm0pjnq9zmftr0qf4p5lmah4rlz02ucjkawr2s9quau67p3jq3d7yp3kreghs0wdcqpf6pkc8jcgsqrn5l',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
5,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgzs50vjjhewdrxnm0z4y77w7juycf6crny9q0kzeg7vxv3erw77qpauthaf7sfwsgnszjzcqh7zql74m8yxnhcj07dry3v5fgr5x42mpzxvfl76gpuayccvk2nczc7ner3q842rj9v033nykvja6cql',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab',
|
||||||
|
1,
|
||||||
|
0x00,
|
||||||
|
'ncryptsec1qgqnx59n7duv6ec3hhrvn33q25u2qfd7m69vv6plsg7spnw6d4r9hq0ayjsnlw99eghqqzj8ps7vfwx40nqp9gpw7yzyy09jmwkq3a3z8q0ph5jahs2hap5k6h2wfrme7w2nuek4jnwpzfht4q3u79ra',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ÅΩẛ̣',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
9,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgy5kwr5v8p206vwaflp4g6r083kwts6q5sh8m4d0q56edpxwhrly78ema2z7jpdeldsz7u5wpxpyhs6m0405skdsep9n37uncw7xlc8q8meyw6d6ky47vcl0guhqpt5dx8ejxc8hvzf6y2gwsl5s0nw',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ÅΩṩ',
|
||||||
|
'11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944',
|
||||||
|
9,
|
||||||
|
0x01,
|
||||||
|
'ncryptsec1qgy5f4lcx873yarkfpngaudarxfj4wj939xn4azmd66j6jrwcml6av87d6vnelzn70kszgkg4lj9rsdjlqz0wn7m7456sr2q5yjpy72ykgkdwckevl857hpcfnwzswj9lajxtln0tsr9h7xdwqm6pqzf',
|
||||||
|
],
|
||||||
|
]
|
||||||
45
nip49.ts
Normal file
45
nip49.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { scrypt } from '@noble/hashes/scrypt'
|
||||||
|
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
|
||||||
|
import { concatBytes, randomBytes } from '@noble/hashes/utils'
|
||||||
|
import { Bech32MaxSize, encodeBytes } from './nip19'
|
||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {
|
||||||
|
let salt = randomBytes(16)
|
||||||
|
let n = 2 ** logn
|
||||||
|
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||||
|
let nonce = randomBytes(24)
|
||||||
|
let aad = Uint8Array.from([ksb])
|
||||||
|
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||||
|
let ciphertext = xc2p1.encrypt(sec)
|
||||||
|
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
|
||||||
|
return encodeBytes('ncryptsec', b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(ncryptsec: string, password: string): Uint8Array {
|
||||||
|
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
|
||||||
|
if (prefix !== 'ncryptsec') {
|
||||||
|
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
|
||||||
|
}
|
||||||
|
let b = new Uint8Array(bech32.fromWords(words))
|
||||||
|
|
||||||
|
let version = b[0]
|
||||||
|
if (version !== 0x02) {
|
||||||
|
throw new Error(`invalid version ${version}, expected 0x02`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let logn = b[1]
|
||||||
|
let n = 2 ** logn
|
||||||
|
|
||||||
|
let salt = b.slice(2, 2 + 16)
|
||||||
|
let nonce = b.slice(2 + 16, 2 + 16 + 24)
|
||||||
|
let ksb = b[2 + 16 + 24]
|
||||||
|
let aad = Uint8Array.from([ksb])
|
||||||
|
let ciphertext = b.slice(2 + 16 + 24 + 1)
|
||||||
|
|
||||||
|
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
|
||||||
|
let xc2p1 = xchacha20poly1305(key, nonce, aad)
|
||||||
|
let sec = xc2p1.decrypt(ciphertext)
|
||||||
|
|
||||||
|
return sec
|
||||||
|
}
|
||||||
319
nip57.test.ts
Normal file
319
nip57.test.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { describe, test, expect, mock } from 'bun:test'
|
||||||
|
import { finalizeEvent } from './pure.ts'
|
||||||
|
import { getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
describe('getZapEndpoint', () => {
|
||||||
|
test('returns null if neither lud06 nor lud16 is present', async () => {
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null if fetch fails', async () => {
|
||||||
|
const fetchImplementation = mock(() => Promise.reject(new Error()))
|
||||||
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null if the response does not allow Nostr payments', async () => {
|
||||||
|
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
|
||||||
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the callback URL if the response allows Nostr payments', async () => {
|
||||||
|
const fetchImplementation = mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
json: () => ({
|
||||||
|
allowsNostr: true,
|
||||||
|
nostrPubkey: 'pubkey',
|
||||||
|
callback: 'callback',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
useFetchImplementation(fetchImplementation)
|
||||||
|
|
||||||
|
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
|
||||||
|
const result = await getZapEndpoint(metadata)
|
||||||
|
|
||||||
|
expect(result).toBe('callback')
|
||||||
|
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makeZapRequest', () => {
|
||||||
|
test('throws an error if amount is not given', () => {
|
||||||
|
expect(() =>
|
||||||
|
// @ts-expect-error
|
||||||
|
makeZapRequest({
|
||||||
|
profile: 'profile',
|
||||||
|
event: null,
|
||||||
|
relays: [],
|
||||||
|
comment: '',
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error if profile is not given', () => {
|
||||||
|
expect(() =>
|
||||||
|
// @ts-expect-error
|
||||||
|
makeZapRequest({
|
||||||
|
event: null,
|
||||||
|
amount: 100,
|
||||||
|
relays: [],
|
||||||
|
comment: '',
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a valid Zap request', () => {
|
||||||
|
const result = makeZapRequest({
|
||||||
|
profile: 'profile',
|
||||||
|
event: 'event',
|
||||||
|
amount: 100,
|
||||||
|
relays: ['relay1', 'relay2'],
|
||||||
|
comment: 'comment',
|
||||||
|
})
|
||||||
|
expect(result.kind).toBe(9734)
|
||||||
|
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
|
||||||
|
expect(result.content).toBe('comment')
|
||||||
|
expect(result.tags).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
['p', 'profile'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
['e', 'event'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateZapRequest', () => {
|
||||||
|
test('returns an error message for invalid JSON', () => {
|
||||||
|
expect(validateZapRequest('invalid JSON')).toBe('Invalid zap request JSON.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the Zap request is not a valid Nostr event', () => {
|
||||||
|
const zapRequest = {
|
||||||
|
kind: 1234,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', 'profile'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Zap request is not a valid Nostr event.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the signature on the Zap request is invalid', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = {
|
||||||
|
pubkey: publicKey,
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe('Invalid signature on zap request.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the Zap request does not have a "p" tag', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'p' tag.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', 'invalid hex'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'p' tag is not valid hex.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['e', 'invalid hex'],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request 'e' tag is not valid hex.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns an error message if the Zap request does not have a relays tag', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['amount', '100'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe("Zap request doesn't have a 'relays' tag.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a valid Zap request', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
|
||||||
|
const zapRequest = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', publicKey],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('makeZapReceipt', () => {
|
||||||
|
const privateKey = generateSecretKey()
|
||||||
|
const publicKey = getPublicKey(privateKey)
|
||||||
|
const target = 'efeb5d6e74ce6ffea6cae4094a9f29c26b5c56d7b44fae9f490f3410fd708c45'
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt with a preimage', () => {
|
||||||
|
const zapRequest = JSON.stringify(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', target],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const preimage = 'preimage'
|
||||||
|
const bolt11 = 'bolt11'
|
||||||
|
const paidAt = new Date()
|
||||||
|
|
||||||
|
const result = makeZapReceipt({ zapRequest, preimage, bolt11, paidAt })
|
||||||
|
|
||||||
|
expect(result.kind).toBe(9735)
|
||||||
|
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||||
|
expect(result.content).toBe('')
|
||||||
|
expect(result.tags).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
['bolt11', bolt11],
|
||||||
|
['description', zapRequest],
|
||||||
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
|
['preimage', preimage],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a valid Zap receipt without a preimage', () => {
|
||||||
|
const zapRequest = JSON.stringify(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Date.now() / 1000,
|
||||||
|
content: 'content',
|
||||||
|
tags: [
|
||||||
|
['p', target],
|
||||||
|
['amount', '100'],
|
||||||
|
['relays', 'relay1', 'relay2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const bolt11 = 'bolt11'
|
||||||
|
const paidAt = new Date()
|
||||||
|
|
||||||
|
const result = makeZapReceipt({ zapRequest, bolt11, paidAt })
|
||||||
|
|
||||||
|
expect(result.kind).toBe(9735)
|
||||||
|
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
|
||||||
|
expect(result.content).toBe('')
|
||||||
|
expect(result.tags).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
['bolt11', bolt11],
|
||||||
|
['description', zapRequest],
|
||||||
|
['p', target],
|
||||||
|
['P', publicKey],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(JSON.stringify(result.tags)).not.toContain('preimage')
|
||||||
|
})
|
||||||
|
})
|
||||||
130
nip57.ts
Normal file
130
nip57.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { bech32 } from '@scure/base'
|
||||||
|
|
||||||
|
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
|
||||||
|
import { utf8Decoder } from './utils.ts'
|
||||||
|
|
||||||
|
var _fetch: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
_fetch = fetch
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export function useFetchImplementation(fetchImplementation: any) {
|
||||||
|
_fetch = fetchImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
|
||||||
|
try {
|
||||||
|
let lnurl: string = ''
|
||||||
|
let { lud06, lud16 } = JSON.parse(metadata.content)
|
||||||
|
if (lud06) {
|
||||||
|
let { words } = bech32.decode(lud06, 1000)
|
||||||
|
let data = bech32.fromWords(words)
|
||||||
|
lnurl = utf8Decoder.decode(data)
|
||||||
|
} else if (lud16) {
|
||||||
|
let [name, domain] = lud16.split('@')
|
||||||
|
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await _fetch(lnurl)
|
||||||
|
let body = await res.json()
|
||||||
|
|
||||||
|
if (body.allowsNostr && body.nostrPubkey) {
|
||||||
|
return body.callback
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
/*-*/
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeZapRequest({
|
||||||
|
profile,
|
||||||
|
event,
|
||||||
|
amount,
|
||||||
|
relays,
|
||||||
|
comment = '',
|
||||||
|
}: {
|
||||||
|
profile: string
|
||||||
|
event: string | null
|
||||||
|
amount: number
|
||||||
|
comment: string
|
||||||
|
relays: string[]
|
||||||
|
}): EventTemplate {
|
||||||
|
if (!amount) throw new Error('amount not given')
|
||||||
|
if (!profile) throw new Error('profile not given')
|
||||||
|
|
||||||
|
let zr: EventTemplate = {
|
||||||
|
kind: 9734,
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: comment,
|
||||||
|
tags: [
|
||||||
|
['p', profile],
|
||||||
|
['amount', amount.toString()],
|
||||||
|
['relays', ...relays],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
zr.tags.push(['e', event])
|
||||||
|
}
|
||||||
|
|
||||||
|
return zr
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateZapRequest(zapRequestString: string): string | null {
|
||||||
|
let zapRequest: Event
|
||||||
|
|
||||||
|
try {
|
||||||
|
zapRequest = JSON.parse(zapRequestString)
|
||||||
|
} catch (err) {
|
||||||
|
return 'Invalid zap request JSON.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEvent(zapRequest)) return 'Zap request is not a valid Nostr event.'
|
||||||
|
|
||||||
|
if (!verifyEvent(zapRequest)) return 'Invalid signature on zap request.'
|
||||||
|
|
||||||
|
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
|
||||||
|
if (!p) return "Zap request doesn't have a 'p' tag."
|
||||||
|
if (!p[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'p' tag is not valid hex."
|
||||||
|
|
||||||
|
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
|
||||||
|
if (e && !e[1].match(/^[a-f0-9]{64}$/)) return "Zap request 'e' tag is not valid hex."
|
||||||
|
|
||||||
|
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
|
||||||
|
if (!relays) return "Zap request doesn't have a 'relays' tag."
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeZapReceipt({
|
||||||
|
zapRequest,
|
||||||
|
preimage,
|
||||||
|
bolt11,
|
||||||
|
paidAt,
|
||||||
|
}: {
|
||||||
|
zapRequest: string
|
||||||
|
preimage?: string
|
||||||
|
bolt11: string
|
||||||
|
paidAt: Date
|
||||||
|
}): EventTemplate {
|
||||||
|
let zr: Event = JSON.parse(zapRequest)
|
||||||
|
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
|
||||||
|
|
||||||
|
let zap: EventTemplate = {
|
||||||
|
kind: 9735,
|
||||||
|
created_at: Math.round(paidAt.getTime() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [...tagsFromZapRequest, ['P', zr.pubkey], ['bolt11', bolt11], ['description', zapRequest]],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preimage) {
|
||||||
|
zap.tags.push(['preimage', preimage])
|
||||||
|
}
|
||||||
|
|
||||||
|
return zap
|
||||||
|
}
|
||||||
203
nip75.test.ts
Normal file
203
nip75.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
|
||||||
|
import { ZapGoal } from './kinds.ts'
|
||||||
|
import { Goal, generateGoalEventTemplate, validateZapGoalEvent } from './nip75.ts'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||||
|
|
||||||
|
describe('Goal Type', () => {
|
||||||
|
it('should create a proper Goal object', () => {
|
||||||
|
const goal: Goal = {
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
amount: '100000000',
|
||||||
|
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
closedAt: 1671150419,
|
||||||
|
image: 'https://example.com/goal-image.jpg',
|
||||||
|
summary: 'Help us reach our fundraising goal!',
|
||||||
|
r: 'https://example.com/additional-info',
|
||||||
|
a: 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146',
|
||||||
|
zapTags: [
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(goal.content).toBe('Fundraising for a new project')
|
||||||
|
expect(goal.amount).toBe('100000000')
|
||||||
|
expect(goal.relays).toEqual(['wss://relay1.example.com', 'wss://relay2.example.com'])
|
||||||
|
expect(goal.closedAt).toBe(1671150419)
|
||||||
|
expect(goal.image).toBe('https://example.com/goal-image.jpg')
|
||||||
|
expect(goal.summary).toBe('Help us reach our fundraising goal!')
|
||||||
|
expect(goal.r).toBe('https://example.com/additional-info')
|
||||||
|
expect(goal.a).toBe('fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146')
|
||||||
|
expect(goal.zapTags).toEqual([
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateGoalEventTemplate', () => {
|
||||||
|
it('should generate an EventTemplate for a fundraising goal', () => {
|
||||||
|
const goal: Goal = {
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
amount: '100000000',
|
||||||
|
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
closedAt: 1671150419,
|
||||||
|
image: 'https://example.com/goal-image.jpg',
|
||||||
|
summary: 'Help us reach our fundraising goal!',
|
||||||
|
r: 'https://example.com/additional-info',
|
||||||
|
zapTags: [
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTemplate = generateGoalEventTemplate(goal)
|
||||||
|
|
||||||
|
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||||
|
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['amount', '100000000'],
|
||||||
|
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
['closed_at', '1671150419'],
|
||||||
|
['image', 'https://example.com/goal-image.jpg'],
|
||||||
|
['summary', 'Help us reach our fundraising goal!'],
|
||||||
|
['r', 'https://example.com/additional-info'],
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate an EventTemplate for a fundraising goal without optional properties', () => {
|
||||||
|
const goal: Goal = {
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
amount: '100000000',
|
||||||
|
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTemplate = generateGoalEventTemplate(goal)
|
||||||
|
|
||||||
|
expect(eventTemplate.kind).toBe(ZapGoal)
|
||||||
|
expect(eventTemplate.content).toBe('Fundraising for a new project')
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['amount', '100000000'],
|
||||||
|
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate an EventTemplate that is valid', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const goal: Goal = {
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
amount: '100000000',
|
||||||
|
relays: ['wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
closedAt: 1671150419,
|
||||||
|
image: 'https://example.com/goal-image.jpg',
|
||||||
|
summary: 'Help us reach our fundraising goal!',
|
||||||
|
r: 'https://example.com/additional-info',
|
||||||
|
zapTags: [
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const eventTemplate = generateGoalEventTemplate(goal)
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateZapGoalEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateZapGoalEvent', () => {
|
||||||
|
it('should validate a proper Goal event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate = {
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ZapGoal,
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
tags: [
|
||||||
|
['amount', '100000000'],
|
||||||
|
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
['closed_at', '1671150419'],
|
||||||
|
['image', 'https://example.com/goal-image.jpg'],
|
||||||
|
['summary', 'Help us reach our fundraising goal!'],
|
||||||
|
['r', 'https://example.com/additional-info'],
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateZapGoalEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not validate an event with an incorrect kind', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate = {
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 0, // Incorrect kind
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
tags: [
|
||||||
|
['amount', '100000000'],
|
||||||
|
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
['closed_at', '1671150419'],
|
||||||
|
['image', 'https://example.com/goal-image.jpg'],
|
||||||
|
['summary', 'Help us reach our fundraising goal!'],
|
||||||
|
['r', 'https://example.com/additional-info'],
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateZapGoalEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not validate an event with missing required "amount" tag', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate = {
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ZapGoal,
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
tags: [
|
||||||
|
// Missing "amount" tag
|
||||||
|
['relays', 'wss://relay1.example.com', 'wss://relay2.example.com'],
|
||||||
|
['closed_at', '1671150419'],
|
||||||
|
['image', 'https://example.com/goal-image.jpg'],
|
||||||
|
['summary', 'Help us reach our fundraising goal!'],
|
||||||
|
['r', 'https://example.com/additional-info'],
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateZapGoalEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not validate an event with missing required "relays" tag', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const eventTemplate = {
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ZapGoal,
|
||||||
|
content: 'Fundraising for a new project',
|
||||||
|
tags: [
|
||||||
|
['amount', '100000000'],
|
||||||
|
// Missing "relays" tag
|
||||||
|
['closed_at', '1671150419'],
|
||||||
|
['image', 'https://example.com/goal-image.jpg'],
|
||||||
|
['summary', 'Help us reach our fundraising goal!'],
|
||||||
|
['r', 'https://example.com/additional-info'],
|
||||||
|
['zap', 'beneficiary1'],
|
||||||
|
['zap', 'beneficiary2'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const event = finalizeEvent(eventTemplate, sk)
|
||||||
|
const isValid = validateZapGoalEvent(event)
|
||||||
|
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
115
nip75.ts
Normal file
115
nip75.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Event, EventTemplate } from './core'
|
||||||
|
import { ZapGoal } from './kinds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a fundraising goal in the Nostr network as defined by NIP-75.
|
||||||
|
* This type is used to structure the information needed to create a goal event (`kind:9041`).
|
||||||
|
*/
|
||||||
|
export type Goal = {
|
||||||
|
/**
|
||||||
|
* A human-readable description of the fundraising goal.
|
||||||
|
* This content should provide clear information about the purpose of the fundraising.
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target amount for the fundraising goal in milisats.
|
||||||
|
* This defines the financial target that the fundraiser aims to reach.
|
||||||
|
*/
|
||||||
|
amount: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of relays where the zaps towards this goal will be sent to and tallied from.
|
||||||
|
* Each relay is represented by its WebSocket URL.
|
||||||
|
*/
|
||||||
|
relays: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional timestamp (in seconds, UNIX epoch) indicating when the fundraising goal is considered closed.
|
||||||
|
* Zaps published after this timestamp should not count towards the goal progress.
|
||||||
|
* If not provided, the goal remains open indefinitely or until manually closed.
|
||||||
|
*/
|
||||||
|
closedAt?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional URL to an image related to the goal.
|
||||||
|
* This can be used to visually represent the goal on client interfaces.
|
||||||
|
*/
|
||||||
|
image?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional brief description or summary of the goal.
|
||||||
|
* This can provide a quick overview of the goal, separate from the detailed `content`.
|
||||||
|
*/
|
||||||
|
summary?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional URL related to the goal, providing additional information or actions through an 'r' tag.
|
||||||
|
* This is a single URL, as per NIP-75 specifications for linking additional resources.
|
||||||
|
*/
|
||||||
|
r?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional parameterized replaceable event linked to the goal, specified through an 'a' tag.
|
||||||
|
* This is a single event id, aligning with NIP-75's allowance for linking to specific events.
|
||||||
|
*/
|
||||||
|
a?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional tags specifying multiple beneficiary pubkeys or additional criteria for zapping,
|
||||||
|
* allowing contributions to be directed towards multiple recipients or according to specific conditions.
|
||||||
|
*/
|
||||||
|
zapTags?: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an EventTemplate for a fundraising goal based on the provided ZapGoal object.
|
||||||
|
* This function is tailored to fit the structure of EventTemplate as defined in the library.
|
||||||
|
* @param zapGoal The ZapGoal object containing the details of the fundraising goal.
|
||||||
|
* @returns An EventTemplate object structured for creating a Nostr event.
|
||||||
|
*/
|
||||||
|
export function generateGoalEventTemplate({
|
||||||
|
amount,
|
||||||
|
content,
|
||||||
|
relays,
|
||||||
|
a,
|
||||||
|
closedAt,
|
||||||
|
image,
|
||||||
|
r,
|
||||||
|
summary,
|
||||||
|
zapTags,
|
||||||
|
}: Goal): EventTemplate {
|
||||||
|
const tags: string[][] = [
|
||||||
|
['amount', amount],
|
||||||
|
['relays', ...relays],
|
||||||
|
]
|
||||||
|
|
||||||
|
// Append optional tags based on the presence of optional properties in zapGoal
|
||||||
|
closedAt && tags.push(['closed_at', closedAt.toString()])
|
||||||
|
image && tags.push(['image', image])
|
||||||
|
summary && tags.push(['summary', summary])
|
||||||
|
r && tags.push(['r', r])
|
||||||
|
a && tags.push(['a', a])
|
||||||
|
zapTags && tags.push(...zapTags)
|
||||||
|
|
||||||
|
// Construct the EventTemplate object
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ZapGoal,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateZapGoalEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== ZapGoal) return false
|
||||||
|
|
||||||
|
const requiredTags = ['amount', 'relays'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
374
nip94.test.ts
Normal file
374
nip94.test.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
|
||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||||
|
import { FileMetadataObject, generateEventTemplate, parseEvent, validateEvent } from './nip94.ts'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure.ts'
|
||||||
|
|
||||||
|
describe('generateEventTemplate', () => {
|
||||||
|
it('should generate the correct event template', () => {
|
||||||
|
const fileMetadataObject: FileMetadataObject = {
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
url: 'https://example.com/image.jpg',
|
||||||
|
m: 'image/jpeg',
|
||||||
|
x: 'image',
|
||||||
|
ox: 'original',
|
||||||
|
size: '1024',
|
||||||
|
dim: '800x600',
|
||||||
|
i: 'abc123',
|
||||||
|
blurhash: 'abcdefg',
|
||||||
|
thumb: 'https://example.com/thumb.jpg',
|
||||||
|
image: 'https://example.com/image.jpg',
|
||||||
|
summary: 'Lorem ipsum',
|
||||||
|
alt: 'Image alt text',
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEventTemplate: EventTemplate = {
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: expect.any(Number),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTemplate = generateEventTemplate(fileMetadataObject)
|
||||||
|
|
||||||
|
expect(eventTemplate).toEqual(expectedEventTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
it('should return true for a valid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if kind is not FileMetadataKind', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 0, // not FileMetadataKind
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if content is empty', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: '', // empty
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if required tags are missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const eventWithoutUrl: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
// missing url
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventWithoutM: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
// missing m
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventWithoutX: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
// missing x
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventWithoutOx: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
// missing ox
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(eventWithoutUrl)).toBe(false)
|
||||||
|
expect(validateEvent(eventWithoutM)).toBe(false)
|
||||||
|
expect(validateEvent(eventWithoutX)).toBe(false)
|
||||||
|
expect(validateEvent(eventWithoutOx)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if size is not a number', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', 'abc'], // not a number
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if dim is not a valid dimension string', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const eventWithInvalidDim: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', 'abc'], // invalid dim
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(eventWithInvalidDim)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseEvent', () => {
|
||||||
|
it('should parse a valid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsedEvent = parseEvent(event)
|
||||||
|
|
||||||
|
expect(parsedEvent).toEqual({
|
||||||
|
content: 'Lorem ipsum dolor sit amet',
|
||||||
|
url: 'https://example.com/image.jpg',
|
||||||
|
m: 'image/jpeg',
|
||||||
|
x: 'image',
|
||||||
|
ox: 'original',
|
||||||
|
size: '1024',
|
||||||
|
dim: '800x600',
|
||||||
|
i: 'abc123',
|
||||||
|
blurhash: 'abcdefg',
|
||||||
|
thumb: 'https://example.com/thumb.jpg',
|
||||||
|
image: 'https://example.com/image.jpg',
|
||||||
|
summary: 'Lorem ipsum',
|
||||||
|
alt: 'Image alt text',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if the event is invalid', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
content: '', // invalid
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', 'https://example.com/image.jpg'],
|
||||||
|
['m', 'image/jpeg'],
|
||||||
|
['x', 'image'],
|
||||||
|
['ox', 'original'],
|
||||||
|
['size', '1024'],
|
||||||
|
['dim', '800x600'],
|
||||||
|
['i', 'abc123'],
|
||||||
|
['blurhash', 'abcdefg'],
|
||||||
|
['thumb', 'https://example.com/thumb.jpg'],
|
||||||
|
['image', 'https://example.com/image.jpg'],
|
||||||
|
['summary', 'Lorem ipsum'],
|
||||||
|
['alt', 'Image alt text'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseEvent(event)).toThrow('Invalid event')
|
||||||
|
})
|
||||||
|
})
|
||||||
201
nip94.ts
Normal file
201
nip94.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Event, EventTemplate } from './core'
|
||||||
|
import { FileMetadata as FileMetadataKind } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type definition for File Metadata as specified in NIP-94.
|
||||||
|
* This type is used to represent the metadata associated with a file sharing event (kind: 1063).
|
||||||
|
*/
|
||||||
|
export type FileMetadataObject = {
|
||||||
|
/**
|
||||||
|
* A description or caption for the file content.
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to download the file.
|
||||||
|
*/
|
||||||
|
url: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MIME type of the file, in lowercase.
|
||||||
|
*/
|
||||||
|
m: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SHA-256 hex-encoded string of the file.
|
||||||
|
*/
|
||||||
|
x: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SHA-256 hex-encoded string of the original file, before any transformations done by the upload server.
|
||||||
|
*/
|
||||||
|
ox: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The size of the file in bytes.
|
||||||
|
*/
|
||||||
|
size?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The dimensions of the file in pixels, in the format "<width>x<height>".
|
||||||
|
*/
|
||||||
|
dim?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The URI to the magnet file.
|
||||||
|
*/
|
||||||
|
magnet?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The torrent infohash.
|
||||||
|
*/
|
||||||
|
i?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The blurhash string to show while the file is being loaded by the client.
|
||||||
|
*/
|
||||||
|
blurhash?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The URL of the thumbnail image with the same aspect ratio as the original file.
|
||||||
|
*/
|
||||||
|
thumb?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: The URL of a preview image with the same dimensions as the original file.
|
||||||
|
*/
|
||||||
|
image?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: A text excerpt or summary of the file's content.
|
||||||
|
*/
|
||||||
|
summary?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: A description for accessibility, providing context or a brief description of the file.
|
||||||
|
*/
|
||||||
|
alt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template based on a file metadata object.
|
||||||
|
*
|
||||||
|
* @param fileMetadata - The file metadata object.
|
||||||
|
* @returns The event template.
|
||||||
|
*/
|
||||||
|
export function generateEventTemplate(fileMetadata: FileMetadataObject): EventTemplate {
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
content: fileMetadata.content,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: FileMetadataKind,
|
||||||
|
tags: [
|
||||||
|
['url', fileMetadata.url],
|
||||||
|
['m', fileMetadata.m],
|
||||||
|
['x', fileMetadata.x],
|
||||||
|
['ox', fileMetadata.ox],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileMetadata.size) eventTemplate.tags.push(['size', fileMetadata.size])
|
||||||
|
if (fileMetadata.dim) eventTemplate.tags.push(['dim', fileMetadata.dim])
|
||||||
|
if (fileMetadata.i) eventTemplate.tags.push(['i', fileMetadata.i])
|
||||||
|
if (fileMetadata.blurhash) eventTemplate.tags.push(['blurhash', fileMetadata.blurhash])
|
||||||
|
if (fileMetadata.thumb) eventTemplate.tags.push(['thumb', fileMetadata.thumb])
|
||||||
|
if (fileMetadata.image) eventTemplate.tags.push(['image', fileMetadata.image])
|
||||||
|
if (fileMetadata.summary) eventTemplate.tags.push(['summary', fileMetadata.summary])
|
||||||
|
if (fileMetadata.alt) eventTemplate.tags.push(['alt', fileMetadata.alt])
|
||||||
|
|
||||||
|
return eventTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an event to ensure it is a valid file metadata event.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateEvent(event: Event): boolean {
|
||||||
|
if (event.kind !== FileMetadataKind) return false
|
||||||
|
|
||||||
|
if (!event.content) return false
|
||||||
|
|
||||||
|
const requiredTags = ['url', 'm', 'x', 'ox'] as const
|
||||||
|
for (const tag of requiredTags) {
|
||||||
|
if (!event.tags.find(([t]) => t == tag)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate optional size tag
|
||||||
|
const sizeTag = event.tags.find(([t]) => t == 'size')
|
||||||
|
if (sizeTag && isNaN(Number(sizeTag[1]))) return false
|
||||||
|
|
||||||
|
// validate optional dim tag
|
||||||
|
const dimTag = event.tags.find(([t]) => t == 'dim')
|
||||||
|
if (dimTag && !dimTag[1].match(/^\d+x\d+$/)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an event and returns a file metadata object.
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns The file metadata object.
|
||||||
|
* @throws Error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseEvent(event: Event): FileMetadataObject {
|
||||||
|
if (!validateEvent(event)) {
|
||||||
|
throw new Error('Invalid event')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileMetadata: FileMetadataObject = {
|
||||||
|
content: event.content,
|
||||||
|
url: '',
|
||||||
|
m: '',
|
||||||
|
x: '',
|
||||||
|
ox: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tag, value] of event.tags) {
|
||||||
|
switch (tag) {
|
||||||
|
case 'url':
|
||||||
|
fileMetadata.url = value
|
||||||
|
break
|
||||||
|
case 'm':
|
||||||
|
fileMetadata.m = value
|
||||||
|
break
|
||||||
|
case 'x':
|
||||||
|
fileMetadata.x = value
|
||||||
|
break
|
||||||
|
case 'ox':
|
||||||
|
fileMetadata.ox = value
|
||||||
|
break
|
||||||
|
case 'size':
|
||||||
|
fileMetadata.size = value
|
||||||
|
break
|
||||||
|
case 'dim':
|
||||||
|
fileMetadata.dim = value
|
||||||
|
break
|
||||||
|
case 'magnet':
|
||||||
|
fileMetadata.magnet = value
|
||||||
|
break
|
||||||
|
case 'i':
|
||||||
|
fileMetadata.i = value
|
||||||
|
break
|
||||||
|
case 'blurhash':
|
||||||
|
fileMetadata.blurhash = value
|
||||||
|
break
|
||||||
|
case 'thumb':
|
||||||
|
fileMetadata.thumb = value
|
||||||
|
break
|
||||||
|
case 'image':
|
||||||
|
fileMetadata.image = value
|
||||||
|
break
|
||||||
|
case 'summary':
|
||||||
|
fileMetadata.summary = value
|
||||||
|
break
|
||||||
|
case 'alt':
|
||||||
|
fileMetadata.alt = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileMetadata
|
||||||
|
}
|
||||||
654
nip96.test.ts
Normal file
654
nip96.test.ts
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { HttpResponse, http } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
|
||||||
|
import { FileServerPreference } from './kinds.ts'
|
||||||
|
import {
|
||||||
|
calculateFileHash,
|
||||||
|
checkFileProcessingStatus,
|
||||||
|
deleteFile,
|
||||||
|
generateDownloadUrl,
|
||||||
|
generateFSPEventTemplate,
|
||||||
|
readServerConfig,
|
||||||
|
uploadFile,
|
||||||
|
validateDelayedProcessingResponse,
|
||||||
|
validateFileUploadResponse,
|
||||||
|
validateServerConfiguration,
|
||||||
|
type DelayedProcessingResponse,
|
||||||
|
type FileUploadResponse,
|
||||||
|
type ServerConfiguration,
|
||||||
|
} from './nip96.ts'
|
||||||
|
|
||||||
|
describe('validateServerConfiguration', () => {
|
||||||
|
it("should return true if 'api_url' is valid URL", () => {
|
||||||
|
const config: ServerConfiguration = {
|
||||||
|
api_url: 'http://example.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateServerConfiguration(config)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false if 'api_url' is empty", () => {
|
||||||
|
const config: ServerConfiguration = {
|
||||||
|
api_url: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateServerConfiguration(config)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false if both 'api_url' and 'delegated_to_url' are provided", () => {
|
||||||
|
const config: ServerConfiguration = {
|
||||||
|
api_url: 'http://example.com',
|
||||||
|
delegated_to_url: 'http://example.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateServerConfiguration(config)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readServerConfig', () => {
|
||||||
|
it('should return a valid ServerConfiguration object', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||||
|
const validConfig: ServerConfiguration = {
|
||||||
|
api_url: 'http://example.com',
|
||||||
|
}
|
||||||
|
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||||
|
return HttpResponse.json(validConfig)
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const result = await readServerConfig('http://example.com/')
|
||||||
|
|
||||||
|
expect(result).toEqual(validConfig)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if response is not valid', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||||
|
const invalidConfig = {
|
||||||
|
// missing api_url
|
||||||
|
}
|
||||||
|
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||||
|
return HttpResponse.json(invalidConfig)
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if response is not proper json', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||||
|
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||||
|
return HttpResponse.json(null)
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if response status is not 200', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||||
|
const handler = http.get(`http://example.com${HTTPROUTE}`, () => {
|
||||||
|
return new HttpResponse(null, { status: 400 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
expect(readServerConfig('http://example.com/')).rejects.toThrow()
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if input url is not valid', async () => {
|
||||||
|
expect(readServerConfig('invalid-url')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateFileUploadResponse', () => {
|
||||||
|
it('should return true if response is valid', () => {
|
||||||
|
const mockResponse: FileUploadResponse = {
|
||||||
|
status: 'error',
|
||||||
|
message: 'File uploaded failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if status is undefined', () => {
|
||||||
|
const mockResponse: Omit<FileUploadResponse, 'status'> = {
|
||||||
|
// status: 'error',
|
||||||
|
message: 'File upload failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if message is undefined', () => {
|
||||||
|
const mockResponse: Omit<FileUploadResponse, 'message'> = {
|
||||||
|
status: 'error',
|
||||||
|
// message: 'message',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if status is not valid', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'something else',
|
||||||
|
message: 'message',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if "message" is not a string', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'error',
|
||||||
|
message: 123,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if status is "processing" and "processing_url" is undefined', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'message',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if status is "processing" and "processing_url" is not a string', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'message',
|
||||||
|
processing_url: 123,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if status is "success" and "nip94_event" is undefined', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'message',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if "nip94_event" tags are invalid', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'message',
|
||||||
|
nip94_event: {
|
||||||
|
tags: [
|
||||||
|
// missing url
|
||||||
|
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if "nip94_event" tags are empty', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'message',
|
||||||
|
nip94_event: {
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true if "nip94_event" tags are valid', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'message',
|
||||||
|
nip94_event: {
|
||||||
|
tags: [
|
||||||
|
['url', 'http://example.com'],
|
||||||
|
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateFileUploadResponse(mockResponse)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('uploadFile', () => {
|
||||||
|
it('should return a valid FileUploadResponse object', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const validFileUploadResponse: FileUploadResponse = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'message',
|
||||||
|
nip94_event: {
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['url', 'http://example.com'],
|
||||||
|
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return HttpResponse.json(validFileUploadResponse, { status: 200 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const serverUploadUrl = 'http://example.com/upload'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
const result = await uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)
|
||||||
|
|
||||||
|
expect(result).toEqual(validFileUploadResponse)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 413', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 413 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const serverUploadUrl = 'http://example.com/upload'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('File too large!')
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 400', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 400 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const serverUploadUrl = 'http://example.com/upload'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||||
|
'Bad request! Some fields are missing or invalid!',
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 403', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 403 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const serverUploadUrl = 'http://example.com/upload'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||||
|
'Forbidden! Payload tag does not match the requested file!',
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is 402', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 402 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const serverUploadUrl = 'http://example.com/upload'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow('Payment required!')
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw a proper error if response status is not 200, 400, 402, 403, 413', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.post('http://example.com/upload', () => {
|
||||||
|
return new HttpResponse(null, { status: 500 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const serverUploadUrl = 'http://example.com/upload'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
expect(uploadFile(file, serverUploadUrl, nip98AuthorizationHeader)).rejects.toThrow(
|
||||||
|
'Unknown error in uploading file!',
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateDownloadUrl', () => {
|
||||||
|
it('should generate a download URL without file extension', () => {
|
||||||
|
const fileHash = 'abc123'
|
||||||
|
const serverDownloadUrl = 'http://example.com/download'
|
||||||
|
const expectedUrl = 'http://example.com/download/abc123'
|
||||||
|
|
||||||
|
const result = generateDownloadUrl(fileHash, serverDownloadUrl)
|
||||||
|
|
||||||
|
expect(result).toBe(expectedUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate a download URL with file extension', () => {
|
||||||
|
const fileHash = 'abc123'
|
||||||
|
const serverDownloadUrl = 'http://example.com/download'
|
||||||
|
const fileExtension = '.jpg'
|
||||||
|
const expectedUrl = 'http://example.com/download/abc123.jpg'
|
||||||
|
|
||||||
|
const result = generateDownloadUrl(fileHash, serverDownloadUrl, fileExtension)
|
||||||
|
|
||||||
|
expect(result).toBe(expectedUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteFile', () => {
|
||||||
|
it('should return a basic json response for successful delete', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||||
|
return HttpResponse.json({ status: 'success', message: 'File deleted.' }, { status: 200 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const fileHash = 'abc123'
|
||||||
|
const serverDeleteUrl = 'http://example.com/delete'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
const result = await deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'success', message: 'File deleted.' })
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error for unsuccessful delete', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.delete('http://example.com/delete/abc123', () => {
|
||||||
|
return new HttpResponse(null, { status: 400 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const fileHash = 'abc123'
|
||||||
|
const serverDeleteUrl = 'http://example.com/delete'
|
||||||
|
const nip98AuthorizationHeader = 'Nostr abcabc'
|
||||||
|
|
||||||
|
expect(deleteFile(fileHash, serverDeleteUrl, nip98AuthorizationHeader)).rejects.toThrow()
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateDelayedProcessingResponse', () => {
|
||||||
|
it('should return false for non-object input', () => {
|
||||||
|
expect(validateDelayedProcessingResponse('not an object')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for null input', () => {
|
||||||
|
expect(validateDelayedProcessingResponse(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for object missing required properties', () => {
|
||||||
|
const missingStatus: Omit<DelayedProcessingResponse, 'status'> = {
|
||||||
|
// missing status
|
||||||
|
message: 'test',
|
||||||
|
percentage: 50,
|
||||||
|
}
|
||||||
|
const missingMessage: Omit<DelayedProcessingResponse, 'message'> = {
|
||||||
|
status: 'processing',
|
||||||
|
// missing message
|
||||||
|
percentage: 50,
|
||||||
|
}
|
||||||
|
const missingPercentage: Omit<DelayedProcessingResponse, 'percentage'> = {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'test',
|
||||||
|
// missing percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateDelayedProcessingResponse(missingStatus)).toBe(false)
|
||||||
|
expect(validateDelayedProcessingResponse(missingMessage)).toBe(false)
|
||||||
|
expect(validateDelayedProcessingResponse(missingPercentage)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for invalid status', () => {
|
||||||
|
expect(validateDelayedProcessingResponse({ status: 'invalid', message: 'test', percentage: 50 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for non-string message', () => {
|
||||||
|
expect(validateDelayedProcessingResponse({ status: 'processing', message: 123, percentage: 50 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for non-number percentage', () => {
|
||||||
|
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: '50' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for percentage out of range', () => {
|
||||||
|
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 150 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true for valid input', () => {
|
||||||
|
expect(validateDelayedProcessingResponse({ status: 'processing', message: 'test', percentage: 50 })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('checkFileProcessingStatus', () => {
|
||||||
|
it('should throw an error if response is not ok', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.get('http://example.com/status/abc123', () => {
|
||||||
|
return new HttpResponse(null, { status: 400 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const processingUrl = 'http://example.com/status/abc123'
|
||||||
|
|
||||||
|
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if response is not a valid json', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const handler = http.get('http://example.com/status/abc123', () => {
|
||||||
|
return HttpResponse.text('not a json', { status: 200 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const processingUrl = 'http://example.com/status/abc123'
|
||||||
|
|
||||||
|
expect(checkFileProcessingStatus(processingUrl)).rejects.toThrow()
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a valid DelayedProcessingResponse object if response status is 200', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const validDelayedProcessingResponse: DelayedProcessingResponse = {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'test',
|
||||||
|
percentage: 50,
|
||||||
|
}
|
||||||
|
const handler = http.get('http://example.com/status/abc123', () => {
|
||||||
|
return HttpResponse.json(validDelayedProcessingResponse, { status: 200 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const processingUrl = 'http://example.com/status/abc123'
|
||||||
|
|
||||||
|
const result = await checkFileProcessingStatus(processingUrl)
|
||||||
|
|
||||||
|
expect(result).toEqual(validDelayedProcessingResponse)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a valid FileUploadResponse object if response status is 201', async () => {
|
||||||
|
// setup mock server
|
||||||
|
const validFileUploadResponse: FileUploadResponse = {
|
||||||
|
status: 'success',
|
||||||
|
message: 'message',
|
||||||
|
nip94_event: {
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['url', 'http://example.com'],
|
||||||
|
['ox', '719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const handler = http.get('http://example.com/status/abc123', () => {
|
||||||
|
return HttpResponse.json(validFileUploadResponse, { status: 201 })
|
||||||
|
})
|
||||||
|
const server = setupServer(handler)
|
||||||
|
server.listen()
|
||||||
|
|
||||||
|
const processingUrl = 'http://example.com/status/abc123'
|
||||||
|
|
||||||
|
const result = await checkFileProcessingStatus(processingUrl)
|
||||||
|
|
||||||
|
expect(result).toEqual(validFileUploadResponse)
|
||||||
|
|
||||||
|
// cleanup mock server
|
||||||
|
server.resetHandlers()
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateFSPEventTemplate', () => {
|
||||||
|
it('should generate FSP event template', () => {
|
||||||
|
const serverUrls = ['http://example.com', 'https://example.org']
|
||||||
|
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||||
|
|
||||||
|
expect(eventTemplate.kind).toBe(FileServerPreference)
|
||||||
|
expect(eventTemplate.content).toBe('')
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['server', 'http://example.com'],
|
||||||
|
['server', 'https://example.org'],
|
||||||
|
])
|
||||||
|
expect(typeof eventTemplate.created_at).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter invalid server URLs', () => {
|
||||||
|
const serverUrls = ['http://example.com', 'invalid-url', 'https://example.org']
|
||||||
|
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||||
|
|
||||||
|
expect(eventTemplate.tags).toEqual([
|
||||||
|
['server', 'http://example.com'],
|
||||||
|
['server', 'https://example.org'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty server URLs', () => {
|
||||||
|
const serverUrls: string[] = []
|
||||||
|
const eventTemplate = generateFSPEventTemplate(serverUrls)
|
||||||
|
|
||||||
|
expect(eventTemplate.tags).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateFileHash', () => {
|
||||||
|
it('should calculate file hash', async () => {
|
||||||
|
const file = new File(['hello world'], 'hello.txt')
|
||||||
|
const hash = await calculateFileHash(file)
|
||||||
|
|
||||||
|
expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate file hash with empty file', async () => {
|
||||||
|
const file = new File([], 'empty.txt')
|
||||||
|
const hash = await calculateFileHash(file)
|
||||||
|
|
||||||
|
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||||
|
})
|
||||||
|
})
|
||||||
590
nip96.ts
Normal file
590
nip96.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
import { EventTemplate } from './core'
|
||||||
|
import { FileServerPreference } from './kinds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the configuration for a server compliant with NIP-96.
|
||||||
|
*/
|
||||||
|
export type ServerConfiguration = {
|
||||||
|
/**
|
||||||
|
* The base URL from which file upload and deletion operations are served.
|
||||||
|
* Also used for downloads if "download_url" is not specified.
|
||||||
|
*/
|
||||||
|
api_url: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. The base URL from which files are downloaded.
|
||||||
|
* Used if different from the "api_url".
|
||||||
|
*/
|
||||||
|
download_url?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. URL of another HTTP file storage server's configuration.
|
||||||
|
* Used by nostr relays to delegate to another server.
|
||||||
|
* In this case, "api_url" must be an empty string.
|
||||||
|
*/
|
||||||
|
delegated_to_url?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. An array of NIP numbers that this server supports.
|
||||||
|
*/
|
||||||
|
supported_nips?: number[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. URL to the server's Terms of Service.
|
||||||
|
*/
|
||||||
|
tos_url?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. An array of MIME types supported by the server.
|
||||||
|
*/
|
||||||
|
content_types?: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. Defines various storage plans offered by the server.
|
||||||
|
*/
|
||||||
|
plans?: {
|
||||||
|
[planKey: string]: {
|
||||||
|
/**
|
||||||
|
* The name of the storage plan.
|
||||||
|
*/
|
||||||
|
name: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. Indicates whether NIP-98 is required for uploads in this plan.
|
||||||
|
*/
|
||||||
|
is_nip98_required?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. URL to a landing page providing more information about the plan.
|
||||||
|
*/
|
||||||
|
url?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. The maximum file size allowed under this plan, in bytes.
|
||||||
|
*/
|
||||||
|
max_byte_size?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. Defines the range of file expiration in days.
|
||||||
|
* The first value indicates the minimum expiration time, and the second value indicates the maximum.
|
||||||
|
* A value of 0 indicates no expiration.
|
||||||
|
*/
|
||||||
|
file_expiration?: [number, number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. Specifies the types of media transformations supported under this plan.
|
||||||
|
* Currently, only image transformations are considered.
|
||||||
|
*/
|
||||||
|
media_transformations?: {
|
||||||
|
/**
|
||||||
|
* Optional. An array of supported image transformation types.
|
||||||
|
*/
|
||||||
|
image?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the optional form data fields for file upload in accordance with NIP-96.
|
||||||
|
*/
|
||||||
|
export type OptionalFormDataFields = {
|
||||||
|
/**
|
||||||
|
* Specifies the desired expiration time of the file on the server.
|
||||||
|
* It should be a string representing a UNIX timestamp in seconds.
|
||||||
|
* An empty string indicates that the file should be stored indefinitely.
|
||||||
|
*/
|
||||||
|
expiration?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates the size of the file in bytes.
|
||||||
|
* This field can be used by the server to pre-validate the file size before processing the upload.
|
||||||
|
*/
|
||||||
|
size?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a strict description of the file for accessibility purposes,
|
||||||
|
* particularly useful for visibility-impaired users.
|
||||||
|
*/
|
||||||
|
alt?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loose, more descriptive caption for the file.
|
||||||
|
* This can be used for additional context or commentary about the file.
|
||||||
|
*/
|
||||||
|
caption?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the intended use of the file.
|
||||||
|
* Can be either 'avatar' or 'banner', indicating if the file is to be used as an avatar or a banner.
|
||||||
|
* Absence of this field suggests standard file upload without special treatment.
|
||||||
|
*/
|
||||||
|
media_type?: 'avatar' | 'banner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MIME type of the file being uploaded.
|
||||||
|
* This can be used for early rejection by the server if the file type isn't supported.
|
||||||
|
*/
|
||||||
|
content_type?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other custom form data fields.
|
||||||
|
*/
|
||||||
|
[key: string]: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing the response from a NIP-96 compliant server after a file upload request.
|
||||||
|
*/
|
||||||
|
export type FileUploadResponse = {
|
||||||
|
/**
|
||||||
|
* The status of the upload request.
|
||||||
|
* - 'success': Indicates the file was successfully uploaded.
|
||||||
|
* - 'error': Indicates there was an error in the upload process.
|
||||||
|
* - 'processing': Indicates the file is still being processed (used in cases of delayed processing).
|
||||||
|
*/
|
||||||
|
status: 'success' | 'error' | 'processing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message provided by the server, which could be a success message, error description, or processing status.
|
||||||
|
*/
|
||||||
|
message: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. A URL provided by the server where the upload processing status can be checked.
|
||||||
|
* This is relevant in cases where the file upload involves delayed processing.
|
||||||
|
*/
|
||||||
|
processing_url?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. An event object conforming to NIP-94, which includes details about the uploaded file.
|
||||||
|
* This object is typically provided in the response for a successful upload and contains
|
||||||
|
* essential information such as the download URL and file metadata.
|
||||||
|
*/
|
||||||
|
nip94_event?: {
|
||||||
|
/**
|
||||||
|
* A collection of key-value pairs (tags) providing metadata about the uploaded file.
|
||||||
|
* Standard tags include:
|
||||||
|
* - 'url': The URL where the file can be accessed.
|
||||||
|
* - 'ox': The SHA-256 hash of the original file before any server-side transformations.
|
||||||
|
* Additional optional tags might include file dimensions, MIME type, etc.
|
||||||
|
*/
|
||||||
|
tags: Array<[string, string]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A content field, which is typically empty for file upload events but included for consistency with the NIP-94 structure.
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing the response from a NIP-96 compliant server after a delayed processing request.
|
||||||
|
*/
|
||||||
|
export type DelayedProcessingResponse = {
|
||||||
|
/**
|
||||||
|
* The status of the delayed processing request.
|
||||||
|
* - 'processing': Indicates the file is still being processed.
|
||||||
|
* - 'error': Indicates there was an error in the processing.
|
||||||
|
*/
|
||||||
|
status: 'processing' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message provided by the server, which could be a success message or error description.
|
||||||
|
*/
|
||||||
|
message: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The percentage of the file that has been processed. This is a number between 0 and 100.
|
||||||
|
*/
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the server configuration.
|
||||||
|
*
|
||||||
|
* @param config - The server configuration object.
|
||||||
|
* @returns True if the configuration is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateServerConfiguration(config: ServerConfiguration): boolean {
|
||||||
|
if (Boolean(config.api_url) == false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(config.delegated_to_url) && Boolean(config.api_url)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches, parses, and validates the server configuration from the given URL.
|
||||||
|
*
|
||||||
|
* @param serverUrl The URL of the server.
|
||||||
|
* @returns The server configuration, or an error if the configuration could not be fetched or parsed.
|
||||||
|
*/
|
||||||
|
export async function readServerConfig(serverUrl: string): Promise<ServerConfiguration> {
|
||||||
|
const HTTPROUTE = '/.well-known/nostr/nip96.json' as const
|
||||||
|
let fetchUrl = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { origin } = new URL(serverUrl)
|
||||||
|
fetchUrl = origin + HTTPROUTE
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Invalid URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(fetchUrl)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error fetching ${fetchUrl}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json()
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('No data')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateServerConfiguration(data)) {
|
||||||
|
throw new Error('Invalid configuration data')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`Error fetching.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given object is a valid FileUploadResponse.
|
||||||
|
*
|
||||||
|
* @param response - The object to validate.
|
||||||
|
* @returns true if the object is a valid FileUploadResponse, otherwise false.
|
||||||
|
*/
|
||||||
|
export function validateFileUploadResponse(response: any): response is FileUploadResponse {
|
||||||
|
if (typeof response !== 'object' || response === null) return false
|
||||||
|
|
||||||
|
if (!response.status || !response.message) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.message !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 'processing' && !response.processing_url) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.processing_url) {
|
||||||
|
if (typeof response.processing_url !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 'success' && !response.nip94_event) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.nip94_event) {
|
||||||
|
if (
|
||||||
|
!response.nip94_event.tags ||
|
||||||
|
!Array.isArray(response.nip94_event.tags) ||
|
||||||
|
response.nip94_event.tags.length === 0
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of response.nip94_event.tags) {
|
||||||
|
if (!Array.isArray(tag) || tag.length !== 2) return false
|
||||||
|
|
||||||
|
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a file to a NIP-96 compliant server.
|
||||||
|
*
|
||||||
|
* @param file - The file to be uploaded.
|
||||||
|
* @param serverApiUrl - The API URL of the server, retrieved from the server's configuration.
|
||||||
|
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||||
|
* @param optionalFormDataFields - Optional form data fields.
|
||||||
|
* @returns A promise that resolves to the server's response.
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
file: File,
|
||||||
|
serverApiUrl: string,
|
||||||
|
nip98AuthorizationHeader: string,
|
||||||
|
optionalFormDataFields?: OptionalFormDataFields,
|
||||||
|
): Promise<FileUploadResponse> {
|
||||||
|
// Create FormData object
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
// Append the authorization header to HTML Form Data
|
||||||
|
formData.append('Authorization', nip98AuthorizationHeader)
|
||||||
|
|
||||||
|
// Append optional fields to FormData
|
||||||
|
optionalFormDataFields &&
|
||||||
|
Object.entries(optionalFormDataFields).forEach(([key, value]) => {
|
||||||
|
if (value) {
|
||||||
|
formData.append(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Append the file to FormData as the last field
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
// Make the POST request to the server
|
||||||
|
const response = await fetch(serverApiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: nip98AuthorizationHeader,
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok === false) {
|
||||||
|
// 413 Payload Too Large
|
||||||
|
if (response.status === 413) {
|
||||||
|
throw new Error('File too large!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 400 Bad Request
|
||||||
|
if (response.status === 400) {
|
||||||
|
throw new Error('Bad request! Some fields are missing or invalid!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new Error('Forbidden! Payload tag does not match the requested file!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 402 Payment Required
|
||||||
|
if (response.status === 402) {
|
||||||
|
throw new Error('Payment required!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknown error
|
||||||
|
throw new Error('Unknown error in uploading file!')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedResponse = await response.json()
|
||||||
|
|
||||||
|
if (!validateFileUploadResponse(parsedResponse)) {
|
||||||
|
throw new Error('Invalid response from the server!')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error parsing JSON response!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the URL for downloading a file from a NIP-96 compliant server.
|
||||||
|
*
|
||||||
|
* @param fileHash - The SHA-256 hash of the original file.
|
||||||
|
* @param serverDownloadUrl - The base URL provided by the server, retrieved from the server's configuration.
|
||||||
|
* @param fileExtension - An optional parameter that specifies the file extension (e.g., '.jpg', '.png').
|
||||||
|
* @returns A string representing the complete URL to download the file.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function generateDownloadUrl(fileHash: string, serverDownloadUrl: string, fileExtension?: string): string {
|
||||||
|
// Construct the base download URL using the file hash
|
||||||
|
let downloadUrl = `${serverDownloadUrl}/${fileHash}`
|
||||||
|
|
||||||
|
// Append the file extension if provided
|
||||||
|
if (fileExtension) {
|
||||||
|
downloadUrl += fileExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a request to delete a file from a NIP-96 compliant server.
|
||||||
|
*
|
||||||
|
* @param fileHash - The SHA-256 hash of the original file.
|
||||||
|
* @param serverApiUrl - The base API URL of the server, retrieved from the server's configuration.
|
||||||
|
* @param nip98AuthorizationHeader - The authorization header from NIP-98.
|
||||||
|
* @returns A promise that resolves to the server's response to the deletion request.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export async function deleteFile(
|
||||||
|
fileHash: string,
|
||||||
|
serverApiUrl: string,
|
||||||
|
nip98AuthorizationHeader: string,
|
||||||
|
): Promise<any> {
|
||||||
|
// make sure the serverApiUrl ends with a slash
|
||||||
|
if (!serverApiUrl.endsWith('/')) {
|
||||||
|
serverApiUrl += '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the URL for the delete request
|
||||||
|
const deleteUrl = `${serverApiUrl}${fileHash}`
|
||||||
|
|
||||||
|
// Send the DELETE request
|
||||||
|
const response = await fetch(deleteUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: nip98AuthorizationHeader,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle the response
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error deleting file!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the response from the server
|
||||||
|
try {
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error parsing JSON response!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the server's response to a delayed processing request.
|
||||||
|
*
|
||||||
|
* @param response - The server's response to a delayed processing request.
|
||||||
|
* @returns A boolean indicating whether the response is valid.
|
||||||
|
*/
|
||||||
|
export function validateDelayedProcessingResponse(response: any): response is DelayedProcessingResponse {
|
||||||
|
if (typeof response !== 'object' || response === null) return false
|
||||||
|
|
||||||
|
if (!response.status || !response.message || !response.percentage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 'processing' && response.status !== 'error') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.message !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.percentage !== 'number') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(response.percentage) < 0 || Number(response.percentage) > 100) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the processing status of a file when delayed processing is used.
|
||||||
|
*
|
||||||
|
* @param processingUrl - The URL provided by the server where the processing status can be checked.
|
||||||
|
* @returns A promise that resolves to an object containing the processing status and other relevant information.
|
||||||
|
*/
|
||||||
|
export async function checkFileProcessingStatus(
|
||||||
|
processingUrl: string,
|
||||||
|
): Promise<FileUploadResponse | DelayedProcessingResponse> {
|
||||||
|
// Make the GET request to the processing URL
|
||||||
|
const response = await fetch(processingUrl)
|
||||||
|
|
||||||
|
// Handle the response
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to retrieve processing status. Server responded with status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
try {
|
||||||
|
const parsedResponse = await response.json()
|
||||||
|
|
||||||
|
// 201 Created: Indicates the processing is over.
|
||||||
|
if (response.status === 201) {
|
||||||
|
// Validate the response
|
||||||
|
if (!validateFileUploadResponse(parsedResponse)) {
|
||||||
|
throw new Error('Invalid response from the server!')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 OK: Indicates the processing is still ongoing.
|
||||||
|
if (response.status === 200) {
|
||||||
|
// Validate the response
|
||||||
|
if (!validateDelayedProcessingResponse(parsedResponse)) {
|
||||||
|
throw new Error('Invalid response from the server!')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid response from the server!')
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error parsing JSON response!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template to indicate a user's File Server Preferences.
|
||||||
|
* This event is of kind 10096 and is used to specify one or more preferred servers for file uploads.
|
||||||
|
*
|
||||||
|
* @param serverUrls - An array of URLs representing the user's preferred file storage servers.
|
||||||
|
* @returns An object representing a Nostr event template for setting file server preferences.
|
||||||
|
*/
|
||||||
|
export function generateFSPEventTemplate(serverUrls: string[]): EventTemplate {
|
||||||
|
serverUrls = serverUrls.filter(serverUrl => {
|
||||||
|
try {
|
||||||
|
new URL(serverUrl)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: FileServerPreference,
|
||||||
|
content: '',
|
||||||
|
tags: serverUrls.map(serverUrl => ['server', serverUrl]),
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the SHA-256 hash of a given file. This hash is used in various NIP-96 operations,
|
||||||
|
* such as file upload, download, and deletion, to uniquely identify files.
|
||||||
|
*
|
||||||
|
* @param file - The file for which the SHA-256 hash needs to be calculated.
|
||||||
|
* @returns A promise that resolves to the SHA-256 hash of the file.
|
||||||
|
*/
|
||||||
|
export async function calculateFileHash(file: Blob): Promise<string> {
|
||||||
|
// Read the file as an ArrayBuffer
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
|
||||||
|
// Calculate the SHA-256 hash of the file
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||||
|
|
||||||
|
// Convert the hash to a hexadecimal string
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
return hashHex
|
||||||
|
}
|
||||||
350
nip98.test.ts
Normal file
350
nip98.test.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
hashPayload,
|
||||||
|
unpackEventFromToken,
|
||||||
|
validateEvent,
|
||||||
|
validateEventKind,
|
||||||
|
validateEventMethodTag,
|
||||||
|
validateEventPayloadTag,
|
||||||
|
validateEventTimestamp,
|
||||||
|
validateEventUrlTag,
|
||||||
|
validateToken,
|
||||||
|
} from './nip98.ts'
|
||||||
|
import { Event, finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
describe('getToken', () => {
|
||||||
|
test('returns without authorization scheme for GET', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'get'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token without authorization scheme for POST', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk))
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token WITH authorization scheme for POST', async () => {
|
||||||
|
const authorizationScheme = 'Nostr '
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(token.startsWith(authorizationScheme)).toBe(true)
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns token with a valid payload tag when payload is present', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const payload = { test: 'payload' }
|
||||||
|
const payloadHash = hashPayload(payload)
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
|
||||||
|
expect(unpackedEvent.created_at).toBeGreaterThan(0)
|
||||||
|
expect(unpackedEvent.content).toBe('')
|
||||||
|
expect(unpackedEvent.kind).toBe(HTTPAuth)
|
||||||
|
expect(unpackedEvent.pubkey).toBe(getPublicKey(sk))
|
||||||
|
expect(unpackedEvent.tags).toStrictEqual([
|
||||||
|
['u', 'http://test.com'],
|
||||||
|
['method', 'post'],
|
||||||
|
['payload', payloadHash],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateToken', () => {
|
||||||
|
test('returns true for valid token without authorization scheme', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
|
||||||
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
|
expect(isTokenValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for valid token with authorization scheme', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const isTokenValid = await validateToken(token, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid token', async () => {
|
||||||
|
const isTokenValid = validateToken('fake', 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for missing token', async () => {
|
||||||
|
const isTokenValid = validateToken('', 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.kind = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const invalidToken = await getToken('http://test.com', 'get', e => {
|
||||||
|
e.created_at = 0
|
||||||
|
return finalizeEvent(e, sk)
|
||||||
|
})
|
||||||
|
const isTokenValid = validateToken(invalidToken, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid url', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid method', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
|
||||||
|
const isTokenValid = validateToken(token, 'http://test.com', 'post')
|
||||||
|
|
||||||
|
expect(isTokenValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
test('returns true for valid decoded token with authorization scheme', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.kind = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid event timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://wrong-test.com', 'get')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws an error for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post')
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for valid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = await validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag hash', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventValid = validateEvent(unpackedEvent, 'http://test.com', 'post', { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventValid).rejects.toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventTimestamp', () => {
|
||||||
|
test('returns true for valid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid timestamp', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.created_at = 0
|
||||||
|
const isEventTimestampValid = validateEventTimestamp(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventTimestampValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventKind', () => {
|
||||||
|
test('returns true for valid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid kind', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
unpackedEvent.kind = 0
|
||||||
|
const isEventKindValid = validateEventKind(unpackedEvent)
|
||||||
|
|
||||||
|
expect(isEventKindValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventUrlTag', () => {
|
||||||
|
test('returns true for valid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid url tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventUrlTagValid = validateEventUrlTag(unpackedEvent, 'http://wrong-test.com')
|
||||||
|
|
||||||
|
expect(isEventUrlTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventMethodTag', () => {
|
||||||
|
test('returns true for valid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'get')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid method tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventMethodTagValid = validateEventMethodTag(unpackedEvent, 'post')
|
||||||
|
|
||||||
|
expect(isEventMethodTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEventPayloadTag', () => {
|
||||||
|
test('returns true for valid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for invalid payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, { test: 'a-different-payload' })
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for missing payload tag', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const token = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
|
||||||
|
const unpackedEvent: Event = await unpackEventFromToken(token)
|
||||||
|
const isEventPayloadTagValid = validateEventPayloadTag(unpackedEvent, {})
|
||||||
|
|
||||||
|
expect(isEventPayloadTagValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashPayload', () => {
|
||||||
|
test('returns hash for valid payload', async () => {
|
||||||
|
const payload = { test: 'payload' }
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns hash for empty payload', async () => {
|
||||||
|
const payload = {}
|
||||||
|
const computedPayloadHash = hashPayload(payload)
|
||||||
|
const expectedPayloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
|
||||||
|
|
||||||
|
expect(computedPayloadHash).toBe(expectedPayloadHash)
|
||||||
|
})
|
||||||
|
})
|
||||||
206
nip98.ts
Normal file
206
nip98.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { base64 } from '@scure/base'
|
||||||
|
|
||||||
|
import { HTTPAuth } from './kinds.ts'
|
||||||
|
import { Event, EventTemplate, verifyEvent } from './pure.ts'
|
||||||
|
import { utf8Decoder, utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
const _authorizationScheme = 'Nostr '
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const sign = window.nostr.signEvent
|
||||||
|
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
|
||||||
|
*/
|
||||||
|
export async function getToken(
|
||||||
|
loginUrl: string,
|
||||||
|
httpMethod: string,
|
||||||
|
sign: (e: EventTemplate) => Promise<Event> | Event,
|
||||||
|
includeAuthorizationScheme: boolean = false,
|
||||||
|
payload?: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
const event: EventTemplate = {
|
||||||
|
kind: HTTPAuth,
|
||||||
|
tags: [
|
||||||
|
['u', loginUrl],
|
||||||
|
['method', httpMethod],
|
||||||
|
],
|
||||||
|
created_at: Math.round(new Date().getTime() / 1000),
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
event.tags.push(['payload', hashPayload(payload)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedEvent = await sign(event)
|
||||||
|
const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ''
|
||||||
|
|
||||||
|
return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate token for NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
|
||||||
|
*/
|
||||||
|
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
|
||||||
|
const event = await unpackEventFromToken(token).catch(error => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
const valid = await validateEvent(event, url, method).catch(error => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks an event from a token.
|
||||||
|
*
|
||||||
|
* @param token - The token to unpack.
|
||||||
|
* @returns A promise that resolves to the unpacked event.
|
||||||
|
* @throws {Error} If the token is missing, invalid, or cannot be parsed.
|
||||||
|
*/
|
||||||
|
export async function unpackEventFromToken(token: string): Promise<Event> {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Missing token')
|
||||||
|
}
|
||||||
|
|
||||||
|
token = token.replace(_authorizationScheme, '')
|
||||||
|
|
||||||
|
const eventB64 = utf8Decoder.decode(base64.decode(token))
|
||||||
|
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
|
||||||
|
throw new Error('Invalid token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(eventB64) as Event
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the timestamp of an event.
|
||||||
|
* @param event - The event object to validate.
|
||||||
|
* @returns A boolean indicating whether the event timestamp is within the last 60 seconds.
|
||||||
|
*/
|
||||||
|
export function validateEventTimestamp(event: Event): boolean {
|
||||||
|
if (!event.created_at) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(new Date().getTime() / 1000) - event.created_at < 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the kind of an event.
|
||||||
|
* @param event The event to validate.
|
||||||
|
* @returns A boolean indicating whether the event kind is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventKind(event: Event): boolean {
|
||||||
|
return event.kind === HTTPAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given URL matches the URL tag of the event.
|
||||||
|
* @param event - The event object.
|
||||||
|
* @param url - The URL to validate.
|
||||||
|
* @returns A boolean indicating whether the URL is valid or not.
|
||||||
|
*/
|
||||||
|
export function validateEventUrlTag(event: Event, url: string): boolean {
|
||||||
|
const urlTag = event.tags.find(t => t[0] === 'u')
|
||||||
|
|
||||||
|
if (!urlTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlTag.length > 0 && urlTag[1] === url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the given event has a method tag that matches the specified method.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @param method - The method to match against the method tag.
|
||||||
|
* @returns A boolean indicating whether the event has a matching method tag.
|
||||||
|
*/
|
||||||
|
export function validateEventMethodTag(event: Event, method: string): boolean {
|
||||||
|
const methodTag = event.tags.find(t => t[0] === 'method')
|
||||||
|
|
||||||
|
if (!methodTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the hash of a payload.
|
||||||
|
* @param payload - The payload to be hashed.
|
||||||
|
* @returns The hash value as a string.
|
||||||
|
*/
|
||||||
|
export function hashPayload(payload: any): string {
|
||||||
|
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
|
||||||
|
return bytesToHex(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the event payload tag against the provided payload.
|
||||||
|
* @param event The event object.
|
||||||
|
* @param payload The payload to validate.
|
||||||
|
* @returns A boolean indicating whether the payload tag is valid.
|
||||||
|
*/
|
||||||
|
export function validateEventPayloadTag(event: Event, payload: any): boolean {
|
||||||
|
const payloadTag = event.tags.find(t => t[0] === 'payload')
|
||||||
|
|
||||||
|
if (!payloadTag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadHash = hashPayload(payload)
|
||||||
|
return payloadTag.length > 0 && payloadTag[1] === payloadHash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Nostr event for the NIP-98 flow.
|
||||||
|
*
|
||||||
|
* @param event - The Nostr event to validate.
|
||||||
|
* @param url - The URL associated with the event.
|
||||||
|
* @param method - The HTTP method associated with the event.
|
||||||
|
* @param body - The request body associated with the event (optional).
|
||||||
|
* @returns A promise that resolves to a boolean indicating whether the event is valid.
|
||||||
|
* @throws An error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
|
||||||
|
if (!verifyEvent(event)) {
|
||||||
|
throw new Error('Invalid nostr event, signature invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventKind(event)) {
|
||||||
|
throw new Error('Invalid nostr event, kind invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventTimestamp(event)) {
|
||||||
|
throw new Error('Invalid nostr event, created_at timestamp invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventUrlTag(event, url)) {
|
||||||
|
throw new Error('Invalid nostr event, url tag invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEventMethodTag(event, method)) {
|
||||||
|
throw new Error('Invalid nostr event, method tag invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(body) && typeof body === 'object' && Object.keys(body).length > 0) {
|
||||||
|
if (!validateEventPayloadTag(event, body)) {
|
||||||
|
throw new Error('Invalid nostr event, payload tag does not match request body hash')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
506
nip99.test.ts
Normal file
506
nip99.test.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { Event } from './core'
|
||||||
|
import { ClassifiedListing, DraftClassifiedListing } from './kinds'
|
||||||
|
import { ClassifiedListingObject, generateEventTemplate, parseEvent, validateEvent } from './nip99'
|
||||||
|
import { finalizeEvent, generateSecretKey } from './pure'
|
||||||
|
|
||||||
|
describe('validateEvent', () => {
|
||||||
|
test('should return true for a valid classified listing event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "d" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
// Missing 'd' tag
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "title" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
// Missing 'title' tag
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "summary" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
// Missing 'summary' tag
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "published_at" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
// Missing 'published_at' tag
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "location" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
// Missing 'location' tag
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag is missing', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
// Missing 'price' tag
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "published_at" tag is not a valid timestamp', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', 'not-a-valid-timestamp'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid price', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', 'not-a-valid-price', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid currency', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'not-a-valid-currency'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "price" tag has not a valid number of elements', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event1: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return false when the "a" tag has not a valid number of elements', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event1: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['a', 'extra1'],
|
||||||
|
['a', 'extra2', 'value2', 'extra3'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const event2: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: ClassifiedListing,
|
||||||
|
content: 'Lorem ipsum dolor sit amet.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['e', 'extra1'],
|
||||||
|
['e', 'extra2', 'value2', 'extra3'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(validateEvent(event1)).toBe(false)
|
||||||
|
expect(validateEvent(event2)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseEvent', () => {
|
||||||
|
test('should parse a valid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedListing = {
|
||||||
|
title: 'Sample Title',
|
||||||
|
summary: 'Sample Summary',
|
||||||
|
publishedAt: '1296962229',
|
||||||
|
location: 'NYC',
|
||||||
|
price: {
|
||||||
|
amount: '100',
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image1.jpg',
|
||||||
|
dimensions: '800x600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashtags: ['tag1', 'tag2'],
|
||||||
|
additionalTags: {
|
||||||
|
e: ['value1', 'value2'],
|
||||||
|
a: ['value1', 'value2'],
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parseEvent(event)).toEqual(expectedListing)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should throw an error for an invalid event', () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const event: Event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem [ipsum][nostr:nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9] dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
// Missing 'd' tag
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['e', 'value1', 'value2'],
|
||||||
|
['a', 'value1', 'value2'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => parseEvent(event)).toThrow(Error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateEventTemplate', () => {
|
||||||
|
test('should generate the correct event template for a classified listing', () => {
|
||||||
|
const listing: ClassifiedListingObject = {
|
||||||
|
title: 'Sample Title',
|
||||||
|
summary: 'Sample Summary',
|
||||||
|
publishedAt: '1296962229',
|
||||||
|
location: 'NYC',
|
||||||
|
price: {
|
||||||
|
amount: '100',
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image1.jpg',
|
||||||
|
dimensions: '800x600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://example.com/image2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hashtags: ['tag1', 'tag2'],
|
||||||
|
additionalTags: {
|
||||||
|
extra1: 'value1',
|
||||||
|
extra2: 'value2',
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
isDraft: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEventTemplate = {
|
||||||
|
kind: DraftClassifiedListing,
|
||||||
|
content:
|
||||||
|
'Lorem ipsum dolor sit amet. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at nostr:naddr1qqzkjurnw4ksz9thwden5te0wfjkccte9ehx7um5wghx7un8qgs2d90kkcq3nk2jry62dyf50k0h36rhpdtd594my40w9pkal876jxgrqsqqqa28pccpzu.',
|
||||||
|
tags: [
|
||||||
|
['d', 'sample-title'],
|
||||||
|
['title', 'Sample Title'],
|
||||||
|
['published_at', '1296962229'],
|
||||||
|
['summary', 'Sample Summary'],
|
||||||
|
['location', 'NYC'],
|
||||||
|
['price', '100', 'USD'],
|
||||||
|
['image', 'https://example.com/image1.jpg', '800x600'],
|
||||||
|
['image', 'https://example.com/image2.jpg'],
|
||||||
|
['t', 'tag1'],
|
||||||
|
['t', 'tag2'],
|
||||||
|
['extra1', 'value1'],
|
||||||
|
['extra2', 'value2'],
|
||||||
|
],
|
||||||
|
created_at: expect.any(Number),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(generateEventTemplate(listing)).toEqual(expectedEventTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
228
nip99.ts
Normal file
228
nip99.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { Event, EventTemplate } from './core.ts'
|
||||||
|
import { ClassifiedListing, DraftClassifiedListing } from './kinds.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the details of a price.
|
||||||
|
* @example { amount: '100', currency: 'USD', frequency: 'month' }
|
||||||
|
* @example { amount: '100', currency: 'EUR' }
|
||||||
|
*/
|
||||||
|
export type PriceDetails = {
|
||||||
|
/**
|
||||||
|
* The amount of the price.
|
||||||
|
*/
|
||||||
|
amount: string
|
||||||
|
/**
|
||||||
|
* The currency of the price in 3-letter ISO 4217 format.
|
||||||
|
* @example 'USD'
|
||||||
|
*/
|
||||||
|
currency: string
|
||||||
|
/**
|
||||||
|
* The optional frequency of payment.
|
||||||
|
* Can be one of: 'hour', 'day', 'week', 'month', 'year', or a custom string.
|
||||||
|
*/
|
||||||
|
frequency?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a classified listing object.
|
||||||
|
*/
|
||||||
|
export type ClassifiedListingObject = {
|
||||||
|
/**
|
||||||
|
* Whether the listing is a draft or not.
|
||||||
|
*/
|
||||||
|
isDraft: boolean
|
||||||
|
/**
|
||||||
|
* A title of the listing.
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* A short summary or tagline.
|
||||||
|
*/
|
||||||
|
summary: string
|
||||||
|
/**
|
||||||
|
* A description in Markdown format.
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
/**
|
||||||
|
* Timestamp in unix seconds of when the listing was published.
|
||||||
|
*/
|
||||||
|
publishedAt: string
|
||||||
|
/**
|
||||||
|
* Location of the listing.
|
||||||
|
* @example 'NYC'
|
||||||
|
*/
|
||||||
|
location: string
|
||||||
|
/**
|
||||||
|
* Price details.
|
||||||
|
*/
|
||||||
|
price: PriceDetails
|
||||||
|
/**
|
||||||
|
* Images of the listing with optional dimensions.
|
||||||
|
*/
|
||||||
|
images: Array<{
|
||||||
|
url: string
|
||||||
|
dimensions?: string
|
||||||
|
}>
|
||||||
|
/**
|
||||||
|
* Tags/Hashtags (i.e. categories, keywords, etc.)
|
||||||
|
*/
|
||||||
|
hashtags: string[]
|
||||||
|
/**
|
||||||
|
* Other standard tags.
|
||||||
|
* @example "g", a geohash for more precise location
|
||||||
|
*/
|
||||||
|
additionalTags: Record<string, string | string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an event to ensure it is a valid classified listing event.
|
||||||
|
* @param event - The event to validate.
|
||||||
|
* @returns True if the event is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function validateEvent(event: Event): boolean {
|
||||||
|
if (![ClassifiedListing, DraftClassifiedListing].includes(event.kind)) return false
|
||||||
|
|
||||||
|
const requiredTags = ['d', 'title', 'summary', 'location', 'published_at', 'price']
|
||||||
|
const requiredTagCount = requiredTags.length
|
||||||
|
const tagCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
if (event.tags.length < requiredTagCount) return false
|
||||||
|
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag.length < 2) return false
|
||||||
|
|
||||||
|
const [tagName, ...tagValues] = tag
|
||||||
|
|
||||||
|
if (tagName == 'published_at') {
|
||||||
|
const timestamp = parseInt(tagValues[0])
|
||||||
|
if (isNaN(timestamp)) return false
|
||||||
|
} else if (tagName == 'price') {
|
||||||
|
if (tagValues.length < 2) return false
|
||||||
|
|
||||||
|
const price = parseInt(tagValues[0])
|
||||||
|
if (isNaN(price) || tagValues[1].length != 3) return false
|
||||||
|
} else if ((tagName == 'e' || tagName == 'a') && tag.length != 3) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredTags.includes(tagName)) {
|
||||||
|
tagCounts[tagName] = (tagCounts[tagName] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(tagCounts).every(count => count == 1) && Object.keys(tagCounts).length == requiredTagCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an event and returns a classified listing object.
|
||||||
|
* @param event - The event to parse.
|
||||||
|
* @returns The classified listing object.
|
||||||
|
* @throws Error if the event is invalid.
|
||||||
|
*/
|
||||||
|
export function parseEvent(event: Event): ClassifiedListingObject {
|
||||||
|
if (!validateEvent(event)) {
|
||||||
|
throw new Error('Invalid event')
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing: ClassifiedListingObject = {
|
||||||
|
isDraft: event.kind === DraftClassifiedListing,
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: event.content,
|
||||||
|
publishedAt: '',
|
||||||
|
location: '',
|
||||||
|
price: {
|
||||||
|
amount: '',
|
||||||
|
currency: '',
|
||||||
|
},
|
||||||
|
images: [],
|
||||||
|
hashtags: [],
|
||||||
|
additionalTags: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i]
|
||||||
|
const [tagName, ...tagValues] = tag
|
||||||
|
|
||||||
|
if (tagName == 'title') {
|
||||||
|
listing.title = tagValues[0]
|
||||||
|
} else if (tagName == 'summary') {
|
||||||
|
listing.summary = tagValues[0]
|
||||||
|
} else if (tagName == 'published_at') {
|
||||||
|
listing.publishedAt = tagValues[0]
|
||||||
|
} else if (tagName == 'location') {
|
||||||
|
listing.location = tagValues[0]
|
||||||
|
} else if (tagName == 'price') {
|
||||||
|
listing.price.amount = tagValues[0]
|
||||||
|
listing.price.currency = tagValues[1]
|
||||||
|
|
||||||
|
if (tagValues.length == 3) {
|
||||||
|
listing.price.frequency = tagValues[2]
|
||||||
|
}
|
||||||
|
} else if (tagName == 'image') {
|
||||||
|
listing.images.push({
|
||||||
|
url: tagValues[0],
|
||||||
|
dimensions: tagValues?.[1] ?? undefined,
|
||||||
|
})
|
||||||
|
} else if (tagName == 't') {
|
||||||
|
listing.hashtags.push(tagValues[0])
|
||||||
|
} else if (tagName == 'e' || tagName == 'a') {
|
||||||
|
listing.additionalTags[tagName] = [...tagValues]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an event template based on a classified listing object.
|
||||||
|
*
|
||||||
|
* @param listing - The classified listing object.
|
||||||
|
* @returns The event template.
|
||||||
|
*/
|
||||||
|
export function generateEventTemplate(listing: ClassifiedListingObject): EventTemplate {
|
||||||
|
const priceTag = ['price', listing.price.amount, listing.price.currency]
|
||||||
|
if (listing.price.frequency) priceTag.push(listing.price.frequency)
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', listing.title.trim().toLowerCase().replace(/ /g, '-')],
|
||||||
|
['title', listing.title],
|
||||||
|
['published_at', listing.publishedAt],
|
||||||
|
['summary', listing.summary],
|
||||||
|
['location', listing.location],
|
||||||
|
priceTag,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < listing.images.length; i++) {
|
||||||
|
const image = listing.images[i]
|
||||||
|
const imageTag = ['image', image.url]
|
||||||
|
if (image.dimensions) imageTag.push(image.dimensions)
|
||||||
|
|
||||||
|
tags.push(imageTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < listing.hashtags.length; i++) {
|
||||||
|
const t = listing.hashtags[i]
|
||||||
|
|
||||||
|
tags.push(['t', t])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(listing.additionalTags)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const val = value[i]
|
||||||
|
|
||||||
|
tags.push([key, val])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tags.push([key, value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: listing.isDraft ? DraftClassifiedListing : ClassifiedListing,
|
||||||
|
content: listing.content,
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
261
package.json
261
package.json
@@ -1,44 +1,259 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"name": "nostr-tools",
|
"name": "nostr-tools",
|
||||||
"version": "0.23.1",
|
"version": "2.2.1",
|
||||||
"description": "Tools for making a Nostr client.",
|
"description": "Tools for making a Nostr client.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/fiatjaf/nostr-tools.git"
|
"url": "https://github.com/nbd-wtf/nostr-tools.git"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"sideEffects": false,
|
||||||
|
"module": "./lib/esm/index.js",
|
||||||
|
"main": "./lib/cjs/index.js",
|
||||||
|
"types": "./lib/types/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./lib/esm/index.js",
|
||||||
|
"require": "./lib/cjs/index.js",
|
||||||
|
"types": "./lib/types/index.d.ts"
|
||||||
|
},
|
||||||
|
"./core": {
|
||||||
|
"import": "./lib/esm/core.js",
|
||||||
|
"require": "./lib/cjs/core.js",
|
||||||
|
"types": "./lib/types/core.d.ts"
|
||||||
|
},
|
||||||
|
"./pure": {
|
||||||
|
"import": "./lib/esm/pure.js",
|
||||||
|
"require": "./lib/cjs/pure.js",
|
||||||
|
"types": "./lib/types/pure.d.ts"
|
||||||
|
},
|
||||||
|
"./wasm": {
|
||||||
|
"import": "./lib/esm/wasm.js",
|
||||||
|
"require": "./lib/cjs/wasm.js",
|
||||||
|
"types": "./lib/types/wasm.d.ts"
|
||||||
|
},
|
||||||
|
"./kinds": {
|
||||||
|
"import": "./lib/esm/kinds.js",
|
||||||
|
"require": "./lib/cjs/kinds.js",
|
||||||
|
"types": "./lib/types/kinds.d.ts"
|
||||||
|
},
|
||||||
|
"./filter": {
|
||||||
|
"import": "./lib/esm/filter.js",
|
||||||
|
"require": "./lib/cjs/filter.js",
|
||||||
|
"types": "./lib/types/filter.d.ts"
|
||||||
|
},
|
||||||
|
"./abstract-relay": {
|
||||||
|
"import": "./lib/esm/abstract-relay.js",
|
||||||
|
"require": "./lib/cjs/abstract-relay.js",
|
||||||
|
"types": "./lib/types/abstract-relay.d.ts"
|
||||||
|
},
|
||||||
|
"./relay": {
|
||||||
|
"import": "./lib/esm/relay.js",
|
||||||
|
"require": "./lib/cjs/relay.js",
|
||||||
|
"types": "./lib/types/relay.d.ts"
|
||||||
|
},
|
||||||
|
"./abstract-pool": {
|
||||||
|
"import": "./lib/esm/abstract-pool.js",
|
||||||
|
"require": "./lib/cjs/abstract-pool.js",
|
||||||
|
"types": "./lib/types/abstract-pool.d.ts"
|
||||||
|
},
|
||||||
|
"./pool": {
|
||||||
|
"import": "./lib/esm/pool.js",
|
||||||
|
"require": "./lib/cjs/pool.js",
|
||||||
|
"types": "./lib/types/pool.d.ts"
|
||||||
|
},
|
||||||
|
"./references": {
|
||||||
|
"import": "./lib/esm/references.js",
|
||||||
|
"require": "./lib/cjs/references.js",
|
||||||
|
"types": "./lib/types/references.d.ts"
|
||||||
|
},
|
||||||
|
"./nip04": {
|
||||||
|
"import": "./lib/esm/nip04.js",
|
||||||
|
"require": "./lib/cjs/nip04.js",
|
||||||
|
"types": "./lib/types/nip04.d.ts"
|
||||||
|
},
|
||||||
|
"./nip05": {
|
||||||
|
"import": "./lib/esm/nip05.js",
|
||||||
|
"require": "./lib/cjs/nip05.js",
|
||||||
|
"types": "./lib/types/nip05.d.ts"
|
||||||
|
},
|
||||||
|
"./nip06": {
|
||||||
|
"import": "./lib/esm/nip06.js",
|
||||||
|
"require": "./lib/cjs/nip06.js",
|
||||||
|
"types": "./lib/types/nip06.d.ts"
|
||||||
|
},
|
||||||
|
"./nip10": {
|
||||||
|
"import": "./lib/esm/nip10.js",
|
||||||
|
"require": "./lib/cjs/nip10.js",
|
||||||
|
"types": "./lib/types/nip10.d.ts"
|
||||||
|
},
|
||||||
|
"./nip11": {
|
||||||
|
"import": "./lib/esm/nip11.js",
|
||||||
|
"require": "./lib/cjs/nip11.js",
|
||||||
|
"types": "./lib/types/nip11.d.ts"
|
||||||
|
},
|
||||||
|
"./nip13": {
|
||||||
|
"import": "./lib/esm/nip13.js",
|
||||||
|
"require": "./lib/cjs/nip13.js",
|
||||||
|
"types": "./lib/types/nip13.d.ts"
|
||||||
|
},
|
||||||
|
"./nip18": {
|
||||||
|
"import": "./lib/esm/nip18.js",
|
||||||
|
"require": "./lib/cjs/nip18.js",
|
||||||
|
"types": "./lib/types/nip18.d.ts"
|
||||||
|
},
|
||||||
|
"./nip19": {
|
||||||
|
"import": "./lib/esm/nip19.js",
|
||||||
|
"require": "./lib/cjs/nip19.js",
|
||||||
|
"types": "./lib/types/nip19.d.ts"
|
||||||
|
},
|
||||||
|
"./nip21": {
|
||||||
|
"import": "./lib/esm/nip21.js",
|
||||||
|
"require": "./lib/cjs/nip21.js",
|
||||||
|
"types": "./lib/types/nip21.d.ts"
|
||||||
|
},
|
||||||
|
"./nip25": {
|
||||||
|
"import": "./lib/esm/nip25.js",
|
||||||
|
"require": "./lib/cjs/nip25.js",
|
||||||
|
"types": "./lib/types/nip25.d.ts"
|
||||||
|
},
|
||||||
|
"./nip27": {
|
||||||
|
"import": "./lib/esm/nip27.js",
|
||||||
|
"require": "./lib/cjs/nip27.js",
|
||||||
|
"types": "./lib/types/nip27.d.ts"
|
||||||
|
},
|
||||||
|
"./nip28": {
|
||||||
|
"import": "./lib/esm/nip28.js",
|
||||||
|
"require": "./lib/cjs/nip28.js",
|
||||||
|
"types": "./lib/types/nip28.d.ts"
|
||||||
|
},
|
||||||
|
"./nip29": {
|
||||||
|
"import": "./lib/esm/nip29.js",
|
||||||
|
"require": "./lib/cjs/nip29.js",
|
||||||
|
"types": "./lib/types/nip29.d.ts"
|
||||||
|
},
|
||||||
|
"./nip30": {
|
||||||
|
"import": "./lib/esm/nip30.js",
|
||||||
|
"require": "./lib/cjs/nip30.js",
|
||||||
|
"types": "./lib/types/nip30.d.ts"
|
||||||
|
},
|
||||||
|
"./nip39": {
|
||||||
|
"import": "./lib/esm/nip39.js",
|
||||||
|
"require": "./lib/cjs/nip39.js",
|
||||||
|
"types": "./lib/types/nip39.d.ts"
|
||||||
|
},
|
||||||
|
"./nip42": {
|
||||||
|
"import": "./lib/esm/nip42.js",
|
||||||
|
"require": "./lib/cjs/nip42.js",
|
||||||
|
"types": "./lib/types/nip42.d.ts"
|
||||||
|
},
|
||||||
|
"./nip44": {
|
||||||
|
"import": "./lib/esm/nip44.js",
|
||||||
|
"require": "./lib/cjs/nip44.js",
|
||||||
|
"types": "./lib/types/nip44.d.ts"
|
||||||
|
},
|
||||||
|
"./nip46": {
|
||||||
|
"import": "./lib/esm/nip46.js",
|
||||||
|
"require": "./lib/cjs/nip46.js",
|
||||||
|
"types": "./lib/types/nip46.d.ts"
|
||||||
|
},
|
||||||
|
"./nip49": {
|
||||||
|
"import": "./lib/esm/nip49.js",
|
||||||
|
"require": "./lib/cjs/nip49.js",
|
||||||
|
"types": "./lib/types/nip49.d.ts"
|
||||||
|
},
|
||||||
|
"./nip57": {
|
||||||
|
"import": "./lib/esm/nip57.js",
|
||||||
|
"require": "./lib/cjs/nip57.js",
|
||||||
|
"types": "./lib/types/nip57.d.ts"
|
||||||
|
},
|
||||||
|
"./nip75": {
|
||||||
|
"import": "./lib/esm/nip75.js",
|
||||||
|
"require": "./lib/cjs/nip75.js",
|
||||||
|
"types": "./lib/types/nip75.d.ts"
|
||||||
|
},
|
||||||
|
"./nip94": {
|
||||||
|
"import": "./lib/esm/nip94.js",
|
||||||
|
"require": "./lib/cjs/nip94.js",
|
||||||
|
"types": "./lib/types/nip94.d.ts"
|
||||||
|
},
|
||||||
|
"./nip96": {
|
||||||
|
"import": "./lib/esm/nip96.js",
|
||||||
|
"require": "./lib/cjs/nip96.js",
|
||||||
|
"types": "./lib/types/nip96.d.ts"
|
||||||
|
},
|
||||||
|
"./nip98": {
|
||||||
|
"import": "./lib/esm/nip98.js",
|
||||||
|
"require": "./lib/cjs/nip98.js",
|
||||||
|
"types": "./lib/types/nip98.d.ts"
|
||||||
|
},
|
||||||
|
"./nip99": {
|
||||||
|
"import": "./lib/esm/nip99.js",
|
||||||
|
"require": "./lib/cjs/nip99.js",
|
||||||
|
"types": "./lib/types/nip99.d.ts"
|
||||||
|
},
|
||||||
|
"./fakejson": {
|
||||||
|
"import": "./lib/esm/fakejson.js",
|
||||||
|
"require": "./lib/cjs/fakejson.js",
|
||||||
|
"types": "./lib/types/fakejson.d.ts"
|
||||||
|
},
|
||||||
|
"./utils": {
|
||||||
|
"import": "./lib/esm/utils.js",
|
||||||
|
"require": "./lib/cjs/utils.js",
|
||||||
|
"types": "./lib/types/utils.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^0.5.7",
|
"@noble/ciphers": "0.2.0",
|
||||||
"@noble/secp256k1": "^1.5.2",
|
"@noble/curves": "1.2.0",
|
||||||
"browserify-cipher": ">=1",
|
"@noble/hashes": "1.3.1",
|
||||||
"buffer": ">=5",
|
"@scure/base": "1.1.1",
|
||||||
"create-hash": "^1.2.0",
|
"@scure/bip32": "1.3.1",
|
||||||
"cross-fetch": "^3.1.4",
|
"@scure/bip39": "1.2.1"
|
||||||
"micro-bip32": "^0.1.0",
|
},
|
||||||
"micro-bip39": "^0.1.3",
|
"optionalDependencies": {
|
||||||
"websocket-polyfill": "^0.0.3"
|
"nostr-wasm": "v0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"decentralization",
|
"decentralization",
|
||||||
"twitter",
|
|
||||||
"p2p",
|
|
||||||
"mastodon",
|
|
||||||
"ssb",
|
|
||||||
"social",
|
"social",
|
||||||
"unstoppable",
|
|
||||||
"censorship",
|
|
||||||
"censorship-resistance",
|
"censorship-resistance",
|
||||||
"client"
|
"client",
|
||||||
|
"nostr"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
|
"@types/node": "^18.13.0",
|
||||||
"esbuild": "^0.14.38",
|
"@types/node-fetch": "^2.6.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||||
|
"@typescript-eslint/parser": "^6.5.0",
|
||||||
|
"bun-types": "^1.0.18",
|
||||||
|
"esbuild": "0.16.9",
|
||||||
"esbuild-plugin-alias": "^0.2.1",
|
"esbuild-plugin-alias": "^0.2.1",
|
||||||
"eslint": "^8.5.0",
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-babel": "^5.3.1",
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
|
"esm-loader-typescript": "^1.0.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"readable-stream": "^3.6.0"
|
"mitata": "^0.1.6",
|
||||||
|
"mock-socket": "^9.3.1",
|
||||||
|
"msw": "^2.1.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"typescript": "^5.0.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepublish": "node build.js"
|
"prepublish": "just build"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
pool.js
204
pool.js
@@ -1,204 +0,0 @@
|
|||||||
import {getEventHash, verifySignature, signEvent} from './event'
|
|
||||||
import {relayConnect, normalizeRelayURL} from './relay'
|
|
||||||
|
|
||||||
export function relayPool() {
|
|
||||||
var globalPrivateKey
|
|
||||||
var globalSigningFunction
|
|
||||||
|
|
||||||
const poolPolicy = {
|
|
||||||
// setting this to a number will cause events to be published to a random
|
|
||||||
// set of relays only, instead of publishing to all relays all the time
|
|
||||||
randomChoice: null,
|
|
||||||
|
|
||||||
// setting this to true will cause .publish() calls to wait until the event has
|
|
||||||
// been published -- or at least attempted to be published -- to all relays
|
|
||||||
wait: false
|
|
||||||
}
|
|
||||||
const relays = {}
|
|
||||||
const noticeCallbacks = []
|
|
||||||
|
|
||||||
function propagateNotice(notice, relayURL) {
|
|
||||||
for (let i = 0; i < noticeCallbacks.length; i++) {
|
|
||||||
let {relay} = relays[relayURL]
|
|
||||||
noticeCallbacks[i](notice, relay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSubscriptions = {}
|
|
||||||
|
|
||||||
const sub = (
|
|
||||||
{cb, filter, beforeSend},
|
|
||||||
id = Math.random().toString().slice(2)
|
|
||||||
) => {
|
|
||||||
const subControllers = Object.fromEntries(
|
|
||||||
Object.values(relays)
|
|
||||||
.filter(({policy}) => policy.read)
|
|
||||||
.map(({relay}) => [
|
|
||||||
relay.url,
|
|
||||||
relay.sub({filter, cb: event => cb(event, relay.url), beforeSend}, id)
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeCallback = cb
|
|
||||||
const activeFilters = filter
|
|
||||||
const activeBeforeSend = beforeSend
|
|
||||||
|
|
||||||
const unsub = () => {
|
|
||||||
Object.values(subControllers).forEach(sub => sub.unsub())
|
|
||||||
delete activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
const sub = ({
|
|
||||||
cb = activeCallback,
|
|
||||||
filter = activeFilters,
|
|
||||||
beforeSend = activeBeforeSend
|
|
||||||
}) => {
|
|
||||||
Object.entries(subControllers).map(([relayURL, sub]) => [
|
|
||||||
relayURL,
|
|
||||||
sub.sub({cb, filter, beforeSend}, id)
|
|
||||||
])
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
const addRelay = relay => {
|
|
||||||
subControllers[relay.url] = relay.sub({cb, filter}, id)
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
const removeRelay = relayURL => {
|
|
||||||
if (relayURL in subControllers) {
|
|
||||||
subControllers[relayURL].unsub()
|
|
||||||
if (Object.keys(subControllers).length === 0) unsub()
|
|
||||||
}
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
activeSubscriptions[id] = {
|
|
||||||
sub,
|
|
||||||
unsub,
|
|
||||||
addRelay,
|
|
||||||
removeRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeSubscriptions[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub,
|
|
||||||
relays,
|
|
||||||
setPrivateKey(privateKey) {
|
|
||||||
globalPrivateKey = privateKey
|
|
||||||
},
|
|
||||||
registerSigningFunction(fn) {
|
|
||||||
globalSigningFunction = fn
|
|
||||||
},
|
|
||||||
setPolicy(key, value) {
|
|
||||||
poolPolicy[key] = value
|
|
||||||
},
|
|
||||||
addRelay(url, policy = {read: true, write: true}) {
|
|
||||||
let relayURL = normalizeRelayURL(url)
|
|
||||||
if (relayURL in relays) return
|
|
||||||
|
|
||||||
let relay = relayConnect(url, notice => {
|
|
||||||
propagateNotice(notice, relayURL)
|
|
||||||
})
|
|
||||||
relays[relayURL] = {relay, policy}
|
|
||||||
|
|
||||||
if (policy.read) {
|
|
||||||
Object.values(activeSubscriptions).forEach(subscription =>
|
|
||||||
subscription.addRelay(relay)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return relay
|
|
||||||
},
|
|
||||||
removeRelay(url) {
|
|
||||||
let relayURL = normalizeRelayURL(url)
|
|
||||||
let data = relays[relayURL]
|
|
||||||
if (!data) return
|
|
||||||
|
|
||||||
let {relay} = data
|
|
||||||
Object.values(activeSubscriptions).forEach(subscription =>
|
|
||||||
subscription.removeRelay(relay)
|
|
||||||
)
|
|
||||||
relay.close()
|
|
||||||
delete relays[relayURL]
|
|
||||||
},
|
|
||||||
onNotice(cb) {
|
|
||||||
noticeCallbacks.push(cb)
|
|
||||||
},
|
|
||||||
offNotice(cb) {
|
|
||||||
let index = noticeCallbacks.indexOf(cb)
|
|
||||||
if (index !== -1) noticeCallbacks.splice(index, 1)
|
|
||||||
},
|
|
||||||
async publish(event, statusCallback) {
|
|
||||||
event.id = getEventHash(event)
|
|
||||||
|
|
||||||
if (!event.sig) {
|
|
||||||
event.tags = event.tags || []
|
|
||||||
|
|
||||||
if (globalPrivateKey) {
|
|
||||||
event.sig = await signEvent(event, globalPrivateKey)
|
|
||||||
} else if (globalSigningFunction) {
|
|
||||||
event.sig = await globalSigningFunction(event)
|
|
||||||
if (!event.sig) {
|
|
||||||
// abort here
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
// check
|
|
||||||
if (!(await verifySignature(event)))
|
|
||||||
throw new Error(
|
|
||||||
'signature provided by custom signing function is invalid.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"can't publish unsigned event. either sign this event beforehand, provide a signing function or pass a private key while initializing this relay pool so it can be signed automatically."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let writeable = Object.values(relays)
|
|
||||||
.filter(({policy}) => policy.write)
|
|
||||||
.sort(() => Math.random() - 0.5) // random
|
|
||||||
|
|
||||||
let maxTargets = poolPolicy.randomChoice
|
|
||||||
? poolPolicy.randomChoice
|
|
||||||
: writeable.length
|
|
||||||
|
|
||||||
let successes = 0
|
|
||||||
|
|
||||||
if (poolPolicy.wait) {
|
|
||||||
for (let i = 0; i < writeable.length; i++) {
|
|
||||||
let {relay} = writeable[i]
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
await relay.publish(event, status => {
|
|
||||||
if (statusCallback) statusCallback(status, relay.url)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
if (statusCallback) statusCallback(-1, relay.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
successes++
|
|
||||||
if (successes >= maxTargets) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
/***/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
writeable.forEach(async ({relay}) => {
|
|
||||||
let callback = statusCallback
|
|
||||||
? status => statusCallback(status, relay.url)
|
|
||||||
: null
|
|
||||||
relay.publish(event, callback)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
127
pool.test.ts
Normal file
127
pool.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { SimplePool } from './pool.ts'
|
||||||
|
import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from './pure.ts'
|
||||||
|
import { useWebSocketImplementation } from './relay.ts'
|
||||||
|
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||||
|
|
||||||
|
useWebSocketImplementation(MockWebSocketClient)
|
||||||
|
|
||||||
|
let pool: SimplePool
|
||||||
|
let mockRelays: MockRelay[]
|
||||||
|
let relayURLs: string[]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pool = new SimplePool()
|
||||||
|
mockRelays = Array.from({ length: 10 }, () => new MockRelay())
|
||||||
|
relayURLs = mockRelays.map(mr => mr.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
pool.close(relayURLs)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removing duplicates when subscribing', async () => {
|
||||||
|
let priv = generateSecretKey()
|
||||||
|
let pub = getPublicKey(priv)
|
||||||
|
let received: Event[] = []
|
||||||
|
let event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: 'test',
|
||||||
|
kind: 22345,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
priv,
|
||||||
|
)
|
||||||
|
|
||||||
|
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||||
|
onevent(event: Event) {
|
||||||
|
// this should be called only once even though we're listening
|
||||||
|
// to multiple relays because the events will be caught and
|
||||||
|
// deduplicated efficiently (without even being parsed)
|
||||||
|
received.push(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.any(pool.publish(relayURLs, event))
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
expect(received[0]).toEqual(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('same with double subs', async () => {
|
||||||
|
let priv = generateSecretKey()
|
||||||
|
let pub = getPublicKey(priv)
|
||||||
|
|
||||||
|
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||||
|
onevent(event) {
|
||||||
|
received.push(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pool.subscribeMany(relayURLs, [{ authors: [pub] }], {
|
||||||
|
onevent(event) {
|
||||||
|
received.push(event)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let received: Event[] = []
|
||||||
|
|
||||||
|
let event = finalizeEvent(
|
||||||
|
{
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
content: 'test2',
|
||||||
|
kind: 22346,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
priv,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.any(pool.publish(relayURLs, event))
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
|
||||||
|
|
||||||
|
expect(received).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('query a bunch of events and cancel on eose', async () => {
|
||||||
|
let events = new Set<string>()
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], {
|
||||||
|
onevent(event) {
|
||||||
|
events.add(event.id)
|
||||||
|
},
|
||||||
|
onclose: resolve as any,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events.size).toBeGreaterThan(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('querySync()', async () => {
|
||||||
|
let authors = mockRelays.flatMap(mr => mr.authors)
|
||||||
|
|
||||||
|
let events = await pool.querySync(relayURLs, {
|
||||||
|
authors: authors,
|
||||||
|
kinds: [1],
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
|
||||||
|
|
||||||
|
// the actual received number will be greater than 2, but there will be no duplicates
|
||||||
|
expect(events.length).toBeGreaterThan(2)
|
||||||
|
expect(events).toHaveLength(uniqueEventCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('get()', async () => {
|
||||||
|
let ids = mockRelays.flatMap(mr => mr.ids)
|
||||||
|
|
||||||
|
let event = await pool.get(relayURLs, {
|
||||||
|
ids: [ids[0]],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(event).not.toBeNull()
|
||||||
|
expect(event).toHaveProperty('id', ids[0])
|
||||||
|
})
|
||||||
10
pool.ts
Normal file
10
pool.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractSimplePool } from './abstract-pool.ts'
|
||||||
|
|
||||||
|
export class SimplePool extends AbstractSimplePool {
|
||||||
|
constructor() {
|
||||||
|
super({ verifyEvent })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './abstract-pool.ts'
|
||||||
59
pure.ts
Normal file
59
pure.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { schnorr } from '@noble/curves/secp256k1'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core.ts'
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
|
||||||
|
import { utf8Encoder } from './utils.ts'
|
||||||
|
|
||||||
|
class JS implements Nostr {
|
||||||
|
generateSecretKey(): Uint8Array {
|
||||||
|
return schnorr.utils.randomPrivateKey()
|
||||||
|
}
|
||||||
|
getPublicKey(secretKey: Uint8Array): string {
|
||||||
|
return bytesToHex(schnorr.getPublicKey(secretKey))
|
||||||
|
}
|
||||||
|
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||||
|
const event = t as VerifiedEvent
|
||||||
|
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
||||||
|
event.id = getEventHash(event)
|
||||||
|
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
|
||||||
|
event[verifiedSymbol] = true
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
verifyEvent(event: Event): event is VerifiedEvent {
|
||||||
|
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
|
||||||
|
|
||||||
|
const hash = getEventHash(event)
|
||||||
|
if (hash !== event.id) {
|
||||||
|
event[verifiedSymbol] = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const valid = schnorr.verify(event.sig, hash, event.pubkey)
|
||||||
|
event[verifiedSymbol] = valid
|
||||||
|
return valid
|
||||||
|
} catch (err) {
|
||||||
|
event[verifiedSymbol] = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeEvent(evt: UnsignedEvent): string {
|
||||||
|
if (!validateEvent(evt)) throw new Error("can't serialize event with wrong or missing properties")
|
||||||
|
return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventHash(event: UnsignedEvent): string {
|
||||||
|
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
|
||||||
|
return bytesToHex(eventHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = new JS()
|
||||||
|
|
||||||
|
export const generateSecretKey = i.generateSecretKey
|
||||||
|
export const getPublicKey = i.getPublicKey
|
||||||
|
export const finalizeEvent = i.finalizeEvent
|
||||||
|
export const verifyEvent = i.verifyEvent
|
||||||
|
export * from './core.ts'
|
||||||
47
references.test.ts
Normal file
47
references.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from 'bun:test'
|
||||||
|
import { parseReferences } from './references.ts'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
|
||||||
|
test('parse mentions', () => {
|
||||||
|
let evt = buildEvent({
|
||||||
|
tags: [
|
||||||
|
['p', 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8', 'wss://nostr.com'],
|
||||||
|
['e', 'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33', 'wss://other.com', 'reply'],
|
||||||
|
['e', '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8', ''],
|
||||||
|
],
|
||||||
|
content:
|
||||||
|
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parseReferences(evt)).toEqual([
|
||||||
|
{
|
||||||
|
text: '#[0]',
|
||||||
|
profile: {
|
||||||
|
pubkey: 'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
|
||||||
|
relays: ['wss://nostr.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '#[2]',
|
||||||
|
event: {
|
||||||
|
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
|
||||||
|
profile: {
|
||||||
|
pubkey: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
|
||||||
|
event: {
|
||||||
|
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
|
||||||
|
relays: [],
|
||||||
|
author: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
104
references.ts
Normal file
104
references.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { decode, type AddressPointer, type ProfilePointer, type EventPointer } from './nip19.ts'
|
||||||
|
|
||||||
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
|
type Reference = {
|
||||||
|
text: string
|
||||||
|
profile?: ProfilePointer
|
||||||
|
event?: EventPointer
|
||||||
|
address?: AddressPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionRegex = /\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
|
||||||
|
|
||||||
|
export function parseReferences(evt: Event): Reference[] {
|
||||||
|
let references: Reference[] = []
|
||||||
|
for (let ref of evt.content.matchAll(mentionRegex)) {
|
||||||
|
if (ref[2]) {
|
||||||
|
// it's a NIP-27 mention
|
||||||
|
try {
|
||||||
|
let { type, data } = decode(ref[1])
|
||||||
|
switch (type) {
|
||||||
|
case 'npub': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
profile: { pubkey: data as string, relays: [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'nprofile': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
profile: data as ProfilePointer,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'note': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
event: { id: data as string, relays: [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
event: data as EventPointer,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'naddr': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
address: data as AddressPointer,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
} else if (ref[3]) {
|
||||||
|
// it's a NIP-10 mention
|
||||||
|
let idx = parseInt(ref[3], 10)
|
||||||
|
let tag = evt.tags[idx]
|
||||||
|
if (!tag) continue
|
||||||
|
|
||||||
|
switch (tag[0]) {
|
||||||
|
case 'p': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
profile: { pubkey: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'e': {
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
event: { id: tag[1], relays: tag[2] ? [tag[2]] : [] },
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'a': {
|
||||||
|
try {
|
||||||
|
let [kind, pubkey, identifier] = tag[1].split(':')
|
||||||
|
references.push({
|
||||||
|
text: ref[0],
|
||||||
|
address: {
|
||||||
|
identifier,
|
||||||
|
pubkey,
|
||||||
|
kind: parseInt(kind, 10),
|
||||||
|
relays: tag[2] ? [tag[2]] : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
/***/
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return references
|
||||||
|
}
|
||||||
192
relay.js
192
relay.js
@@ -1,192 +0,0 @@
|
|||||||
/* global WebSocket */
|
|
||||||
|
|
||||||
import 'websocket-polyfill'
|
|
||||||
|
|
||||||
import {verifySignature, validateEvent} from './event'
|
|
||||||
import {matchFilters} from './filter'
|
|
||||||
|
|
||||||
export function normalizeRelayURL(url) {
|
|
||||||
let [host, ...qs] = url.trim().split('?')
|
|
||||||
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
|
|
||||||
if (host.slice(0, 2) !== 'ws') host = 'wss://' + host
|
|
||||||
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
|
|
||||||
return [host, ...qs].join('?')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
|
|
||||||
url = normalizeRelayURL(url)
|
|
||||||
|
|
||||||
var ws, resolveOpen, untilOpen, wasClosed
|
|
||||||
var openSubs = {}
|
|
||||||
let attemptNumber = 1
|
|
||||||
let nextAttemptSeconds = 1
|
|
||||||
|
|
||||||
function resetOpenState() {
|
|
||||||
untilOpen = new Promise(resolve => {
|
|
||||||
resolveOpen = resolve
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var channels = {}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
ws = new WebSocket(url)
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log('connected to', url)
|
|
||||||
resolveOpen()
|
|
||||||
|
|
||||||
// restablish old subscriptions
|
|
||||||
if (wasClosed) {
|
|
||||||
wasClosed = false
|
|
||||||
for (let channel in openSubs) {
|
|
||||||
let filters = openSubs[channel]
|
|
||||||
let cb = channels[channel]
|
|
||||||
sub({cb, filter: filters}, channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onerror = err => {
|
|
||||||
console.log('error connecting to relay', url)
|
|
||||||
onError(err)
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
resetOpenState()
|
|
||||||
attemptNumber++
|
|
||||||
nextAttemptSeconds += attemptNumber ** 3
|
|
||||||
if (nextAttemptSeconds > 14400) {
|
|
||||||
nextAttemptSeconds = 14400 // 4 hours
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
|
|
||||||
)
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
connect()
|
|
||||||
} catch (err) {}
|
|
||||||
}, nextAttemptSeconds * 1000)
|
|
||||||
|
|
||||||
wasClosed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = async e => {
|
|
||||||
var data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(e.data)
|
|
||||||
} catch (err) {
|
|
||||||
data = e.data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.length > 1) {
|
|
||||||
if (data[0] === 'NOTICE') {
|
|
||||||
if (data.length < 2) return
|
|
||||||
|
|
||||||
console.log('message from relay ' + url + ': ' + data[1])
|
|
||||||
onNotice(data[1])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data[0] === 'EVENT') {
|
|
||||||
if (data.length < 3) return
|
|
||||||
|
|
||||||
let channel = data[1]
|
|
||||||
let event = data[2]
|
|
||||||
|
|
||||||
if (
|
|
||||||
validateEvent(event) &&
|
|
||||||
verifySignature(event) &&
|
|
||||||
channels[channel] &&
|
|
||||||
matchFilters(openSubs[channel], event)
|
|
||||||
) {
|
|
||||||
channels[channel](event)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetOpenState()
|
|
||||||
|
|
||||||
try {
|
|
||||||
connect()
|
|
||||||
} catch (err) {}
|
|
||||||
|
|
||||||
async function trySend(params) {
|
|
||||||
let msg = JSON.stringify(params)
|
|
||||||
|
|
||||||
await untilOpen
|
|
||||||
ws.send(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sub = (
|
|
||||||
{cb, filter, beforeSend},
|
|
||||||
channel = Math.random().toString().slice(2)
|
|
||||||
) => {
|
|
||||||
var filters = []
|
|
||||||
if (Array.isArray(filter)) {
|
|
||||||
filters = filter
|
|
||||||
} else {
|
|
||||||
filters.push(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (beforeSend) {
|
|
||||||
const beforeSendResult = beforeSend({filter, relay: url, channel})
|
|
||||||
filters = beforeSendResult.filter
|
|
||||||
}
|
|
||||||
|
|
||||||
trySend(['REQ', channel, ...filters])
|
|
||||||
channels[channel] = cb
|
|
||||||
openSubs[channel] = filters
|
|
||||||
|
|
||||||
const activeCallback = cb
|
|
||||||
const activeFilters = filters
|
|
||||||
const activeBeforeSend = beforeSend
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: ({
|
|
||||||
cb = activeCallback,
|
|
||||||
filter = activeFilters,
|
|
||||||
beforeSend = activeBeforeSend
|
|
||||||
}) => sub({cb, filter, beforeSend}, channel),
|
|
||||||
unsub: () => {
|
|
||||||
delete openSubs[channel]
|
|
||||||
delete channels[channel]
|
|
||||||
trySend(['CLOSE', channel])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
sub,
|
|
||||||
async publish(event, statusCallback) {
|
|
||||||
try {
|
|
||||||
await trySend(['EVENT', event])
|
|
||||||
if (statusCallback) {
|
|
||||||
statusCallback(0)
|
|
||||||
let {unsub} = sub(
|
|
||||||
{
|
|
||||||
cb: () => {
|
|
||||||
statusCallback(1)
|
|
||||||
unsub()
|
|
||||||
clearTimeout(willUnsub)
|
|
||||||
},
|
|
||||||
filter: {ids: [event.id]}
|
|
||||||
},
|
|
||||||
`monitor-${event.id.slice(0, 5)}`
|
|
||||||
)
|
|
||||||
let willUnsub = setTimeout(unsub, 5000)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (statusCallback) statusCallback(-1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
ws.close()
|
|
||||||
},
|
|
||||||
get status() {
|
|
||||||
return ws.readyState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
94
relay.test.ts
Normal file
94
relay.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
|
||||||
|
import { Relay, useWebSocketImplementation } from './relay.ts'
|
||||||
|
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||||
|
|
||||||
|
useWebSocketImplementation(MockWebSocketClient)
|
||||||
|
|
||||||
|
test('connectivity', async () => {
|
||||||
|
const mockRelay = new MockRelay()
|
||||||
|
|
||||||
|
const relay = new Relay(mockRelay.url)
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
expect(relay.connected).toBeTrue()
|
||||||
|
|
||||||
|
relay.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connectivity, with Relay.connect()', async () => {
|
||||||
|
const mockRelay = new MockRelay()
|
||||||
|
const relay = await Relay.connect(mockRelay.url)
|
||||||
|
expect(relay.connected).toBeTrue()
|
||||||
|
relay.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('querying', async done => {
|
||||||
|
const mockRelay = new MockRelay()
|
||||||
|
const kind = 0
|
||||||
|
const relay = new Relay(mockRelay.url)
|
||||||
|
await relay.connect()
|
||||||
|
relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
authors: mockRelay.authors,
|
||||||
|
kinds: [kind],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
expect(mockRelay.authors).toContain(event.pubkey)
|
||||||
|
expect(event).toHaveProperty('kind', kind)
|
||||||
|
|
||||||
|
relay.close()
|
||||||
|
done()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listening and publishing and closing', async done => {
|
||||||
|
const mockRelay = new MockRelay()
|
||||||
|
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const pk = getPublicKey(sk)
|
||||||
|
const kind = 23571
|
||||||
|
|
||||||
|
const relay = new Relay(mockRelay.url)
|
||||||
|
await relay.connect()
|
||||||
|
|
||||||
|
let sub = relay.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [kind],
|
||||||
|
authors: [pk],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onevent(event) {
|
||||||
|
expect(event).toHaveProperty('pubkey', pk)
|
||||||
|
expect(event).toHaveProperty('kind', kind)
|
||||||
|
expect(event).toHaveProperty('content', 'content')
|
||||||
|
|
||||||
|
sub.close() // close the subscription and will trigger onclose()
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
relay.close()
|
||||||
|
done()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
relay.publish(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
content: 'content',
|
||||||
|
created_at: 0,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
23
relay.ts
Normal file
23
relay.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { verifyEvent } from './pure.ts'
|
||||||
|
import { AbstractRelay } from './abstract-relay.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use Relay.connect() instead.
|
||||||
|
*/
|
||||||
|
export function relayConnect(url: string): Promise<Relay> {
|
||||||
|
return Relay.connect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Relay extends AbstractRelay {
|
||||||
|
constructor(url: string) {
|
||||||
|
super(url, { verifyEvent })
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string) {
|
||||||
|
const relay = new Relay(url)
|
||||||
|
await relay.connect()
|
||||||
|
return relay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './abstract-relay.ts'
|
||||||
120
test-helpers.ts
Normal file
120
test-helpers.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Server, WebSocket } from 'mock-socket'
|
||||||
|
|
||||||
|
import { finalizeEvent, type Event, getPublicKey, generateSecretKey } from './pure.ts'
|
||||||
|
import { matchFilters, type Filter } from './filter.ts'
|
||||||
|
|
||||||
|
export const MockWebSocketClient = WebSocket
|
||||||
|
|
||||||
|
export function buildEvent(params: Partial<Event>): Event {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: '',
|
||||||
|
created_at: 0,
|
||||||
|
content: '',
|
||||||
|
tags: [],
|
||||||
|
sig: '',
|
||||||
|
...params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let serial = 0
|
||||||
|
|
||||||
|
export class MockRelay {
|
||||||
|
private _server: Server
|
||||||
|
|
||||||
|
public url: string
|
||||||
|
public secretKeys: Uint8Array[]
|
||||||
|
public preloadedEvents: Event[]
|
||||||
|
|
||||||
|
constructor(url?: string | undefined) {
|
||||||
|
serial++
|
||||||
|
this.url = url ?? `wss://random.mock.relay/${serial}`
|
||||||
|
this.secretKeys = [generateSecretKey(), generateSecretKey(), generateSecretKey(), generateSecretKey()]
|
||||||
|
this.preloadedEvents = this.secretKeys.map(sk =>
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 1,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
this._server = new Server(this.url)
|
||||||
|
this._server.on('connection', (conn: any) => {
|
||||||
|
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||||
|
|
||||||
|
conn.on('message', (message: string) => {
|
||||||
|
const data = JSON.parse(message)
|
||||||
|
|
||||||
|
switch (data[0]) {
|
||||||
|
case 'REQ': {
|
||||||
|
let subId = data[1]
|
||||||
|
let filters = data.slice(2)
|
||||||
|
subs[subId] = { conn, filters }
|
||||||
|
|
||||||
|
this.preloadedEvents.forEach(event => {
|
||||||
|
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||||
|
})
|
||||||
|
|
||||||
|
filters.forEach((filter: Filter) => {
|
||||||
|
const kinds = filter.kinds?.length ? filter.kinds : [1]
|
||||||
|
|
||||||
|
kinds.forEach(kind => {
|
||||||
|
this.secretKeys.forEach(sk => {
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
sk,
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.send(JSON.stringify(['EVENT', subId, event]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.send(JSON.stringify(['EOSE', subId]))
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'CLOSE': {
|
||||||
|
let subId = data[1]
|
||||||
|
delete subs[subId]
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'EVENT': {
|
||||||
|
let event = data[1]
|
||||||
|
|
||||||
|
conn.send(JSON.stringify(['OK', event.id, 'true']))
|
||||||
|
|
||||||
|
for (let subId in subs) {
|
||||||
|
const { filters, conn: listener } = subs[subId]
|
||||||
|
|
||||||
|
if (matchFilters(filters, event)) {
|
||||||
|
listener.send(JSON.stringify(['EVENT', subId, event]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get authors() {
|
||||||
|
return this.secretKeys.map(getPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
get ids() {
|
||||||
|
return this.preloadedEvents.map(evt => evt.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"outDir": "lib/types",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
265
utils.test.ts
Normal file
265
utils.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test'
|
||||||
|
import { buildEvent } from './test-helpers.ts'
|
||||||
|
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
|
||||||
|
|
||||||
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
|
describe('inserting into a desc sorted list of events', () => {
|
||||||
|
test('insert into an empty list', async () => {
|
||||||
|
const list0: Event[] = []
|
||||||
|
expect(insertEventIntoDescendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list with same created_at', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 30 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 10 })]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(4)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the middle of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 30 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
buildEvent({ created_at: 1 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 15,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(5)
|
||||||
|
expect(list1[2].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the end of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 5,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||||
|
const list0: Event[] = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('do not insert duplicates', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 10, id: 'abc' }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoDescendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inserting into a asc sorted list of events', () => {
|
||||||
|
test('insert into an empty list', async () => {
|
||||||
|
const list0: Event[] = []
|
||||||
|
expect(insertEventIntoAscendingList(list0, buildEvent({ id: 'abc', created_at: 10 }))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 })]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the beginning of a list with same created_at', async () => {
|
||||||
|
const list0 = [buildEvent({ created_at: 10 }), buildEvent({ created_at: 20 }), buildEvent({ created_at: 30 })]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(4)
|
||||||
|
expect(list1[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the middle of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 10 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 30 }),
|
||||||
|
buildEvent({ created_at: 40 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 25,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(5)
|
||||||
|
expect(list1[2].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the end of a list', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 40 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 50,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-1)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('insert in the last-to-end of a list with same created_at', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 30 }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(6)
|
||||||
|
expect(list1.slice(-2)[0].id).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('do not insert duplicates', async () => {
|
||||||
|
const list0 = [
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 20 }),
|
||||||
|
buildEvent({ created_at: 30, id: 'abc' }),
|
||||||
|
]
|
||||||
|
const list1 = insertEventIntoAscendingList(
|
||||||
|
list0,
|
||||||
|
buildEvent({
|
||||||
|
id: 'abc',
|
||||||
|
created_at: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(list1).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enqueue a message into MessageQueue', () => {
|
||||||
|
test('enqueue into an empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
expect(queue.first!.value).toBe('node1')
|
||||||
|
})
|
||||||
|
test('enqueue into a non-empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
queue.enqueue('node3')
|
||||||
|
queue.enqueue('node2')
|
||||||
|
expect(queue.first!.value).toBe('node1')
|
||||||
|
expect(queue.last!.value).toBe('node2')
|
||||||
|
})
|
||||||
|
test('dequeue from an empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
const item1 = queue.dequeue()
|
||||||
|
expect(item1).toBe(null)
|
||||||
|
})
|
||||||
|
test('dequeue from a non-empty queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
queue.enqueue('node3')
|
||||||
|
queue.enqueue('node2')
|
||||||
|
const item1 = queue.dequeue()
|
||||||
|
expect(item1).toBe('node1')
|
||||||
|
const item2 = queue.dequeue()
|
||||||
|
expect(item2).toBe('node3')
|
||||||
|
})
|
||||||
|
test('dequeue more than in queue', () => {
|
||||||
|
const queue = new Queue()
|
||||||
|
queue.enqueue('node1')
|
||||||
|
queue.enqueue('node3')
|
||||||
|
const item1 = queue.dequeue()
|
||||||
|
expect(item1).toBe('node1')
|
||||||
|
const item2 = queue.dequeue()
|
||||||
|
expect(item2).toBe('node3')
|
||||||
|
const item3 = queue.dequeue()
|
||||||
|
expect(item3).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('binary search', () => {
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('c' < b ? -1 : 'c' === b ? 0 : 1))).toEqual([2, false])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
|
||||||
|
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
|
||||||
|
})
|
||||||
117
utils.ts
Normal file
117
utils.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Event } from './core.ts'
|
||||||
|
|
||||||
|
export const utf8Decoder = new TextDecoder('utf-8')
|
||||||
|
export const utf8Encoder = new TextEncoder()
|
||||||
|
|
||||||
|
export function normalizeURL(url: string): string {
|
||||||
|
if (url.indexOf('://') === -1) url = 'wss://' + url
|
||||||
|
let p = new URL(url)
|
||||||
|
p.pathname = p.pathname.replace(/\/+/g, '/')
|
||||||
|
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
|
||||||
|
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) p.port = ''
|
||||||
|
p.searchParams.sort()
|
||||||
|
p.hash = ''
|
||||||
|
return p.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
|
||||||
|
const [idx, found] = binarySearch(sortedArray, b => {
|
||||||
|
if (event.id === b.id) return 0
|
||||||
|
if (event.created_at === b.created_at) return -1
|
||||||
|
return b.created_at - event.created_at
|
||||||
|
})
|
||||||
|
if (!found) {
|
||||||
|
sortedArray.splice(idx, 0, event)
|
||||||
|
}
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
|
||||||
|
const [idx, found] = binarySearch(sortedArray, b => {
|
||||||
|
if (event.id === b.id) return 0
|
||||||
|
if (event.created_at === b.created_at) return -1
|
||||||
|
return event.created_at - b.created_at
|
||||||
|
})
|
||||||
|
if (!found) {
|
||||||
|
sortedArray.splice(idx, 0, event)
|
||||||
|
}
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
|
||||||
|
let start = 0
|
||||||
|
let end = arr.length - 1
|
||||||
|
|
||||||
|
while (start <= end) {
|
||||||
|
const mid = Math.floor((start + end) / 2)
|
||||||
|
const cmp = compare(arr[mid])
|
||||||
|
|
||||||
|
if (cmp === 0) {
|
||||||
|
return [mid, true]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmp < 0) {
|
||||||
|
end = mid - 1
|
||||||
|
} else {
|
||||||
|
start = mid + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [start, false]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueueNode<V> {
|
||||||
|
public value: V
|
||||||
|
public next: QueueNode<V> | null = null
|
||||||
|
public prev: QueueNode<V> | null = null
|
||||||
|
|
||||||
|
constructor(message: V) {
|
||||||
|
this.value = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Queue<V> {
|
||||||
|
public first: QueueNode<V> | null
|
||||||
|
public last: QueueNode<V> | null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.first = null
|
||||||
|
this.last = null
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(value: V): boolean {
|
||||||
|
const newNode = new QueueNode(value)
|
||||||
|
if (!this.last) {
|
||||||
|
// list is empty
|
||||||
|
this.first = newNode
|
||||||
|
this.last = newNode
|
||||||
|
} else if (this.last === this.first) {
|
||||||
|
// list has a single element
|
||||||
|
this.last = newNode
|
||||||
|
this.last.prev = this.first
|
||||||
|
this.first.next = newNode
|
||||||
|
} else {
|
||||||
|
// list has elements, add as last
|
||||||
|
newNode.prev = this.last
|
||||||
|
this.last.next = newNode
|
||||||
|
this.last = newNode
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
dequeue(): V | null {
|
||||||
|
if (!this.first) return null
|
||||||
|
|
||||||
|
if (this.first === this.last) {
|
||||||
|
const target = this.first
|
||||||
|
this.first = null
|
||||||
|
this.last = null
|
||||||
|
return target.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.first
|
||||||
|
this.first = target.next
|
||||||
|
|
||||||
|
return target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
38
wasm.ts
Normal file
38
wasm.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
import { Nostr as NostrWasm } from 'nostr-wasm'
|
||||||
|
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
|
||||||
|
|
||||||
|
let nw: NostrWasm
|
||||||
|
|
||||||
|
export function setNostrWasm(x: NostrWasm) {
|
||||||
|
nw = x
|
||||||
|
}
|
||||||
|
|
||||||
|
class Wasm implements Nostr {
|
||||||
|
generateSecretKey(): Uint8Array {
|
||||||
|
return nw.generateSecretKey()
|
||||||
|
}
|
||||||
|
getPublicKey(secretKey: Uint8Array): string {
|
||||||
|
return bytesToHex(nw.getPublicKey(secretKey))
|
||||||
|
}
|
||||||
|
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
|
||||||
|
nw.finalizeEvent(t as any, secretKey)
|
||||||
|
return t as VerifiedEvent
|
||||||
|
}
|
||||||
|
verifyEvent(event: Event): event is VerifiedEvent {
|
||||||
|
try {
|
||||||
|
nw.verifyEvent(event)
|
||||||
|
event[verifiedSymbol] = true
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = new Wasm()
|
||||||
|
export const generateSecretKey = i.generateSecretKey
|
||||||
|
export const getPublicKey = i.getPublicKey
|
||||||
|
export const finalizeEvent = i.finalizeEvent
|
||||||
|
export const verifyEvent = i.verifyEvent
|
||||||
|
export * from './core.ts'
|
||||||
Reference in New Issue
Block a user