n8n-nodes-ai-trader 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/fetch.cjs +203 -0
- package/dist/nodes/AiPublicFetch/AiPublicFetch.node.js +100 -0
- package/package.json +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nova969
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# AI-Trader Public Fetch
|
|
2
|
+
|
|
3
|
+
Configurable bounded public HTTPS fetch for self-hosted n8n.
|
|
4
|
+
Early release for self-hosted n8n. Transport and node loading are tested locally;
|
|
5
|
+
acceptance on the intended live server is still pending.
|
|
6
|
+
|
|
7
|
+
## Sources and security
|
|
8
|
+
|
|
9
|
+
Built-in choices: Binance time, metadata for BTCEUR/ETHEUR/SOLEUR/XRPEUR/ADAEUR,
|
|
10
|
+
hourly candles for those symbols, Bitcoin Core, Bitcoin Optech and Fed monetary.
|
|
11
|
+
Additional Allowed HTTPS Target accepts a URL only when it is also listed in
|
|
12
|
+
Options → Additional Allowed HTTPS URLs. List one exact complete URL per line.
|
|
13
|
+
Matching normalizes the URL but includes its path and query; no wildcards.
|
|
14
|
+
|
|
15
|
+
HTTPS port443, no URL credentials or fragments, and public-only DNS answers are
|
|
16
|
+
mandatory. One validated IP is pinned; TLS certificates, hostname validation and
|
|
17
|
+
SNI remain enabled. Environment proxies are not used. TLS/DNS protections cannot
|
|
18
|
+
be disabled. This node does not trade, store data, manage provider state or retry.
|
|
19
|
+
|
|
20
|
+
## Options
|
|
21
|
+
|
|
22
|
+
| Setting | Default | Behavior |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Maximum Response Size (MiB) | 2 | Positive whole MiB, for example10; streaming byte limit. |
|
|
25
|
+
| Total Timeout (Seconds) | 20 | Positive finite milliseconds-precise budget for DNS, TLS, headers, body and all redirects. |
|
|
26
|
+
| Additional Allowed HTTPS URLs | Empty | One exact additional target per line. |
|
|
27
|
+
| Follow Redirects | Off | Every hop must be explicitly allowed and pass fresh DNS/TLS checks. |
|
|
28
|
+
| Maximum Redirects | 3 when enabled | Integer1–20; deadline never resets. |
|
|
29
|
+
| HTTP Status Handling | Return All Statuses | Preserve final status and selected headers for downstream policy. |
|
|
30
|
+
| Accepted HTTP Status Codes | 200 | Require Selected Statuses accepts comma-separated codes such as200,201,304; other final statuses throw. |
|
|
31
|
+
|
|
32
|
+
Size/deadline cannot be disabled: zero, infinity and invalid values fail before
|
|
33
|
+
network access. Budgets must fit Node's safe buffer/timer ranges. Existing node
|
|
34
|
+
configurations without options retain2MiB/20seconds/no redirects.
|
|
35
|
+
Changing options does not change the AI-Trader collector policy automatically.
|
|
36
|
+
|
|
37
|
+
## Output
|
|
38
|
+
|
|
39
|
+
sourceId, statusCode, selected headers, receivedAt (UTC), bodyBase64, bodyBytes,
|
|
40
|
+
sha256, redirectCount and policy with effective limits/settings.
|
|
41
|
+
Policy diagnostics omit URLs and input items.
|
|
42
|
+
|
|
43
|
+
Return All Statuses preserves the original behavior: read200 bodies; other
|
|
44
|
+
statuses return an empty body and close the connection. Require Selected Statuses
|
|
45
|
+
reads accepted2xx bodies and returns accepted other statuses with empty bodies;
|
|
46
|
+
unaccepted final statuses throw. Enabled redirect handling precedes final-status
|
|
47
|
+
selection. Missing/disallowed locations or excessive hops fail.
|
|
48
|
+
|
|
49
|
+
Only complete identity-encoded bodies are accepted. Declared Content-Length and
|
|
50
|
+
streaming overflow are checked. SHA256 hashes exact returned bytes, including
|
|
51
|
+
an empty body for a discarded provider error; decimal strings are not parsed.
|
|
52
|
+
Exactly one input item is accepted. Use Return All Statuses when downstream
|
|
53
|
+
workflows need Retry-After, durable429/418 cooldowns and403/451 manual blocks.
|
|
54
|
+
|
|
55
|
+
## Installation and release
|
|
56
|
+
|
|
57
|
+
Intended route: Settings → Community nodes → Install an approved npm name/version.
|
|
58
|
+
[Official guide](https://docs.n8n.io/integrations/community-nodes/installation-and-management/gui-installation.md).
|
|
59
|
+
|
|
60
|
+
Licensed under MIT; see LICENSE. This license covers this node package only.
|
|
61
|
+
Package: n8n-nodes-ai-trader, published by nova969. npm publication
|
|
62
|
+
does not imply n8n verification or live compatibility. Manual custom-directory
|
|
63
|
+
copying is not the intended route. Future rollback: restore consumer workflows
|
|
64
|
+
while inactive, then uninstall via Community nodes; preserve data and unrelated
|
|
65
|
+
workflows. Activation is separate.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
Node>=22, no runtime dependencies; CommonJS source is under dist/.
|
|
70
|
+
npm test uses local fixtures only. TLS tests need OpenSSL and temporary local
|
|
71
|
+
certificates. Run npm pack --ignore-scripts and inspect the actual archive.
|
|
72
|
+
|
|
73
|
+
Expected files: package.json, README.md, LICENSE, dist/fetch.cjs and
|
|
74
|
+
dist/nodes/AiPublicFetch/AiPublicFetch.node.js. Exclude tests, handoff/audit notes,
|
|
75
|
+
exports, logs, .env and .npmrc. Re-audit every changed release.
|
package/dist/fetch.cjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const https = require('node:https');
|
|
3
|
+
const dns = require('node:dns').promises;
|
|
4
|
+
const { isIP } = require('node:net');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { constants: bufferConstants } = require('node:buffer');
|
|
7
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const DEADLINE_MS = 20_000;
|
|
9
|
+
const SYMBOLS = Object.freeze(['BTCEUR', 'ETHEUR', 'SOLEUR', 'XRPEUR', 'ADAEUR']);
|
|
10
|
+
const FEEDS = Object.freeze({
|
|
11
|
+
'bitcoin-core': 'https://bitcoincore.org/en/feed.xml',
|
|
12
|
+
'bitcoin-optech': 'https://bitcoinops.org/feed.xml',
|
|
13
|
+
'fed-monetary': 'https://www.federalreserve.gov/feeds/press_monetary.xml',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function httpsTarget(value) {
|
|
17
|
+
let url;
|
|
18
|
+
try { url = new URL(value); } catch { throw new Error('Invalid HTTPS target'); }
|
|
19
|
+
if (url.protocol !== 'https:' || (url.port && url.port !== '443')) throw new Error('HTTPS port 443 is required');
|
|
20
|
+
if (url.username || url.password) throw new Error('URL credentials are not allowed');
|
|
21
|
+
if (url.hash) throw new Error('URL fragments are not allowed');
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
function transportPolicy(options = {}) {
|
|
25
|
+
const keys = ['maxBytes', 'deadlineMs', 'followRedirects', 'maxRedirects', 'allowedUrls', 'statusMode', 'acceptedStatusCodes'];
|
|
26
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Invalid transport options');
|
|
27
|
+
if (Object.keys(options).some(key => !keys.includes(key))) throw new Error('Unsupported transport option');
|
|
28
|
+
const policy = { maxBytes: MAX_BYTES, deadlineMs: DEADLINE_MS, followRedirects: false,
|
|
29
|
+
maxRedirects: 3, allowedUrls: [], statusMode: 'allStatuses', acceptedStatusCodes: [200], ...options };
|
|
30
|
+
if (!Number.isSafeInteger(policy.maxBytes) || policy.maxBytes < 1 || policy.maxBytes > bufferConstants.MAX_LENGTH) throw new Error('Invalid byte limit');
|
|
31
|
+
if (!Number.isSafeInteger(policy.deadlineMs) || policy.deadlineMs < 1 || policy.deadlineMs > 2147483647) throw new Error('Invalid deadline');
|
|
32
|
+
if (typeof policy.followRedirects !== 'boolean') throw new Error('Invalid redirect setting');
|
|
33
|
+
if (!Number.isSafeInteger(policy.maxRedirects) || policy.maxRedirects < (policy.followRedirects ? 1 : 0) || policy.maxRedirects > 20) throw new Error('Invalid redirect limit');
|
|
34
|
+
if (!Array.isArray(policy.allowedUrls) || policy.allowedUrls.some(url => typeof url !== 'string')) throw new Error('Invalid target allowlist');
|
|
35
|
+
policy.allowedUrls = [...new Set(policy.allowedUrls.map(url => httpsTarget(url).href))];
|
|
36
|
+
if (!['allStatuses', 'selectedStatuses'].includes(policy.statusMode)) throw new Error('Invalid status mode');
|
|
37
|
+
if (!Array.isArray(policy.acceptedStatusCodes) || !policy.acceptedStatusCodes.length ||
|
|
38
|
+
policy.acceptedStatusCodes.some(code => !Number.isInteger(code) || code < 100 || code > 599)) throw new Error('Invalid accepted status codes');
|
|
39
|
+
policy.acceptedStatusCodes = [...new Set(policy.acceptedStatusCodes)];
|
|
40
|
+
return policy;
|
|
41
|
+
}
|
|
42
|
+
function targetFor(source, params = {}, allowedUrls = []) {
|
|
43
|
+
if (source === 'custom') {
|
|
44
|
+
if (Object.keys(params).some(key => key !== 'url')) throw new Error('Unapproved parameter');
|
|
45
|
+
const url = httpsTarget(params.url);
|
|
46
|
+
if (!allowedUrls.includes(url.href)) throw new Error('Target is not in the explicit allowlist');
|
|
47
|
+
return url;
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(params).some(key => !['symbol', 'limit', 'startTime', 'endTime'].includes(key))) throw new Error('Unapproved parameter');
|
|
50
|
+
if (Object.hasOwn(FEEDS, source)) {
|
|
51
|
+
if (Object.keys(params).length) throw new Error('Feed parameters are not allowed');
|
|
52
|
+
return new URL(FEEDS[source]);
|
|
53
|
+
}
|
|
54
|
+
const paths = { 'binance-time': 'time', 'binance-exchange-info': 'exchangeInfo', 'binance-klines': 'klines' };
|
|
55
|
+
if (!Object.hasOwn(paths, source)) throw new Error('Unapproved source');
|
|
56
|
+
const url = new URL(`https://data-api.binance.vision/api/v3/${paths[source]}`);
|
|
57
|
+
if (source !== 'binance-klines' && Object.keys(params).length) throw new Error('Endpoint parameters are not allowed');
|
|
58
|
+
if (source === 'binance-exchange-info') url.searchParams.set('symbols', JSON.stringify(SYMBOLS));
|
|
59
|
+
if (source === 'binance-klines') {
|
|
60
|
+
if (!SYMBOLS.includes(params.symbol)) throw new Error('Unapproved symbol');
|
|
61
|
+
const limit = params.limit ?? 2;
|
|
62
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('Invalid limit');
|
|
63
|
+
url.searchParams.set('symbol', params.symbol);
|
|
64
|
+
url.searchParams.set('interval', '1h');
|
|
65
|
+
url.searchParams.set('limit', String(limit));
|
|
66
|
+
for (const key of ['startTime', 'endTime']) if (params[key] !== undefined) {
|
|
67
|
+
if (!Number.isSafeInteger(params[key]) || params[key] < 0) throw new Error(`Invalid ${key}`);
|
|
68
|
+
url.searchParams.set(key, String(params[key]));
|
|
69
|
+
}
|
|
70
|
+
if (params.startTime !== undefined && params.endTime !== undefined && params.startTime > params.endTime) throw new Error('Invalid time window');
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inV4Range(value, base, bits) {
|
|
76
|
+
return (value >>> (32 - bits)) === (base >>> (32 - bits));
|
|
77
|
+
}
|
|
78
|
+
function isPublicAddress(address) {
|
|
79
|
+
if (typeof address !== 'string' || address.includes('%')) return false;
|
|
80
|
+
const family = isIP(address);
|
|
81
|
+
if (family === 4) {
|
|
82
|
+
const value = address.split('.').reduce((sum, part) => (sum * 256 + Number(part)) >>> 0, 0);
|
|
83
|
+
return ![
|
|
84
|
+
[0x00000000, 8], [0x0a000000, 8], [0x64400000, 10], [0x7f000000, 8],
|
|
85
|
+
[0xa9fe0000, 16], [0xac100000, 12], [0xc0000000, 24], [0xc0000200, 24],
|
|
86
|
+
[0xc0586300, 24], [0xc0a80000, 16], [0xc6120000, 15], [0xc6336400, 24],
|
|
87
|
+
[0xcb007100, 24], [0xe0000000, 4], [0xf0000000, 4],
|
|
88
|
+
].some(([base, bits]) => inV4Range(value, base, bits));
|
|
89
|
+
}
|
|
90
|
+
if (family !== 6 || address.includes('.')) return false;
|
|
91
|
+
const halves = address.toLowerCase().split('::');
|
|
92
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
93
|
+
const right = halves[1] ? halves[1].split(':') : [];
|
|
94
|
+
const words = halves.length === 2 ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right] : left;
|
|
95
|
+
const value = words.reduce((sum, word) => (sum << 16n) | BigInt(`0x${word}`), 0n);
|
|
96
|
+
// Conservative public-only policy: global unicast, excluding special-use,
|
|
97
|
+
// documentation and transition ranges. Deliberately stricter than is_global.
|
|
98
|
+
const within = (base, bits) => (value >> BigInt(128 - bits)) === (base >> BigInt(128 - bits));
|
|
99
|
+
return within(0x20000000000000000000000000000000n, 3)
|
|
100
|
+
&& !within(0x20010000000000000000000000000000n, 23)
|
|
101
|
+
&& !within(0x20010db8000000000000000000000000n, 32)
|
|
102
|
+
&& !within(0x20020000000000000000000000000000n, 16)
|
|
103
|
+
&& !within(0x3fff0000000000000000000000000000n, 20);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Dependency injection is limited to the library's I/O boundary. The n8n node
|
|
107
|
+
// always uses native I/O. User policy can change budgets/targets, never TLS/DNS checks.
|
|
108
|
+
async function fetchPublic(source, params = {}, dependencies = {}, options = {}) {
|
|
109
|
+
const policy = transportPolicy(options);
|
|
110
|
+
const initialUrl = targetFor(source, params, policy.allowedUrls);
|
|
111
|
+
const allowed = new Set([initialUrl.href, ...policy.allowedUrls]);
|
|
112
|
+
const resolve = dependencies.resolve ?? (host => dns.lookup(host, { all: true, verbatim: true }));
|
|
113
|
+
const request = dependencies.request ?? https.request;
|
|
114
|
+
const deadlineMs = dependencies.deadlineMs ?? policy.deadlineMs;
|
|
115
|
+
if (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || deadlineMs > policy.deadlineMs) throw new Error('Invalid deadline');
|
|
116
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
117
|
+
let req, res, done = false, attempt = 0, redirectCount = 0;
|
|
118
|
+
const finish = (error, result) => {
|
|
119
|
+
if (done) return;
|
|
120
|
+
done = true;
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
if (error || result?.statusCode !== 200) { res?.destroy(); req?.destroy(); }
|
|
123
|
+
if (error) rejectResult(error); else resolveResult(result);
|
|
124
|
+
};
|
|
125
|
+
const timer = setTimeout(() => finish(new Error('Total fetch deadline exceeded')), deadlineMs);
|
|
126
|
+
const open = url => {
|
|
127
|
+
const currentAttempt = ++attempt;
|
|
128
|
+
const current = () => !done && attempt === currentAttempt;
|
|
129
|
+
Promise.resolve().then(() => current() ? resolve(url.hostname) : []).then(addresses => {
|
|
130
|
+
if (!current()) return;
|
|
131
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some(item => !isPublicAddress(item.address))) throw new Error('Unsafe or missing DNS address');
|
|
132
|
+
const address = addresses[0].address;
|
|
133
|
+
const family = isIP(address);
|
|
134
|
+
req = request({
|
|
135
|
+
hostname: url.hostname, port: 443, path: url.pathname + url.search,
|
|
136
|
+
method: 'GET', servername: url.hostname, rejectUnauthorized: true, agent: false,
|
|
137
|
+
lookup: (_host, options, callback) => options?.all
|
|
138
|
+
? callback(null, [{ address, family }]) : callback(null, address, family),
|
|
139
|
+
headers: { 'User-Agent': 'CryptoLab/0.1 (public-feed research)',
|
|
140
|
+
Accept: source.startsWith('binance-') ? 'application/json' : 'application/rss+xml, application/atom+xml, application/xml, text/xml',
|
|
141
|
+
'Accept-Encoding': 'identity' },
|
|
142
|
+
}, response => {
|
|
143
|
+
if (!current()) { response.destroy(); return; }
|
|
144
|
+
res = response;
|
|
145
|
+
res.on('error', error => { if (current()) finish(error); });
|
|
146
|
+
res.on('aborted', () => { if (current()) finish(new Error('Incomplete response')); });
|
|
147
|
+
const headers = {};
|
|
148
|
+
for (const name of ['date', 'etag', 'last-modified', 'retry-after', 'x-mbx-used-weight-1m', 'content-type']) {
|
|
149
|
+
const value = res.headers[name];
|
|
150
|
+
if (typeof value === 'string') headers[name] = value;
|
|
151
|
+
}
|
|
152
|
+
const result = body => ({ sourceId: source, statusCode: res.statusCode, headers,
|
|
153
|
+
receivedAt: new Date().toISOString(), bodyBase64: body.toString('base64'),
|
|
154
|
+
bodyBytes: body.length, sha256: createHash('sha256').update(body).digest('hex'), redirectCount,
|
|
155
|
+
policy: { maxBytes: policy.maxBytes, deadlineMs, followRedirects: policy.followRedirects,
|
|
156
|
+
maxRedirects: policy.followRedirects ? policy.maxRedirects : 0,
|
|
157
|
+
statusMode: policy.statusMode, acceptedStatusCodes: policy.acceptedStatusCodes,
|
|
158
|
+
allowedTargetCount: allowed.size } });
|
|
159
|
+
if (policy.followRedirects && [301, 302, 303, 307, 308].includes(res.statusCode)) {
|
|
160
|
+
try {
|
|
161
|
+
if (redirectCount >= policy.maxRedirects) throw new Error('Redirect limit exceeded');
|
|
162
|
+
if (typeof res.headers.location !== 'string') throw new Error('Invalid redirect location');
|
|
163
|
+
const next = httpsTarget(new URL(res.headers.location, url).href);
|
|
164
|
+
if (!allowed.has(next.href)) throw new Error('Redirect target is not in the explicit allowlist');
|
|
165
|
+
redirectCount += 1;
|
|
166
|
+
// Invalidate old events before destroying the previous response.
|
|
167
|
+
attempt += 1; res.destroy(); req?.destroy(); open(next);
|
|
168
|
+
} catch (error) { finish(error); }
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (policy.statusMode === 'selectedStatuses' && !policy.acceptedStatusCodes.includes(res.statusCode)) {
|
|
172
|
+
finish(new Error(`Unaccepted HTTP ${res.statusCode}`)); return;
|
|
173
|
+
}
|
|
174
|
+
const readBody = res.statusCode === 200 || (policy.statusMode === 'selectedStatuses' &&
|
|
175
|
+
res.statusCode >= 200 && res.statusCode < 300);
|
|
176
|
+
if (!readBody) { finish(null, result(Buffer.alloc(0))); return; }
|
|
177
|
+
const encoding = res.headers['content-encoding'];
|
|
178
|
+
if (encoding && encoding.toLowerCase().trim() !== 'identity') { finish(new Error('Unsupported content encoding')); return; }
|
|
179
|
+
const length = res.headers['content-length'];
|
|
180
|
+
if (length !== undefined && (!/^\d+$/.test(length) || BigInt(length) > BigInt(policy.maxBytes))) { finish(new Error('Body byte limit exceeded')); return; }
|
|
181
|
+
let size = 0;
|
|
182
|
+
const chunks = [];
|
|
183
|
+
res.on('data', chunk => {
|
|
184
|
+
if (!current()) return;
|
|
185
|
+
size += chunk.length;
|
|
186
|
+
if (size > policy.maxBytes) { finish(new Error('Body byte limit exceeded')); return; }
|
|
187
|
+
chunks.push(chunk);
|
|
188
|
+
});
|
|
189
|
+
res.on('end', () => {
|
|
190
|
+
if (!current()) return;
|
|
191
|
+
if (!res.complete || (length !== undefined && BigInt(length) !== BigInt(size))) finish(new Error('Incomplete response'));
|
|
192
|
+
else finish(null, result(Buffer.concat(chunks, size)));
|
|
193
|
+
});
|
|
194
|
+
res.on('close', () => { if (current() && !response.complete) finish(new Error('Incomplete response')); });
|
|
195
|
+
});
|
|
196
|
+
req.on('error', error => { if (current()) finish(error); });
|
|
197
|
+
req.end();
|
|
198
|
+
}).catch(error => { if (current()) finish(error); });
|
|
199
|
+
};
|
|
200
|
+
open(initialUrl);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
module.exports = { fetchPublic, targetFor, isPublicAddress, transportPolicy, SYMBOLS, MAX_BYTES, DEADLINE_MS };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { fetchPublic, SYMBOLS } = require('../../fetch.cjs');
|
|
3
|
+
class AiPublicFetch {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.description = {
|
|
6
|
+
displayName: 'AI-Trader Public Fetch', name: 'aiPublicFetch', group: ['input'],
|
|
7
|
+
version: 1, description: 'Bounded HTTPS fetch from explicitly approved public sources',
|
|
8
|
+
defaults: { name: 'AI-Trader Public Fetch' }, inputs: ['main'], outputs: ['main'],
|
|
9
|
+
properties: [
|
|
10
|
+
{ displayName: 'Source', name: 'source', type: 'options', default: 'binance-time',
|
|
11
|
+
options: [
|
|
12
|
+
{ name: 'Binance Server Time', value: 'binance-time' },
|
|
13
|
+
{ name: 'Binance Five EUR Symbols Metadata', value: 'binance-exchange-info' },
|
|
14
|
+
{ name: 'Binance Hourly Candles', value: 'binance-klines' },
|
|
15
|
+
{ name: 'Bitcoin Core', value: 'bitcoin-core' },
|
|
16
|
+
{ name: 'Bitcoin Optech', value: 'bitcoin-optech' },
|
|
17
|
+
{ name: 'Federal Reserve Monetary', value: 'fed-monetary' },
|
|
18
|
+
{ name: 'Additional Allowed HTTPS Target', value: 'custom' },
|
|
19
|
+
] },
|
|
20
|
+
{ displayName: 'HTTPS URL', name: 'url', type: 'string', default: '',
|
|
21
|
+
description: 'Must exactly match an entry in Additional Allowed HTTPS URLs',
|
|
22
|
+
displayOptions: { show: { source: ['custom'] } } },
|
|
23
|
+
{ displayName: 'Symbol', name: 'symbol', type: 'options', default: 'BTCEUR',
|
|
24
|
+
options: SYMBOLS.map(value => ({ name: value, value })),
|
|
25
|
+
displayOptions: { show: { source: ['binance-klines'] } } },
|
|
26
|
+
{ displayName: 'Limit', name: 'limit', type: 'number', default: 2,
|
|
27
|
+
typeOptions: { minValue: 1, maxValue: 1000, numberPrecision: 0 },
|
|
28
|
+
displayOptions: { show: { source: ['binance-klines'] } } },
|
|
29
|
+
{ displayName: 'Start Time (UTC Epoch Milliseconds)', name: 'startTime', type: 'string', default: '',
|
|
30
|
+
displayOptions: { show: { source: ['binance-klines'] } } },
|
|
31
|
+
{ displayName: 'End Time (UTC Epoch Milliseconds)', name: 'endTime', type: 'string', default: '',
|
|
32
|
+
displayOptions: { show: { source: ['binance-klines'] } } },
|
|
33
|
+
{ displayName: 'Options', name: 'options', type: 'collection', default: {},
|
|
34
|
+
placeholder: 'Add Option', options: [
|
|
35
|
+
{ displayName: 'Maximum Response Size (MiB)', name: 'maxResponseMiB', type: 'number', default: 2,
|
|
36
|
+
typeOptions: { minValue: 1, numberPrecision: 0 }, description: 'Required finite receive limit; 1 MiB is 1048576 bytes' },
|
|
37
|
+
{ displayName: 'Total Timeout (Seconds)', name: 'totalTimeoutSeconds', type: 'number', default: 20,
|
|
38
|
+
typeOptions: { minValue: 0.001, maxValue: 2147483.647, numberPrecision: 3 },
|
|
39
|
+
description: 'One deadline for DNS, TLS, headers, body and the complete redirect chain' },
|
|
40
|
+
{ displayName: 'Additional Allowed HTTPS URLs', name: 'allowedUrls', type: 'string', default: '',
|
|
41
|
+
typeOptions: { rows: 4 }, description: 'One exact HTTPS URL per line. TLS and public-address checks always apply.' },
|
|
42
|
+
{ displayName: 'Follow Redirects', name: 'followRedirects', type: 'boolean', default: false,
|
|
43
|
+
description: 'Every target must be explicitly allowed and pass DNS/TLS checks' },
|
|
44
|
+
{ displayName: 'Maximum Redirects', name: 'maxRedirects', type: 'number', default: 3,
|
|
45
|
+
typeOptions: { minValue: 1, maxValue: 20, numberPrecision: 0 },
|
|
46
|
+
displayOptions: { show: { followRedirects: [true] } } },
|
|
47
|
+
{ displayName: 'HTTP Status Handling', name: 'statusMode', type: 'options', default: 'allStatuses',
|
|
48
|
+
options: [{ name: 'Return All Statuses', value: 'allStatuses' },
|
|
49
|
+
{ name: 'Require Selected Statuses', value: 'selectedStatuses' }] },
|
|
50
|
+
{ displayName: 'Accepted HTTP Status Codes', name: 'acceptedStatusCodes', type: 'string', default: '200',
|
|
51
|
+
description: 'Comma-separated codes; non-selected statuses stop the request',
|
|
52
|
+
displayOptions: { show: { statusMode: ['selectedStatuses'] } } },
|
|
53
|
+
] },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async execute() {
|
|
58
|
+
const output = [];
|
|
59
|
+
const input = this.getInputData();
|
|
60
|
+
if (input.length !== 1) throw new Error('Exactly one input item is required; apply provider policy between requests');
|
|
61
|
+
for (let i = 0; i < input.length; i++) {
|
|
62
|
+
const source = this.getNodeParameter('source', i);
|
|
63
|
+
if (!['binance-time', 'binance-exchange-info', 'binance-klines', 'bitcoin-core', 'bitcoin-optech', 'fed-monetary', 'custom'].includes(source)) throw new Error('Unapproved source');
|
|
64
|
+
const params = {};
|
|
65
|
+
if (source === 'custom') params.url = this.getNodeParameter('url', i);
|
|
66
|
+
if (source === 'binance-klines') {
|
|
67
|
+
params.symbol = this.getNodeParameter('symbol', i);
|
|
68
|
+
params.limit = this.getNodeParameter('limit', i);
|
|
69
|
+
for (const key of ['startTime', 'endTime']) {
|
|
70
|
+
const raw = this.getNodeParameter(key, i);
|
|
71
|
+
if (raw !== '') {
|
|
72
|
+
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) throw new Error(`Invalid ${key}`);
|
|
73
|
+
params[key] = Number(raw);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Fail closed. Status results must be handled by the collector's durable
|
|
78
|
+
// cooldown/block policy before another request; this node never retries.
|
|
79
|
+
const options = this.getNodeParameter('options', i, {});
|
|
80
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Invalid options');
|
|
81
|
+
const known = ['maxResponseMiB', 'totalTimeoutSeconds', 'allowedUrls', 'followRedirects', 'maxRedirects', 'statusMode', 'acceptedStatusCodes'];
|
|
82
|
+
if (Object.keys(options).some(key => !known.includes(key))) throw new Error('Unsupported option');
|
|
83
|
+
const maxResponseMiB = options.maxResponseMiB ?? 2;
|
|
84
|
+
const seconds = options.totalTimeoutSeconds ?? 20;
|
|
85
|
+
if (!Number.isSafeInteger(maxResponseMiB) || maxResponseMiB < 1) throw new Error('Invalid byte limit');
|
|
86
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) throw new Error('Invalid deadline');
|
|
87
|
+
const allowedUrls = options.allowedUrls ?? '';
|
|
88
|
+
const statusCodes = options.acceptedStatusCodes ?? '200';
|
|
89
|
+
if (typeof allowedUrls !== 'string' || typeof statusCodes !== 'string' ||
|
|
90
|
+
!/^\s*\d{3}(\s*,\s*\d{3})*\s*$/.test(statusCodes)) throw new Error('Invalid allowlist or status codes');
|
|
91
|
+
const policy = { maxBytes: maxResponseMiB * 1024 * 1024, deadlineMs: Math.round(seconds * 1000),
|
|
92
|
+
allowedUrls: allowedUrls.split(/\r?\n/).map(value => value.trim()).filter(Boolean),
|
|
93
|
+
followRedirects: options.followRedirects ?? false, maxRedirects: options.maxRedirects ?? 3,
|
|
94
|
+
statusMode: options.statusMode ?? 'allStatuses', acceptedStatusCodes: statusCodes.split(',').map(value => Number(value.trim())) };
|
|
95
|
+
output.push({ json: await fetchPublic(source, params, {}, policy), pairedItem: { item: i } });
|
|
96
|
+
}
|
|
97
|
+
return [output];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
module.exports = { AiPublicFetch };
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "n8n-nodes-ai-trader",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Configurable bounded public HTTPS fetch node for AI-Trader",
|
|
5
|
+
"keywords": ["n8n-community-node-package", "n8n", "https"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "nova969",
|
|
8
|
+
"engines": { "node": ">=22" },
|
|
9
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
10
|
+
"scripts": { "test": "node --test test/*.test.cjs" },
|
|
11
|
+
"n8n": {
|
|
12
|
+
"n8nNodesApiVersion": 1,
|
|
13
|
+
"nodes": ["dist/nodes/AiPublicFetch/AiPublicFetch.node.js"]
|
|
14
|
+
}
|
|
15
|
+
}
|