@ironfang/n8n-nodes-ironfang 0.2.3 → 0.3.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/README.md +164 -100
- package/dist/credentials/IronfangApi.credentials.js +6 -1
- package/dist/credentials/IronfangS3.credentials.d.ts +8 -0
- package/dist/credentials/IronfangS3.credentials.js +15 -0
- package/dist/credentials/IronfangWebhookApi.credentials.d.ts +8 -0
- package/dist/credentials/IronfangWebhookApi.credentials.js +14 -0
- package/dist/nodes/Ironfang/Ironfang.node.d.ts +7 -10
- package/dist/nodes/Ironfang/Ironfang.node.js +36 -535
- package/dist/nodes/Ironfang/IronfangTrigger.node.d.ts +11 -0
- package/dist/nodes/Ironfang/IronfangTrigger.node.js +56 -0
- package/dist/nodes/Ironfang/catalog.d.ts +2 -0
- package/dist/nodes/Ironfang/catalog.js +1806 -0
- package/dist/nodes/Ironfang/credentialTest.js +24 -14
- package/dist/nodes/Ironfang/destinationCredentialTest.d.ts +2 -0
- package/dist/nodes/Ironfang/destinationCredentialTest.js +12 -0
- package/dist/nodes/Ironfang/execute.d.ts +2 -0
- package/dist/nodes/Ironfang/execute.js +205 -0
- package/dist/nodes/Ironfang/files.d.ts +10 -0
- package/dist/nodes/Ironfang/files.js +50 -0
- package/dist/nodes/Ironfang/parameters.d.ts +2 -0
- package/dist/nodes/Ironfang/parameters.js +50 -0
- package/dist/nodes/Ironfang/renderOptions.d.ts +2 -0
- package/dist/nodes/Ironfang/renderOptions.js +8 -0
- package/dist/nodes/Ironfang/renderwolf.d.ts +19 -0
- package/dist/nodes/Ironfang/renderwolf.js +556 -0
- package/dist/nodes/Ironfang/templates.d.ts +2 -0
- package/dist/nodes/Ironfang/templates.js +15 -0
- package/dist/nodes/Ironfang/transport.d.ts +3 -1
- package/dist/nodes/Ironfang/transport.js +20 -5
- package/dist/nodes/Ironfang/types.d.ts +31 -0
- package/dist/nodes/Ironfang/types.js +2 -0
- package/dist/nodes/Ironfang/webhook.d.ts +4 -0
- package/dist/nodes/Ironfang/webhook.js +39 -0
- package/docs/operations.md +1046 -0
- package/docs/validation.md +58 -0
- package/examples/audit-polling.json +223 -0
- package/examples/invoice-generation-storage.json +83 -0
- package/examples/invoice-validation.json +54 -0
- package/package.json +16 -7
|
@@ -5,22 +5,32 @@ const transport_1 = require("./transport");
|
|
|
5
5
|
// n8n's credential-test context exposes request; execution uses the authenticated
|
|
6
6
|
// httpRequest helper. A structured scope refusal proves authentication succeeded.
|
|
7
7
|
async function credentialTest(credential) {
|
|
8
|
+
const data = credential.data ?? {};
|
|
9
|
+
const selected = String(data.testProduct || 'auto');
|
|
10
|
+
const products = selected === 'auto' ? ['renderwolf', 'financewolf', 'auditwolf'] : [selected];
|
|
11
|
+
const { request: testRequest } = this.helpers;
|
|
12
|
+
let lastStatus;
|
|
8
13
|
try {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
for (const product of products) {
|
|
15
|
+
const base = product === 'renderwolf' ? (0, transport_1.renderwolfBase)(data.baseUrl) : (0, transport_1.productBase)(data.baseUrl, product);
|
|
16
|
+
const endpoint = product === 'renderwolf' ? '/v1/usage' : product === 'financewolf' ? '/v1/einvoices/results' : '/v1/sites';
|
|
17
|
+
const response = await testRequest({
|
|
18
|
+
method: 'GET', uri: base + endpoint,
|
|
19
|
+
headers: { Authorization: `Bearer ${String(data.apiKey ?? '')}` },
|
|
20
|
+
json: true, resolveWithFullResponse: true, simple: false,
|
|
21
|
+
followRedirect: false, timeout: 15_000,
|
|
22
|
+
});
|
|
23
|
+
lastStatus = response.statusCode;
|
|
24
|
+
const body = response.body;
|
|
25
|
+
const valid = product === 'renderwolf' ? typeof body?.limit === 'number' || body?.unmetered === true : Array.isArray(body?.[product === 'financewolf' ? 'results' : 'sites']);
|
|
26
|
+
if (response.statusCode === 200 && valid)
|
|
27
|
+
return { status: 'OK', message: `Connected to ${product}. Each operation requires its own scope.` };
|
|
28
|
+
if (response.statusCode === 403 && (body?.error?.code ?? body?.code) === 'insufficient_scope')
|
|
29
|
+
return { status: 'OK', message: `API key authenticated by ${product}. The test read is outside its scopes; choose only the scopes your workflow needs.` };
|
|
30
|
+
if (response.statusCode !== 401 && response.statusCode !== 404)
|
|
31
|
+
break;
|
|
19
32
|
}
|
|
20
|
-
|
|
21
|
-
return { status: 'OK', message: 'API key authenticated. Usage needs renderwolf:usage:read; other operations use their own scopes.' };
|
|
22
|
-
}
|
|
23
|
-
return { status: 'Error', message: response.statusCode === 401 ? 'API key was not accepted. Use an if_live_ platform key or an existing rw_live_ Renderwolf key.' : `Connection check returned HTTP ${response.statusCode}. Check the base URL and service availability.` };
|
|
33
|
+
return { status: 'Error', message: lastStatus === 401 ? 'API key was not accepted. Use an if_live_ platform key or an existing rw_live_ Renderwolf key.' : `Connection check returned HTTP ${lastStatus}. Check the base URL and service availability.` };
|
|
24
34
|
}
|
|
25
35
|
catch {
|
|
26
36
|
return { status: 'Error', message: 'Could not connect. Check the base URL, TLS and service availability.' };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.destinationCredentialTest = destinationCredentialTest;
|
|
4
|
+
// A signing key cannot be authenticated without the destination bucket and region.
|
|
5
|
+
// Keep the connection check explicit about that limit; destination Test performs
|
|
6
|
+
// the actual upload/verification through Ironfang once the destination exists.
|
|
7
|
+
async function destinationCredentialTest(credential) {
|
|
8
|
+
const data = credential.data ?? {};
|
|
9
|
+
if (!data.accessKey || !data.secretKey)
|
|
10
|
+
return { status: 'Error', message: 'Access key and secret key are required' };
|
|
11
|
+
return { status: 'OK', message: 'Credential fields are present. To verify S3 access, register the destination and run Test Destination. No S3 connection has been made by this check.' };
|
|
12
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeProducts = executeProducts;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
6
|
+
const catalog_1 = require("./catalog");
|
|
7
|
+
const transport_1 = require("./transport");
|
|
8
|
+
const files_1 = require("./files");
|
|
9
|
+
function jsonObject(value) {
|
|
10
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
11
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
12
|
+
throw new Error('Request body must be a JSON object');
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
15
|
+
function parameter(context, name, item, fallback = '') {
|
|
16
|
+
return context.getNodeParameter(name, item, fallback);
|
|
17
|
+
}
|
|
18
|
+
async function outputFile(context, item, response, field, fallback, json = {}) {
|
|
19
|
+
if (!field.trim())
|
|
20
|
+
throw new Error('Output binary field must not be empty');
|
|
21
|
+
if (!Buffer.isBuffer(response.body))
|
|
22
|
+
throw new Error('Expected a binary API response');
|
|
23
|
+
const binary = await context.helpers.prepareBinaryData(response.body, (0, files_1.filename)(response.headers, fallback === 'result.bin' ? 'result.' + ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'application/pdf': 'pdf', 'application/zip': 'zip', 'video/mp4': 'mp4' }[response.headers['content-type']?.split(';')[0]] || 'bin') : fallback), response.headers['content-type']?.split(';')[0] || 'application/octet-stream');
|
|
24
|
+
return { json: { ...json, bytes: response.body.length, byteLength: response.body.length, _ironfang: response.metadata }, binary: { [field]: binary }, pairedItem: { item } };
|
|
25
|
+
}
|
|
26
|
+
async function jsonOutput(context, item, op, response) {
|
|
27
|
+
const json = response.body && typeof response.body === 'object' && !Array.isArray(response.body) ? response.body : { result: response.body ?? null };
|
|
28
|
+
if (op.id === 'generateEInvoice' && !json.artifact)
|
|
29
|
+
throw new Error('Financewolf generation response is missing the validated XML artifact');
|
|
30
|
+
if (op.product === 'financewolf' && ['generateEInvoice', 'getValidationResult'].includes(op.id) && json.artifact) {
|
|
31
|
+
const artifact = json.artifact;
|
|
32
|
+
if (typeof artifact.data_base64 !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(artifact.data_base64))
|
|
33
|
+
throw new Error('Financewolf returned an invalid XML artifact');
|
|
34
|
+
const xml = Buffer.from(artifact.data_base64, 'base64');
|
|
35
|
+
if (!xml.length || xml.length > 5_242_880 || xml.length !== artifact.bytes || (0, crypto_1.createHash)('sha256').update(xml).digest('hex') !== artifact.sha256)
|
|
36
|
+
throw new Error('Financewolf XML artifact size or SHA-256 does not match its metadata');
|
|
37
|
+
return outputFile(context, item, { ...response, body: xml, headers: { 'content-type': 'application/xml' } }, String(parameter(context, 'outputBinaryField', item, 'data')), 'invoice.xml', json);
|
|
38
|
+
}
|
|
39
|
+
return { json: { ...json, _ironfang: response.metadata }, pairedItem: { item } };
|
|
40
|
+
}
|
|
41
|
+
async function runOperation(context, item, op) {
|
|
42
|
+
const publicMode = op.product === 'tools' || (op.product === 'financewolf' && parameter(context, 'authentication', item, 'apiKey') === 'public');
|
|
43
|
+
if (publicMode && !op.public)
|
|
44
|
+
throw new Error('This operation requires an API key; public mode cannot access saved results');
|
|
45
|
+
const base = publicMode ? parameter(context, 'publicBaseUrl', item, 'https://api.ironfang.uk') : (await context.getCredentials('ironfangApi')).baseUrl;
|
|
46
|
+
const path = op.path.replace(/\{(\w+)\}/g, (_, name) => (0, transport_1.identifier)(context, parameter(context, name, item), item));
|
|
47
|
+
const url = (0, transport_1.productBase)(base, op.product) + path;
|
|
48
|
+
const qs = { ...parameter(context, 'query', item, {}) };
|
|
49
|
+
const allowed = new Set(op.query.map(q => q.name));
|
|
50
|
+
for (const key of Object.keys(qs)) {
|
|
51
|
+
if (!allowed.has(key))
|
|
52
|
+
throw new Error(`Unknown query field: ${key}`);
|
|
53
|
+
if (qs[key] === '' || qs[key] === undefined)
|
|
54
|
+
delete qs[key];
|
|
55
|
+
}
|
|
56
|
+
for (const q of op.query) {
|
|
57
|
+
if (q.required) {
|
|
58
|
+
qs[q.name] = parameter(context, q.name, item);
|
|
59
|
+
if (qs[q.name] === '')
|
|
60
|
+
throw new Error(`${q.label} is required`);
|
|
61
|
+
}
|
|
62
|
+
if (q.type === 'array' && qs[q.name] !== undefined) {
|
|
63
|
+
if (typeof qs[q.name] === 'string')
|
|
64
|
+
qs[q.name] = JSON.parse(qs[q.name]);
|
|
65
|
+
if (!Array.isArray(qs[q.name]) || !qs[q.name].every(value => typeof value === 'string'))
|
|
66
|
+
throw new Error(`${q.label} must be a JSON array of strings`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const headers = {};
|
|
70
|
+
if (op.idempotency) {
|
|
71
|
+
const key = String(parameter(context, 'idempotencyKey', item));
|
|
72
|
+
if (key && publicMode)
|
|
73
|
+
throw new Error('Public Financewolf calls cannot use an idempotency key');
|
|
74
|
+
if (['submitJob', 'submitBatch'].includes(op.id) && !key.trim())
|
|
75
|
+
throw new Error('Use a stable idempotency key for job or batch submission');
|
|
76
|
+
if (key)
|
|
77
|
+
headers['Idempotency-Key'] = key;
|
|
78
|
+
}
|
|
79
|
+
let body;
|
|
80
|
+
if (op.input === 'json') {
|
|
81
|
+
body = jsonObject(parameter(context, 'requestBody', item, op.example ?? {}));
|
|
82
|
+
headers['Content-Type'] = 'application/json';
|
|
83
|
+
}
|
|
84
|
+
if (op.id === 'createDestination' || op.id === 'createExportDestinations') {
|
|
85
|
+
const destination = body;
|
|
86
|
+
if (['access_key', 'secret_key', 'session_token'].some(key => key in destination))
|
|
87
|
+
throw new Error('Store destination secrets in the Ironfang S3 Destination credential');
|
|
88
|
+
const s3 = op.id === 'createExportDestinations' || parameter(context, 'destinationType', item, 'webhook') === 's3';
|
|
89
|
+
if (op.product === 'renderwolf')
|
|
90
|
+
destination.type = s3 ? 's3' : 'webhook';
|
|
91
|
+
if (s3) {
|
|
92
|
+
const credential = await context.getCredentials('ironfangS3');
|
|
93
|
+
destination.access_key = String(credential.accessKey || '');
|
|
94
|
+
destination.secret_key = String(credential.secretKey || '');
|
|
95
|
+
if (credential.sessionToken)
|
|
96
|
+
destination.session_token = String(credential.sessionToken);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (op.input === 'xml') {
|
|
100
|
+
body = parameter(context, 'xmlSource', item, 'binary') === 'text' ? Buffer.from(String(parameter(context, 'xml', item)), 'utf8') : await context.helpers.getBinaryDataBuffer(item, String(parameter(context, 'inputBinaryField', item, 'data')));
|
|
101
|
+
if (!Buffer.isBuffer(body) || !body.length || body.length > 5_242_880)
|
|
102
|
+
throw new Error('XML input must contain between 1 byte and 5 MiB');
|
|
103
|
+
headers['Content-Type'] = 'application/xml';
|
|
104
|
+
}
|
|
105
|
+
if (op.input === 'upload') {
|
|
106
|
+
const input = await context.helpers.getBinaryDataBuffer(item, String(parameter(context, 'inputBinaryField', item, 'data')));
|
|
107
|
+
if (!input.length || input.length > 8 * 1024 * 1024 - 8192)
|
|
108
|
+
throw new Error('Image input must fit within the 8 MiB multipart request limit');
|
|
109
|
+
const multipart = (0, files_1.multipartUpload)(input, jsonObject(parameter(context, 'requestBody', item, op.example ?? {})));
|
|
110
|
+
body = multipart.body;
|
|
111
|
+
headers['Content-Type'] = multipart.contentType;
|
|
112
|
+
}
|
|
113
|
+
const options = { method: op.method, url, qs, headers, body, arrayFormat: 'repeat', ...(op.response === 'json' ? { json: true } : { encoding: 'arraybuffer' }) };
|
|
114
|
+
if (op.response === 'redirect')
|
|
115
|
+
options.ignoreHttpStatusErrors = { ignore: true, except: Array.from({ length: 300 }, (_, i) => i + 300).filter(code => code !== 302) };
|
|
116
|
+
let response;
|
|
117
|
+
if (op.pagination)
|
|
118
|
+
return paginated(context, item, op, options, !publicMode);
|
|
119
|
+
response = await transport_1.request.call(context, options, !publicMode);
|
|
120
|
+
if (op.response === 'redirect') {
|
|
121
|
+
const location = response.headers.location;
|
|
122
|
+
if (response.statusCode !== 302 || !location)
|
|
123
|
+
throw new Error('Job result did not return a signed download location');
|
|
124
|
+
const target = new URL(location, url);
|
|
125
|
+
const baseURL = new URL((0, transport_1.productBase)(base, op.product));
|
|
126
|
+
if (target.origin !== baseURL.origin || !target.pathname.startsWith(`${baseURL.pathname}/v1/results/`) || target.username || target.password || target.hash)
|
|
127
|
+
throw new Error('Job result download location is outside the expected signed-result endpoint');
|
|
128
|
+
response = await transport_1.request.call(context, { method: 'GET', url: target.toString(), encoding: 'arraybuffer' }, false);
|
|
129
|
+
}
|
|
130
|
+
const field = String(parameter(context, 'outputBinaryField', item, 'data'));
|
|
131
|
+
if (op.response === 'multipart' || (op.response === 'redirect' && response.headers['content-type']?.startsWith('multipart/'))) {
|
|
132
|
+
if (field === 'poster')
|
|
133
|
+
throw new Error('Use an output field other than poster for the preview video');
|
|
134
|
+
const parts = (0, files_1.previewParts)(response.body, response.headers['content-type']);
|
|
135
|
+
const out = await outputFile(context, item, { ...response, body: parts.video, headers: { 'content-type': 'video/mp4' } }, field, 'preview.mp4');
|
|
136
|
+
out.binary.poster = await context.helpers.prepareBinaryData(parts.poster, 'poster.jpg', 'image/jpeg');
|
|
137
|
+
out.json.posterBytes = parts.poster.length;
|
|
138
|
+
return [out];
|
|
139
|
+
}
|
|
140
|
+
if (op.response !== 'json')
|
|
141
|
+
return [await outputFile(context, item, response, field, 'result.bin')];
|
|
142
|
+
return [await jsonOutput(context, item, op, response)];
|
|
143
|
+
}
|
|
144
|
+
async function paginated(context, item, op, options, authenticated) {
|
|
145
|
+
const paging = op.pagination;
|
|
146
|
+
const all = parameter(context, 'returnAll', item, false) === true;
|
|
147
|
+
const limit = all ? Infinity : Number(parameter(context, 'limit', item, 50));
|
|
148
|
+
if (!all && (!Number.isSafeInteger(limit) || limit < 1))
|
|
149
|
+
throw new Error('Limit must be a positive integer');
|
|
150
|
+
let cursor = parameter(context, 'pageStart', item, paging.parameter === 'offset' ? 0 : '');
|
|
151
|
+
if (paging.parameter === 'offset' && (!Number.isSafeInteger(Number(cursor)) || Number(cursor) < 0))
|
|
152
|
+
throw new Error('Start offset must be a non-negative integer');
|
|
153
|
+
const output = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (let page = 0; page < 1000; page++) {
|
|
156
|
+
const qs = { ...options.qs };
|
|
157
|
+
if (cursor !== '')
|
|
158
|
+
qs[paging.parameter] = cursor;
|
|
159
|
+
if (paging.parameter !== 'before')
|
|
160
|
+
qs.limit = Math.min(paging.size, limit - output.length);
|
|
161
|
+
const response = await transport_1.request.call(context, { ...options, qs }, authenticated);
|
|
162
|
+
const data = jsonObject(response.body);
|
|
163
|
+
const rows = data[paging.key];
|
|
164
|
+
if (!Array.isArray(rows))
|
|
165
|
+
throw new Error(`API list response is missing ${paging.key}`);
|
|
166
|
+
for (const row of rows.slice(0, limit - output.length))
|
|
167
|
+
output.push({ json: { ...row, _ironfang: { ...response.metadata, nextCursor: data.next_cursor ?? '', ...(data.retention_days ? { retentionDays: data.retention_days } : {}) } }, pairedItem: { item } });
|
|
168
|
+
if (output.length >= limit || !rows.length)
|
|
169
|
+
return output;
|
|
170
|
+
if (paging.parameter === 'offset') {
|
|
171
|
+
if (rows.length < Number(qs.limit))
|
|
172
|
+
return output;
|
|
173
|
+
cursor = Number(cursor) + rows.length;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const next = data.next_cursor;
|
|
177
|
+
if (!next)
|
|
178
|
+
return output;
|
|
179
|
+
if (typeof next !== 'string' || next === cursor || seen.has(next))
|
|
180
|
+
throw new Error('API repeated a pagination cursor');
|
|
181
|
+
seen.add(next);
|
|
182
|
+
cursor = next;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw new Error('Pagination exceeded 1,000 pages; narrow the query or continue from a cursor');
|
|
186
|
+
}
|
|
187
|
+
async function executeProducts() {
|
|
188
|
+
const output = [];
|
|
189
|
+
for (let item = 0; item < this.getInputData().length; item++) {
|
|
190
|
+
try {
|
|
191
|
+
const product = parameter(this, 'resource', item);
|
|
192
|
+
const id = parameter(this, 'operation', item);
|
|
193
|
+
const op = catalog_1.operations.find(candidate => candidate.product === product && candidate.id === id);
|
|
194
|
+
if (!op)
|
|
195
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Unknown product operation', { itemIndex: item });
|
|
196
|
+
output.push(...await runOperation(this, item, op));
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (!this.continueOnFail())
|
|
200
|
+
throw (0, transport_1.apiError)(this, error, item);
|
|
201
|
+
output.push((0, transport_1.errorOutput)(this, error, item));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return [output];
|
|
205
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IDataObject } from 'n8n-workflow';
|
|
2
|
+
export declare function multipartUpload(data: Buffer, options: IDataObject): {
|
|
3
|
+
body: Buffer;
|
|
4
|
+
contentType: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function previewParts(body: Buffer, contentType: string): {
|
|
7
|
+
poster: Buffer;
|
|
8
|
+
video: Buffer;
|
|
9
|
+
};
|
|
10
|
+
export declare function filename(headers: Record<string, string>, fallback: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.multipartUpload = multipartUpload;
|
|
4
|
+
exports.previewParts = previewParts;
|
|
5
|
+
exports.filename = filename;
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
function multipartUpload(data, options) {
|
|
8
|
+
const boundary = `ironfang-${(0, crypto_1.randomBytes)(24).toString('hex')}`;
|
|
9
|
+
const chunks = [];
|
|
10
|
+
for (const [key, value] of Object.entries(options)) {
|
|
11
|
+
if (!/^[a-z][a-z0-9_]*$/.test(key) || !['string', 'number', 'boolean'].includes(typeof value))
|
|
12
|
+
throw new Error('Form options must contain named string, number or boolean values');
|
|
13
|
+
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${String(value)}\r\n`));
|
|
14
|
+
}
|
|
15
|
+
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="image"; filename="image"\r\nContent-Type: application/octet-stream\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
16
|
+
return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
17
|
+
}
|
|
18
|
+
function previewParts(body, contentType) {
|
|
19
|
+
const boundary = /boundary=(?:"([^"\r\n]+)"|([^;\s]+))/i.exec(contentType);
|
|
20
|
+
const value = boundary?.[1] ?? boundary?.[2];
|
|
21
|
+
if (!value || value.length > 128)
|
|
22
|
+
throw new Error('Site Preview response has no valid multipart boundary');
|
|
23
|
+
const marker = Buffer.from(`--${value}`);
|
|
24
|
+
const separator = Buffer.from(`\r\n--${value}`);
|
|
25
|
+
const parts = {};
|
|
26
|
+
let offset = body.indexOf(marker);
|
|
27
|
+
while (offset >= 0) {
|
|
28
|
+
offset += marker.length;
|
|
29
|
+
if (body.subarray(offset, offset + 2).toString() === '--')
|
|
30
|
+
break;
|
|
31
|
+
const headerEnd = body.indexOf('\r\n\r\n', offset);
|
|
32
|
+
if (headerEnd < 0 || headerEnd - offset > 8192)
|
|
33
|
+
throw new Error('Invalid Site Preview part headers');
|
|
34
|
+
const header = body.subarray(offset, headerEnd).toString('ascii');
|
|
35
|
+
const name = /(?:^|;)\s*name="(poster|video)"/m.exec(header)?.[1];
|
|
36
|
+
const end = body.indexOf(separator, headerEnd + 4);
|
|
37
|
+
if (end < 0 || !name || parts[name])
|
|
38
|
+
throw new Error('Invalid or duplicate Site Preview part');
|
|
39
|
+
parts[name] = body.subarray(headerEnd + 4, end);
|
|
40
|
+
offset = end + 2;
|
|
41
|
+
}
|
|
42
|
+
if (!parts.poster?.length || !parts.video?.length)
|
|
43
|
+
throw new Error('Site Preview must return both poster and video');
|
|
44
|
+
return { poster: parts.poster, video: parts.video };
|
|
45
|
+
}
|
|
46
|
+
function filename(headers, fallback) {
|
|
47
|
+
const match = /filename="?([^";\r\n]+)"?/i.exec(headers['content-disposition'] ?? '');
|
|
48
|
+
const name = match?.[1]?.split(/[\\/]/).pop();
|
|
49
|
+
return name && /^[\w. -]{1,180}$/.test(name) && !name.startsWith('.') ? name : fallback;
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.productParameters = void 0;
|
|
4
|
+
const catalog_1 = require("./catalog");
|
|
5
|
+
const show = (op) => ({ show: { resource: [op.product], operation: [op.id] } });
|
|
6
|
+
function queryField(field) {
|
|
7
|
+
const base = { displayName: field.label, name: field.name, default: field.default ?? '', description: field.description?.slice(0, 500) || `API query parameter: ${field.name}` };
|
|
8
|
+
if (field.enum?.length)
|
|
9
|
+
return { ...base, type: 'options', default: field.default || field.enum[0], options: field.enum.map(value => ({ name: value, value })) };
|
|
10
|
+
if (field.type === 'boolean')
|
|
11
|
+
return { ...base, type: 'boolean', default: field.default === true };
|
|
12
|
+
if (['integer', 'number'].includes(field.type))
|
|
13
|
+
return { ...base, type: 'number', default: typeof field.default === 'number' ? field.default : 0 };
|
|
14
|
+
if (field.type === 'array')
|
|
15
|
+
return { ...base, type: 'json', default: '[]', description: 'JSON array; each value is sent as a repeated query parameter' };
|
|
16
|
+
return { ...base, type: 'string' };
|
|
17
|
+
}
|
|
18
|
+
exports.productParameters = [
|
|
19
|
+
{ displayName: 'Authentication', name: 'authentication', type: 'options', noDataExpression: true, default: 'apiKey', displayOptions: { show: { resource: ['financewolf'] } }, options: [{ name: 'API Key', value: 'apiKey' }, { name: 'Public (No Saved Results)', value: 'public' }], description: 'Public validation, generation and ruleset reads only. An invalid key never falls back to public access.' },
|
|
20
|
+
{ displayName: 'Public API Base URL', name: 'publicBaseUrl', type: 'string', default: 'https://api.ironfang.uk', displayOptions: { show: { resource: ['tools', 'financewolf'] } }, description: 'Used by public requests only; authenticated requests use the credential base URL' },
|
|
21
|
+
{ displayName: 'Public tools share per-address limits. Screenshot is limited to 20/hour/address and a shared daily ceiling. Renderwolf Screenshot is the account-based automation service.', name: 'toolsNotice', type: 'notice', default: '', displayOptions: { show: { resource: ['tools'] } } },
|
|
22
|
+
...catalog_1.operations.flatMap((op) => {
|
|
23
|
+
const fields = [{ displayName: `Permission: ${op.scope}. ${op.description}.`, name: 'operationNotice', type: 'notice', default: '', displayOptions: show(op) }];
|
|
24
|
+
for (const match of op.path.matchAll(/\{(\w+)\}/g)) {
|
|
25
|
+
const name = match[1];
|
|
26
|
+
fields.push({ displayName: name === 'id' ? 'ID' : name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/Id$/, 'ID').replace(/^./, s => s.toUpperCase()), name, type: 'string', default: '', required: true, displayOptions: show(op), description: 'Identifier from an earlier operation or the Ironfang portal' });
|
|
27
|
+
}
|
|
28
|
+
if (op.id === 'createDestination')
|
|
29
|
+
fields.push({ displayName: 'Destination Type', name: 'destinationType', type: 'options', default: 'webhook', displayOptions: show(op), options: [{ name: 'S3 Storage', value: 's3' }, { name: 'Webhook', value: 'webhook' }], description: 'S3 credentials are stored in an n8n credential, separate from the request body' });
|
|
30
|
+
const required = op.query.filter(q => q.required);
|
|
31
|
+
for (const q of required)
|
|
32
|
+
fields.push({ ...queryField(q), required: true, displayOptions: show(op) });
|
|
33
|
+
const optional = op.query.filter(q => !q.required && !['cursor', 'before', 'offset', 'limit'].includes(q.name));
|
|
34
|
+
if (optional.length)
|
|
35
|
+
fields.push({ displayName: 'Query Options', name: 'query', type: 'collection', placeholder: 'Add Query Option', default: {}, displayOptions: show(op), options: optional.map(queryField).sort((a, b) => a.displayName.localeCompare(b.displayName)) });
|
|
36
|
+
if (op.input === 'json' || op.input === 'upload')
|
|
37
|
+
fields.push({ displayName: op.input === 'upload' ? 'Form Options (JSON)' : 'Request Body (JSON)', name: 'requestBody', type: 'json', default: JSON.stringify(op.example ?? {}, null, 2), required: op.input === 'json', displayOptions: show(op), description: 'API request object. Replace example values. Nested objects and decimal strings are preserved. See the operation reference for the request schema.' });
|
|
38
|
+
if (op.input === 'xml')
|
|
39
|
+
fields.push({ displayName: 'XML Source', name: 'xmlSource', type: 'options', default: 'binary', displayOptions: show(op), options: [{ name: 'Binary Field', value: 'binary' }, { name: 'XML Text', value: 'text' }] }, { displayName: 'XML', name: 'xml', type: 'string', typeOptions: { rows: 8 }, default: '', required: true, displayOptions: { show: { ...show(op).show, xmlSource: ['text'] } } });
|
|
40
|
+
if (['xml', 'upload'].includes(op.input))
|
|
41
|
+
fields.push({ displayName: 'Input Binary Field', name: 'inputBinaryField', type: 'string', default: 'data', displayOptions: op.input === 'xml' ? { show: { ...show(op).show, xmlSource: ['binary'] } } : show(op), description: 'Binary field containing the XML or image file; works with filesystem and external binary storage' });
|
|
42
|
+
if (op.response !== 'json' || ['generateEInvoice', 'getValidationResult'].includes(op.id))
|
|
43
|
+
fields.push({ displayName: 'Output Binary Field', name: 'outputBinaryField', type: 'string', default: 'data', displayOptions: show(op), description: 'Field for the returned file. Site Preview uses this field for MP4 and adds a poster field.' });
|
|
44
|
+
if (op.idempotency)
|
|
45
|
+
fields.push({ displayName: 'Idempotency Key', name: 'idempotencyKey', type: 'string', default: '', required: ['submitJob', 'submitBatch'].includes(op.id), displayOptions: show(op), description: 'Stable reference for this business operation. Reuse it only when retrying identical input. Public Financewolf calls cannot use a key.' });
|
|
46
|
+
if (op.pagination)
|
|
47
|
+
fields.push({ displayName: 'Return All', name: 'returnAll', type: 'boolean', default: false, displayOptions: show(op), description: 'Whether to return all results or only up to a given limit' }, { displayName: 'Limit', name: 'limit', type: 'number', default: 50, typeOptions: { minValue: 1 }, displayOptions: { show: { ...show(op).show, returnAll: [false] } }, description: 'Max number of results to return' }, { displayName: op.pagination.parameter === 'offset' ? 'Start Offset' : 'Start Cursor', name: 'pageStart', type: op.pagination.parameter === 'offset' ? 'number' : 'string', default: op.pagination.parameter === 'offset' ? 0 : '', displayOptions: show(op) });
|
|
48
|
+
return fields;
|
|
49
|
+
}),
|
|
50
|
+
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderOptions = void 0;
|
|
4
|
+
exports.renderOptions = [
|
|
5
|
+
{ displayName: 'Advanced Render Options (JSON)', name: 'advancedRenderOptions', type: 'json', default: '{}', displayOptions: { show: { resource: ['renderwolf'], operation: ['screenshot', 'pdf', 'image'] } }, description: 'Additional API fields: wait_until, wait_for_selector, timeout_ms, block_ads, block_cookie_banners, hide_selectors, headers, cookies, authorization, user_agent, device_scale_factor; screenshot clip region; PDF paper_format and margin in inches; template QR variables. Do not include the source or template variables here.' },
|
|
6
|
+
{ displayName: 'Template Selection', name: 'templateSelection', type: 'options', default: 'id', displayOptions: { show: { resource: ['renderwolf'], operation: ['image'] } }, options: [{ name: 'By ID', value: 'id' }, { name: 'From List', value: 'list' }], description: 'Listing templates also requires renderwolf:templates:read' },
|
|
7
|
+
{ displayName: 'Template', name: 'selectedTemplate', type: 'resourceLocator', default: { mode: 'list', value: '' }, displayOptions: { show: { resource: ['renderwolf'], operation: ['image'], templateSelection: ['list'] } }, modes: [{ displayName: 'From List', name: 'list', type: 'list', typeOptions: { searchListMethod: 'searchTemplates', searchable: false } }] },
|
|
8
|
+
];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IExecuteFunctions, INodeExecutionData, INodeTypeDescription } from 'n8n-workflow';
|
|
2
|
+
import { credentialTest } from './credentialTest';
|
|
3
|
+
/**
|
|
4
|
+
* Ironfang node. One node per vendor: pick a product with Resource, then an
|
|
5
|
+
* operation within it. Renderwolf (https://ironfang.uk/renderwolf/docs) is the
|
|
6
|
+
* first product; later ones are added as further resources rather than as
|
|
7
|
+
* separate nodes.
|
|
8
|
+
*
|
|
9
|
+
* Render operations return binary data; sign and usage return JSON.
|
|
10
|
+
*/
|
|
11
|
+
export declare const renderwolf: {
|
|
12
|
+
description: INodeTypeDescription;
|
|
13
|
+
methods: {
|
|
14
|
+
credentialTest: {
|
|
15
|
+
ironfangApiTest: typeof credentialTest;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
|
|
19
|
+
};
|