diff --git a/package-lock.json b/package-lock.json
index 9779aa6383..f930631371 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "n8n",
"version": "0.179.0",
"dependencies": {
+ "@apidevtools/swagger-cli": "^4.0.4",
"@babel/core": "^7.14.6",
"@fontsource/open-sans": "^4.5.0",
"@fortawesome/fontawesome-svg-core": "^1.2.35",
@@ -80,11 +81,13 @@
"@types/ssh2-sftp-client": "^5.1.0",
"@types/superagent": "4.1.13",
"@types/supertest": "^2.0.11",
+ "@types/swagger-ui-express": "^4.1.3",
"@types/tmp": "^0.2.0",
"@types/uuid": "^8.3.2",
"@types/validator": "^13.7.0",
"@types/vorpal": "^1.11.0",
"@types/xml2js": "^0.4.3",
+ "@types/yamljs": "^0.2.31",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"@vue/cli-plugin-babel": "~4.5.0",
@@ -137,6 +140,7 @@
"eslint-plugin-vue": "^7.16.0",
"eventsource": "^2.0.2",
"express": "^4.16.4",
+ "express-openapi-validator": "^4.13.6",
"fast-glob": "^3.2.5",
"fflate": "^0.7.0",
"file-saver": "^2.0.2",
@@ -163,6 +167,7 @@
"jsdom": "19.0.0",
"jshint": "^2.9.7",
"json-diff": "^0.5.4",
+ "jsonschema": "^1.4.1",
"jsonwebtoken": "^8.5.1",
"jsplumb": "2.15.4",
"jwks-rsa": "~1.12.1",
@@ -200,6 +205,7 @@
"normalize-wheel": "^1.0.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
+ "openapi-types": "^10.0.0",
"p-cancelable": "^2.0.0",
"passport": "^0.5.0",
"passport-cookie": "^1.0.9",
@@ -235,6 +241,7 @@
"storybook-addon-themes": "^6.1.0",
"string-template-parser": "^1.2.6",
"supertest": "^6.2.2",
+ "swagger-ui-express": "^4.3.0",
"timeago.js": "^4.0.2",
"tmp-promise": "^3.0.2",
"trim": ">=0.0.3",
@@ -271,7 +278,8 @@
"winston": "^3.3.3",
"xlsx": "^0.17.0",
"xml2js": "^0.4.23",
- "xss": "^1.0.10"
+ "xss": "^1.0.10",
+ "yamljs": "^0.3.0"
},
"devDependencies": {
"lerna": "^3.13.1",
@@ -304,6 +312,240 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz",
+ "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==",
+ "dependencies": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.6",
+ "call-me-maybe": "^1.0.1",
+ "js-yaml": "^4.1.0"
+ }
+ },
+ "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ },
+ "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/@apidevtools/openapi-schemas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
+ "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz",
+ "integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==",
+ "dependencies": {
+ "@apidevtools/swagger-parser": "^10.0.1",
+ "chalk": "^4.1.0",
+ "js-yaml": "^3.14.0",
+ "yargs": "^15.4.1"
+ },
+ "bin": {
+ "swagger-cli": "bin/swagger-cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@apidevtools/swagger-cli/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@apidevtools/swagger-methods": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
+ "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="
+ },
+ "node_modules/@apidevtools/swagger-parser": {
+ "version": "10.0.3",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
+ "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "^9.0.6",
+ "@apidevtools/openapi-schemas": "^2.0.4",
+ "@apidevtools/swagger-methods": "^3.0.2",
+ "@jsdevtools/ono": "^7.1.3",
+ "call-me-maybe": "^1.0.1",
+ "z-schema": "^5.0.1"
+ },
+ "peerDependencies": {
+ "openapi-types": ">=7"
+ }
+ },
"node_modules/@azure/abort-controller": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz",
@@ -4200,6 +4442,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@jsdevtools/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
+ },
"node_modules/@kafkajs/confluent-schema-registry": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@kafkajs/confluent-schema-registry/-/confluent-schema-registry-1.0.6.tgz",
@@ -14794,6 +15041,14 @@
"@types/tedious": "*"
}
},
+ "node_modules/@types/multer": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
+ "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "14.17.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.27.tgz",
@@ -15113,6 +15368,15 @@
"@types/superagent": "*"
}
},
+ "node_modules/@types/swagger-ui-express": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz",
+ "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==",
+ "dependencies": {
+ "@types/express": "*",
+ "@types/serve-static": "*"
+ }
+ },
"node_modules/@types/tapable": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
@@ -15249,6 +15513,11 @@
"@types/node": "*"
}
},
+ "node_modules/@types/yamljs": {
+ "version": "0.2.31",
+ "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.31.tgz",
+ "integrity": "sha512-QcJ5ZczaXAqbVD3o8mw/mEBhRvO5UAdTtbvgwL/OgoWubvNBh6/MxLBAigtcgIFaq3shon9m3POIxQaLQt4fxQ=="
+ },
"node_modules/@types/yargs": {
"version": "15.0.14",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz",
@@ -19990,6 +20259,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY="
+ },
"node_modules/aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@@ -22745,6 +23019,18 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
+ "node_modules/busboy": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
+ "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=",
+ "dependencies": {
+ "dicer": "0.2.5",
+ "readable-stream": "1.1.x"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/byline": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
@@ -27807,6 +28093,18 @@
"kuler": "1.0.x"
}
},
+ "node_modules/dicer": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
+ "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=",
+ "dependencies": {
+ "readable-stream": "1.1.x",
+ "streamsearch": "0.1.2"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -30456,6 +30754,30 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/express-openapi-validator": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-4.13.7.tgz",
+ "integrity": "sha512-MMTwGT5RyDyQJ2IWzdLkoKwTAqnHKUOQF+9pYrKqXaVmCIhHyjM1woenO4UkfV/kRLbqauWEE4yj+Urvy4//lg==",
+ "dependencies": {
+ "@types/multer": "^1.4.7",
+ "ajv": "^6.12.6",
+ "content-type": "^1.0.4",
+ "json-schema-ref-parser": "^9.0.9",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.get": "^4.4.2",
+ "lodash.uniq": "^4.5.0",
+ "lodash.zipobject": "^4.1.3",
+ "media-typer": "^1.1.0",
+ "multer": "^1.4.4",
+ "ono": "^7.1.3",
+ "path-to-regexp": "^6.2.0"
+ }
+ },
+ "node_modules/express-openapi-validator/node_modules/path-to-regexp": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz",
+ "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg=="
+ },
"node_modules/express/node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
@@ -42374,6 +42696,17 @@
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
+ "node_modules/json-schema-ref-parser": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz",
+ "integrity": "sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "9.0.9"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -43546,6 +43879,11 @@
"resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.5.2.tgz",
"integrity": "sha512-bwKX88k2JhCV9D1vtE8+naDKlLiGrSmf8zi/Y9ivFHwbmRfA8RxS/aVJ+sIht2XOwqoNr4xUPUkGZpc1sHFEKg=="
},
+ "node_modules/lodash.zipobject": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz",
+ "integrity": "sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg="
+ },
"node_modules/log-symbols": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz",
@@ -44272,7 +44610,7 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
- "node": ">= 0.6"
+ "node": ">= 0.8"
}
},
"node_modules/memfs": {
@@ -45124,6 +45462,24 @@
"node": ">=6"
}
},
+ "node_modules/multer": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4.tgz",
+ "integrity": "sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^0.2.11",
+ "concat-stream": "^1.5.2",
+ "mkdirp": "^0.5.4",
+ "object-assign": "^4.1.1",
+ "on-finished": "^2.3.0",
+ "type-is": "^1.6.4",
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
"node_modules/multicast-dns": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz",
@@ -46780,6 +47136,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==",
+ "dependencies": {
+ "@jsdevtools/ono": "7.1.3"
+ }
+ },
"node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
@@ -46795,6 +47159,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/openapi-types": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-10.0.0.tgz",
+ "integrity": "sha512-Y8xOCT2eiKGYDzMW9R4x5cmfc3vGaaI4EL2pwhDmodWw1HlK18YcZ4uJxc7Rdp7/gGzAygzH9SXr6GKYIXbRcQ=="
+ },
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -54058,6 +54427,14 @@
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
"integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ=="
},
+ "node_modules/streamsearch": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
+ "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
@@ -54634,6 +55011,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swagger-ui-dist": {
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.10.3.tgz",
+ "integrity": "sha512-eR4vsd7sYo0Sx7ZKRP5Z04yij7JkNmIlUQfrDQgC+xO5ABYx+waabzN+nDsQTLAJ4Z04bjkRd8xqkJtbxr3G7w=="
+ },
+ "node_modules/swagger-ui-express": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz",
+ "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==",
+ "dependencies": {
+ "swagger-ui-dist": ">=4.1.3"
+ },
+ "engines": {
+ "node": ">= v0.10.32"
+ },
+ "peerDependencies": {
+ "express": ">=4.0.0"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -56496,6 +56892,14 @@
"node": ">= 0.6"
}
},
+ "node_modules/type-is/node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -60843,6 +61247,19 @@
"node": ">= 6"
}
},
+ "node_modules/yamljs": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz",
+ "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "glob": "^7.0.5"
+ },
+ "bin": {
+ "json2yaml": "bin/json2yaml",
+ "yaml2json": "bin/yaml2json"
+ }
+ },
"node_modules/yargonaut": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz",
@@ -61116,6 +61533,31 @@
"node": ">=10"
}
},
+ "node_modules/z-schema": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.3.tgz",
+ "integrity": "sha512-sGvEcBOTNum68x9jCpCVGPFJ6mWnkD0YxOcddDlJHRx3tKdB2q8pCHExMVZo/AV/6geuVJXG7hljDaWG8+5GDw==",
+ "dependencies": {
+ "lodash.get": "^4.4.2",
+ "lodash.isequal": "^4.5.0",
+ "validator": "^13.7.0"
+ },
+ "bin": {
+ "z-schema": "bin/z-schema"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "commander": "^2.20.3"
+ }
+ },
+ "node_modules/z-schema/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "optional": true
+ },
"node_modules/zwitch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
@@ -61146,6 +61588,181 @@
"@jridgewell/trace-mapping": "^0.3.9"
}
},
+ "@apidevtools/json-schema-ref-parser": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz",
+ "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==",
+ "requires": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.6",
+ "call-me-maybe": "^1.0.1",
+ "js-yaml": "^4.1.0"
+ },
+ "dependencies": {
+ "argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ },
+ "js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "requires": {
+ "argparse": "^2.0.1"
+ }
+ }
+ }
+ },
+ "@apidevtools/openapi-schemas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
+ "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="
+ },
+ "@apidevtools/swagger-cli": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz",
+ "integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==",
+ "requires": {
+ "@apidevtools/swagger-parser": "^10.0.1",
+ "chalk": "^4.1.0",
+ "js-yaml": "^3.14.0",
+ "yargs": "^15.4.1"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "requires": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
+ },
+ "locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "requires": {
+ "p-locate": "^4.1.0"
+ }
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "requires": {
+ "p-limit": "^2.2.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "requires": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ }
+ },
+ "yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "requires": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ }
+ }
+ }
+ },
+ "@apidevtools/swagger-methods": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
+ "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="
+ },
+ "@apidevtools/swagger-parser": {
+ "version": "10.0.3",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
+ "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
+ "requires": {
+ "@apidevtools/json-schema-ref-parser": "^9.0.6",
+ "@apidevtools/openapi-schemas": "^2.0.4",
+ "@apidevtools/swagger-methods": "^3.0.2",
+ "@jsdevtools/ono": "^7.1.3",
+ "call-me-maybe": "^1.0.1",
+ "z-schema": "^5.0.1"
+ }
+ },
"@azure/abort-controller": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz",
@@ -64017,6 +64634,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "@jsdevtools/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
+ },
"@kafkajs/confluent-schema-registry": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@kafkajs/confluent-schema-registry/-/confluent-schema-registry-1.0.6.tgz",
@@ -72894,6 +73516,14 @@
"@types/tedious": "*"
}
},
+ "@types/multer": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
+ "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
+ "requires": {
+ "@types/express": "*"
+ }
+ },
"@types/node": {
"version": "14.17.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.27.tgz",
@@ -73210,6 +73840,15 @@
"@types/superagent": "*"
}
},
+ "@types/swagger-ui-express": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz",
+ "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==",
+ "requires": {
+ "@types/express": "*",
+ "@types/serve-static": "*"
+ }
+ },
"@types/tapable": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz",
@@ -73345,6 +73984,11 @@
"@types/node": "*"
}
},
+ "@types/yamljs": {
+ "version": "0.2.31",
+ "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.31.tgz",
+ "integrity": "sha512-QcJ5ZczaXAqbVD3o8mw/mEBhRvO5UAdTtbvgwL/OgoWubvNBh6/MxLBAigtcgIFaq3shon9m3POIxQaLQt4fxQ=="
+ },
"@types/yargs": {
"version": "15.0.14",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz",
@@ -77050,6 +77694,11 @@
"buffer-equal": "^1.0.0"
}
},
+ "append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY="
+ },
"aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@@ -79255,6 +79904,15 @@
}
}
},
+ "busboy": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
+ "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=",
+ "requires": {
+ "dicer": "0.2.5",
+ "readable-stream": "1.1.x"
+ }
+ },
"byline": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
@@ -83213,6 +83871,15 @@
"kuler": "1.0.x"
}
},
+ "dicer": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
+ "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=",
+ "requires": {
+ "readable-stream": "1.1.x",
+ "streamsearch": "0.1.2"
+ }
+ },
"diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -85190,6 +85857,32 @@
}
}
},
+ "express-openapi-validator": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-4.13.7.tgz",
+ "integrity": "sha512-MMTwGT5RyDyQJ2IWzdLkoKwTAqnHKUOQF+9pYrKqXaVmCIhHyjM1woenO4UkfV/kRLbqauWEE4yj+Urvy4//lg==",
+ "requires": {
+ "@types/multer": "^1.4.7",
+ "ajv": "^6.12.6",
+ "content-type": "^1.0.4",
+ "json-schema-ref-parser": "^9.0.9",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.get": "^4.4.2",
+ "lodash.uniq": "^4.5.0",
+ "lodash.zipobject": "^4.1.3",
+ "media-typer": "^1.1.0",
+ "multer": "^1.4.4",
+ "ono": "^7.1.3",
+ "path-to-regexp": "^6.2.0"
+ },
+ "dependencies": {
+ "path-to-regexp": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz",
+ "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg=="
+ }
+ }
+ },
"ext": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz",
@@ -94285,6 +94978,14 @@
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
+ "json-schema-ref-parser": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz",
+ "integrity": "sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==",
+ "requires": {
+ "@apidevtools/json-schema-ref-parser": "9.0.9"
+ }
+ },
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -95286,6 +95987,11 @@
"resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.5.2.tgz",
"integrity": "sha512-bwKX88k2JhCV9D1vtE8+naDKlLiGrSmf8zi/Y9ivFHwbmRfA8RxS/aVJ+sIht2XOwqoNr4xUPUkGZpc1sHFEKg=="
},
+ "lodash.zipobject": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz",
+ "integrity": "sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg="
+ },
"log-symbols": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz",
@@ -96555,6 +97261,21 @@
"tedious": "^6.7.1"
}
},
+ "multer": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4.tgz",
+ "integrity": "sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==",
+ "requires": {
+ "append-field": "^1.0.0",
+ "busboy": "^0.2.11",
+ "concat-stream": "^1.5.2",
+ "mkdirp": "^0.5.4",
+ "object-assign": "^4.1.1",
+ "on-finished": "^2.3.0",
+ "type-is": "^1.6.4",
+ "xtend": "^4.0.0"
+ }
+ },
"multicast-dns": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz",
@@ -97908,6 +98629,14 @@
"mimic-fn": "^2.1.0"
}
},
+ "ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==",
+ "requires": {
+ "@jsdevtools/ono": "7.1.3"
+ }
+ },
"open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
@@ -97917,6 +98646,11 @@
"is-wsl": "^2.1.1"
}
},
+ "openapi-types": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-10.0.0.tgz",
+ "integrity": "sha512-Y8xOCT2eiKGYDzMW9R4x5cmfc3vGaaI4EL2pwhDmodWw1HlK18YcZ4uJxc7Rdp7/gGzAygzH9SXr6GKYIXbRcQ=="
+ },
"opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -103691,6 +104425,11 @@
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
"integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ=="
},
+ "streamsearch": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
+ "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
+ },
"strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
@@ -104126,6 +104865,19 @@
}
}
},
+ "swagger-ui-dist": {
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.10.3.tgz",
+ "integrity": "sha512-eR4vsd7sYo0Sx7ZKRP5Z04yij7JkNmIlUQfrDQgC+xO5ABYx+waabzN+nDsQTLAJ4Z04bjkRd8xqkJtbxr3G7w=="
+ },
+ "swagger-ui-express": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz",
+ "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==",
+ "requires": {
+ "swagger-ui-dist": ">=4.1.3"
+ }
+ },
"symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -105540,6 +106292,13 @@
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
+ },
+ "dependencies": {
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+ }
}
},
"typedarray": {
@@ -108911,6 +109670,15 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
},
+ "yamljs": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz",
+ "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==",
+ "requires": {
+ "argparse": "^1.0.7",
+ "glob": "^7.0.5"
+ }
+ },
"yargonaut": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz",
@@ -109123,6 +109891,25 @@
"toposort": "^2.0.2"
}
},
+ "z-schema": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.3.tgz",
+ "integrity": "sha512-sGvEcBOTNum68x9jCpCVGPFJ6mWnkD0YxOcddDlJHRx3tKdB2q8pCHExMVZo/AV/6geuVJXG7hljDaWG8+5GDw==",
+ "requires": {
+ "commander": "^2.20.3",
+ "lodash.get": "^4.4.2",
+ "lodash.isequal": "^4.5.0",
+ "validator": "^13.7.0"
+ },
+ "dependencies": {
+ "commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "optional": true
+ }
+ }
+ },
"zwitch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts
index c39ed271fa..d422776f55 100644
--- a/packages/cli/config/schema.ts
+++ b/packages/cli/config/schema.ts
@@ -582,6 +582,21 @@ export const schema = {
},
},
+ publicApi: {
+ disabled: {
+ format: Boolean,
+ default: false,
+ env: 'N8N_PUBLIC_API_DISABLED',
+ doc: 'Whether to disable the Public API',
+ },
+ path: {
+ format: String,
+ default: 'api',
+ env: 'N8N_PUBLIC_API_ENDPOINT',
+ doc: 'Path for the public api endpoints',
+ },
+ },
+
workflowTagsDisabled: {
format: Boolean,
default: false,
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 90d0478e06..42e9589a26 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -20,7 +20,7 @@
},
"scripts": {
"build": "run-script-os",
- "build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email",
+ "build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email && cp ./src/PublicApi/swaggerTheme.css ./dist/src/PublicApi/swaggerTheme.css; find ./src/PublicApi -iname 'openapi.yml' -exec swagger-cli bundle {} --type yaml --outfile \"./dist\"/{} \\;",
"build:windows": "tsc && xcopy /E /I src\\UserManagement\\email\\templates dist\\src\\UserManagement\\email\\templates",
"dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"",
"format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write",
@@ -37,7 +37,7 @@
"test:postgres:alt-schema": "export DB_POSTGRESDB_SCHEMA=alt_schema; npm run test:postgres",
"test:mysql": "export N8N_LOG_LEVEL=silent; export DB_TYPE=mysqldb; jest",
"watch": "tsc --watch",
- "typeorm": "ts-node ../../node_modules/typeorm/cli.js"
+ "typeorm": "ts-node -T ../../node_modules/typeorm/cli.js"
},
"bin": {
"n8n": "./bin/n8n"
@@ -72,8 +72,7 @@
"@types/express": "^4.17.6",
"@types/jest": "^27.4.0",
"@types/localtunnel": "^1.9.0",
- "@types/lodash.get": "^4.4.6",
- "@types/lodash.merge": "^4.6.6",
+ "@types/lodash": "^4.14.182",
"@types/node": "14.17.27",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
@@ -95,11 +94,14 @@
"typescript": "~4.6.0"
},
"dependencies": {
+ "@apidevtools/swagger-cli": "4.0.0",
"@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2",
"@rudderstack/rudder-sdk-node": "1.0.6",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.5.2",
+ "@types/swagger-ui-express": "^4.1.3",
+ "@types/yamljs": "^0.2.31",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
@@ -117,16 +119,17 @@
"csrf": "^3.1.0",
"dotenv": "^8.0.0",
"express": "^4.16.4",
+ "express-openapi-validator": "^4.13.6",
"fast-glob": "^3.2.5",
"flatted": "^3.2.4",
"google-timezones-json": "^1.0.2",
"inquirer": "^7.0.1",
"json-diff": "^0.5.4",
+ "jsonschema": "^1.4.1",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "~1.12.1",
"localtunnel": "^2.0.0",
- "lodash.get": "^4.4.2",
- "lodash.merge": "^4.6.2",
+ "lodash": "^4.17.21",
"mysql2": "~2.3.0",
"n8n-core": "~0.120.0",
"n8n-editor-ui": "~0.146.0",
@@ -135,6 +138,7 @@
"nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
+ "openapi-types": "^10.0.0",
"p-cancelable": "^2.0.0",
"passport": "^0.5.0",
"passport-cookie": "^1.0.9",
@@ -144,10 +148,12 @@
"request-promise-native": "^1.0.7",
"sqlite3": "^5.0.2",
"sse-channel": "^3.1.1",
+ "swagger-ui-express": "^4.3.0",
"tslib": "1.14.1",
"typeorm": "0.2.30",
"uuid": "^8.3.0",
"validator": "13.7.0",
- "winston": "^3.3.3"
+ "winston": "^3.3.3",
+ "yamljs": "^0.3.0"
}
}
diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts
index 74661181ee..3a14fd5b32 100644
--- a/packages/cli/src/Interfaces.ts
+++ b/packages/cli/src/Interfaces.ts
@@ -178,6 +178,8 @@ export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb {
export type DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite';
export type SaveExecutionDataType = 'all' | 'none';
+export type ExecutionDataFieldFormat = 'empty' | 'flattened' | 'json';
+
export interface IExecutionBase {
id?: number | string;
mode: WorkflowExecuteMode;
@@ -229,6 +231,19 @@ export interface IExecutionFlattedResponse extends IExecutionFlatted {
retryOf?: string;
}
+export interface IExecutionResponseApi {
+ id: number | string;
+ mode: WorkflowExecuteMode;
+ startedAt: Date;
+ stoppedAt?: Date;
+ workflowId?: string;
+ finished: boolean;
+ retryOf?: number | string;
+ retrySuccessId?: number | string;
+ data?: string; // Just that we can remove it
+ waitTill?: Date | null;
+ workflowData: IWorkflowBase;
+}
export interface IExecutionsListResponse {
count: number;
// results: IExecutionShortResponse[];
@@ -363,16 +378,20 @@ export interface IInternalHooksClass {
firstWorkflowCreatedAt?: Date,
): Promise;
onPersonalizationSurveySubmitted(userId: string, answers: Record): Promise;
- onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise;
- onWorkflowDeleted(userId: string, workflowId: string): Promise;
- onWorkflowSaved(userId: string, workflow: IWorkflowBase): Promise;
+ onWorkflowCreated(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise;
+ onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise;
+ onWorkflowSaved(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise;
onWorkflowPostExecute(
executionId: string,
workflow: IWorkflowBase,
runData?: IRun,
userId?: string,
): Promise;
- onUserDeletion(userId: string, userDeletionData: ITelemetryUserDeletionData): Promise;
+ onUserDeletion(
+ userId: string,
+ userDeletionData: ITelemetryUserDeletionData,
+ publicApi: boolean,
+ ): Promise;
onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise;
onUserReinvite(userReinviteData: { user_id: string; target_user_id: string }): Promise;
onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise;
@@ -468,6 +487,7 @@ export interface IN8nUISettings {
personalizationSurveyEnabled: boolean;
defaultLocale: string;
userManagement: IUserManagementSettings;
+ publicApi: IPublicApiSettings;
workflowTagsDisabled: boolean;
logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent';
hiringBannerEnabled: boolean;
@@ -495,6 +515,11 @@ export interface IUserManagementSettings {
showSetupOnFirstLoad?: boolean;
smtpSetup: boolean;
}
+export interface IPublicApiSettings {
+ enabled: boolean;
+ latestVersion: number;
+ path: string;
+}
export interface IPackageVersions {
cli: string;
diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts
index 2bfe4cb382..12d6d009a9 100644
--- a/packages/cli/src/InternalHooks.ts
+++ b/packages/cli/src/InternalHooks.ts
@@ -64,24 +64,30 @@ export class InternalHooksClass implements IInternalHooksClass {
);
}
- async onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise {
+ async onWorkflowCreated(
+ userId: string,
+ workflow: IWorkflowBase,
+ publicApi: boolean,
+ ): Promise {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
return this.telemetry.track('User created workflow', {
user_id: userId,
workflow_id: workflow.id,
node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
+ public_api: publicApi,
});
}
- async onWorkflowDeleted(userId: string, workflowId: string): Promise {
+ async onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise {
return this.telemetry.track('User deleted workflow', {
user_id: userId,
workflow_id: workflowId,
+ public_api: publicApi,
});
}
- async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise {
+ async onWorkflowSaved(userId: string, workflow: IWorkflowDb, publicApi: boolean): Promise {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
const notesCount = Object.keys(nodeGraph.notes).length;
@@ -98,6 +104,7 @@ export class InternalHooksClass implements IInternalHooksClass {
notes_count_non_overlapping: notesCount - overlappingCount,
version_cli: this.versionCli,
num_tags: workflow.tags?.length ?? 0,
+ public_api: publicApi,
});
}
@@ -215,21 +222,73 @@ export class InternalHooksClass implements IInternalHooksClass {
async onUserDeletion(
userId: string,
userDeletionData: ITelemetryUserDeletionData,
+ publicApi: boolean,
): Promise {
- return this.telemetry.track('User deleted user', { ...userDeletionData, user_id: userId });
+ return this.telemetry.track('User deleted user', {
+ ...userDeletionData,
+ user_id: userId,
+ public_api: publicApi,
+ });
}
- async onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise {
+ async onUserInvite(userInviteData: {
+ user_id: string;
+ target_user_id: string[];
+ public_api: boolean;
+ }): Promise {
return this.telemetry.track('User invited new user', userInviteData);
}
async onUserReinvite(userReinviteData: {
user_id: string;
target_user_id: string;
+ public_api: boolean;
}): Promise {
return this.telemetry.track('User resent new user invite email', userReinviteData);
}
+ async onUserRetrievedUser(userRetrievedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('User retrieved user', userRetrievedData);
+ }
+
+ async onUserRetrievedAllUsers(userRetrievedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('User retrieved all users', userRetrievedData);
+ }
+
+ async onUserRetrievedExecution(userRetrievedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('User retrieved execution', userRetrievedData);
+ }
+
+ async onUserRetrievedAllExecutions(userRetrievedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('User retrieved all executions', userRetrievedData);
+ }
+
+ async onUserRetrievedWorkflow(userRetrievedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('User retrieved workflow', userRetrievedData);
+ }
+
+ async onUserRetrievedAllWorkflows(userRetrievedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('User retrieved all workflows', userRetrievedData);
+ }
+
async onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise {
return this.telemetry.track('User changed personal settings', userUpdateData);
}
@@ -248,13 +307,37 @@ export class InternalHooksClass implements IInternalHooksClass {
async onUserTransactionalEmail(userTransactionalEmailData: {
user_id: string;
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
+ public_api: boolean;
}): Promise {
return this.telemetry.track(
- 'Instance sent transactional email to user',
+ 'Instance sent transacptional email to user',
userTransactionalEmailData,
);
}
+ async onUserInvokedApi(userInvokedApiData: {
+ user_id: string;
+ path: string;
+ method: string;
+ api_version: string;
+ }): Promise {
+ return this.telemetry.track('User invoked API', userInvokedApiData);
+ }
+
+ async onApiKeyDeleted(apiKeyDeletedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('API key deleted', apiKeyDeletedData);
+ }
+
+ async onApiKeyCreated(apiKeyCreatedData: {
+ user_id: string;
+ public_api: boolean;
+ }): Promise {
+ return this.telemetry.track('API key created', apiKeyCreatedData);
+ }
+
async onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise {
return this.telemetry.track(
'User requested password reset while logged out',
@@ -273,6 +356,7 @@ export class InternalHooksClass implements IInternalHooksClass {
async onEmailFailed(failedEmailData: {
user_id: string;
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
+ public_api: boolean;
}): Promise {
return this.telemetry.track(
'Instance failed to send transactional email to user',
diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts
new file mode 100644
index 0000000000..5a8585148c
--- /dev/null
+++ b/packages/cli/src/PublicApi/index.ts
@@ -0,0 +1,126 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable no-restricted-syntax */
+/* eslint-disable @typescript-eslint/naming-convention */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable import/no-cycle */
+import express, { Router } from 'express';
+import * as OpenApiValidator from 'express-openapi-validator';
+import { HttpError } from 'express-openapi-validator/dist/framework/types';
+import fs from 'fs/promises';
+import { OpenAPIV3 } from 'openapi-types';
+import path from 'path';
+import * as swaggerUi from 'swagger-ui-express';
+import validator from 'validator';
+import * as YAML from 'yamljs';
+import { Db, InternalHooksManager } from '..';
+import config from '../../config';
+import { getInstanceBaseUrl } from '../UserManagement/UserManagementHelper';
+
+function createApiRouter(
+ version: string,
+ openApiSpecPath: string,
+ hanldersDirectory: string,
+ swaggerThemeCss: string,
+ publicApiEndpoint: string,
+): Router {
+ const n8nPath = config.getEnv('path');
+ const swaggerDocument = YAML.load(openApiSpecPath) as swaggerUi.JsonObject;
+ // add the server depeding on the config so the user can interact with the API
+ // from the swagger UI
+ swaggerDocument.server = [
+ {
+ url: `${getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`,
+ },
+ ];
+ const apiController = express.Router();
+ apiController.use(
+ `/${publicApiEndpoint}/${version}/docs`,
+ swaggerUi.serveFiles(swaggerDocument),
+ swaggerUi.setup(swaggerDocument, {
+ customCss: swaggerThemeCss,
+ customSiteTitle: 'n8n Public API UI',
+ customfavIcon: `${n8nPath}favicon.ico`,
+ }),
+ );
+ apiController.use(`/${publicApiEndpoint}/${version}`, express.json());
+ apiController.use(
+ `/${publicApiEndpoint}/${version}`,
+ OpenApiValidator.middleware({
+ apiSpec: openApiSpecPath,
+ operationHandlers: hanldersDirectory,
+ validateRequests: true,
+ validateApiSpec: true,
+ formats: [
+ {
+ name: 'email',
+ type: 'string',
+ validate: (email: string) => validator.isEmail(email),
+ },
+ {
+ name: 'identifier',
+ type: 'string',
+ validate: (identifier: string) =>
+ validator.isUUID(identifier) || validator.isEmail(identifier),
+ },
+ ],
+ validateSecurity: {
+ handlers: {
+ ApiKeyAuth: async (
+ req: express.Request,
+ _scopes: unknown,
+ schema: OpenAPIV3.ApiKeySecurityScheme,
+ ): Promise => {
+ const apiKey = req.headers[schema.name.toLowerCase()];
+ const user = await Db.collections.User?.findOne({
+ where: {
+ apiKey,
+ },
+ relations: ['globalRole'],
+ });
+
+ if (!user) {
+ return false;
+ }
+
+ void InternalHooksManager.getInstance().onUserInvokedApi({
+ user_id: user.id,
+ path: req.path,
+ method: req.method,
+ api_version: version,
+ });
+
+ req.user = user;
+
+ return true;
+ },
+ },
+ },
+ }),
+ );
+ apiController.use(
+ (error: HttpError, req: express.Request, res: express.Response, next: express.NextFunction) => {
+ return res.status(error.status || 400).json({
+ message: error.message,
+ });
+ },
+ );
+ return apiController;
+}
+
+export const loadPublicApiVersions = async (
+ publicApiEndpoint: string,
+): Promise<{ apiRouters: express.Router[]; apiLatestVersion: number }> => {
+ const swaggerThemePath = path.join(__dirname, 'swaggerTheme.css');
+ const folders = await fs.readdir(__dirname);
+ const css = (await fs.readFile(swaggerThemePath)).toString();
+ const versions = folders.filter((folderName) => folderName.startsWith('v'));
+ const apiRouters: express.Router[] = [];
+ for (const version of versions) {
+ const openApiPath = path.join(__dirname, version, 'openapi.yml');
+ apiRouters.push(createApiRouter(version, openApiPath, __dirname, css, publicApiEndpoint));
+ }
+ return {
+ apiRouters,
+ apiLatestVersion: Number(versions.pop()?.charAt(1)) ?? 1,
+ };
+};
diff --git a/packages/cli/src/PublicApi/swaggerTheme.css b/packages/cli/src/PublicApi/swaggerTheme.css
new file mode 100644
index 0000000000..b7682cc8fa
--- /dev/null
+++ b/packages/cli/src/PublicApi/swaggerTheme.css
@@ -0,0 +1,26 @@
+.swagger-ui .info .title small:last-child {
+ background-color: #ff6d5a !important
+}
+
+.swagger-ui .topbar {
+ background-color: #fff;
+}
+
+.swagger-ui img {
+ content: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI0IiBoZWlnaHQ9IjI4IiB2aWV3Qm94PSIwIDAgMTI0IDI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGRhdGEtdi03OGMyN2E5YT0iIj48dGl0bGUgZGF0YS12LTc4YzI3YTlhPSIiPm44PC90aXRsZT4gPGcgaWQ9Im5hdi1tZW51LShWMSkiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGRhdGEtdi03OGMyN2E5YT0iIj48ZyBpZD0ibmF2LW1lbnUtKHYxKSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEyMC4wMDAwMDAsIC0xMTYuMDAwMDAwKSIgZmlsbC1ydWxlPSJub256ZXJvIiBkYXRhLXYtNzhjMjdhOWE9IiIgZmlsbD0ibm9uZSI+PGcgaWQ9Im44IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjAuMDAwMDAwLCAxMTYuMDAwMDAwKSIgZGF0YS12LTc4YzI3YTlhPSIiIGZpbGw9Im5vbmUiPjxwYXRoIGQ9Ik00OC43Mzg0OTA2LDAuMTkwMTg4Njc5IEM0Ni4xNTc3MzU4LDAuMTkwMTg4Njc5IDQzLjk4NjQxNTEsMS45Njc5MjQ1MyA0My4zNzM1ODQ5LDQuMzYxMTMyMDggTDM1LjY1MjQ1MjgsNC4zNjExMzIwOCBDMzIuNjIyNjQxNSw0LjM2MTEzMjA4IDMwLjE1ODExMzIsNi44MjU2NjAzOCAzMC4xNTgxMTMyLDkuODU1NDcxNyBDMzAuMTU4MTEzMiwxMS4zNjkwNTY2IDI4LjkyNzE2OTgsMTIuNjAyNjQxNSAyNy40MTA5NDM0LDEyLjYwMjY0MTUgTDI2LjMwOTQzNCwxMi42MDI2NDE1IEMyNS42OTY2MDM4LDEwLjIwOTQzNCAyMy41Mjc5MjQ1LDguNDMxNjk4MTEgMjAuOTQ0NTI4Myw4LjQzMTY5ODExIEMxOC4zNjM3NzM2LDguNDMxNjk4MTEgMTYuMTkyNDUyOCwxMC4yMDk0MzQgMTUuNTc5NjIyNiwxMi42MDI2NDE1IEwxMS4xNjgzMDE5LDEyLjYwMjY0MTUgQzEwLjU1NTQ3MTcsMTAuMjA5NDM0IDguMzg2NzkyNDUsOC40MzE2OTgxMSA1LjgwMzM5NjIzLDguNDMxNjk4MTEgQzIuNzQ3MTY5ODEsOC40MzE2OTgxMSAwLjI1ODg2NzkyNSwxMC45MTczNTg1IDAuMjU4ODY3OTI1LDEzLjk3NjIyNjQgQzAuMjU4ODY3OTI1LDE3LjAzMjQ1MjggMi43NDQ1MjgzLDE5LjUyMDc1NDcgNS44MDMzOTYyMywxOS41MjA3NTQ3IEM4LjM4NDE1MDk0LDE5LjUyMDc1NDcgMTAuNTU1NDcxNywxNy43NDMwMTg5IDExLjE2ODMwMTksMTUuMzQ5ODExMyBMMTUuNTg0OTA1NywxNS4zNDk4MTEzIEMxNi4xOTc3MzU4LDE3Ljc0MzAxODkgMTguMzY2NDE1MSwxOS41MjA3NTQ3IDIwLjk0OTgxMTMsMTkuNTIwNzU0NyBDMjMuNTE0NzE3LDE5LjUyMDc1NDcgMjUuNjcwMTg4NywxNy43Njk0MzQgMjYuMzAxNTA5NCwxNS40IEwyNy40MTM1ODQ5LDE1LjQgQzI4LjkyNzE2OTgsMTUuNCAzMC4xNjA3NTQ3LDE2LjYzMDk0MzQgMzAuMTYwNzU0NywxOC4xNDcxNjk4IEMzMC4xNjA3NTQ3LDIxLjE3Njk4MTEgMzIuNjI1MjgzLDIzLjY0MTUwOTQgMzUuNjU1MDk0MywyMy42NDE1MDk0IEwzNy40NTM5NjIzLDIzLjY0MTUwOTQgQzM4LjA2Njc5MjUsMjYuMDM0NzE3IDQwLjIzNTQ3MTcsMjcuODEyNDUyOCA0Mi44MTg4Njc5LDI3LjgxMjQ1MjggQzQ1Ljg3NTA5NDMsMjcuODEyNDUyOCA0OC4zNjMzOTYyLDI1LjMyNjc5MjUgNDguMzYzMzk2MiwyMi4yNjc5MjQ1IEM0OC4zNjMzOTYyLDE5LjIxMTY5ODEgNDUuODc3NzM1OCwxNi43MjMzOTYyIDQyLjgxODg2NzksMTYuNzIzMzk2MiBDNDAuMjM4MTEzMiwxNi43MjMzOTYyIDM4LjA2Njc5MjUsMTguNTAxMTMyMSAzNy40NTM5NjIzLDIwLjg5NDMzOTYgTDM1LjY1NTA5NDMsMjAuODk0MzM5NiBDMzQuMTQxNTA5NCwyMC44OTQzMzk2IDMyLjkwNzkyNDUsMTkuNjYzMzk2MiAzMi45MDc5MjQ1LDE4LjE0NzE2OTggQzMyLjkwNzkyNDUsMTYuNDkzNTg0OSAzMi4xNjgzMDE5LDE1LjAwOTA1NjYgMzEuMDA4Njc5MiwxNC4wMDI2NDE1IEMzMi4xNzA5NDM0LDEyLjk5MzU4NDkgMzIuOTA3OTI0NSwxMS41MTE2OTgxIDMyLjkwNzkyNDUsOS44NTgxMTMyMSBDMzIuOTA3OTI0NSw4LjM0NDUyODMgMzQuMTM4ODY3OSw3LjExMDk0MzQgMzUuNjU1MDk0Myw3LjExMDk0MzQgTDQzLjM3NjIyNjQsNy4xMTA5NDM0IEM0My45ODkwNTY2LDkuNTA0MTUwOTQgNDYuMTU3NzM1OCwxMS4yODE4ODY4IDQ4Ljc0MTEzMjEsMTEuMjgxODg2OCBDNTEuNzk3MzU4NSwxMS4yODE4ODY4IDU0LjI4NTY2MDQsOC43OTYyMjY0MiA1NC4yODU2NjA0LDUuNzM3MzU4NDkgQzU0LjI4MzAxODksMi42Nzg0OTA1NyA1MS43OTQ3MTcsMC4xOTAxODg2NzkgNDguNzM4NDkwNiwwLjE5MDE4ODY3OSBaIE01LjgwODY3OTI1LDE2Ljc3MDk0MzQgQzQuMjY2MDM3NzQsMTYuNzcwOTQzNCAzLjAxMTMyMDc1LDE1LjUxNjIyNjQgMy4wMTEzMjA3NSwxMy45NzM1ODQ5IEMzLjAxMTMyMDc1LDEyLjQzMDk0MzQgNC4yNjYwMzc3NCwxMS4xNzYyMjY0IDUuODA4Njc5MjUsMTEuMTc2MjI2NCBDNy4zNTEzMjA3NSwxMS4xNzYyMjY0IDguNjA2MDM3NzQsMTIuNDMwOTQzNCA4LjYwNjAzNzc0LDEzLjk3MzU4NDkgQzguNjA2MDM3NzQsMTUuNTE2MjI2NCA3LjM1MTMyMDc1LDE2Ljc3MDk0MzQgNS44MDg2NzkyNSwxNi43NzA5NDM0IFogTTIwLjk0OTgxMTMsMTYuNzcwOTQzNCBDMTkuNDA3MTY5OCwxNi43NzA5NDM0IDE4LjE1MjQ1MjgsMTUuNTE2MjI2NCAxOC4xNTI0NTI4LDEzLjk3MzU4NDkgQzE4LjE1MjQ1MjgsMTIuNDMwOTQzNCAxOS40MDcxNjk4LDExLjE3NjIyNjQgMjAuOTQ5ODExMywxMS4xNzYyMjY0IEMyMi40OTI0NTI4LDExLjE3NjIyNjQgMjMuNzQ3MTY5OCwxMi40MzA5NDM0IDIzLjc0NzE2OTgsMTMuOTczNTg0OSBDMjMuNzQ3MTY5OCwxNS41MTYyMjY0IDIyLjQ5MjQ1MjgsMTYuNzcwOTQzNCAyMC45NDk4MTEzLDE2Ljc3MDk0MzQgWiBNNDIuODE2MjI2NCwxOS40Njc5MjQ1IEM0NC4zNTg4Njc5LDE5LjQ2NzkyNDUgNDUuNjEzNTg0OSwyMC43MjI2NDE1IDQ1LjYxMzU4NDksMjIuMjY1MjgzIEM0NS42MTM1ODQ5LDIzLjgwNzkyNDUgNDQuMzU4ODY3OSwyNS4wNjI2NDE1IDQyLjgxNjIyNjQsMjUuMDYyNjQxNSBDNDEuMjczNTg0OSwyNS4wNjI2NDE1IDQwLjAxODg2NzksMjMuODA3OTI0NSA0MC4wMTg4Njc5LDIyLjI2NTI4MyBDNDAuMDIxNTA5NCwyMC43MjI2NDE1IDQxLjI3NjIyNjQsMTkuNDY3OTI0NSA0Mi44MTYyMjY0LDE5LjQ2NzkyNDUgWiBNNDguNzM4NDkwNiw4LjUzMjA3NTQ3IEM0Ny4xOTU4NDkxLDguNTMyMDc1NDcgNDUuOTQxMTMyMSw3LjI3NzM1ODQ5IDQ1Ljk0MTEzMjEsNS43MzQ3MTY5OCBDNDUuOTQxMTMyMSw0LjE5MjA3NTQ3IDQ3LjE5NTg0OTEsMi45MzczNTg0OSA0OC43Mzg0OTA2LDIuOTM3MzU4NDkgQzUwLjI4MTEzMjEsMi45MzczNTg0OSA1MS41MzU4NDkxLDQuMTkyMDc1NDcgNTEuNTM1ODQ5MSw1LjczNDcxNjk4IEM1MS41MzU4NDkxLDcuMjc3MzU4NDkgNTAuMjgxMTMyMSw4LjUzMjA3NTQ3IDQ4LjczODQ5MDYsOC41MzIwNzU0NyBaIiBpZD0iU2hhcGUiIGZpbGw9IiNGRjZENUEiIGRhdGEtdi03OGMyN2E5YT0iIj48L3BhdGg+IDxnIGlkPSJHcm91cCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTYuNTI4MzAyLCA1LjU0NzE3MCkiIGZpbGw9IiMzODRENUIiIGRhdGEtdi03OGMyN2E5YT0iIj48cGF0aCBkPSJNMS41Nzk2MjI2NCw3LjA5NzczNTg1IEMxLjU3OTYyMjY0LDYuNzY0OTA1NjYgMS40MDI2NDE1MSw2LjYwOTA1NjYgMS4wOTA5NDM0LDYuNjA5MDU2NiBMMC4xNzk2MjI2NDIsNi42MDkwNTY2IEwwLjE3OTYyMjY0Miw0Ljc2NTI4MzAyIEwyLjI0NzkyNDUzLDQuNzY1MjgzMDIgQzMuMjA0MTUwOTQsNC43NjUyODMwMiAzLjY3MTY5ODExLDUuMTg3OTI0NTMgMy42NzE2OTgxMSw2LjAwOTQzMzk2IEwzLjY3MTY5ODExLDYuNDMyMDc1NDcgQzMuNjcxNjk4MTEsNi43ODg2NzkyNSAzLjYyNjc5MjQ1LDcuMDc2NjAzNzcgMy42MjY3OTI0NSw3LjA3NjYwMzc3IEwzLjY3MTY5ODExLDcuMDc2NjAzNzcgQzQuMTE1NDcxNyw2LjA5OTI0NTI4IDUuNDQ5NDMzOTYsNC40OTg0OTA1NyA3Ljg5NTQ3MTcsNC40OTg0OTA1NyBDMTAuNTYzMzk2Miw0LjQ5ODQ5MDU3IDExLjc2MjY0MTUsNS45NDMzOTYyMyAxMS43NjI2NDE1LDguODA5NDMzOTYgTDExLjc2MjY0MTUsMTMuNjc3NzM1OCBDMTEuNzYyNjQxNSwxNC4wMTA1NjYgMTEuOTM5NjIyNiwxNC4xNjY0MTUxIDEyLjI1MTMyMDgsMTQuMTY2NDE1MSBMMTMuMTYyNjQxNSwxNC4xNjY0MTUxIEwxMy4xNjI2NDE1LDE2LjAxMDE4ODcgTDExLjAyODMwMTksMTYuMDEwMTg4NyBDMTAuMDI3MTY5OCwxNi4wMTAxODg3IDkuNjA0NTI4MywxNS41ODc1NDcyIDkuNjA0NTI4MywxNC41ODY0MTUxIEw5LjYwNDUyODMsOS4yOTgxMTMyMSBDOS42MDQ1MjgzLDcuNzE4NDkwNTcgOS4yOTI4MzAxOSw2LjQ3NDMzOTYyIDcuNDkzOTYyMjYsNi40NzQzMzk2MiBDNS43NjExMzIwOCw2LjQ3NDMzOTYyIDQuMzgyMjY0MTUsNy42MDc1NDcxNyAzLjkzODQ5MDU3LDkuMjMyMDc1NDcgQzMuNzgyNjQxNTEsOS42NzU4NDkwNiAzLjczNzczNTg1LDEwLjE4ODMwMTkgMy43Mzc3MzU4NSwxMC43NDMwMTg5IEwzLjczNzczNTg1LDE2LjAxMDE4ODcgTDEuNTgyMjY0MTUsMTYuMDEwMTg4NyBMMS41ODIyNjQxNSw3LjA5NzczNTg1IEwxLjU3OTYyMjY0LDcuMDk3NzM1ODUgWiIgaWQ9IlBhdGgiIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BhdGg+IDxwYXRoIGQ9Ik0xNy42NjkwNTY2LDcuNDk2NjAzNzcgTDE3LjY2OTA1NjYsNy40NTE2OTgxMSBDMTcuNjY5MDU2Niw3LjQ1MTY5ODExIDE1LjczNTQ3MTcsNi40Mjk0MzM5NiAxNS43MzU0NzE3LDQuMjUwMTg4NjggQzE1LjczNTQ3MTcsMi4wNzA5NDM0IDE3LjQ2ODMwMTksMC4wNTAxODg2NzkyIDIwLjYyNDkwNTcsMC4wNTAxODg2NzkyIEMyMy42MjU2NjA0LDAuMDUwMTg4Njc5MiAyNS41MzgxMTMyLDEuODUxNjk4MTEgMjUuNTM4MTEzMiw0LjI5NTA5NDM0IEMyNS41MzgxMTMyLDYuNjA2NDE1MDkgMjMuNjQ5NDM0LDguMDMwMTg4NjggMjMuNjQ5NDM0LDguMDMwMTg4NjggTDIzLjY0OTQzNCw4LjA3NTA5NDM0IEMyNS4wNzMyMDc1LDguODk2NjAzNzcgMjUuOTg0NTI4Myw5Ljk4NzU0NzE3IDI1Ljk4NDUyODMsMTEuNjc1NDcxNyBDMjUuOTg0NTI4MywxNC4xMjE1MDk0IDIzLjc2MzAxODksMTYuMjc2OTgxMSAyMC41NjE1MDk0LDE2LjI3Njk4MTEgQzE3LjYwNTY2MDQsMTYuMjc2OTgxMSAxNS4wOTM1ODI5LDE0LjQzMzIwNzUgMTUuMDkzNTgyOSwxMS41MTk2MjI2IEMxNS4wOTA5NDM0LDguOTQxNTA5NDMgMTcuNjY5MDU2Niw3LjQ5NjYwMzc3IDE3LjY2OTA1NjYsNy40OTY2MDM3NyBaIE0yMC41NTg4Njc5LDE0LjI1MzU4NDkgQzIyLjIwNDUyODMsMTQuMjUzNTg0OSAyMy43MzY2MDM4LDEzLjE2NTI4MyAyMy43MzY2MDM4LDExLjYwOTQzNCBDMjMuNzM2NjAzOCwxMC4yMzA1NjYgMjIuNTU4NDkwNiw5LjYzMDk0MzQgMjEuMDkyNDUyOCw5LjAzMTMyMDc1IEMyMC40OTI4MzAyLDguNzg1NjYwMzggMTkuNjQ3NTQ3Miw4LjQ1MjgzMDE5IDE5LjQ3MDU2Niw4LjQ1MjgzMDE5IEMxOC45MTU4NDkxLDguNDUyODMwMTkgMTcuMzM2MjI2NCw5Ljc0MTg4Njc5IDE3LjMzNjIyNjQsMTEuNDA4Njc5MiBDMTcuMzM2MjI2NCwxMy4xNjUyODMgMTguODQ3MTY5OCwxNC4yNTM1ODQ5IDIwLjU1ODg2NzksMTQuMjUzNTg0OSBaIE0yMS43MTU4NDkxLDcuMTQgQzIyLjI0OTQzNCw3LjE0IDIzLjM4MjY0MTUsNS44MjcxNjk4MSAyMy4zODI2NDE1LDQuNDI3MTY5ODEgQzIzLjM4MjY0MTUsMi45ODIyNjQxNSAyMi4yMjU2NjA0LDIuMDcwOTQzNCAyMC42Mjc1NDcyLDIuMDcwOTQzNCBDMTguOTE1ODQ5MSwyLjA3MDk0MzQgMTcuOTE0NzE3LDMuMDQ4MzAxODkgMTcuOTE0NzE3LDQuMjkyNDUyODMgQzE3LjkxNDcxNyw1LjY3MTMyMDc1IDE5LjA5MjgzMDIsNi4yMDQ5MDU2NiAyMC40OTI4MzAyLDYuNzU5NjIyNjQgQzIwLjgwNDUyODMsNi44OTY5ODExMyAyMS40NDkwNTY2LDcuMTQgMjEuNzE1ODQ5MSw3LjE0IFoiIGlkPSJTaGFwZSIgZGF0YS12LTc4YzI3YTlhPSIiIGZpbGw9IiMzODRENUIiPjwvcGF0aD4gPHBhdGggZD0iTTI5LjQwNTI4Myw3LjA5NzczNTg1IEMyOS40MDUyODMsNi43NjQ5MDU2NiAyOS4yMjgzMDE5LDYuNjA5MDU2NiAyOC45MTY2MDM4LDYuNjA5MDU2NiBMMjguMDA1MjgzLDYuNjA5MDU2NiBMMjguMDA1MjgzLDQuNzY1MjgzMDIgTDMwLjA3MzU4NDksNC43NjUyODMwMiBDMzEuMDI5ODExMyw0Ljc2NTI4MzAyIDMxLjQ5NzM1ODUsNS4xODc5MjQ1MyAzMS40OTczNTg1LDYuMDA5NDMzOTYgTDMxLjQ5NzM1ODUsNi40MzIwNzU0NyBDMzEuNDk3MzU4NSw2Ljc4ODY3OTI1IDMxLjQ1MjQ1MjgsNy4wNzY2MDM3NyAzMS40NTI0NTI4LDcuMDc2NjAzNzcgTDMxLjQ5NzM1ODUsNy4wNzY2MDM3NyBDMzEuOTQxMTMyMSw2LjA5OTI0NTI4IDMzLjI3NTA5NDMsNC40OTg0OTA1NyAzNS43MjExMzIxLDQuNDk4NDkwNTcgQzM4LjM4OTA1NjYsNC40OTg0OTA1NyAzOS41ODgzMDE5LDUuOTQzMzk2MjMgMzkuNTg4MzAxOSw4LjgwOTQzMzk2IEwzOS41ODgzMDE5LDEzLjY3NzczNTggQzM5LjU4ODMwMTksMTQuMDEwNTY2IDM5Ljc2NTI4MywxNC4xNjY0MTUxIDQwLjA3Njk4MTEsMTQuMTY2NDE1MSBMNDAuOTg4MzAxOSwxNC4xNjY0MTUxIEw0MC45ODgzMDE5LDE2LjAxMDE4ODcgTDM4Ljg1Mzk2MjMsMTYuMDEwMTg4NyBDMzcuODUyODMwMiwxNi4wMTAxODg3IDM3LjQzMDE4ODcsMTUuNTg3NTQ3MiAzNy40MzAxODg3LDE0LjU4NjQxNTEgTDM3LjQzMDE4ODcsOS4yOTgxMTMyMSBDMzcuNDMwMTg4Nyw3LjcxODQ5MDU3IDM3LjExODQ5MDYsNi40NzQzMzk2MiAzNS4zMTk2MjI2LDYuNDc0MzM5NjIgQzMzLjU4Njc5MjUsNi40NzQzMzk2MiAzMi4yMDc5MjQ1LDcuNjA3NTQ3MTcgMzEuNzY0MTUwOSw5LjIzMjA3NTQ3IEMzMS42MDgzMDE5LDkuNjc1ODQ5MDYgMzEuNTYzMzk2MiwxMC4xODgzMDE5IDMxLjU2MzM5NjIsMTAuNzQzMDE4OSBMMzEuNTYzMzk2MiwxNi4wMTAxODg3IEwyOS40MDc5MjQ1LDE2LjAxMDE4ODcgTDI5LjQwNzkyNDUsNy4wOTc3MzU4NSBMMjkuNDA1MjgzLDcuMDk3NzM1ODUgWiIgaWQ9IlBhdGgiIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BhdGg+IDxwb2x5Z29uIGlkPSJQYXRoIiBwb2ludHM9IjQzLjU0IDEzLjcyIDQ1Ljc0MDM3NzQgMTMuNzIgNDUuNzQwMzc3NCAxNi4wMTAxODg3IDQzLjU0IDE2LjAxMDE4ODciIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BvbHlnb24+IDxwYXRoIGQ9Ik00OC43MTczNTg1LDcuMDk3NzM1ODUgQzQ4LjcxNzM1ODUsNi43NjQ5MDU2NiA0OC41NDAzNzc0LDYuNjA5MDU2NiA0OC4yMjg2NzkyLDYuNjA5MDU2NiBMNDcuMzE3MzU4NSw2LjYwOTA1NjYgTDQ3LjMxNzM1ODUsNC43NjUyODMwMiBMNDkuNDI3OTI0NSw0Ljc2NTI4MzAyIEM1MC40MjkwNTY2LDQuNzY1MjgzMDIgNTAuODUxNjk4MSw1LjE4NzkyNDUzIDUwLjg1MTY5ODEsNi4xODkwNTY2IEw1MC44NTE2OTgxLDEzLjY4MDM3NzQgQzUwLjg1MTY5ODEsMTQuMDEzMjA3NSA1MS4wMjg2NzkyLDE0LjE2OTA1NjYgNTEuMzQwMzc3NCwxNC4xNjkwNTY2IEw1Mi4yNTE2OTgxLDE0LjE2OTA1NjYgTDUyLjI1MTY5ODEsMTYuMDEyODMwMiBMNTAuMTQxMTMyMSwxNi4wMTI4MzAyIEM0OS4xNCwxNi4wMTI4MzAyIDQ4LjcxNzM1ODUsMTUuNTkwMTg4NyA0OC43MTczNTg1LDE0LjU4OTA1NjYgTDQ4LjcxNzM1ODUsNy4wOTc3MzU4NSBaIiBpZD0iUGF0aCIgZGF0YS12LTc4YzI3YTlhPSIiIGZpbGw9IiMzODRENUIiPjwvcGF0aD4gPHBhdGggZD0iTTYwLjIzMTY5ODEsNC40OTU4NDkwNiBDNjMuNTg5MDU2Niw0LjQ5NTg0OTA2IDY2LjI5OTI0NTMsNi45NjMwMTg4NyA2Ni4yOTkyNDUzLDEwLjM2NTI4MyBDNjYuMjk5MjQ1MywxMy43ODg2NzkyIDYzLjU4NjQxNTEsMTYuMjc2OTgxMSA2MC4yMzE2OTgxLDE2LjI3Njk4MTEgQzU2Ljg3NDMzOTYsMTYuMjc2OTgxMSA1NC4xODUyODMsMTMuNzg2MDM3NyA1NC4xODUyODMsMTAuMzY1MjgzIEM1NC4xODUyODMsNi45NjMwMTg4NyA1Ni44NzQzMzk2LDQuNDk1ODQ5MDYgNjAuMjMxNjk4MSw0LjQ5NTg0OTA2IFogTTYwLjIzMTY5ODEsMTQuNDA5NDM0IEM2Mi4zNjYwMzc3LDE0LjQwOTQzNCA2NC4wOTg4Njc5LDEyLjcxODg2NzkgNjQuMDk4ODY3OSwxMC4zNjI2NDE1IEM2NC4wOTg4Njc5LDguMDI3NTQ3MTcgNjIuMzY2MDM3Nyw2LjM2MDc1NDcyIDYwLjIzMTY5ODEsNi4zNjA3NTQ3MiBDNTguMTIxMTMyMSw2LjM2MDc1NDcyIDU2LjM4NTY2MDQsOC4wMjc1NDcxNyA1Ni4zODU2NjA0LDEwLjM2MjY0MTUgQzU2LjM4NTY2MDQsMTIuNzIxNTA5NCA1OC4xMTg0OTA2LDE0LjQwOTQzNCA2MC4yMzE2OTgxLDE0LjQwOTQzNCBaIiBpZD0iU2hhcGUiIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BhdGg+PC9nPiA8cGF0aCBkPSJNMTA2LjIzMDk0Myw5LjYzODg2NzkyIEMxMDUuMTI0MTUxLDkuNjM4ODY3OTIgMTA0LjIyMzM5Niw4LjczODExMzIxIDEwNC4yMjMzOTYsNy42MzEzMjA3NSBDMTA0LjIyMzM5Niw2LjUyNDUyODMgMTA1LjEyNDE1MSw1LjYyMzc3MzU4IDEwNi4yMzA5NDMsNS42MjM3NzM1OCBDMTA3LjMzNzczNiw1LjYyMzc3MzU4IDEwOC4yMzg0OTEsNi41MjQ1MjgzIDEwOC4yMzg0OTEsNy42MzEzMjA3NSBDMTA4LjIzODQ5MSw4LjczODExMzIxIDEwNy4zMzc3MzYsOS42Mzg4Njc5MiAxMDYuMjMwOTQzLDkuNjM4ODY3OTIgWiBNMTA2LjIzMDk0Myw2LjU4NzkyNDUzIEMxMDUuNjU3NzM2LDYuNTg3OTI0NTMgMTA1LjE5MDE4OSw3LjA1NTQ3MTcgMTA1LjE5MDE4OSw3LjYyODY3OTI1IEMxMDUuMTkwMTg5LDguMjAxODg2NzkgMTA1LjY1NzczNiw4LjY2OTQzMzk2IDEwNi4yMzA5NDMsOC42Njk0MzM5NiBDMTA2LjgwNDE1MSw4LjY2OTQzMzk2IDEwNy4yNzE2OTgsOC4yMDE4ODY3OSAxMDcuMjcxNjk4LDcuNjI4Njc5MjUgQzEwNy4yNzE2OTgsNy4wNTU0NzE3IDEwNi44MDQxNTEsNi41ODc5MjQ1MyAxMDYuMjMwOTQzLDYuNTg3OTI0NTMgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjRkY2RDVBIiBkYXRhLXYtNzhjMjdhOWE9IiI+PC9wYXRoPjwvZz48L2c+PC9nPjwvc3ZnPg==);
+ width: 140px;
+ height: 40px;
+}
+
+.swagger-ui .btn.authorize {
+ border-color: #ff6d5a;
+ color: #3b4151;
+}
+
+.swagger-ui .btn.authorize svg {
+ fill: #7d8492
+}
+
+.swagger-ui select {
+ border-color: #ff6d5a;
+}
diff --git a/packages/cli/src/PublicApi/types.d.ts b/packages/cli/src/PublicApi/types.d.ts
new file mode 100644
index 0000000000..501f7b028d
--- /dev/null
+++ b/packages/cli/src/PublicApi/types.d.ts
@@ -0,0 +1,165 @@
+/* eslint-disable import/no-cycle */
+import express from 'express';
+import { IDataObject } from 'n8n-workflow';
+
+import type { User } from '../databases/entities/User';
+
+import type { Role } from '../databases/entities/Role';
+
+import type { WorkflowEntity } from '../databases/entities/WorkflowEntity';
+
+import * as UserManagementMailer from '../UserManagement/email/UserManagementMailer';
+
+export type ExecutionStatus = 'error' | 'running' | 'success' | 'waiting' | null;
+
+export type AuthlessRequest<
+ RouteParams = {},
+ ResponseBody = {},
+ RequestBody = {},
+ RequestQuery = {},
+> = express.Request;
+
+export type AuthenticatedRequest<
+ RouteParams = {},
+ ResponseBody = {},
+ RequestBody = {},
+ RequestQuery = {},
+> = express.Request & {
+ user: User;
+ globalMemberRole?: Role;
+ mailer?: UserManagementMailer.UserManagementMailer;
+};
+
+export type PaginatatedRequest = AuthenticatedRequest<
+ {},
+ {},
+ {},
+ {
+ limit?: number;
+ cursor?: string;
+ offset?: number;
+ lastId?: number;
+ }
+>;
+export declare namespace ExecutionRequest {
+ type GetAll = AuthenticatedRequest<
+ {},
+ {},
+ {},
+ {
+ status?: ExecutionStatus;
+ limit?: number;
+ cursor?: string;
+ offset?: number;
+ includeData?: boolean;
+ workflowId?: number;
+ lastId?: number;
+ }
+ >;
+
+ type Get = AuthenticatedRequest<{ id: number }, {}, {}, { includeData?: boolean }>;
+ type Delete = Get;
+}
+
+export declare namespace CredentialTypeRequest {
+ type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>;
+}
+
+export declare namespace WorkflowRequest {
+ type GetAll = AuthenticatedRequest<
+ {},
+ {},
+ {},
+ {
+ tags?: string;
+ status?: ExecutionStatus;
+ limit?: number;
+ cursor?: string;
+ offset?: number;
+ workflowId?: number;
+ active: boolean;
+ }
+ >;
+
+ type Create = AuthenticatedRequest<{}, {}, WorkflowEntity, {}>;
+ type Get = AuthenticatedRequest<{ id: number }, {}, {}, {}>;
+ type Delete = Get;
+ type Update = AuthenticatedRequest<{ id: number }, {}, WorkflowEntity, {}>;
+ type Activate = Get;
+}
+
+export declare namespace UserRequest {
+ export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>;
+
+ export type ResolveSignUp = AuthlessRequest<
+ {},
+ {},
+ {},
+ { inviterId?: string; inviteeId?: string }
+ >;
+
+ export type SignUp = AuthenticatedRequest<
+ { id: string },
+ { inviterId?: string; inviteeId?: string }
+ >;
+
+ export type Delete = AuthenticatedRequest<
+ { id: string; email: string },
+ {},
+ {},
+ { transferId?: string; includeRole: boolean }
+ >;
+
+ export type Get = AuthenticatedRequest<
+ { id: string; email: string },
+ {},
+ {},
+ { limit?: number; offset?: number; cursor?: string; includeRole?: boolean }
+ >;
+
+ export type Reinvite = AuthenticatedRequest<{ id: string }>;
+
+ export type Update = AuthlessRequest<
+ { id: string },
+ {},
+ {
+ inviterId: string;
+ firstName: string;
+ lastName: string;
+ password: string;
+ }
+ >;
+}
+
+export declare namespace CredentialRequest {
+ type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>;
+}
+
+export type OperationID = 'getUsers' | 'getUser';
+
+type PaginationBase = { limit: number };
+
+type PaginationOffsetDecoded = PaginationBase & { offset: number };
+
+type PaginationCursorDecoded = PaginationBase & { lastId: number };
+
+type OffsetPagination = PaginationBase & { offset: number; numberOfTotalRecords: number };
+
+type CursorPagination = PaginationBase & { lastId: number; numberOfNextRecords: number };
+export interface IRequired {
+ required?: string[];
+ not?: { required?: string[] };
+}
+export interface IDependency {
+ if?: { properties: {} };
+ then?: { oneOf: IRequired[] };
+ else?: { allOf: IRequired[] };
+}
+
+export interface IJsonSchema {
+ additionalProperties: boolean;
+ type: 'object';
+ properties: { [key: string]: { type: string } };
+ allOf?: IDependency[];
+ required: string[];
+}
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts
new file mode 100644
index 0000000000..6896706c34
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts
@@ -0,0 +1,112 @@
+import express = require('express');
+import { CredentialsHelper } from '../../../../CredentialsHelper';
+import { CredentialTypes } from '../../../../CredentialTypes';
+
+import { CredentialsEntity } from '../../../../databases/entities/CredentialsEntity';
+import { CredentialRequest } from '../../../../requests';
+import { CredentialTypeRequest } from '../../../types';
+import { authorize } from '../../shared/middlewares/global.middleware';
+import { validCredentialsProperties, validCredentialType } from './credentials.middleware';
+
+import {
+ createCredential,
+ encryptCredential,
+ getCredentials,
+ getSharedCredentials,
+ removeCredential,
+ sanitizeCredentials,
+ saveCredential,
+ toJsonSchema,
+} from './credentials.service';
+
+export = {
+ createCredential: [
+ authorize(['owner', 'member']),
+ validCredentialType,
+ validCredentialsProperties,
+ async (
+ req: CredentialRequest.Create,
+ res: express.Response,
+ ): Promise>> => {
+ try {
+ const newCredential = await createCredential(req.body as Partial);
+
+ const encryptedData = await encryptCredential(newCredential);
+
+ Object.assign(newCredential, encryptedData);
+
+ const savedCredential = await saveCredential(newCredential, req.user, encryptedData);
+
+ // LoggerProxy.verbose('New credential created', {
+ // credentialId: newCredential.id,
+ // ownerId: req.user.id,
+ // });
+
+ return res.json(sanitizeCredentials(savedCredential));
+ } catch ({ message, httpStatusCode }) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ return res.status(httpStatusCode ?? 500).json({ message });
+ }
+ },
+ ],
+ deleteCredential: [
+ authorize(['owner', 'member']),
+ async (
+ req: CredentialRequest.Delete,
+ res: express.Response,
+ ): Promise>> => {
+ const { id: credentialId } = req.params;
+ let credentials: CredentialsEntity | undefined;
+
+ if (req.user.globalRole.name !== 'owner') {
+ const shared = await getSharedCredentials(req.user.id, credentialId, [
+ 'credentials',
+ 'role',
+ ]);
+
+ if (shared?.role.name === 'owner') {
+ credentials = shared.credentials;
+ } else {
+ // LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', {
+ // credentialId,
+ // userId: req.user.id,
+ // });
+ }
+ } else {
+ credentials = (await getCredentials(credentialId)) as CredentialsEntity;
+ }
+
+ if (!credentials) {
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ await removeCredential(credentials);
+ credentials.id = Number(credentialId);
+
+ return res.json(sanitizeCredentials(credentials));
+ },
+ ],
+
+ getCredentialType: [
+ authorize(['owner', 'member']),
+ async (req: CredentialTypeRequest.Get, res: express.Response): Promise => {
+ const { credentialTypeName } = req.params;
+
+ try {
+ CredentialTypes().getByName(credentialTypeName);
+ } catch (error) {
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ let schema = new CredentialsHelper('').getCredentialsProperties(credentialTypeName);
+
+ schema = schema.filter((nodeProperty) => nodeProperty.type !== 'hidden');
+
+ return res.json(toJsonSchema(schema));
+ },
+ ],
+};
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts
new file mode 100644
index 0000000000..db4c35cf82
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts
@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable consistent-return */
+import { RequestHandler } from 'express';
+import { validate } from 'jsonschema';
+import { CredentialsHelper, CredentialTypes } from '../../../..';
+import { CredentialRequest } from '../../../types';
+import { toJsonSchema } from './credentials.service';
+
+export const validCredentialType: RequestHandler = async (
+ req: CredentialRequest.Create,
+ res,
+ next,
+): Promise => {
+ const { type } = req.body;
+ try {
+ CredentialTypes().getByName(type);
+ } catch (error) {
+ return res.status(400).json({
+ message: 'req.body.type is not a known type',
+ });
+ }
+ next();
+};
+
+export const validCredentialsProperties: RequestHandler = async (
+ req: CredentialRequest.Create,
+ res,
+ next,
+): Promise => {
+ const { type, data } = req.body;
+
+ let properties = new CredentialsHelper('').getCredentialsProperties(type);
+
+ properties = properties.filter((nodeProperty) => nodeProperty.type !== 'hidden');
+
+ const schema = toJsonSchema(properties);
+
+ const { valid, errors } = validate(data, schema, { nestedErrors: true });
+
+ if (!valid) {
+ return res.status(400).json({
+ message: errors.map((error) => `request.body.data ${error.message}`).join(','),
+ });
+ }
+
+ next();
+};
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts
new file mode 100644
index 0000000000..2239b89821
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts
@@ -0,0 +1,249 @@
+/* eslint-disable no-restricted-syntax */
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-return */
+import { FindOneOptions } from 'typeorm';
+import { UserSettings, Credentials } from 'n8n-core';
+import { IDataObject, INodeProperties, INodePropertyOptions } from 'n8n-workflow';
+import { Db, ICredentialsDb } from '../../../..';
+import { CredentialsEntity } from '../../../../databases/entities/CredentialsEntity';
+import { SharedCredentials } from '../../../../databases/entities/SharedCredentials';
+import { User } from '../../../../databases/entities/User';
+import { externalHooks } from '../../../../Server';
+import { IDependency, IJsonSchema } from '../../../types';
+
+export async function getCredentials(
+ credentialId: number | string,
+): Promise {
+ return Db.collections.Credentials.findOne(credentialId);
+}
+
+export async function getSharedCredentials(
+ userId: string,
+ credentialId: number | string,
+ relations?: string[],
+): Promise {
+ const options: FindOneOptions = {
+ where: {
+ user: { id: userId },
+ credentials: { id: credentialId },
+ },
+ };
+
+ if (relations) {
+ options.relations = relations;
+ }
+
+ return Db.collections.SharedCredentials.findOne(options);
+}
+
+export async function createCredential(
+ properties: Partial,
+): Promise {
+ const newCredential = new CredentialsEntity();
+
+ Object.assign(newCredential, properties);
+
+ if (!newCredential.nodesAccess || newCredential.nodesAccess.length === 0) {
+ newCredential.nodesAccess = [
+ {
+ nodeType: `n8n-nodes-base.${properties.type?.toLowerCase() ?? 'unknown'}`,
+ date: new Date(),
+ },
+ ];
+ } else {
+ // Add the added date for node access permissions
+ newCredential.nodesAccess.forEach((nodeAccess) => {
+ // eslint-disable-next-line no-param-reassign
+ nodeAccess.date = new Date();
+ });
+ }
+
+ return newCredential;
+}
+
+export async function saveCredential(
+ credential: CredentialsEntity,
+ user: User,
+ encryptedData: ICredentialsDb,
+): Promise {
+ const role = await Db.collections.Role.findOneOrFail({
+ name: 'owner',
+ scope: 'credential',
+ });
+
+ await externalHooks.run('credentials.create', [encryptedData]);
+
+ return Db.transaction(async (transactionManager) => {
+ const savedCredential = await transactionManager.save(credential);
+
+ savedCredential.data = credential.data;
+
+ const newSharedCredential = new SharedCredentials();
+
+ Object.assign(newSharedCredential, {
+ role,
+ user,
+ credentials: savedCredential,
+ });
+
+ await transactionManager.save(newSharedCredential);
+
+ return savedCredential;
+ });
+}
+
+export async function removeCredential(credentials: CredentialsEntity): Promise {
+ await externalHooks.run('credentials.delete', [credentials.id]);
+ return Db.collections.Credentials.remove(credentials);
+}
+
+export async function encryptCredential(credential: CredentialsEntity): Promise {
+ const encryptionKey = await UserSettings.getEncryptionKey();
+
+ // Encrypt the data
+ const coreCredential = new Credentials(
+ { id: null, name: credential.name },
+ credential.type,
+ credential.nodesAccess,
+ );
+
+ // @ts-ignore
+ coreCredential.setData(credential.data, encryptionKey);
+
+ return coreCredential.getDataToSave() as ICredentialsDb;
+}
+
+export function sanitizeCredentials(credentials: CredentialsEntity): Partial;
+export function sanitizeCredentials(
+ credentials: CredentialsEntity[],
+): Array>;
+
+export function sanitizeCredentials(
+ credentials: CredentialsEntity | CredentialsEntity[],
+): Partial | Array> {
+ const argIsArray = Array.isArray(credentials);
+ const credentialsList = argIsArray ? credentials : [credentials];
+
+ const sanitizedCredentials = credentialsList.map((credential) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { data, nodesAccess, shared, ...rest } = credential;
+ return rest;
+ });
+
+ return argIsArray ? sanitizedCredentials : sanitizedCredentials[0];
+}
+
+/**
+ * toJsonSchema
+ * Take an array of crendentials parameter and map it
+ * to a JSON Schema (see https://json-schema.org/). With
+ * the JSON Schema defintion we can validate the credential's shape
+ * @param properties - Credentials properties
+ * @returns The credentials schema definition.
+ */
+export function toJsonSchema(properties: INodeProperties[]): IDataObject {
+ const jsonSchema: IJsonSchema = {
+ additionalProperties: false,
+ type: 'object',
+ properties: {},
+ allOf: [],
+ required: [],
+ };
+
+ const optionsValues: { [key: string]: string[] } = {};
+ const resolveProperties: string[] = [];
+
+ // get all posible values of properties type "options"
+ // so we can later resolve the displayOptions dependencies
+ properties
+ .filter((property) => property.type === 'options')
+ .forEach((property) => {
+ Object.assign(optionsValues, {
+ [property.name]: property.options?.map((option: INodePropertyOptions) => option.value),
+ });
+ });
+
+ let requiredFields: string[] = [];
+
+ const propertyRequiredDependencies: { [key: string]: IDependency } = {};
+
+ // add all credential's properties to the properties
+ // object in the JSON Schema definition. This allows us
+ // to later validate that only this properties are set in
+ // the credentials sent in the API call.
+ properties.forEach((property) => {
+ requiredFields.push(property.name);
+ if (property.type === 'options') {
+ // if the property is type options,
+ // include all possible values in the anum property.
+ Object.assign(jsonSchema.properties, {
+ [property.name]: {
+ type: 'string',
+ enum: property.options?.map((data: INodePropertyOptions) => data.value),
+ },
+ });
+ } else {
+ Object.assign(jsonSchema.properties, {
+ [property.name]: {
+ type: property.type,
+ },
+ });
+ }
+
+ // if the credential property has a dependency
+ // then add a JSON Schema condition that satisfy each property value
+ // e.x: If A has value X then required B, else required C
+ // see https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
+ if (property.displayOptions?.show) {
+ const dependantName = Object.keys(property.displayOptions?.show)[0] || '';
+ const displayOptionsValues = property.displayOptions.show[dependantName];
+ let dependantValue: string | number | boolean = '';
+
+ if (displayOptionsValues && Array.isArray(displayOptionsValues) && displayOptionsValues[0]) {
+ // eslint-disable-next-line prefer-destructuring
+ dependantValue = displayOptionsValues[0];
+ }
+
+ if (propertyRequiredDependencies[dependantName] === undefined) {
+ propertyRequiredDependencies[dependantName] = {};
+ }
+
+ if (!resolveProperties.includes(dependantName)) {
+ propertyRequiredDependencies[dependantName] = {
+ if: {
+ properties: {
+ [dependantName]: {
+ enum: [dependantValue],
+ },
+ },
+ },
+ then: {
+ oneOf: [],
+ },
+ else: {
+ allOf: [],
+ },
+ };
+ }
+
+ propertyRequiredDependencies[dependantName].then?.oneOf.push({ required: [property.name] });
+ propertyRequiredDependencies[dependantName].else?.allOf.push({
+ not: { required: [property.name] },
+ });
+
+ resolveProperties.push(dependantName);
+ // remove global required
+ requiredFields = requiredFields.filter((field) => field !== property.name);
+ }
+ });
+ Object.assign(jsonSchema, { required: requiredFields });
+
+ jsonSchema.allOf = Object.values(propertyRequiredDependencies);
+
+ if (!jsonSchema.allOf.length) {
+ delete jsonSchema.allOf;
+ }
+
+ return jsonSchema as unknown as IDataObject;
+}
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.yml
new file mode 100644
index 0000000000..b80f81ac02
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.yml
@@ -0,0 +1,26 @@
+delete:
+ x-eov-operation-id: deleteCredential
+ x-eov-operation-handler: v1/handlers/credentials/credentials.handler
+ tags:
+ - Credential
+ summary: Delete credential by ID
+ description: Deletes a credential from your instance. You must be the owner of the credentials
+ operationId: deleteCredential
+ parameters:
+ - name: id
+ in: path
+ description: The credential ID that needs to be deleted
+ required: true
+ schema:
+ type: number
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/credential.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.schema.id.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.schema.id.yml
new file mode 100644
index 0000000000..923d3c0ce1
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.schema.id.yml
@@ -0,0 +1,37 @@
+get:
+ x-eov-operation-id: getCredentialType
+ x-eov-operation-handler: v1/handlers/credentials/credentials.handler
+ tags:
+ - Credential
+ summary: Show credential data schema
+ parameters:
+ - name: credentialTypeName
+ in: path
+ description: The credential type name that you want to get the schema for
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ type: object
+ examples:
+ freshdeskApi:
+ value:
+ additionalProperties: false
+ type: 'object'
+ properties: { apiKey: { type: 'string' }, domain: { type: 'string' } }
+ required: ['apiKey', 'domain']
+ slackOAuth2Api:
+ value:
+ additionalProperties: false
+ type: 'object'
+ properties: { clientId: { type: 'string' }, clientSecret: { type: 'string' } }
+ required: ['clientId', 'clientSecret']
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.yml
new file mode 100644
index 0000000000..eab345f572
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.yml
@@ -0,0 +1,25 @@
+post:
+ x-eov-operation-id: createCredential
+ x-eov-operation-handler: v1/handlers/credentials/credentials.handler
+ tags:
+ - Credential
+ summary: Create a credential
+ description: Creates a credential that can be used by nodes of the specified type.
+ requestBody:
+ description: Credential to be created.
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/credential.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/credential.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '415':
+ description: Unsupported media type.
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credential.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credential.yml
new file mode 100644
index 0000000000..9c66817475
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credential.yml
@@ -0,0 +1,30 @@
+required:
+ - name
+ - type
+ - data
+type: object
+properties:
+ id:
+ type: number
+ readOnly: true
+ example: 42
+ name:
+ type: string
+ example: Joe's Github Credentials
+ type:
+ type: string
+ example: github
+ data:
+ type: object
+ writeOnly: true
+ example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
+ createdAt:
+ type: string
+ format: date-time
+ readOnly: true
+ example: '2022-04-29T11:02:29.842Z'
+ updatedAt:
+ type: string
+ format: date-time
+ readOnly: true
+ example: '2022-04-29T11:02:29.842Z'
diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credentialType.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credentialType.yml
new file mode 100644
index 0000000000..8ccf3bde8d
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credentialType.yml
@@ -0,0 +1,18 @@
+type: object
+properties:
+ displayName:
+ type: string
+ readOnly: true
+ example: Email
+ name:
+ type: string
+ readOnly: true
+ example: email
+ type:
+ type: string
+ readOnly: true
+ example: string
+ default:
+ type: string
+ readOnly: true
+ example: string
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts
new file mode 100644
index 0000000000..c8f98b8b1a
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts
@@ -0,0 +1,154 @@
+import express = require('express');
+
+import { BinaryDataManager } from 'n8n-core';
+import {
+ getExecutions,
+ getExecutionInWorkflows,
+ deleteExecution,
+ getExecutionsCount,
+} from './executions.service';
+
+import { ActiveExecutions } from '../../../..';
+import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
+
+import { ExecutionRequest } from '../../../types';
+import { getSharedWorkflowIds } from '../workflows/workflows.service';
+import { encodeNextCursor } from '../../shared/services/pagination.service';
+import { InternalHooksManager } from '../../../../InternalHooksManager';
+
+export = {
+ deleteExecution: [
+ authorize(['owner', 'member']),
+ async (req: ExecutionRequest.Delete, res: express.Response): Promise => {
+ const { id } = req.params;
+
+ const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
+
+ // user does not have workflows hence no executions
+ // or the execution he is trying to access belongs to a workflow he does not own
+ if (!sharedWorkflowsIds.length) {
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ // look for the execution on the workflow the user owns
+ const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, false);
+
+ // execution was not found
+ if (!execution) {
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ const binaryDataManager = BinaryDataManager.getInstance();
+
+ await binaryDataManager.deleteBinaryDataByExecutionId(execution.id.toString());
+
+ await deleteExecution(execution);
+
+ execution.id = id;
+
+ return res.json(execution);
+ },
+ ],
+ getExecution: [
+ authorize(['owner', 'member']),
+ async (req: ExecutionRequest.Get, res: express.Response): Promise => {
+ const { id } = req.params;
+ const { includeData = false } = req.query;
+
+ const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
+
+ // user does not have workflows hence no executions
+ // or the execution he is trying to access belongs to a workflow he does not own
+ if (!sharedWorkflowsIds.length) {
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ // look for the execution on the workflow the user owns
+ const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, includeData);
+
+ // execution was not found
+ if (!execution) {
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ const telemetryData = {
+ user_id: req.user.id,
+ public_api: true,
+ };
+
+ void InternalHooksManager.getInstance().onUserRetrievedExecution(telemetryData);
+
+ return res.json(execution);
+ },
+ ],
+ getExecutions: [
+ authorize(['owner', 'member']),
+ validCursor,
+ async (req: ExecutionRequest.GetAll, res: express.Response): Promise => {
+ const {
+ lastId = undefined,
+ limit = 100,
+ status = undefined,
+ includeData = false,
+ workflowId = undefined,
+ } = req.query;
+
+ const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
+
+ // user does not have workflows hence no executions
+ // or the execution he is trying to access belongs to a workflow he does not own
+ if (!sharedWorkflowsIds.length) {
+ return res.status(200).json({
+ data: [],
+ nextCursor: null,
+ });
+ }
+
+ // get running workflows so we exclude them from the result
+ const runningExecutionsIds = ActiveExecutions.getInstance()
+ .getActiveExecutions()
+ .map(({ id }) => Number(id));
+
+ const filters = {
+ status,
+ limit,
+ lastId,
+ includeData,
+ ...(workflowId && { workflowIds: [workflowId] }),
+ excludedExecutionsIds: runningExecutionsIds,
+ };
+
+ const executions = await getExecutions(filters);
+
+ const newLastId = !executions.length ? 0 : (executions.slice(-1)[0].id as number);
+
+ filters.lastId = newLastId;
+
+ const count = await getExecutionsCount(filters);
+
+ const telemetryData = {
+ user_id: req.user.id,
+ public_api: true,
+ };
+
+ void InternalHooksManager.getInstance().onUserRetrievedAllExecutions(telemetryData);
+
+ return res.json({
+ data: executions,
+ nextCursor: encodeNextCursor({
+ lastId: newLastId,
+ limit,
+ numberOfNextRecords: count,
+ }),
+ });
+ },
+ ],
+};
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts
new file mode 100644
index 0000000000..8cdac1d6cc
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts
@@ -0,0 +1,115 @@
+import { parse } from 'flatted';
+import { In, Not, ObjectLiteral, LessThan, IsNull } from 'typeorm';
+import { Db, IExecutionFlattedDb, IExecutionResponseApi } from '../../../..';
+import { ExecutionStatus } from '../../../types';
+
+function prepareExecutionData(
+ execution: IExecutionFlattedDb | undefined,
+): IExecutionResponseApi | undefined {
+ if (execution === undefined) {
+ return undefined;
+ }
+
+ if (!execution.data) {
+ return execution;
+ }
+
+ return {
+ ...execution,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ data: parse(execution.data),
+ };
+}
+
+function getStatusCondition(status: ExecutionStatus): ObjectLiteral {
+ const condition: ObjectLiteral = {};
+
+ if (status === 'success') {
+ condition.finished = true;
+ } else if (status === 'waiting') {
+ condition.waitTill = Not(IsNull());
+ } else if (status === 'error') {
+ condition.stoppedAt = Not(IsNull());
+ condition.finished = false;
+ }
+ return condition;
+}
+
+function getExecutionSelectableProperties(includeData?: boolean): Array {
+ const returnData: Array = [
+ 'id',
+ 'mode',
+ 'retryOf',
+ 'retrySuccessId',
+ 'startedAt',
+ 'stoppedAt',
+ 'workflowId',
+ 'waitTill',
+ 'finished',
+ ];
+ if (includeData) {
+ returnData.push('data');
+ }
+ return returnData;
+}
+
+export async function getExecutions(data: {
+ limit: number;
+ includeData?: boolean;
+ lastId?: number;
+ workflowIds?: number[];
+ status?: ExecutionStatus;
+ excludedExecutionsIds?: number[];
+}): Promise {
+ const executions = await Db.collections.Execution.find({
+ select: getExecutionSelectableProperties(data.includeData),
+ where: {
+ ...(data.lastId && { id: LessThan(data.lastId) }),
+ ...(data.status && { ...getStatusCondition(data.status) }),
+ ...(data.workflowIds && { workflowId: In(data.workflowIds.map(String)) }),
+ ...(data.excludedExecutionsIds && { id: Not(In(data.excludedExecutionsIds)) }),
+ },
+ order: { id: 'DESC' },
+ take: data.limit,
+ });
+
+ return executions.map((execution) => prepareExecutionData(execution)) as IExecutionResponseApi[];
+}
+
+export async function getExecutionsCount(data: {
+ limit: number;
+ lastId?: number;
+ workflowIds?: number[];
+ status?: ExecutionStatus;
+ excludedWorkflowIds?: number[];
+}): Promise {
+ const executions = await Db.collections.Execution.count({
+ where: {
+ ...(data.lastId && { id: LessThan(data.lastId) }),
+ ...(data.status && { ...getStatusCondition(data.status) }),
+ ...(data.workflowIds && { workflowId: In(data.workflowIds) }),
+ ...(data.excludedWorkflowIds && { workflowId: Not(In(data.excludedWorkflowIds)) }),
+ },
+ take: data.limit,
+ });
+ return executions;
+}
+
+export async function getExecutionInWorkflows(
+ id: number,
+ workflows: number[],
+ includeData?: boolean,
+): Promise {
+ const execution = await Db.collections.Execution.findOne({
+ select: getExecutionSelectableProperties(includeData),
+ where: {
+ id,
+ workflowId: In(workflows),
+ },
+ });
+ return prepareExecutionData(execution);
+}
+
+export async function deleteExecution(execution: IExecutionResponseApi | undefined): Promise {
+ await Db.collections.Execution.remove(execution as IExecutionFlattedDb);
+}
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.id.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.id.yml
new file mode 100644
index 0000000000..2199d683c3
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.id.yml
@@ -0,0 +1,41 @@
+get:
+ x-eov-operation-id: getExecution
+ x-eov-operation-handler: v1/handlers/executions/executions.handler
+ tags:
+ - Execution
+ summary: Retrieve an execution
+ description: Retrieve an execution from you instance.
+ parameters:
+ - $ref: '../schemas/parameters/executionId.yml'
+ - $ref: '../schemas/parameters/includeData.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/execution.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
+delete:
+ x-eov-operation-id: deleteExecution
+ x-eov-operation-handler: v1/handlers/executions/executions.handler
+ tags:
+ - Execution
+ summary: Delete an execution
+ description: Deletes an execution from your instance.
+ parameters:
+ - $ref: '../schemas/parameters/executionId.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/execution.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml
new file mode 100644
index 0000000000..0f95e82881
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml
@@ -0,0 +1,36 @@
+get:
+ x-eov-operation-id: getExecutions
+ x-eov-operation-handler: v1/handlers/executions/executions.handler
+ tags:
+ - Execution
+ summary: Retrieve all executions
+ description: Retrieve all executions from your instance.
+ parameters:
+ - $ref: '../schemas/parameters/includeData.yml'
+ - name: status
+ in: query
+ description: Status to filter the executions by.
+ required: false
+ schema:
+ type: string
+ enum: ['error', 'success', 'waiting']
+ - name: workflowId
+ in: query
+ description: Workflow to filter the executions by.
+ required: false
+ schema:
+ type: number
+ example: 1000
+ - $ref: '../../../../shared/spec/parameters/limit.yml'
+ - $ref: '../../../../shared/spec/parameters/cursor.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/executionList.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/execution.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/execution.yml
new file mode 100644
index 0000000000..883baa9fa7
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/execution.yml
@@ -0,0 +1,33 @@
+type: object
+properties:
+ id:
+ type: number
+ example: 1000
+ data:
+ type: object
+ finished:
+ type: boolean
+ example: true
+ mode:
+ type: string
+ enum: ['cli', 'error', 'integrated', 'internal', 'manual', 'retry', 'trigger', 'webhook']
+ retryOf:
+ type: string
+ nullable: true
+ retrySuccessId:
+ type: string
+ nullable: true
+ example: 2
+ startedAt:
+ type: string
+ format: date-time
+ stoppedAt:
+ type: string
+ format: date-time
+ workflowId:
+ type: string
+ example: 1000
+ waitTill:
+ type: string
+ nullable: true
+ format: date-time
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/executionList.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/executionList.yml
new file mode 100644
index 0000000000..fb29dd40ff
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/executionList.yml
@@ -0,0 +1,11 @@
+type: object
+properties:
+ data:
+ type: array
+ items:
+ $ref: './execution.yml'
+ nextCursor:
+ type: string
+ description: Paginate through executions by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
+ nullable: true
+ example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/executionId.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/executionId.yml
new file mode 100644
index 0000000000..6ec1bdf094
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/executionId.yml
@@ -0,0 +1,6 @@
+name: id
+in: path
+description: The ID of the execution.
+required: true
+schema:
+ type: number
diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/includeData.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/includeData.yml
new file mode 100644
index 0000000000..f173504228
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/includeData.yml
@@ -0,0 +1,6 @@
+name: includeData
+in: query
+description: Whether or not to include the execution's detailed data.
+required: false
+schema:
+ type: boolean
diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts
new file mode 100644
index 0000000000..f4819ef004
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts
@@ -0,0 +1,14 @@
+import { Db } from '../../../..';
+import { Role } from '../../../../databases/entities/Role';
+import { User } from '../../../../databases/entities/User';
+
+export function isInstanceOwner(user: User): boolean {
+ return user.globalRole.name === 'owner';
+}
+
+export async function getWorkflowOwnerRole(): Promise {
+ return Db.collections.Role.findOneOrFail({
+ name: 'owner',
+ scope: 'workflow',
+ });
+}
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.activate.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.activate.yml
new file mode 100644
index 0000000000..20c40a7451
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.activate.yml
@@ -0,0 +1,20 @@
+post:
+ x-eov-operation-id: activateWorkflow
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Activate a workflow
+ description: Active a workflow.
+ parameters:
+ - $ref: '../schemas/parameters/workflowId.yml'
+ responses:
+ '200':
+ description: Workflow object
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.deactivate.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.deactivate.yml
new file mode 100644
index 0000000000..12b76040a4
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.deactivate.yml
@@ -0,0 +1,20 @@
+post:
+ x-eov-operation-id: deactivateWorkflow
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Deactivate a workflow
+ description: Deactivate a workflow.
+ parameters:
+ - $ref: '../schemas/parameters/workflowId.yml'
+ responses:
+ '200':
+ description: Workflow object
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.yml
new file mode 100644
index 0000000000..37cad74c86
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.yml
@@ -0,0 +1,69 @@
+get:
+ x-eov-operation-id: getWorkflow
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Retrieves a workflow
+ description: Retrieves a workflow.
+ parameters:
+ - $ref: '../schemas/parameters/workflowId.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
+delete:
+ x-eov-operation-id: deleteWorkflow
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Delete a workflow
+ description: Deletes a workflow.
+ parameters:
+ - $ref: '../schemas/parameters/workflowId.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
+put:
+ x-eov-operation-id: updateWorkflow
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Update a workflow
+ description: Update a workflow.
+ parameters:
+ - $ref: '../schemas/parameters/workflowId.yml'
+ requestBody:
+ description: Updated workflow object.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ required: true
+ responses:
+ '200':
+ description: Workflow object
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ '400':
+ $ref: '../../../../shared/spec/responses/badRequest.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+ '404':
+ $ref: '../../../../shared/spec/responses/notFound.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml
new file mode 100644
index 0000000000..1ff9f0a70d
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml
@@ -0,0 +1,57 @@
+post:
+ x-eov-operation-id: createWorkflow
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Create a workflow
+ description: Create a workflow in your instance.
+ requestBody:
+ description: Created workflow object.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ required: true
+ responses:
+ '200':
+ description: A workflow object
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflow.yml'
+ '400':
+ $ref: '../../../../shared/spec/responses/badRequest.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
+get:
+ x-eov-operation-id: getWorkflows
+ x-eov-operation-handler: v1/handlers/workflows/workflows.handler
+ tags:
+ - Workflow
+ summary: Retrieve all workflows
+ description: Retrieve all workflows from your instance.
+ parameters:
+ - name: active
+ in: query
+ schema:
+ type: boolean
+ example: true
+ - name: tags
+ in: query
+ required: false
+ explode: false
+ allowReserved: true
+ schema:
+ type: string
+ example: test,production
+ - $ref: '../../../../shared/spec/parameters/limit.yml'
+ - $ref: '../../../../shared/spec/parameters/cursor.yml'
+ responses:
+ '200':
+ description: Operation successful.
+ content:
+ application/json:
+ schema:
+ $ref: '../schemas/workflowList.yml'
+ '401':
+ $ref: '../../../../shared/spec/responses/unauthorized.yml'
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml
new file mode 100644
index 0000000000..55715dfc4c
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml
@@ -0,0 +1,39 @@
+type: object
+additionalProperties: false
+properties:
+ name:
+ type: string
+ example: Jira
+ webhookId:
+ type: string
+ disabled:
+ type: boolean
+ notesInFlow:
+ type: boolean
+ notes:
+ type: string
+ type:
+ type: string
+ example: n8n-nodes-base.Jira
+ typeVersion:
+ type: number
+ example: 1
+ position:
+ type: array
+ items:
+ type: number
+ example: [-100, 80]
+ parameters:
+ type: object
+ example: { additionalProperties: {} }
+ credentials:
+ type: object
+ example: { jiraSoftwareCloudApi: { id: "35", name: "jiraApi"} }
+ createdAt:
+ type: string
+ format: date-time
+ readOnly: true
+ updatedAt:
+ type: string
+ format: date-time
+ readOnly: true
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/parameters/workflowId.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/parameters/workflowId.yml
new file mode 100644
index 0000000000..6ee64fec8c
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/parameters/workflowId.yml
@@ -0,0 +1,6 @@
+ name: id
+ in: path
+ description: The ID of the workflow.
+ required: true
+ schema:
+ type: number
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml
new file mode 100644
index 0000000000..ff5b571504
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml
@@ -0,0 +1,16 @@
+type: object
+properties:
+ id:
+ type: string
+ example: 12
+ name:
+ type: string
+ example: Production
+ createdAt:
+ type: string
+ format: date-time
+ readOnly: true
+ updatedAt:
+ type: string
+ format: date-time
+ readOnly: true
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml
new file mode 100644
index 0000000000..c15ce8120b
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml
@@ -0,0 +1,43 @@
+type: object
+required:
+ - name
+ - nodes
+ - connections
+ - settings
+properties:
+ id:
+ type: number
+ readOnly: true
+ example: 1
+ name:
+ type: string
+ example: Workflow 1
+ active:
+ type: boolean
+ readOnly: true
+ createdAt:
+ type: string
+ format: date-time
+ readOnly: true
+ updatedAt:
+ type: string
+ format: date-time
+ readOnly: true
+ nodes:
+ type: array
+ items:
+ $ref: './node.yml'
+ connections:
+ type: object
+ example: { main: [{ node: 'Jira', type: 'main', index: 0 }] }
+ settings:
+ $ref: './workflowSettings.yml'
+ staticData:
+ type: string
+ nullable: true
+ example: '{ iterationId: 2 }'
+ tags:
+ type: array
+ items:
+ $ref: './tag.yml'
+ readOnly: true
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowList.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowList.yml
new file mode 100644
index 0000000000..d5e455ee95
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowList.yml
@@ -0,0 +1,11 @@
+type: object
+properties:
+ data:
+ type: array
+ items:
+ $ref: './workflow.yml'
+ nextCursor:
+ type: string
+ description: Paginate through workflows by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
+ nullable: true
+ example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowSettings.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowSettings.yml
new file mode 100644
index 0000000000..78b08b90a6
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowSettings.yml
@@ -0,0 +1,24 @@
+type: object
+additionalProperties: false
+properties:
+ saveExecutionProgress:
+ type: boolean
+ saveManualExecutions:
+ type: boolean
+ saveDataErrorExecution:
+ type: string
+ enum: ['all', 'none']
+ saveDataSuccessExecution:
+ type: string
+ enum: ['all', 'none']
+ executionTimeout:
+ type: number
+ example: 3600
+ maxLength: 3600
+ errorWorkflow:
+ type: string
+ example: 10
+ description: The ID of the workflow that contains the error trigger node.
+ timezone:
+ type: string
+ example: America/New_York
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts
new file mode 100644
index 0000000000..23ff02a7d4
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts
@@ -0,0 +1,303 @@
+import express = require('express');
+import { FindManyOptions, In } from 'typeorm';
+import { ActiveWorkflowRunner, Db } from '../../../..';
+import config = require('../../../../../config');
+import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity';
+import { InternalHooksManager } from '../../../../InternalHooksManager';
+import { externalHooks } from '../../../../Server';
+import { replaceInvalidCredentials } from '../../../../WorkflowHelpers';
+import { WorkflowRequest } from '../../../types';
+import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
+import { encodeNextCursor } from '../../shared/services/pagination.service';
+import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service';
+import {
+ getWorkflowById,
+ getSharedWorkflow,
+ setWorkflowAsActive,
+ setWorkflowAsInactive,
+ updateWorkflow,
+ hasStartNode,
+ getStartNode,
+ getWorkflows,
+ getSharedWorkflows,
+ getWorkflowsCount,
+ createWorkflow,
+ getWorkflowIdsViaTags,
+ parseTagNames,
+} from './workflows.service';
+
+export = {
+ createWorkflow: [
+ authorize(['owner', 'member']),
+ async (req: WorkflowRequest.Create, res: express.Response): Promise => {
+ let workflow = req.body;
+
+ workflow.active = false;
+
+ // if the workflow does not have a start node, add it.
+ if (!hasStartNode(workflow)) {
+ workflow.nodes.push(getStartNode());
+ }
+
+ const role = await getWorkflowOwnerRole();
+
+ await replaceInvalidCredentials(workflow);
+
+ workflow = await createWorkflow(workflow, req.user, role);
+
+ await externalHooks.run('workflow.afterCreate', [workflow]);
+ void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, workflow, true);
+
+ return res.json(workflow);
+ },
+ ],
+ deleteWorkflow: [
+ authorize(['owner', 'member']),
+ async (req: WorkflowRequest.Get, res: express.Response): Promise => {
+ const { id } = req.params;
+
+ const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
+
+ if (!sharedWorkflow) {
+ // user trying to access a workflow he does not own
+ // or workflow does not exist
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ const workflowRunner = ActiveWorkflowRunner.getInstance();
+
+ if (sharedWorkflow.workflow.active) {
+ // deactivate before deleting
+ await workflowRunner.remove(id.toString());
+ }
+
+ await Db.collections.Workflow.delete(id);
+
+ void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, id.toString(), true);
+ await externalHooks.run('workflow.afterDelete', [id.toString()]);
+
+ return res.json(sharedWorkflow.workflow);
+ },
+ ],
+ getWorkflow: [
+ authorize(['owner', 'member']),
+ async (req: WorkflowRequest.Get, res: express.Response): Promise => {
+ const { id } = req.params;
+
+ const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
+
+ if (!sharedWorkflow) {
+ // user trying to access a workflow he does not own
+ // or workflow does not exist
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ const telemetryData = {
+ user_id: req.user.id,
+ public_api: true,
+ };
+
+ void InternalHooksManager.getInstance().onUserRetrievedWorkflow(telemetryData);
+
+ return res.json(sharedWorkflow.workflow);
+ },
+ ],
+ getWorkflows: [
+ authorize(['owner', 'member']),
+ validCursor,
+ async (req: WorkflowRequest.GetAll, res: express.Response): Promise => {
+ const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query;
+
+ let workflows: WorkflowEntity[];
+ let count: number;
+
+ const query: FindManyOptions = {
+ skip: offset,
+ take: limit,
+ where: {
+ ...(active !== undefined && { active }),
+ },
+ ...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }),
+ };
+
+ if (isInstanceOwner(req.user)) {
+ if (tags) {
+ const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
+ Object.assign(query.where, { id: In(workflowIds) });
+ }
+
+ workflows = await getWorkflows(query);
+
+ count = await getWorkflowsCount(query);
+ } else {
+ const options: { workflowIds?: number[] } = {};
+
+ if (tags) {
+ options.workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
+ }
+
+ const sharedWorkflows = await getSharedWorkflows(req.user, options);
+
+ if (!sharedWorkflows.length) {
+ return res.status(200).json({
+ data: [],
+ nextCursor: null,
+ });
+ }
+
+ const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId);
+
+ Object.assign(query.where, { id: In(workflowsIds) });
+
+ workflows = await getWorkflows(query);
+
+ count = await getWorkflowsCount(query);
+ }
+
+ const telemetryData = {
+ user_id: req.user.id,
+ public_api: true,
+ };
+
+ void InternalHooksManager.getInstance().onUserRetrievedAllWorkflows(telemetryData);
+
+ return res.json({
+ data: workflows,
+ nextCursor: encodeNextCursor({
+ offset,
+ limit,
+ numberOfTotalRecords: count,
+ }),
+ });
+ },
+ ],
+ updateWorkflow: [
+ authorize(['owner', 'member']),
+ async (req: WorkflowRequest.Update, res: express.Response): Promise => {
+ const { id } = req.params;
+ const updateData = new WorkflowEntity();
+ Object.assign(updateData, req.body);
+
+ const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
+
+ if (!sharedWorkflow) {
+ // user trying to access a workflow he does not own
+ // or workflow does not exist
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ // if the workflow does not have a start node, add it.
+ // else there is nothing you can do in IU
+ if (!hasStartNode(updateData)) {
+ updateData.nodes.push(getStartNode());
+ }
+
+ // check credentials for old format
+ await replaceInvalidCredentials(updateData);
+
+ const workflowRunner = ActiveWorkflowRunner.getInstance();
+
+ if (sharedWorkflow.workflow.active) {
+ // When workflow gets saved always remove it as the triggers could have been
+ // changed and so the changes would not take effect
+ await workflowRunner.remove(id.toString());
+ }
+
+ await updateWorkflow(sharedWorkflow.workflowId, updateData);
+
+ if (sharedWorkflow.workflow.active) {
+ try {
+ await workflowRunner.add(sharedWorkflow.workflowId.toString(), 'update');
+ } catch (error) {
+ // todo
+ // remove the type assertion
+ const errorObject = error as unknown as { message: string };
+ return res.status(400).json({ error: errorObject.message });
+ }
+ }
+
+ const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId);
+
+ await externalHooks.run('workflow.afterUpdate', [updateData]);
+ void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updateData, true);
+
+ return res.json(updatedWorkflow);
+ },
+ ],
+ activateWorkflow: [
+ authorize(['owner', 'member']),
+ async (req: WorkflowRequest.Activate, res: express.Response): Promise => {
+ const { id } = req.params;
+
+ const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
+
+ if (!sharedWorkflow) {
+ // user trying to access a workflow he does not own
+ // or workflow does not exist
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ const workflowRunner = ActiveWorkflowRunner.getInstance();
+
+ if (!sharedWorkflow.workflow.active) {
+ try {
+ await workflowRunner.add(sharedWorkflow.workflowId.toString(), 'activate');
+ } catch (error) {
+ // todo
+ // remove the type assertion
+ const errorObject = error as unknown as { message: string };
+ return res.status(400).json({ error: errorObject.message });
+ }
+
+ // change the status to active in the DB
+ await setWorkflowAsActive(sharedWorkflow.workflow);
+
+ sharedWorkflow.workflow.active = true;
+
+ return res.json(sharedWorkflow.workflow);
+ }
+
+ // nothing to do as the wokflow is already active
+ return res.json(sharedWorkflow.workflow);
+ },
+ ],
+ deactivateWorkflow: [
+ authorize(['owner', 'member']),
+ async (req: WorkflowRequest.Activate, res: express.Response): Promise => {
+ const { id } = req.params;
+
+ const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
+
+ if (!sharedWorkflow) {
+ // user trying to access a workflow he does not own
+ // or workflow does not exist
+ return res.status(404).json({
+ message: 'Not Found',
+ });
+ }
+
+ const workflowRunner = ActiveWorkflowRunner.getInstance();
+
+ if (sharedWorkflow.workflow.active) {
+ await workflowRunner.remove(sharedWorkflow.workflowId.toString());
+
+ await setWorkflowAsInactive(sharedWorkflow.workflow);
+
+ sharedWorkflow.workflow.active = false;
+
+ return res.json(sharedWorkflow.workflow);
+ }
+
+ // nothing to do as the wokflow is already inactive
+ return res.json(sharedWorkflow.workflow);
+ },
+ ],
+};
diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts
new file mode 100644
index 0000000000..2e62180a24
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts
@@ -0,0 +1,147 @@
+import { intersection } from 'lodash';
+import type { INode } from 'n8n-workflow';
+import { FindManyOptions, In, UpdateResult } from 'typeorm';
+import { User } from '../../../../databases/entities/User';
+import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity';
+import { Db } from '../../../..';
+import { SharedWorkflow } from '../../../../databases/entities/SharedWorkflow';
+import { isInstanceOwner } from '../users/users.service';
+import { Role } from '../../../../databases/entities/Role';
+
+export async function getSharedWorkflowIds(user: User): Promise {
+ const sharedWorkflows = await Db.collections.SharedWorkflow.find({
+ where: {
+ user,
+ },
+ });
+ return sharedWorkflows.map((workflow) => workflow.workflowId);
+}
+
+export async function getSharedWorkflow(
+ user: User,
+ workflowId?: string | undefined,
+): Promise {
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ ...(!isInstanceOwner(user) && { user }),
+ ...(workflowId && { workflow: { id: workflowId } }),
+ },
+ relations: ['workflow'],
+ });
+ return sharedWorkflow;
+}
+
+export async function getSharedWorkflows(
+ user: User,
+ options: {
+ relations?: string[];
+ workflowIds?: number[];
+ },
+): Promise {
+ const sharedWorkflows = await Db.collections.SharedWorkflow.find({
+ where: {
+ ...(!isInstanceOwner(user) && { user }),
+ ...(options.workflowIds && { workflow: { id: In(options.workflowIds) } }),
+ },
+ ...(options.relations && { relations: options.relations }),
+ });
+ return sharedWorkflows;
+}
+
+export async function getWorkflowById(id: number): Promise {
+ const workflow = await Db.collections.Workflow.findOne({
+ where: {
+ id,
+ },
+ });
+ return workflow;
+}
+
+/**
+ * Returns the workflow IDs that have certain tags.
+ * Intersection! e.g. workflow needs to have all provided tags.
+ */
+export async function getWorkflowIdsViaTags(tags: string[]): Promise {
+ const dbTags = await Db.collections.Tag.find({
+ where: {
+ name: In(tags),
+ },
+ relations: ['workflows'],
+ });
+
+ const workflowIdsPerTag = dbTags.map((tag) => tag.workflows.map((workflow) => workflow.id));
+
+ return intersection(...workflowIdsPerTag);
+}
+
+export async function createWorkflow(
+ workflow: WorkflowEntity,
+ user: User,
+ role: Role,
+): Promise {
+ let savedWorkflow: unknown;
+ const newWorkflow = new WorkflowEntity();
+ Object.assign(newWorkflow, workflow);
+ await Db.transaction(async (transactionManager) => {
+ savedWorkflow = await transactionManager.save(newWorkflow);
+ const newSharedWorkflow = new SharedWorkflow();
+ Object.assign(newSharedWorkflow, {
+ role,
+ user,
+ workflow: savedWorkflow,
+ });
+ await transactionManager.save(newSharedWorkflow);
+ });
+ return savedWorkflow as WorkflowEntity;
+}
+
+export async function setWorkflowAsActive(workflow: WorkflowEntity): Promise {
+ return Db.collections.Workflow.update(workflow.id, { active: true, updatedAt: new Date() });
+}
+
+export async function setWorkflowAsInactive(workflow: WorkflowEntity): Promise {
+ return Db.collections.Workflow.update(workflow.id, { active: false, updatedAt: new Date() });
+}
+
+export async function deleteWorkflow(workflow: WorkflowEntity): Promise {
+ return Db.collections.Workflow.remove(workflow);
+}
+
+export async function getWorkflows(
+ options: FindManyOptions,
+): Promise {
+ const workflows = await Db.collections.Workflow.find(options);
+ return workflows;
+}
+
+export async function getWorkflowsCount(options: FindManyOptions): Promise {
+ const count = await Db.collections.Workflow.count(options);
+ return count;
+}
+
+export async function updateWorkflow(
+ workflowId: number,
+ updateData: WorkflowEntity,
+): Promise {
+ return Db.collections.Workflow.update(workflowId, updateData);
+}
+
+export function hasStartNode(workflow: WorkflowEntity): boolean {
+ return !(
+ !workflow.nodes.length || !workflow.nodes.find((node) => node.type === 'n8n-nodes-base.start')
+ );
+}
+
+export function getStartNode(): INode {
+ return {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ };
+}
+
+export function parseTagNames(tags: string): string[] {
+ return tags.split(',').map((tag) => tag.trim());
+}
diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml
new file mode 100644
index 0000000000..f0b12c5c55
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/openapi.yml
@@ -0,0 +1,59 @@
+---
+openapi: 3.0.0
+info:
+ title: n8n Public API
+ description: n8n Public API
+ termsOfService: https://n8n.io/legal/terms
+ contact:
+ email: hello@n8n.io
+ license:
+ name: Sustainable Use License
+ url: https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md
+ version: 1.0.0
+externalDocs:
+ description: n8n API documentation
+ url: https://docs.n8n.io/api/
+servers:
+ - url: /api/v1
+tags:
+ - name: Execution
+ description: Operations about executions
+ - name: Workflow
+ description: Operations about workflows
+ - name: Credential
+ description: Operations about credentials
+
+paths:
+ /credentials:
+ $ref: './handlers/credentials/spec/paths/credentials.yml'
+ /credentials/{id}:
+ $ref: './handlers/credentials/spec/paths/credentials.id.yml'
+ /credentials/schema/{credentialTypeName}:
+ $ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
+ /executions:
+ $ref: './handlers/executions/spec/paths/executions.yml'
+ /executions/{id}:
+ $ref: './handlers/executions/spec/paths/executions.id.yml'
+ /workflows:
+ $ref: './handlers/workflows/spec/paths/workflows.yml'
+ /workflows/{id}:
+ $ref: './handlers/workflows/spec/paths/workflows.id.yml'
+ /workflows/{id}/activate:
+ $ref: './handlers/workflows/spec/paths/workflows.id.activate.yml'
+ /workflows/{id}/deactivate:
+ $ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml'
+components:
+ schemas:
+ $ref: './shared/spec/schemas/_index.yml'
+ responses:
+ $ref: './shared/spec/responses/_index.yml'
+ parameters:
+ $ref: './shared/spec/parameters/_index.yml'
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-N8N-API-KEY
+
+security:
+ - ApiKeyAuth: []
diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts
new file mode 100644
index 0000000000..542e1c967c
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts
@@ -0,0 +1,40 @@
+/* eslint-disable consistent-return */
+import { RequestHandler } from 'express';
+import { PaginatatedRequest } from '../../../types';
+import { decodeCursor } from '../services/pagination.service';
+
+type Role = 'member' | 'owner';
+
+export const authorize: (role: Role[]) => RequestHandler = (role: Role[]) => (req, res, next) => {
+ const {
+ globalRole: { name: userRole },
+ } = req.user as { globalRole: { name: Role } };
+ if (role.includes(userRole)) {
+ return next();
+ }
+ return res.status(403).json({
+ message: 'Forbidden',
+ });
+};
+
+// @ts-ignore
+export const validCursor: RequestHandler = (req: PaginatatedRequest, res, next) => {
+ if (req.query.cursor) {
+ const { cursor } = req.query;
+ try {
+ const paginationData = decodeCursor(cursor);
+ if ('offset' in paginationData) {
+ req.query.offset = paginationData.offset;
+ req.query.limit = paginationData.limit;
+ } else {
+ req.query.lastId = paginationData.lastId;
+ req.query.limit = paginationData.limit;
+ }
+ } catch (error) {
+ return res.status(400).json({
+ message: 'An invalid cursor was provided',
+ });
+ }
+ }
+ next();
+};
diff --git a/packages/cli/src/PublicApi/v1/shared/services/pagination.service.ts b/packages/cli/src/PublicApi/v1/shared/services/pagination.service.ts
new file mode 100644
index 0000000000..519ee1984d
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/services/pagination.service.ts
@@ -0,0 +1,46 @@
+import {
+ CursorPagination,
+ OffsetPagination,
+ PaginationCursorDecoded,
+ PaginationOffsetDecoded,
+} from '../../../types';
+
+export const decodeCursor = (cursor: string): PaginationOffsetDecoded | PaginationCursorDecoded => {
+ return JSON.parse(Buffer.from(cursor, 'base64').toString()) as
+ | PaginationCursorDecoded
+ | PaginationOffsetDecoded;
+};
+
+const encodeOffSetPagination = (pagination: OffsetPagination): string | null => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (pagination.numberOfTotalRecords > pagination.offset + pagination.limit) {
+ return Buffer.from(
+ JSON.stringify({
+ limit: pagination.limit,
+ offset: pagination.offset + pagination.limit,
+ }),
+ ).toString('base64');
+ }
+ return null;
+};
+
+const encodeCursorPagination = (pagination: CursorPagination): string | null => {
+ if (pagination.numberOfNextRecords) {
+ return Buffer.from(
+ JSON.stringify({
+ lastId: pagination.lastId,
+ limit: pagination.limit,
+ }),
+ ).toString('base64');
+ }
+ return null;
+};
+
+export const encodeNextCursor = (
+ pagination: OffsetPagination | CursorPagination,
+): string | null => {
+ if ('offset' in pagination) {
+ return encodeOffSetPagination(pagination);
+ }
+ return encodeCursorPagination(pagination);
+};
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml
new file mode 100644
index 0000000000..b994cdc872
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml
@@ -0,0 +1,10 @@
+Cursor:
+ $ref: './cursor.yml'
+Limit:
+ $ref: './limit.yml'
+ExecutionId:
+ $ref: '../../../handlers/executions/spec/schemas/parameters/executionId.yml'
+WorkflowId:
+ $ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml'
+IncludeData:
+ $ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml'
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/cursor.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/cursor.yml
new file mode 100644
index 0000000000..a7fcc012a3
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/cursor.yml
@@ -0,0 +1,7 @@
+name: cursor
+in: query
+description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request's response. Default value fetches the first "page" of the collection. See pagination for more detail.
+required: false
+style: form
+schema:
+ type: string
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/limit.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/limit.yml
new file mode 100644
index 0000000000..2f6a135e54
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/limit.yml
@@ -0,0 +1,9 @@
+name: limit
+in: query
+description: The maximum number of items to return.
+required: false
+schema:
+ type: number
+ example: 100
+ default: 100
+ maximum: 250
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml
new file mode 100644
index 0000000000..1d02bb8156
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml
@@ -0,0 +1,6 @@
+NotFound:
+ $ref: './notFound.yml'
+Unauthorized:
+ $ref: './unauthorized.yml'
+BadRequest:
+ $ref: './badRequest.yml'
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/badRequest.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/badRequest.yml
new file mode 100644
index 0000000000..2ada886abb
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/badRequest.yml
@@ -0,0 +1 @@
+description: The request is invalid or provides malformed data.
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/notFound.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/notFound.yml
new file mode 100644
index 0000000000..610f03a5f2
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/notFound.yml
@@ -0,0 +1 @@
+description: The specified resource was not found.
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/unauthorized.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/unauthorized.yml
new file mode 100644
index 0000000000..be59626c91
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/unauthorized.yml
@@ -0,0 +1 @@
+description: Unauthorized
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml
new file mode 100644
index 0000000000..a8b150a5ff
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml
@@ -0,0 +1,20 @@
+Error:
+ $ref: './error.yml'
+Execution:
+ $ref: './../../../handlers/executions/spec/schemas/execution.yml'
+Node:
+ $ref: './../../../handlers/workflows/spec/schemas/node.yml'
+Tag:
+ $ref: './../../../handlers/workflows/spec/schemas/tag.yml'
+Workflow:
+ $ref: './../../../handlers/workflows/spec/schemas/workflow.yml'
+WorkflowSettings:
+ $ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml'
+ExecutionList:
+ $ref: './../../../handlers/executions/spec/schemas/executionList.yml'
+WorkflowList:
+ $ref: './../../../handlers/workflows/spec/schemas/workflowList.yml'
+Credential:
+ $ref: './../../../handlers/credentials/spec/schemas/credential.yml'
+CredentialType:
+ $ref: './../../../handlers/credentials/spec/schemas/credentialType.yml'
diff --git a/packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml b/packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml
new file mode 100644
index 0000000000..4d5507dfbd
--- /dev/null
+++ b/packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml
@@ -0,0 +1,10 @@
+required:
+ - message
+type: object
+properties:
+ code:
+ type: string
+ message:
+ type: string
+ description:
+ type: string
diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts
index 3eae444995..0b427e6a46 100644
--- a/packages/cli/src/ResponseHelper.ts
+++ b/packages/cli/src/ResponseHelper.ts
@@ -130,7 +130,6 @@ export function sendErrorResponse(res: Response, error: ResponseError) {
// @ts-ignore
response.stack = error.stack;
}
-
res.status(httpStatusCode).json(response);
}
@@ -147,12 +146,13 @@ const isUniqueConstraintError = (error: Error) =>
* @returns
*/
-export function send(processFunction: (req: Request, res: Response) => Promise) {
+export function send(processFunction: (req: Request, res: Response) => Promise, raw = false) {
+ // eslint-disable-next-line consistent-return
return async (req: Request, res: Response) => {
try {
const data = await processFunction(req, res);
- sendSuccessResponse(res, data);
+ sendSuccessResponse(res, data, raw);
} catch (error) {
if (error instanceof Error && isUniqueConstraintError(error)) {
error.message = 'There is already an entry with this name';
diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts
index 82620691fc..ef0f3d8005 100644
--- a/packages/cli/src/Server.ts
+++ b/packages/cli/src/Server.ts
@@ -53,7 +53,7 @@ import clientOAuth2 from 'client-oauth2';
import clientOAuth1, { RequestOptions } from 'oauth-1.0a';
import csrf from 'csrf';
import requestPromise, { OptionsWithUrl } from 'request-promise-native';
-import { createHmac } from 'crypto';
+import { createHmac, randomBytes } from 'crypto';
// IMPORTANT! Do not switch to anther bcrypt library unless really necessary and
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
import { compare } from 'bcryptjs';
@@ -102,6 +102,7 @@ import {
ICredentialsDb,
ICredentialsOverwrite,
ICustomRequest,
+ IDiagnosticInfo,
IExecutionFlattedDb,
IExecutionFlattedResponse,
IExecutionPushResponse,
@@ -110,7 +111,6 @@ import {
IExecutionsStopData,
IExecutionsSummary,
IExternalHooksClass,
- IDiagnosticInfo,
IN8nUISettings,
IPackageVersions,
ITagWithCountDb,
@@ -146,11 +146,13 @@ import { userManagementRouter } from './UserManagement';
import { resolveJwt } from './UserManagement/auth/jwt';
import { User } from './databases/entities/User';
import type {
+ AuthenticatedRequest,
+ CredentialRequest,
ExecutionRequest,
- WorkflowRequest,
NodeParameterOptionsRequest,
OAuthRequest,
TagsRequest,
+ WorkflowRequest,
} from './requests';
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers';
import { ExecutionEntity } from './databases/entities/ExecutionEntity';
@@ -162,6 +164,7 @@ import {
isEmailSetUp,
isUserManagementEnabled,
} from './UserManagement/UserManagementHelper';
+import { loadPublicApiVersions } from './PublicApi';
require('body-parser-xml')(bodyParser);
@@ -210,6 +213,8 @@ class App {
restEndpoint: string;
+ publicApiEndpoint: string;
+
frontendSettings: IN8nUISettings;
protocol: string;
@@ -234,14 +239,15 @@ class App {
this.defaultWorkflowName = config.getEnv('workflows.defaultName');
this.defaultCredentialsName = config.getEnv('credentials.defaultName');
- this.saveDataErrorExecution = config.getEnv('executions.saveDataOnError');
- this.saveDataSuccessExecution = config.getEnv('executions.saveDataOnSuccess');
- this.saveManualExecutions = config.getEnv('executions.saveDataManualExecutions');
- this.executionTimeout = config.getEnv('executions.timeout');
- this.maxExecutionTimeout = config.getEnv('executions.maxTimeout');
- this.payloadSizeMax = config.getEnv('endpoints.payloadSizeMax');
- this.timezone = config.getEnv('generic.timezone');
- this.restEndpoint = config.getEnv('endpoints.rest');
+ this.saveDataErrorExecution = config.get('executions.saveDataOnError');
+ this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess');
+ this.saveManualExecutions = config.get('executions.saveDataManualExecutions');
+ this.executionTimeout = config.get('executions.timeout');
+ this.maxExecutionTimeout = config.get('executions.maxTimeout');
+ this.payloadSizeMax = config.get('endpoints.payloadSizeMax');
+ this.timezone = config.get('generic.timezone');
+ this.restEndpoint = config.get('endpoints.rest');
+ this.publicApiEndpoint = config.get('publicApi.path');
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
this.testWebhooks = TestWebhooks.getInstance();
@@ -310,6 +316,11 @@ class App {
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
smtpSetup: isEmailSetUp(),
},
+ publicApi: {
+ enabled: config.getEnv('publicApi.disabled') === false,
+ latestVersion: 1,
+ path: config.getEnv('publicApi.path'),
+ },
workflowTagsDisabled: config.getEnv('workflowTagsDisabled'),
logLevel: config.getEnv('logs.level'),
hiringBannerEnabled: config.getEnv('hiringBanner.enabled'),
@@ -373,6 +384,9 @@ class App {
this.endpointWebhookTest,
this.endpointPresetCredentials,
];
+ if (!config.getEnv('publicApi.disabled')) {
+ ignoredEndpoints.push(this.publicApiEndpoint);
+ }
// eslint-disable-next-line prefer-spread
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
@@ -484,8 +498,9 @@ class App {
// eslint-disable-next-line no-inner-declarations
function isTenantAllowed(decodedToken: object): boolean {
- if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '')
+ if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') {
return true;
+ }
for (const [k, v] of Object.entries(decodedToken)) {
if (k === jwtNamespace) {
@@ -543,6 +558,15 @@ class App {
});
}
+ // ----------------------------------------
+ // Public API
+ // ----------------------------------------
+
+ if (!config.getEnv('publicApi.disabled')) {
+ const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(this.publicApiEndpoint);
+ this.app.use(...apiRouters);
+ this.frontendSettings.publicApi.latestVersion = apiLatestVersion;
+ }
// Parse cookies for easier access
this.app.use(cookieParser());
@@ -786,7 +810,7 @@ class App {
}
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
- void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow);
+ void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
@@ -1076,7 +1100,11 @@ class App {
}
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
- void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow);
+ void InternalHooksManager.getInstance().onWorkflowSaved(
+ req.user.id,
+ updatedWorkflow,
+ false,
+ );
if (updatedWorkflow.active) {
// When the workflow is supposed to be active add it again
@@ -1144,7 +1172,7 @@ class App {
await Db.collections.Workflow.delete(workflowId);
- void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId);
+ void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId, false);
await this.externalHooks.run('workflow.afterDelete', [workflowId]);
return true;
diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts
index 8f01975127..cc4df147fd 100644
--- a/packages/cli/src/UserManagement/UserManagementHelper.ts
+++ b/packages/cli/src/UserManagement/UserManagementHelper.ts
@@ -123,6 +123,7 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
resetPasswordTokenExpiration,
createdAt,
updatedAt,
+ apiKey,
...sanitizedUser
} = user;
if (withoutKeys) {
diff --git a/packages/cli/src/UserManagement/auth/jwt.ts b/packages/cli/src/UserManagement/auth/jwt.ts
index 97d2e30ef0..647e17617b 100644
--- a/packages/cli/src/UserManagement/auth/jwt.ts
+++ b/packages/cli/src/UserManagement/auth/jwt.ts
@@ -6,7 +6,7 @@ import { Response } from 'express';
import { createHash } from 'crypto';
import { Db } from '../..';
import { AUTH_COOKIE_NAME } from '../../constants';
-import { JwtToken, JwtPayload } from '../Interfaces';
+import { JwtPayload, JwtToken } from '../Interfaces';
import { User } from '../../databases/entities/User';
import * as config from '../../../config';
diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts
index 54f471bb81..07ca87dcec 100644
--- a/packages/cli/src/UserManagement/routes/me.ts
+++ b/packages/cli/src/UserManagement/routes/me.ts
@@ -1,9 +1,11 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable import/no-cycle */
import express from 'express';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
+import { randomBytes } from 'crypto';
import { Db, InternalHooksManager, ResponseHelper } from '../..';
import { issueCookie } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
@@ -149,4 +151,58 @@ export function meNamespace(this: N8nApp): void {
return { success: true };
}),
);
+
+ /**
+ * Creates an API Key
+ */
+ this.app.post(
+ `/${this.restEndpoint}/me/api-key`,
+ ResponseHelper.send(async (req: AuthenticatedRequest) => {
+ const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;
+
+ await Db.collections.User.update(req.user.id, {
+ apiKey,
+ });
+
+ const telemetryData = {
+ user_id: req.user.id,
+ public_api: false,
+ };
+
+ void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData);
+
+ return { apiKey };
+ }),
+ );
+
+ /**
+ * Deletes an API Key
+ */
+ this.app.delete(
+ `/${this.restEndpoint}/me/api-key`,
+ ResponseHelper.send(async (req: AuthenticatedRequest) => {
+ await Db.collections.User.update(req.user.id, {
+ apiKey: null,
+ });
+
+ const telemetryData = {
+ user_id: req.user.id,
+ public_api: false,
+ };
+
+ void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData);
+
+ return { success: true };
+ }),
+ );
+
+ /**
+ * Get an API Key
+ */
+ this.app.get(
+ `/${this.restEndpoint}/me/api-key`,
+ ResponseHelper.send(async (req: AuthenticatedRequest) => {
+ return { apiKey: req.user.apiKey };
+ }),
+ );
}
diff --git a/packages/cli/src/UserManagement/routes/passwordReset.ts b/packages/cli/src/UserManagement/routes/passwordReset.ts
index 1e1b19f51c..be40c3240c 100644
--- a/packages/cli/src/UserManagement/routes/passwordReset.ts
+++ b/packages/cli/src/UserManagement/routes/passwordReset.ts
@@ -89,6 +89,7 @@ export function passwordResetNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onEmailFailed({
user_id: user.id,
message_type: 'Reset password',
+ public_api: false,
});
if (error instanceof Error) {
throw new ResponseHelper.ResponseError(
@@ -103,6 +104,7 @@ export function passwordResetNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onUserTransactionalEmail({
user_id: id,
message_type: 'Reset password',
+ public_api: false,
});
void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({
diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts
index 4e3be7a653..d82edc5a2c 100644
--- a/packages/cli/src/UserManagement/routes/users.ts
+++ b/packages/cli/src/UserManagement/routes/users.ts
@@ -156,6 +156,7 @@ export function usersNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onUserInvite({
user_id: req.user.id,
target_user_id: Object.values(createUsers) as string[],
+ public_api: false,
});
} catch (error) {
Logger.error('Failed to create user shells', { userShells: createUsers });
@@ -193,11 +194,13 @@ export function usersNamespace(this: N8nApp): void {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user_id: id!,
message_type: 'New user invite',
+ public_api: false,
});
} else {
void InternalHooksManager.getInstance().onEmailFailed({
user_id: req.user.id,
message_type: 'New user invite',
+ public_api: false,
});
Logger.error('Failed to send email', {
userId: req.user.id,
@@ -378,6 +381,7 @@ export function usersNamespace(this: N8nApp): void {
*/
this.app.delete(
`/${this.restEndpoint}/users/:id`,
+ // @ts-ignore
ResponseHelper.send(async (req: UserRequest.Delete) => {
const { id: idToDelete } = req.params;
@@ -472,7 +476,7 @@ export function usersNamespace(this: N8nApp): void {
telemetryData.migration_user_id = transferId;
}
- void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData);
+ void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false);
return { success: true };
}),
@@ -538,6 +542,7 @@ export function usersNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onEmailFailed({
user_id: req.user.id,
message_type: 'Resend invite',
+ public_api: false,
});
Logger.error('Failed to send email', {
email: reinvitee.email,
@@ -554,11 +559,13 @@ export function usersNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onUserReinvite({
user_id: req.user.id,
target_user_id: reinvitee.id,
+ public_api: false,
});
void InternalHooksManager.getInstance().onUserTransactionalEmail({
user_id: reinvitee.id,
message_type: 'Resend invite',
+ public_api: false,
});
return { success: true };
diff --git a/packages/cli/src/WebhookServer.ts b/packages/cli/src/WebhookServer.ts
index 50c934d968..48e9409790 100644
--- a/packages/cli/src/WebhookServer.ts
+++ b/packages/cli/src/WebhookServer.ts
@@ -10,8 +10,6 @@ import express from 'express';
import { readFileSync } from 'fs';
import { getConnectionManager } from 'typeorm';
import bodyParser from 'body-parser';
-// eslint-disable-next-line import/no-extraneous-dependencies, @typescript-eslint/no-unused-vars
-import _ from 'lodash';
import compression from 'compression';
// eslint-disable-next-line import/no-extraneous-dependencies
diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts
index 20043251ea..dca67a5e7c 100644
--- a/packages/cli/src/databases/entities/User.ts
+++ b/packages/cli/src/databases/entities/User.ts
@@ -137,6 +137,10 @@ export class User {
this.updatedAt = new Date();
}
+ @Column({ type: String, nullable: true })
+ @Index({ unique: true })
+ apiKey?: string | null;
+
/**
* Whether the user is pending setup completion.
*/
diff --git a/packages/cli/src/databases/mysqldb/migrations/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/mysqldb/migrations/1652905585850-AddAPIKeyColumn.ts
new file mode 100644
index 0000000000..a527b321e0
--- /dev/null
+++ b/packages/cli/src/databases/mysqldb/migrations/1652905585850-AddAPIKeyColumn.ts
@@ -0,0 +1,29 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+import config from '../../../../config';
+
+export class AddAPIKeyColumn1652905585850 implements MigrationInterface {
+ name = 'AddAPIKeyColumn1652905585850';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ const tablePrefix = config.getEnv('database.tablePrefix');
+
+ await queryRunner.query(
+ 'ALTER TABLE `' + tablePrefix + 'user` ADD COLUMN `apiKey` VARCHAR(255)',
+ );
+ await queryRunner.query(
+ 'CREATE UNIQUE INDEX `UQ_' +
+ tablePrefix +
+ 'ie0zomxves9w3p774drfrkxtj5` ON `' +
+ tablePrefix +
+ 'user` (`apiKey`)',
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ const tablePrefix = config.get('database.tablePrefix');
+ await queryRunner.query(
+ 'DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`',
+ );
+ await queryRunner.query('ALTER TABLE `' + tablePrefix + 'user` DROP COLUMN `apiKey`');
+ }
+}
diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts
index 87fcd62e61..31993d41cd 100644
--- a/packages/cli/src/databases/mysqldb/migrations/index.ts
+++ b/packages/cli/src/databases/mysqldb/migrations/index.ts
@@ -14,6 +14,7 @@ import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecu
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
+import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
export const mysqlMigrations = [
InitialMigration1588157391238,
@@ -32,4 +33,5 @@ export const mysqlMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
+ AddAPIKeyColumn1652905585850,
];
diff --git a/packages/cli/src/databases/postgresdb/migrations/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/postgresdb/migrations/1652905585850-AddAPIKeyColumn.ts
new file mode 100644
index 0000000000..d52adb3a01
--- /dev/null
+++ b/packages/cli/src/databases/postgresdb/migrations/1652905585850-AddAPIKeyColumn.ts
@@ -0,0 +1,28 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+import config from '../../../../config';
+
+export class AddAPIKeyColumn1652905585850 implements MigrationInterface {
+ name = 'AddAPIKeyColumn1652905585850';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ let tablePrefix = config.getEnv('database.tablePrefix');
+ const schema = config.getEnv('database.postgresdb.schema');
+ if (schema) {
+ tablePrefix = schema + '.' + tablePrefix;
+ }
+ await queryRunner.query(`ALTER TABLE ${tablePrefix}user ADD COLUMN "apiKey" VARCHAR(255)`);
+ await queryRunner.query(
+ `CREATE UNIQUE INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5" ON ${tablePrefix}user ("apiKey")`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ let tablePrefix = config.getEnv('database.tablePrefix');
+ const schema = config.getEnv('database.postgresdb.schema');
+ if (schema) {
+ tablePrefix = schema + '.' + tablePrefix;
+ }
+ await queryRunner.query(`DROP INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5"`);
+ await queryRunner.query(`ALTER TABLE ${tablePrefix}user DROP COLUMN "apiKey"`);
+ }
+}
diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts
index c528cd2804..30cc17b873 100644
--- a/packages/cli/src/databases/postgresdb/migrations/index.ts
+++ b/packages/cli/src/databases/postgresdb/migrations/index.ts
@@ -12,6 +12,7 @@ import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseT
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
+import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
export const postgresMigrations = [
InitialMigration1587669153312,
@@ -28,4 +29,5 @@ export const postgresMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
+ AddAPIKeyColumn1652905585850,
];
diff --git a/packages/cli/src/databases/sqlite/migrations/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/sqlite/migrations/1652905585850-AddAPIKeyColumn.ts
new file mode 100644
index 0000000000..23d0cdd475
--- /dev/null
+++ b/packages/cli/src/databases/sqlite/migrations/1652905585850-AddAPIKeyColumn.ts
@@ -0,0 +1,54 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+import * as config from '../../../../config';
+import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
+export class AddAPIKeyColumn1652905585850 implements MigrationInterface {
+ name = 'AddAPIKeyColumn1652905585850';
+
+ async up(queryRunner: QueryRunner): Promise {
+ logMigrationStart(this.name);
+
+ const tablePrefix = config.getEnv('database.tablePrefix');
+
+ await queryRunner.query('PRAGMA foreign_keys=OFF');
+
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, "apiKey" varchar, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings" FROM "${tablePrefix}user"`,
+ );
+ await queryRunner.query(`DROP TABLE "${tablePrefix}user"`);
+ await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "${tablePrefix}user"`);
+
+ await queryRunner.query(
+ `CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
+ );
+ await queryRunner.query(
+ `CREATE UNIQUE INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5" ON "${tablePrefix}user" ("apiKey")`,
+ );
+
+ await queryRunner.query('PRAGMA foreign_keys=ON');
+
+ logMigrationEnd(this.name);
+ }
+
+ async down(queryRunner: QueryRunner): Promise {
+ const tablePrefix = config.getEnv('database.tablePrefix');
+
+ await queryRunner.query('PRAGMA foreign_keys=OFF');
+
+ await queryRunner.query(`ALTER TABLE "${tablePrefix}user" RENAME TO "temporary_user"`);
+ await queryRunner.query(
+ `CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
+ );
+ await queryRunner.query(
+ `INSERT INTO "${tablePrefix}user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings" FROM "temporary_user"`,
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user"`);
+ await queryRunner.query(
+ `CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
+ );
+
+ await queryRunner.query('PRAGMA foreign_keys=ON');
+ }
+}
diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts
index 43029766cd..56a18d98c4 100644
--- a/packages/cli/src/databases/sqlite/migrations/index.ts
+++ b/packages/cli/src/databases/sqlite/migrations/index.ts
@@ -13,6 +13,7 @@ import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecu
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
+import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
const sqliteMigrations = [
InitialMigration1588102412422,
@@ -28,6 +29,7 @@ const sqliteMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
+ AddAPIKeyColumn1652905585850,
];
export { sqliteMigrations };
diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts
index e373cb2ad0..2e9e7ca71d 100644
--- a/packages/cli/src/requests.d.ts
+++ b/packages/cli/src/requests.d.ts
@@ -11,8 +11,10 @@ import {
} from 'n8n-workflow';
import { User } from './databases/entities/User';
+import { Role } from './databases/entities/Role';
import type { IExecutionDeleteFilter, IWorkflowDb } from '.';
import type { PublicUser } from './UserManagement/Interfaces';
+import * as UserManagementMailer from './UserManagement/email/UserManagementMailer';
export type AuthlessRequest<
RouteParams = {},
@@ -26,7 +28,11 @@ export type AuthenticatedRequest<
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
-> = express.Request & { user: User };
+> = express.Request & {
+ user: User;
+ mailer?: UserManagementMailer.UserManagementMailer;
+ globalMemberRole?: Role;
+};
// ----------------------------------
// /workflows
@@ -196,7 +202,19 @@ export declare namespace UserRequest {
{ inviterId?: string; inviteeId?: string }
>;
- export type Delete = AuthenticatedRequest<{ id: string }, {}, {}, { transferId?: string }>;
+ export type Delete = AuthenticatedRequest<
+ { id: string; email: string; identifier: string },
+ {},
+ {},
+ { transferId?: string; includeRole: boolean }
+ >;
+
+ export type Get = AuthenticatedRequest<
+ { id: string; email: string; identifier: string },
+ {},
+ {},
+ { limit?: number; offset?: number; cursor?: string; includeRole?: boolean }
+ >;
export type Reinvite = AuthenticatedRequest<{ id: string }>;
diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts
index 03f9d0c108..9ae08f60c0 100644
--- a/packages/cli/test/integration/auth.api.test.ts
+++ b/packages/cli/test/integration/auth.api.test.ts
@@ -18,7 +18,7 @@ let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
- app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
+ app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@@ -68,6 +68,7 @@ test('POST /login should log user in', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -81,6 +82,7 @@ test('POST /login should log user in', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
@@ -146,6 +148,7 @@ test('GET /login should return logged-in owner shell', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -159,6 +162,7 @@ test('GET /login should return logged-in owner shell', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
@@ -181,6 +185,7 @@ test('GET /login should return logged-in member shell', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -194,6 +199,7 @@ test('GET /login should return logged-in member shell', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
@@ -216,6 +222,7 @@ test('GET /login should return logged-in owner', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -229,6 +236,7 @@ test('GET /login should return logged-in owner', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
@@ -251,6 +259,7 @@ test('GET /login should return logged-in member', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -264,6 +273,7 @@ test('GET /login should return logged-in member', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts
index 872ae0c589..6652c23b26 100644
--- a/packages/cli/test/integration/auth.mw.test.ts
+++ b/packages/cli/test/integration/auth.mw.test.ts
@@ -17,7 +17,7 @@ let testDbName = '';
let globalMemberRole: Role;
beforeAll(async () => {
- app = utils.initTestServer({
+ app = await utils.initTestServer({
applyAuth: true,
endpointGroups: ['me', 'auth', 'owner', 'users'],
});
diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts
index bc77017878..97486e7726 100644
--- a/packages/cli/test/integration/commands/reset.cmd.test.ts
+++ b/packages/cli/test/integration/commands/reset.cmd.test.ts
@@ -14,7 +14,7 @@ let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
- app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
+ app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
diff --git a/packages/cli/test/integration/credentials.api.test.ts b/packages/cli/test/integration/credentials.api.test.ts
index e1ef311821..cc6e8275f0 100644
--- a/packages/cli/test/integration/credentials.api.test.ts
+++ b/packages/cli/test/integration/credentials.api.test.ts
@@ -19,7 +19,7 @@ let globalMemberRole: Role;
let saveCredential: SaveCredentialFunction;
beforeAll(async () => {
- app = utils.initTestServer({
+ app = await utils.initTestServer({
endpointGroups: ['credentials'],
applyAuth: true,
});
diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts
index 5b4cffb3ec..3737195372 100644
--- a/packages/cli/test/integration/me.api.test.ts
+++ b/packages/cli/test/integration/me.api.test.ts
@@ -7,7 +7,13 @@ import * as utils from './shared/utils';
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { Db } from '../../src';
import type { Role } from '../../src/databases/entities/Role';
-import { randomValidPassword, randomEmail, randomName, randomString } from './shared/random';
+import {
+ randomApiKey,
+ randomEmail,
+ randomName,
+ randomString,
+ randomValidPassword,
+} from './shared/random';
import * as testDb from './shared/testDb';
jest.mock('../../src/telemetry');
@@ -18,7 +24,7 @@ let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
- app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
+ app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@@ -55,6 +61,7 @@ describe('Owner shell', () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -67,6 +74,7 @@ describe('Owner shell', () => {
expect(isPending).toBe(true);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
});
test('PATCH /me should succeed with valid inputs', async () => {
@@ -88,6 +96,7 @@ describe('Owner shell', () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -100,6 +109,7 @@ describe('Owner shell', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const storedOwnerShell = await Db.collections.User.findOneOrFail(id);
@@ -175,6 +185,50 @@ describe('Owner shell', () => {
expect(storedShellOwner.personalizationAnswers).toEqual(validPayload);
}
});
+
+ test('POST /me/api-key should create an api key', async () => {
+ const ownerShell = await testDb.createUserShell(globalOwnerRole);
+ const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
+
+ const response = await authOwnerShellAgent.post('/me/api-key');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.apiKey).toBeDefined();
+ expect(response.body.data.apiKey).not.toBeNull();
+
+ const storedShellOwner = await Db.collections.User.findOneOrFail({
+ where: { email: IsNull() },
+ });
+
+ expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey);
+ });
+
+ test('GET /me/api-key should fetch the api key', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+ const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
+
+ const response = await authOwnerShellAgent.get('/me/api-key');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.apiKey).toEqual(ownerShell.apiKey);
+ });
+
+ test('DELETE /me/api-key should fetch the api key', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+ const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
+
+ const response = await authOwnerShellAgent.delete('/me/api-key');
+
+ expect(response.statusCode).toBe(200);
+
+ const storedShellOwner = await Db.collections.User.findOneOrFail({
+ where: { email: IsNull() },
+ });
+
+ expect(storedShellOwner.apiKey).toBeNull();
+ });
});
describe('Member', () => {
@@ -209,6 +263,7 @@ describe('Member', () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -221,6 +276,7 @@ describe('Member', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
});
test('PATCH /me should succeed with valid inputs', async () => {
@@ -242,6 +298,7 @@ describe('Member', () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -254,6 +311,7 @@ describe('Member', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const storedMember = await Db.collections.User.findOneOrFail(id);
@@ -335,6 +393,53 @@ describe('Member', () => {
expect(storedAnswers).toEqual(validPayload);
}
});
+
+ test('POST /me/api-key should create an api key', async () => {
+ const member = await testDb.createUser({
+ globalRole: globalMemberRole,
+ apiKey: randomApiKey(),
+ });
+ const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
+
+ const response = await authMemberAgent.post('/me/api-key');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.apiKey).toBeDefined();
+ expect(response.body.data.apiKey).not.toBeNull();
+
+ const storedMember = await Db.collections.User.findOneOrFail(member.id);
+
+ expect(storedMember.apiKey).toEqual(response.body.data.apiKey);
+ });
+
+ test('GET /me/api-key should fetch the api key', async () => {
+ const member = await testDb.createUser({
+ globalRole: globalMemberRole,
+ apiKey: randomApiKey(),
+ });
+ const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
+
+ const response = await authMemberAgent.get('/me/api-key');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.apiKey).toEqual(member.apiKey);
+ });
+
+ test('DELETE /me/api-key should fetch the api key', async () => {
+ const member = await testDb.createUser({
+ globalRole: globalMemberRole,
+ apiKey: randomApiKey(),
+ });
+ const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
+
+ const response = await authMemberAgent.delete('/me/api-key');
+
+ expect(response.statusCode).toBe(200);
+
+ const storedMember = await Db.collections.User.findOneOrFail(member.id);
+
+ expect(storedMember.apiKey).toBeNull();
+ });
});
describe('Owner', () => {
@@ -364,6 +469,7 @@ describe('Owner', () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -376,6 +482,7 @@ describe('Owner', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
});
test('PATCH /me should succeed with valid inputs', async () => {
@@ -397,6 +504,7 @@ describe('Owner', () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -409,6 +517,7 @@ describe('Owner', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const storedOwner = await Db.collections.User.findOneOrFail(id);
diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts
index 55be99f30f..c5899fce33 100644
--- a/packages/cli/test/integration/owner.api.test.ts
+++ b/packages/cli/test/integration/owner.api.test.ts
@@ -20,7 +20,7 @@ let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
- app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
+ app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@@ -66,6 +66,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
password,
resetPasswordToken,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -78,6 +79,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
expect(resetPasswordToken).toBeUndefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
+ expect(apiKey).toBeUndefined();
const storedOwner = await Db.collections.User.findOneOrFail(id);
expect(storedOwner.password).not.toBe(newOwnerData.password);
diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts
index 8501394463..deaf2f04f2 100644
--- a/packages/cli/test/integration/passwordReset.api.test.ts
+++ b/packages/cli/test/integration/passwordReset.api.test.ts
@@ -24,7 +24,7 @@ let globalMemberRole: Role;
let isSmtpAvailable = false;
beforeAll(async () => {
- app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
+ app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts
new file mode 100644
index 0000000000..f3817b3d50
--- /dev/null
+++ b/packages/cli/test/integration/publicApi/credentials.test.ts
@@ -0,0 +1,411 @@
+import express from 'express';
+import { UserSettings } from 'n8n-core';
+import { Db } from '../../../src';
+import { randomApiKey, randomName, randomString } from '../shared/random';
+import * as utils from '../shared/utils';
+import type { CredentialPayload, SaveCredentialFunction } from '../shared/types';
+import type { Role } from '../../../src/databases/entities/Role';
+import type { User } from '../../../src/databases/entities/User';
+import * as testDb from '../shared/testDb';
+import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants';
+
+jest.mock('../../../src/telemetry');
+
+let app: express.Application;
+let testDbName = '';
+let globalOwnerRole: Role;
+let globalMemberRole: Role;
+let workflowOwnerRole: Role;
+let credentialOwnerRole: Role;
+
+let saveCredential: SaveCredentialFunction;
+
+beforeAll(async () => {
+ app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
+ const initResult = await testDb.init();
+ testDbName = initResult.testDbName;
+
+ utils.initConfigFile();
+
+ const [
+ fetchedGlobalOwnerRole,
+ fetchedGlobalMemberRole,
+ fetchedWorkflowOwnerRole,
+ fetchedCredentialOwnerRole,
+ ] = await testDb.getAllRoles();
+
+ globalOwnerRole = fetchedGlobalOwnerRole;
+ globalMemberRole = fetchedGlobalMemberRole;
+ workflowOwnerRole = fetchedWorkflowOwnerRole;
+ credentialOwnerRole = fetchedCredentialOwnerRole;
+
+ saveCredential = affixRoleToSaveCredential(credentialOwnerRole);
+
+ utils.initTestLogger();
+ utils.initTestTelemetry();
+ utils.initCredentialsTypes();
+});
+
+beforeEach(async () => {
+ await testDb.truncate(['User', 'SharedCredentials', 'Credentials'], testDbName);
+});
+
+afterAll(async () => {
+ await testDb.terminate(testDbName);
+});
+
+test('POST /credentials should create credentials', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+ const payload = {
+ name: 'test credential',
+ type: 'githubApi',
+ data: {
+ accessToken: 'abcdefghijklmnopqrstuvwxyz',
+ user: 'test',
+ server: 'testServer',
+ },
+ };
+
+ const response = await authOwnerAgent.post('/credentials').send(payload);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, name, type } = response.body;
+
+ expect(name).toBe(payload.name);
+ expect(type).toBe(payload.type);
+
+ const credential = await Db.collections.Credentials!.findOneOrFail(id);
+
+ expect(credential.name).toBe(payload.name);
+ expect(credential.type).toBe(payload.type);
+ expect(credential.data).not.toBe(payload.data);
+
+ const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({
+ relations: ['user', 'credentials', 'role'],
+ where: { credentials: credential, user: ownerShell },
+ });
+
+ expect(sharedCredential.role).toEqual(credentialOwnerRole);
+ expect(sharedCredential.credentials.name).toBe(payload.name);
+});
+
+test('POST /credentials should fail with invalid inputs', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ await Promise.all(
+ INVALID_PAYLOADS.map(async (invalidPayload) => {
+ const response = await authOwnerAgent.post('/credentials').send(invalidPayload);
+ expect(response.statusCode === 400 || response.statusCode === 415).toBe(true);
+ }),
+ );
+});
+
+test('POST /credentials should fail with missing encryption key', async () => {
+ const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
+ mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY));
+
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ const response = await authOwnerAgent.post('/credentials').send(credentialPayload());
+
+ expect(response.statusCode).toBe(500);
+
+ mock.mockRestore();
+});
+
+test('DELETE /credentials/:id should delete owned cred for owner', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ const savedCredential = await saveCredential(dbCredential(), { user: ownerShell });
+
+ const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { name, type } = response.body;
+
+ expect(name).toBe(savedCredential.name);
+ expect(type).toBe(savedCredential.type);
+
+ const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
+
+ expect(deletedCredential).toBeUndefined(); // deleted
+
+ const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
+
+ expect(deletedSharedCredential).toBeUndefined(); // deleted
+});
+
+test('DELETE /credentials/:id should delete non-owned cred for owner', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const savedCredential = await saveCredential(dbCredential(), { user: member });
+
+ const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
+
+ expect(deletedCredential).toBeUndefined(); // deleted
+
+ const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
+
+ expect(deletedSharedCredential).toBeUndefined(); // deleted
+});
+
+test('DELETE /credentials/:id should delete owned cred for member', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authMemberAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: member,
+ });
+
+ const savedCredential = await saveCredential(dbCredential(), { user: member });
+
+ const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { name, type } = response.body;
+
+ expect(name).toBe(savedCredential.name);
+ expect(type).toBe(savedCredential.type);
+
+ const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
+
+ expect(deletedCredential).toBeUndefined(); // deleted
+
+ const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
+
+ expect(deletedSharedCredential).toBeUndefined(); // deleted
+});
+
+test('DELETE /credentials/:id should delete owned cred for member but leave others untouched', async () => {
+ const member1 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+ const member2 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const savedCredential = await saveCredential(dbCredential(), { user: member1 });
+ const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member1 });
+ const notToBeChangedCredential2 = await saveCredential(dbCredential(), { user: member2 });
+
+ const authMemberAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: member1,
+ });
+
+ const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { name, type } = response.body;
+
+ expect(name).toBe(savedCredential.name);
+ expect(type).toBe(savedCredential.type);
+
+ const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
+
+ expect(deletedCredential).toBeUndefined(); // deleted
+
+ const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne({
+ where: {
+ credentials: savedCredential,
+ },
+ });
+
+ expect(deletedSharedCredential).toBeUndefined(); // deleted
+
+ await Promise.all(
+ [notToBeChangedCredential, notToBeChangedCredential2].map(async (credential) => {
+ const untouchedCredential = await Db.collections.Credentials!.findOne(credential.id);
+
+ expect(untouchedCredential).toEqual(credential); // not deleted
+
+ const untouchedSharedCredential = await Db.collections.SharedCredentials!.findOne({
+ where: {
+ credentials: credential,
+ },
+ });
+
+ expect(untouchedSharedCredential).toBeDefined(); // not deleted
+ }),
+ );
+});
+
+test('DELETE /credentials/:id should not delete non-owned cred for member', async () => {
+ const ownerShell = await testDb.createUserShell(globalOwnerRole);
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authMemberAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: member,
+ });
+ const savedCredential = await saveCredential(dbCredential(), { user: ownerShell });
+
+ const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
+
+ expect(response.statusCode).toBe(404);
+
+ const shellCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
+
+ expect(shellCredential).toBeDefined(); // not deleted
+
+ const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
+
+ expect(deletedSharedCredential).toBeDefined(); // not deleted
+});
+
+test('DELETE /credentials/:id should fail if cred not found', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ const response = await authOwnerAgent.delete('/credentials/123');
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('GET /credentials/schema/:credentialType should fail due to not found type', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ const response = await authOwnerAgent.get('/credentials/schema/testing');
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('GET /credentials/schema/:credentialType should retrieve credential type', async () => {
+ let ownerShell = await testDb.createUserShell(globalOwnerRole);
+ ownerShell = await testDb.addApiKey(ownerShell);
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ version: 1,
+ auth: true,
+ user: ownerShell,
+ });
+
+ const response = await authOwnerAgent.get('/credentials/schema/githubApi');
+
+ const { additionalProperties, type, properties, required } = response.body;
+
+ expect(additionalProperties).toBe(false);
+ expect(type).toBe('object');
+ expect(properties.server).toBeDefined();
+ expect(properties.server.type).toBe('string');
+ expect(properties.user.type).toBeDefined();
+ expect(properties.user.type).toBe('string');
+ expect(properties.accessToken.type).toBeDefined();
+ expect(properties.accessToken.type).toBe('string');
+ expect(required).toEqual(expect.arrayContaining(['server', 'user', 'accessToken']));
+ expect(response.statusCode).toBe(200);
+});
+
+const credentialPayload = (): CredentialPayload => ({
+ name: randomName(),
+ type: 'githubApi',
+ data: {
+ accessToken: randomString(6, 16),
+ server: randomString(1, 10),
+ user: randomString(1, 10),
+ },
+});
+
+const dbCredential = () => {
+ const credential = credentialPayload();
+ credential.nodesAccess = [{ nodeType: credential.type }];
+ return credential;
+};
+
+const INVALID_PAYLOADS = [
+ {
+ type: randomName(),
+ data: { accessToken: randomString(6, 16) },
+ },
+ {
+ name: randomName(),
+ data: { accessToken: randomString(6, 16) },
+ },
+ {
+ name: randomName(),
+ type: randomName(),
+ },
+ {
+ name: randomName(),
+ type: 'githubApi',
+ data: {
+ server: randomName(),
+ },
+ },
+ {},
+ [],
+ undefined,
+];
+
+function affixRoleToSaveCredential(role: Role) {
+ return (credentialPayload: CredentialPayload, { user }: { user: User }) =>
+ testDb.saveCredential(credentialPayload, { user, role });
+}
diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts
new file mode 100644
index 0000000000..9ef5bf77af
--- /dev/null
+++ b/packages/cli/test/integration/publicApi/executions.test.ts
@@ -0,0 +1,423 @@
+import express = require('express');
+
+import { ActiveWorkflowRunner } from '../../../src';
+import config = require('../../../config');
+import { Role } from '../../../src/databases/entities/Role';
+import { randomApiKey } from '../shared/random';
+
+import * as utils from '../shared/utils';
+import * as testDb from '../shared/testDb';
+
+let app: express.Application;
+let testDbName = '';
+let globalOwnerRole: Role;
+
+let workflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
+
+jest.mock('../../../src/telemetry');
+
+beforeAll(async () => {
+ app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
+ const initResult = await testDb.init();
+ testDbName = initResult.testDbName;
+
+ globalOwnerRole = await testDb.getGlobalOwnerRole();
+
+ utils.initTestTelemetry();
+ utils.initTestLogger();
+ // initializing binary manager leave some async operations open
+ // TODO mockup binary data mannager to avoid error
+ await utils.initBinaryManager();
+ await utils.initNodeTypes();
+ workflowRunner = await utils.initActiveWorkflowRunner();
+});
+
+beforeEach(async () => {
+ // do not combine calls - shared tables must be cleared first and separately
+ await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName);
+ await testDb.truncate(['User', 'Workflow', 'Credentials', 'Execution', 'Settings'], testDbName);
+
+ config.set('userManagement.disabled', false);
+ config.set('userManagement.isInstanceOwnerSetUp', true);
+ config.set('userManagement.emails.mode', 'smtp');
+});
+
+afterEach(async () => {
+ await workflowRunner.removeAll();
+});
+
+afterAll(async () => {
+ await testDb.terminate(testDbName);
+});
+
+test.skip('GET /executions/:executionId should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get('/executions/1');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test.skip('GET /executions/:executionId should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get('/executions/1');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test.skip('GET /executions/:executionId should get an execution', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflow({}, owner);
+
+ const execution = await testDb.createSuccessfullExecution(workflow);
+
+ const response = await authOwnerAgent.get(`/executions/${execution.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const {
+ id,
+ finished,
+ mode,
+ retryOf,
+ retrySuccessId,
+ startedAt,
+ stoppedAt,
+ workflowId,
+ waitTill,
+ } = response.body;
+
+ expect(id).toBeDefined();
+ expect(finished).toBe(true);
+ expect(mode).toEqual(execution.mode);
+ expect(retrySuccessId).toBeNull();
+ expect(retryOf).toBeNull();
+ expect(startedAt).not.toBeNull();
+ expect(stoppedAt).not.toBeNull();
+ expect(workflowId).toBe(execution.workflowId);
+ expect(waitTill).toBeNull();
+});
+
+test.skip('DELETE /executions/:executionId should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.delete('/executions/1');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test.skip('DELETE /executions/:executionId should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.delete('/executions/1');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test.skip('DELETE /executions/:executionId should delete an execution', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflow({}, owner);
+
+ const execution = await testDb.createSuccessfullExecution(workflow);
+
+ const response = await authOwnerAgent.delete(`/executions/${execution.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const {
+ id,
+ finished,
+ mode,
+ retryOf,
+ retrySuccessId,
+ startedAt,
+ stoppedAt,
+ workflowId,
+ waitTill,
+ } = response.body;
+
+ expect(id).toBeDefined();
+ expect(finished).toBe(true);
+ expect(mode).toEqual(execution.mode);
+ expect(retrySuccessId).toBeNull();
+ expect(retryOf).toBeNull();
+ expect(startedAt).not.toBeNull();
+ expect(stoppedAt).not.toBeNull();
+ expect(workflowId).toBe(execution.workflowId);
+ expect(waitTill).toBeNull();
+});
+
+test.skip('GET /executions should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get('/executions');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test.skip('GET /executions should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get('/executions');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test.skip('GET /executions should retrieve all successfull executions', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflow({}, owner);
+
+ const successfullExecution = await testDb.createSuccessfullExecution(workflow);
+
+ await testDb.createErrorExecution(workflow);
+
+ const response = await authOwnerAgent.get(`/executions`).query({
+ status: 'success',
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.nextCursor).toBe(null);
+
+ const {
+ id,
+ finished,
+ mode,
+ retryOf,
+ retrySuccessId,
+ startedAt,
+ stoppedAt,
+ workflowId,
+ waitTill,
+ } = response.body.data[0];
+
+ expect(id).toBeDefined();
+ expect(finished).toBe(true);
+ expect(mode).toEqual(successfullExecution.mode);
+ expect(retrySuccessId).toBeNull();
+ expect(retryOf).toBeNull();
+ expect(startedAt).not.toBeNull();
+ expect(stoppedAt).not.toBeNull();
+ expect(workflowId).toBe(successfullExecution.workflowId);
+ expect(waitTill).toBeNull();
+});
+
+test.skip('GET /executions should retrieve all error executions', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflow({}, owner);
+
+ await testDb.createSuccessfullExecution(workflow);
+
+ const errorExecution = await testDb.createErrorExecution(workflow);
+
+ const response = await authOwnerAgent.get(`/executions`).query({
+ status: 'error',
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.nextCursor).toBe(null);
+
+ const {
+ id,
+ finished,
+ mode,
+ retryOf,
+ retrySuccessId,
+ startedAt,
+ stoppedAt,
+ workflowId,
+ waitTill,
+ } = response.body.data[0];
+
+ expect(id).toBeDefined();
+ expect(finished).toBe(false);
+ expect(mode).toEqual(errorExecution.mode);
+ expect(retrySuccessId).toBeNull();
+ expect(retryOf).toBeNull();
+ expect(startedAt).not.toBeNull();
+ expect(stoppedAt).not.toBeNull();
+ expect(workflowId).toBe(errorExecution.workflowId);
+ expect(waitTill).toBeNull();
+});
+
+test.skip('GET /executions should return all waiting executions', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflow({}, owner);
+
+ await testDb.createSuccessfullExecution(workflow);
+
+ await testDb.createErrorExecution(workflow);
+
+ const waitingExecution = await testDb.createWaitingExecution(workflow);
+
+ const response = await authOwnerAgent.get(`/executions`).query({
+ status: 'waiting',
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.nextCursor).toBe(null);
+
+ const {
+ id,
+ finished,
+ mode,
+ retryOf,
+ retrySuccessId,
+ startedAt,
+ stoppedAt,
+ workflowId,
+ waitTill,
+ } = response.body.data[0];
+
+ expect(id).toBeDefined();
+ expect(finished).toBe(false);
+ expect(mode).toEqual(waitingExecution.mode);
+ expect(retrySuccessId).toBeNull();
+ expect(retryOf).toBeNull();
+ expect(startedAt).not.toBeNull();
+ expect(stoppedAt).not.toBeNull();
+ expect(workflowId).toBe(waitingExecution.workflowId);
+ expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000);
+});
+
+test.skip('GET /executions should retrieve all executions of specific workflow', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const [workflow, workflow2] = await testDb.createManyWorkflows(2, {}, owner);
+
+ const savedExecutions = await testDb.createManyExecutions(
+ 2,
+ workflow,
+ // @ts-ignore
+ testDb.createSuccessfullExecution,
+ );
+ // @ts-ignore
+ await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfullExecution);
+
+ const response = await authOwnerAgent.get(`/executions`).query({
+ workflowId: workflow.id.toString(),
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(2);
+ expect(response.body.nextCursor).toBe(null);
+
+ for (const execution of response.body.data) {
+ const {
+ id,
+ finished,
+ mode,
+ retryOf,
+ retrySuccessId,
+ startedAt,
+ stoppedAt,
+ workflowId,
+ waitTill,
+ } = execution;
+
+ expect(savedExecutions.some((exec) => exec.id === id)).toBe(true);
+ expect(finished).toBe(true);
+ expect(mode).toBeDefined();
+ expect(retrySuccessId).toBeNull();
+ expect(retryOf).toBeNull();
+ expect(startedAt).not.toBeNull();
+ expect(stoppedAt).not.toBeNull();
+ expect(workflowId).toBe(workflow.id.toString());
+ expect(waitTill).toBeNull();
+ }
+});
diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts
new file mode 100644
index 0000000000..55ae573dd0
--- /dev/null
+++ b/packages/cli/test/integration/publicApi/workflows.test.ts
@@ -0,0 +1,1268 @@
+import express = require('express');
+
+import { ActiveWorkflowRunner, Db } from '../../../src';
+import config = require('../../../config');
+import { Role } from '../../../src/databases/entities/Role';
+import { randomApiKey } from '../shared/random';
+
+import * as utils from '../shared/utils';
+import * as testDb from '../shared/testDb';
+import { TagEntity } from '../../../src/databases/entities/TagEntity';
+
+let app: express.Application;
+let testDbName = '';
+let globalOwnerRole: Role;
+let globalMemberRole: Role;
+let workflowOwnerRole: Role;
+let credentialOwnerRole: Role;
+let workflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
+
+jest.mock('../../../src/telemetry');
+
+beforeAll(async () => {
+ app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
+ const initResult = await testDb.init();
+ testDbName = initResult.testDbName;
+
+ const [
+ fetchedGlobalOwnerRole,
+ fetchedGlobalMemberRole,
+ fetchedWorkflowOwnerRole,
+ fetchedCredentialOwnerRole,
+ ] = await testDb.getAllRoles();
+
+ globalOwnerRole = fetchedGlobalOwnerRole;
+ globalMemberRole = fetchedGlobalMemberRole;
+ workflowOwnerRole = fetchedWorkflowOwnerRole;
+ credentialOwnerRole = fetchedCredentialOwnerRole;
+
+ utils.initTestTelemetry();
+ utils.initTestLogger();
+ await utils.initNodeTypes();
+ workflowRunner = await utils.initActiveWorkflowRunner();
+});
+
+beforeEach(async () => {
+ // do not combine calls - shared tables must be cleared first and separately
+ await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName);
+ await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName);
+
+ config.set('userManagement.disabled', false);
+ config.set('userManagement.isInstanceOwnerSetUp', true);
+ config.set('userManagement.emails.mode', 'smtp');
+});
+
+afterEach(async () => {
+ await workflowRunner.removeAll();
+});
+
+afterAll(async () => {
+ await testDb.terminate(testDbName);
+});
+
+test('GET /workflows should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get('/workflows');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('GET /workflows should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get('/workflows');
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('GET /workflows should return all owned workflows', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ await Promise.all([
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({}, member),
+ ]);
+
+ const response = await authAgent.get('/workflows');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(3);
+ expect(response.body.nextCursor).toBeNull();
+
+ for (const workflow of response.body.data) {
+ const {
+ id,
+ connections,
+ active,
+ staticData,
+ nodes,
+ settings,
+ name,
+ createdAt,
+ updatedAt,
+ tags,
+ } = workflow;
+
+ expect(id).toBeDefined();
+ expect(name).toBeDefined();
+ expect(connections).toBeDefined();
+ expect(active).toBe(false);
+ expect(staticData).toBeDefined();
+ expect(nodes).toBeDefined();
+ expect(tags).toBeDefined();
+ expect(settings).toBeDefined();
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+ }
+});
+
+test('GET /workflows should return all owned workflows with pagination', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ await Promise.all([
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({}, member),
+ ]);
+
+ const response = await authAgent.get('/workflows?limit=1');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.nextCursor).not.toBeNull();
+
+ const response2 = await authAgent.get(`/workflows?limit=1&cursor=${response.body.nextCursor}`);
+
+ expect(response2.statusCode).toBe(200);
+ expect(response2.body.data.length).toBe(1);
+ expect(response2.body.nextCursor).not.toBeNull();
+ expect(response2.body.nextCursor).not.toBe(response.body.nextCursor);
+
+ const responses = [...response.body.data, ...response2.body.data];
+
+ for (const workflow of responses) {
+ const {
+ id,
+ connections,
+ active,
+ staticData,
+ nodes,
+ settings,
+ name,
+ createdAt,
+ updatedAt,
+ tags,
+ } = workflow;
+
+ expect(id).toBeDefined();
+ expect(name).toBeDefined();
+ expect(connections).toBeDefined();
+ expect(active).toBe(false);
+ expect(staticData).toBeDefined();
+ expect(nodes).toBeDefined();
+ expect(tags).toBeDefined();
+ expect(settings).toBeDefined();
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+ }
+
+ // check that we really received a different result
+ expect(response.body.data[0].id).toBeLessThan(response2.body.data[0].id);
+});
+
+test('GET /workflows should return all owned workflows filtered by tag', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ const tag = await testDb.createTag({});
+
+ const [workflow] = await Promise.all([
+ testDb.createWorkflow({ tags: [tag] }, member),
+ testDb.createWorkflow({}, member),
+ ]);
+
+ const response = await authAgent.get(`/workflows?tags=${tag.name}`);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(1);
+
+ const {
+ id,
+ connections,
+ active,
+ staticData,
+ nodes,
+ settings,
+ name,
+ createdAt,
+ updatedAt,
+ tags: wfTags,
+ } = response.body.data[0];
+
+ expect(id).toBe(workflow.id);
+ expect(name).toBeDefined();
+ expect(connections).toBeDefined();
+ expect(active).toBe(false);
+ expect(staticData).toBeDefined();
+ expect(nodes).toBeDefined();
+ expect(settings).toBeDefined();
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+
+ expect(wfTags.length).toBe(1);
+ expect(wfTags[0].id).toBe(tag.id);
+});
+
+test('GET /workflows should return all owned workflows filtered by tags', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ const tags = await Promise.all([await testDb.createTag({}), await testDb.createTag({})]);
+ const tagNames = tags.map((tag) => tag.name).join(',');
+
+ const [workflow1, workflow2] = await Promise.all([
+ testDb.createWorkflow({ tags }, member),
+ testDb.createWorkflow({ tags }, member),
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({ tags: [tags[0]] }, member),
+ testDb.createWorkflow({ tags: [tags[1]] }, member),
+ ]);
+
+ const response = await authAgent.get(`/workflows?tags=${tagNames}`);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(2);
+
+ for (const workflow of response.body.data) {
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ workflow;
+
+ expect(id).toBeDefined();
+ expect([workflow1.id, workflow2.id].includes(id)).toBe(true);
+
+ expect(name).toBeDefined();
+ expect(connections).toBeDefined();
+ expect(active).toBe(false);
+ expect(staticData).toBeDefined();
+ expect(nodes).toBeDefined();
+ expect(settings).toBeDefined();
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+
+ expect(workflow.tags.length).toBe(2);
+ workflow.tags.forEach((tag: TagEntity) => {
+ expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true);
+ });
+ }
+});
+
+test('GET /workflows should return all workflows for owner', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ await Promise.all([
+ testDb.createWorkflow({}, owner),
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({}, owner),
+ testDb.createWorkflow({}, member),
+ testDb.createWorkflow({}, owner),
+ ]);
+
+ const response = await authOwnerAgent.get('/workflows');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.length).toBe(5);
+ expect(response.body.nextCursor).toBeNull();
+
+ for (const workflow of response.body.data) {
+ const {
+ id,
+ connections,
+ active,
+ staticData,
+ nodes,
+ settings,
+ name,
+ createdAt,
+ updatedAt,
+ tags,
+ } = workflow;
+
+ expect(id).toBeDefined();
+ expect(name).toBeDefined();
+ expect(connections).toBeDefined();
+ expect(active).toBe(false);
+ expect(staticData).toBeDefined();
+ expect(nodes).toBeDefined();
+ expect(tags).toBeDefined();
+ expect(settings).toBeDefined();
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+ }
+});
+
+test('GET /workflows/:workflowId should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ owner.apiKey = null;
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get(`/workflows/2`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('GET /workflows/:workflowId should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get(`/workflows/2`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('GET /workflows/:workflowId should fail due to non existing workflow', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.get(`/workflows/2`);
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('GET /workflows/:workflowId should retrieve workflow', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ // create and assign workflow to owner
+ const workflow = await testDb.createWorkflow({}, member);
+
+ const response = await authAgent.get(`/workflows/${workflow.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(false);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toEqual(workflow.createdAt.toISOString());
+ expect(updatedAt).toEqual(workflow.updatedAt.toISOString());
+});
+
+test('GET /workflows/:workflowId should retrieve non-owned workflow for owner', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ // create and assign workflow to owner
+ const workflow = await testDb.createWorkflow({}, member);
+
+ const response = await authOwnerAgent.get(`/workflows/${workflow.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(false);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toEqual(workflow.createdAt.toISOString());
+ expect(updatedAt).toEqual(workflow.updatedAt.toISOString());
+});
+
+test('DELETE /workflows/:workflowId should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.delete(`/workflows/2`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('DELETE /workflows/:workflowId should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.delete(`/workflows/2`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('DELETE /workflows/:workflowId should fail due to non existing workflow', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.delete(`/workflows/2`);
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('DELETE /workflows/:workflowId should delete the workflow', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ // create and assign workflow to owner
+ const workflow = await testDb.createWorkflow({}, member);
+
+ const response = await authAgent.delete(`/workflows/${workflow.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(false);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toEqual(workflow.createdAt.toISOString());
+ expect(updatedAt).toEqual(workflow.updatedAt.toISOString());
+
+ // make sure the workflow actually deleted from the db
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ workflow,
+ });
+
+ expect(sharedWorkflow).toBeUndefined();
+});
+
+test('DELETE /workflows/:workflowId should delete non-owned workflow when owner', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ // create and assign workflow to owner
+ const workflow = await testDb.createWorkflow({}, member);
+
+ const response = await authAgent.delete(`/workflows/${workflow.id}`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(false);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toEqual(workflow.createdAt.toISOString());
+ expect(updatedAt).toEqual(workflow.updatedAt.toISOString());
+
+ // make sure the workflow actually deleted from the db
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ workflow,
+ });
+
+ expect(sharedWorkflow).toBeUndefined();
+});
+
+test('POST /workflows/:workflowId/activate should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows/2/activate`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('POST /workflows/:workflowId/activate should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows/2/activate`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('POST /workflows/:workflowId/activate should fail due to non existing workflow', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows/2/activate`);
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('POST /workflows/:workflowId/activate should fail due to trying to activate a workflow without a trigger', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflow({}, owner);
+
+ const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`);
+
+ expect(response.statusCode).toBe(400);
+});
+
+test.skip('POST /workflows/:workflowId/activate should set workflow as active', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflowWithTrigger({}, member);
+
+ const response = await authAgent.post(`/workflows/${workflow.id}/activate`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(true);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toEqual(workflow.createdAt.toISOString());
+ expect(updatedAt).toEqual(workflow.updatedAt.toISOString());
+
+ // check whether the workflow is on the database
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow,
+ },
+ relations: ['workflow'],
+ });
+
+ expect(sharedWorkflow?.workflow.active).toBe(true);
+
+ // check whether the workflow is on the active workflow runner
+ expect(await workflowRunner.isActive(workflow.id.toString())).toBe(true);
+});
+
+test.skip('POST /workflows/:workflowId/activate should set non-owned workflow as active when owner', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflowWithTrigger({}, member);
+
+ const response = await authAgent.post(`/workflows/${workflow.id}/activate`);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(true);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toEqual(workflow.createdAt.toISOString());
+ expect(updatedAt).toEqual(workflow.updatedAt.toISOString());
+
+ // check whether the workflow is on the database
+ const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: owner,
+ workflow,
+ },
+ });
+
+ expect(sharedOwnerWorkflow).toBeUndefined();
+
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow,
+ },
+ relations: ['workflow'],
+ });
+
+ expect(sharedWorkflow?.workflow.active).toBe(true);
+
+ // check whether the workflow is on the active workflow runner
+ expect(await workflowRunner.isActive(workflow.id.toString())).toBe(true);
+});
+
+test('POST /workflows/:workflowId/deactivate should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows/2/deactivate`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('POST /workflows/:workflowId/deactivate should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows/2/deactivate`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('POST /workflows/:workflowId/deactivate should fail due to non existing workflow', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows/2/deactivate`);
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('POST /workflows/:workflowId/deactivate should deactive workflow', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflowWithTrigger({}, member);
+
+ await authAgent.post(`/workflows/${workflow.id}/activate`);
+
+ const workflowDeactivationResponse = await authAgent.post(`/workflows/${workflow.id}/deactivate`);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ workflowDeactivationResponse.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(false);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+
+ // get the workflow after it was deactivated
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow,
+ },
+ relations: ['workflow'],
+ });
+
+ // check whether the workflow is deactivated in the database
+ expect(sharedWorkflow?.workflow.active).toBe(false);
+
+ expect(await workflowRunner.isActive(workflow.id.toString())).toBe(false);
+});
+
+test('POST /workflows/:workflowId/deactivate should deactive non-owned workflow when owner', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const workflow = await testDb.createWorkflowWithTrigger({}, member);
+
+ await authAgent.post(`/workflows/${workflow.id}/activate`);
+
+ const workflowDeactivationResponse = await authAgent.post(`/workflows/${workflow.id}/deactivate`);
+
+ const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
+ workflowDeactivationResponse.body;
+
+ expect(id).toEqual(workflow.id);
+ expect(name).toEqual(workflow.name);
+ expect(connections).toEqual(workflow.connections);
+ expect(active).toBe(false);
+ expect(staticData).toEqual(workflow.staticData);
+ expect(nodes).toEqual(workflow.nodes);
+ expect(settings).toEqual(workflow.settings);
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toBeDefined();
+
+ // check whether the workflow is deactivated in the database
+ const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: owner,
+ workflow,
+ },
+ });
+
+ expect(sharedOwnerWorkflow).toBeUndefined();
+
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow,
+ },
+ relations: ['workflow'],
+ });
+
+ expect(sharedWorkflow?.workflow.active).toBe(false);
+
+ expect(await workflowRunner.isActive(workflow.id.toString())).toBe(false);
+});
+
+test('POST /workflows should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('POST /workflows should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('POST /workflows should fail due to invalid body', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.post(`/workflows`).send({});
+
+ expect(response.statusCode).toBe(400);
+});
+
+test('POST /workflows should create workflow', async () => {
+ const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ const payload = {
+ name: 'testing',
+ nodes: [
+ {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ },
+ ],
+ connections: {},
+ staticData: null,
+ settings: {
+ saveExecutionProgress: true,
+ saveManualExecutions: true,
+ saveDataErrorExecution: 'all',
+ saveDataSuccessExecution: 'all',
+ executionTimeout: 3600,
+ timezone: 'America/New_York',
+ },
+ };
+
+ const response = await authAgent.post(`/workflows`).send(payload);
+
+ expect(response.statusCode).toBe(200);
+
+ const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } =
+ response.body;
+
+ expect(id).toBeDefined();
+ expect(name).toBe(payload.name);
+ expect(connections).toEqual(payload.connections);
+ expect(settings).toEqual(payload.settings);
+ expect(staticData).toEqual(payload.staticData);
+ expect(nodes).toEqual(payload.nodes);
+ expect(active).toBe(false);
+ expect(createdAt).toBeDefined();
+ expect(updatedAt).toEqual(createdAt);
+
+ // check if created workflow in DB
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow: response.body,
+ },
+ relations: ['workflow', 'role'],
+ });
+
+ expect(sharedWorkflow?.workflow.name).toBe(name);
+ expect(sharedWorkflow?.workflow.createdAt.toISOString()).toBe(createdAt);
+ expect(sharedWorkflow?.role).toEqual(workflowOwnerRole);
+});
+
+test('PUT /workflows/:workflowId should fail due to missing API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.put(`/workflows/1`);
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('PUT /workflows/:workflowId should fail due to invalid API Key', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ owner.apiKey = 'abcXYZ';
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.put(`/workflows/1`).send({});
+
+ expect(response.statusCode).toBe(401);
+});
+
+test('PUT /workflows/:workflowId should fail due to non existing workflow', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.put(`/workflows/1`).send({
+ name: 'testing',
+ nodes: [
+ {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ },
+ ],
+ connections: {},
+ staticData: null,
+ settings: {
+ saveExecutionProgress: true,
+ saveManualExecutions: true,
+ saveDataErrorExecution: 'all',
+ saveDataSuccessExecution: 'all',
+ executionTimeout: 3600,
+ timezone: 'America/New_York',
+ },
+ });
+
+ expect(response.statusCode).toBe(404);
+});
+
+test('PUT /workflows/:workflowId should fail due to invalid body', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const authOwnerAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const response = await authOwnerAgent.put(`/workflows/1`).send({
+ nodes: [
+ {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ },
+ ],
+ connections: {},
+ staticData: null,
+ settings: {
+ saveExecutionProgress: true,
+ saveManualExecutions: true,
+ saveDataErrorExecution: 'all',
+ saveDataSuccessExecution: 'all',
+ executionTimeout: 3600,
+ timezone: 'America/New_York',
+ },
+ });
+
+ expect(response.statusCode).toBe(400);
+});
+
+test('PUT /workflows/:workflowId should update workflow', async () => {
+ const member = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+
+ const workflow = await testDb.createWorkflow({}, member);
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: member,
+ version: 1,
+ });
+
+ const payload = {
+ name: 'name updated',
+ nodes: [
+ {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ },
+ {
+ parameters: {},
+ name: 'Cron',
+ type: 'n8n-nodes-base.cron',
+ typeVersion: 1,
+ position: [400, 300],
+ },
+ ],
+ connections: {},
+ staticData: '{"id":1}',
+ settings: {
+ saveExecutionProgress: false,
+ saveManualExecutions: false,
+ saveDataErrorExecution: 'all',
+ saveDataSuccessExecution: 'all',
+ executionTimeout: 3600,
+ timezone: 'America/New_York',
+ },
+ };
+
+ const response = await authAgent.put(`/workflows/${workflow.id}`).send(payload);
+
+ const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } =
+ response.body;
+
+ expect(response.statusCode).toBe(200);
+
+ expect(id).toBe(workflow.id);
+ expect(name).toBe(payload.name);
+ expect(connections).toEqual(payload.connections);
+ expect(settings).toEqual(payload.settings);
+ expect(staticData).toEqual(payload.staticData);
+ expect(nodes).toEqual(payload.nodes);
+ expect(active).toBe(false);
+ expect(createdAt).toBe(workflow.createdAt.toISOString());
+ expect(updatedAt).not.toBe(workflow.updatedAt.toISOString());
+
+ // check updated workflow in DB
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow: response.body,
+ },
+ relations: ['workflow'],
+ });
+
+ expect(sharedWorkflow?.workflow.name).toBe(payload.name);
+ expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan(
+ workflow.updatedAt.getTime(),
+ );
+});
+
+test('PUT /workflows/:workflowId should update non-owned workflow if owner', async () => {
+ const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
+ const member = await testDb.createUser({ globalRole: globalMemberRole });
+
+ const workflow = await testDb.createWorkflow({}, member);
+
+ const authAgent = utils.createAgent(app, {
+ apiPath: 'public',
+ auth: true,
+ user: owner,
+ version: 1,
+ });
+
+ const payload = {
+ name: 'name owner updated',
+ nodes: [
+ {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ },
+ {
+ parameters: {},
+ name: 'Cron',
+ type: 'n8n-nodes-base.cron',
+ typeVersion: 1,
+ position: [400, 300],
+ },
+ ],
+ connections: {},
+ staticData: '{"id":1}',
+ settings: {
+ saveExecutionProgress: false,
+ saveManualExecutions: false,
+ saveDataErrorExecution: 'all',
+ saveDataSuccessExecution: 'all',
+ executionTimeout: 3600,
+ timezone: 'America/New_York',
+ },
+ };
+
+ const response = await authAgent.put(`/workflows/${workflow.id}`).send(payload);
+
+ const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } =
+ response.body;
+
+ expect(response.statusCode).toBe(200);
+
+ expect(id).toBe(workflow.id);
+ expect(name).toBe(payload.name);
+ expect(connections).toEqual(payload.connections);
+ expect(settings).toEqual(payload.settings);
+ expect(staticData).toEqual(payload.staticData);
+ expect(nodes).toEqual(payload.nodes);
+ expect(active).toBe(false);
+ expect(createdAt).toBe(workflow.createdAt.toISOString());
+ expect(updatedAt).not.toBe(workflow.updatedAt.toISOString());
+
+ // check updated workflow in DB
+ const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: owner,
+ workflow: response.body,
+ },
+ });
+
+ expect(sharedOwnerWorkflow).toBeUndefined();
+
+ const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
+ where: {
+ user: member,
+ workflow: response.body,
+ },
+ relations: ['workflow', 'role'],
+ });
+
+ expect(sharedWorkflow?.workflow.name).toBe(payload.name);
+ expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan(
+ workflow.updatedAt.getTime(),
+ );
+ expect(sharedWorkflow?.role).toEqual(workflowOwnerRole);
+});
diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts
index 75c2a69a38..d7e165ab4a 100644
--- a/packages/cli/test/integration/shared/constants.ts
+++ b/packages/cli/test/integration/shared/constants.ts
@@ -2,6 +2,8 @@ import config from '../../../config';
export const REST_PATH_SEGMENT = config.getEnv('endpoints.rest') as Readonly;
+export const PUBLIC_API_REST_PATH_SEGMENT = config.getEnv('publicApi.path') as Readonly;
+
export const AUTHLESS_ENDPOINTS: Readonly = [
'healthz',
'metrics',
diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts
index 68ea41cce7..2553d3635f 100644
--- a/packages/cli/test/integration/shared/random.ts
+++ b/packages/cli/test/integration/shared/random.ts
@@ -10,6 +10,10 @@ export function randomString(min: number, max: number) {
return randomBytes(randomInteger / 2).toString('hex');
}
+export function randomApiKey() {
+ return `n8n_api_${randomBytes(20).toString('hex')}`;
+}
+
const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)];
const randomDigit = () => Math.floor(Math.random() * 10);
@@ -17,7 +21,9 @@ const randomDigit = () => Math.floor(Math.random() * 10);
const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
export const randomValidPassword = () =>
- randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) + randomUppercaseLetter() + randomDigit();
+ randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) +
+ randomUppercaseLetter() +
+ randomDigit();
export const randomInvalidPassword = () =>
chooseRandomly([
diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts
index c433029213..d250fa99ec 100644
--- a/packages/cli/test/integration/shared/testDb.ts
+++ b/packages/cli/test/integration/shared/testDb.ts
@@ -6,11 +6,10 @@ import { Credentials, UserSettings } from 'n8n-core';
import config from '../../../config';
import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants';
-import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
-import { randomEmail, randomName, randomString, randomValidPassword } from './random';
+import { Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
+import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity';
import { hashPassword } from '../../../src/UserManagement/UserManagementHelper';
-import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants';
import { entities } from '../../../src/databases/entities';
import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations';
import { postgresMigrations } from '../../../src/databases/postgresdb/migrations';
@@ -19,8 +18,11 @@ import { categorize, getPostgresSchemaSection } from './utils';
import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper';
import type { Role } from '../../../src/databases/entities/Role';
-import type { User } from '../../../src/databases/entities/User';
+import { User } from '../../../src/databases/entities/User';
import type { CollectionName, CredentialPayload } from './types';
+import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity';
+import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity';
+import { TagEntity } from '../../../src/databases/entities/TagEntity';
const exec = promisify(callbackExec);
@@ -56,7 +58,7 @@ export async function init() {
`host: ${pgOptions.host} | port: ${pgOptions.port} | schema: ${pgOptions.schema} | username: ${pgOptions.username} | password: ${pgOptions.password}`,
'Fix by setting correct values via environment variables:',
`${pgConfig.host.env} | ${pgConfig.port.env} | ${pgConfig.schema.env} | ${pgConfig.user.env} | ${pgConfig.password.env}`,
- 'Otherwise, make sure your Postgres server is running.'
+ 'Otherwise, make sure your Postgres server is running.',
].join('\n');
console.error(message);
@@ -72,7 +74,9 @@ export async function init() {
await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`);
} catch (error) {
if (error instanceof Error && error.message.includes('command not found')) {
- console.error('psql command not found. Make sure psql is installed and added to your PATH.');
+ console.error(
+ 'psql command not found. Make sure psql is installed and added to your PATH.',
+ );
}
process.exit(1);
}
@@ -228,15 +232,17 @@ export async function saveCredential(
// user creation
// ----------------------------------
-export async function createUser(attributes: Partial & { globalRole: Role }): Promise {
+/**
+ * Store a user in the DB, defaulting to a `member`.
+ */
+export async function createUser(attributes: Partial = {}): Promise {
const { email, password, firstName, lastName, globalRole, ...rest } = attributes;
-
const user = {
email: email ?? randomEmail(),
password: await hashPassword(password ?? randomValidPassword()),
firstName: firstName ?? randomName(),
lastName: lastName ?? randomName(),
- globalRole,
+ globalRole: globalRole ?? (await getGlobalMemberRole()),
...rest,
};
@@ -257,6 +263,11 @@ export function createUserShell(globalRole: Role): Promise {
return Db.collections.User.save(shell);
}
+export function addApiKey(user: User): Promise {
+ user.apiKey = randomApiKey();
+ return Db.collections.User.save(user);
+}
+
// ----------------------------------
// role fetchers
// ----------------------------------
@@ -298,6 +309,187 @@ export function getAllRoles() {
]);
}
+// ----------------------------------
+// Execution helpers
+// ----------------------------------
+
+export async function createManyExecutions(
+ amount: number,
+ workflow: WorkflowEntity,
+ callback: (workflow: WorkflowEntity) => Promise,
+) {
+ const executionsRequests = [...Array(amount)].map((_) => callback(workflow));
+ return Promise.all(executionsRequests);
+}
+
+/**
+ * Store a execution in the DB and assigns it to a workflow.
+ * @param user user to assign the workflow to
+ */
+export async function createExecution(
+ attributes: Partial = {},
+ workflow: WorkflowEntity,
+) {
+ const { data, finished, mode, startedAt, stoppedAt, waitTill } = attributes;
+
+ const execution = await Db.collections.Execution.save({
+ data: data ?? '[]',
+ finished: finished ?? true,
+ mode: mode ?? 'manual',
+ startedAt: startedAt ?? new Date(),
+ ...(workflow !== undefined && { workflowData: workflow, workflowId: workflow.id.toString() }),
+ stoppedAt: stoppedAt ?? new Date(),
+ waitTill: waitTill ?? null,
+ });
+
+ return execution;
+}
+
+/**
+ * Store a execution in the DB and assigns it to a workflow.
+ * @param user user to assign the workflow to
+ */
+export async function createSuccessfullExecution(workflow: WorkflowEntity) {
+ const execution = await createExecution(
+ {
+ finished: true,
+ },
+ workflow,
+ );
+
+ return execution;
+}
+
+/**
+ * Store a execution in the DB and assigns it to a workflow.
+ * @param user user to assign the workflow to
+ */
+export async function createErrorExecution(workflow: WorkflowEntity) {
+ const execution = await createExecution(
+ {
+ finished: false,
+ stoppedAt: new Date(),
+ },
+ workflow,
+ );
+ return execution;
+}
+
+/**
+ * Store a execution in the DB and assigns it to a workflow.
+ * @param user user to assign the workflow to
+ */
+export async function createWaitingExecution(workflow: WorkflowEntity) {
+ const execution = await createExecution(
+ {
+ finished: false,
+ waitTill: new Date(),
+ },
+ workflow,
+ );
+ return execution;
+}
+
+// ----------------------------------
+// Tags
+// ----------------------------------
+
+export async function createTag(attributes: Partial = {}) {
+ const { name } = attributes;
+
+ return await Db.collections.Tag.save({
+ name: name ?? randomName(),
+ ...attributes,
+ });
+}
+
+// ----------------------------------
+// Workflow helpers
+// ----------------------------------
+
+export async function createManyWorkflows(
+ amount: number,
+ attributes: Partial = {},
+ user?: User,
+) {
+ const workflowRequests = [...Array(amount)].map((_) => createWorkflow(attributes, user));
+ return Promise.all(workflowRequests);
+}
+
+/**
+ * Store a workflow in the DB (without a trigger) and optionally assigns it to a user.
+ * @param user user to assign the workflow to
+ */
+export async function createWorkflow(attributes: Partial = {}, user?: User) {
+ const { active, name, nodes, connections } = attributes;
+
+ const workflow = await Db.collections.Workflow.save({
+ active: active ?? false,
+ name: name ?? 'test workflow',
+ nodes: nodes ?? [
+ {
+ name: 'Start',
+ parameters: {},
+ position: [-20, 260],
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ },
+ ],
+ connections: connections ?? {},
+ ...attributes,
+ });
+
+ if (user) {
+ await Db.collections.SharedWorkflow.save({
+ user,
+ workflow,
+ role: await getWorkflowOwnerRole(),
+ });
+ }
+ return workflow;
+}
+
+/**
+ * Store a workflow in the DB (with a trigger) and optionally assigns it to a user.
+ * @param user user to assign the workflow to
+ */
+export async function createWorkflowWithTrigger(
+ attributes: Partial = {},
+ user?: User,
+) {
+ const workflow = await createWorkflow(
+ {
+ nodes: [
+ {
+ parameters: {},
+ name: 'Start',
+ type: 'n8n-nodes-base.start',
+ typeVersion: 1,
+ position: [240, 300],
+ },
+ {
+ parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } },
+ name: 'Cron',
+ type: 'n8n-nodes-base.cron',
+ typeVersion: 1,
+ position: [500, 300],
+ },
+ {
+ parameters: { options: {} },
+ name: 'Set',
+ type: 'n8n-nodes-base.set',
+ typeVersion: 1,
+ position: [780, 300],
+ },
+ ],
+ connections: { Cron: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } },
+ ...attributes,
+ },
+ user,
+ );
+ return workflow;
+}
+
// ----------------------------------
// connection options
// ----------------------------------
diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts
index 6d90bf64cc..ca10dacb9b 100644
--- a/packages/cli/test/integration/shared/types.d.ts
+++ b/packages/cli/test/integration/shared/types.d.ts
@@ -15,12 +15,14 @@ export type SmtpTestAccount = {
};
};
-type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials';
+export type ApiPath = 'internal' | 'public';
+
+type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials' | 'publicApi';
export type CredentialPayload = {
name: string;
type: string;
- nodesAccess: ICredentialNodeAccess[];
+ nodesAccess?: ICredentialNodeAccess[];
data: ICredentialDataDecryptedObject;
};
diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts
index 021fdc27cb..f3d205476f 100644
--- a/packages/cli/test/integration/shared/utils.ts
+++ b/packages/cli/test/integration/shared/utils.ts
@@ -7,14 +7,33 @@ import { URL } from 'url';
import bodyParser from 'body-parser';
import util from 'util';
import { createTestAccount } from 'nodemailer';
-import { INodeTypes, LoggerProxy } from 'n8n-workflow';
-import { UserSettings } from 'n8n-core';
+import {
+ ICredentialType,
+ IDataObject,
+ IExecuteFunctions,
+ INodeExecutionData,
+ INodeParameters,
+ INodeTypeData,
+ INodeTypes,
+ ITriggerFunctions,
+ ITriggerResponse,
+ LoggerProxy,
+} from 'n8n-workflow';
+import { BinaryDataManager, UserSettings } from 'n8n-core';
+import { CronJob } from 'cron';
-import config from '../../../config';
-import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants';
+import config = require('../../../config');
+import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants';
import { AUTH_COOKIE_NAME } from '../../../src/constants';
import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes';
-import { Db, ExternalHooks, InternalHooksManager } from '../../../src';
+import {
+ ActiveWorkflowRunner,
+ CredentialTypes,
+ Db,
+ ExternalHooks,
+ InternalHooksManager,
+ NodeTypes,
+} from '../../../src';
import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me';
import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users';
import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth';
@@ -23,9 +42,20 @@ import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/U
import { issueJWT } from '../../../src/UserManagement/auth/jwt';
import { getLogger } from '../../../src/Logger';
import { credentialsController } from '../../../src/api/credentials.api';
+import { loadPublicApiVersions } from '../../../src/PublicApi/';
import type { User } from '../../../src/databases/entities/User';
-import type { EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types';
+import type { ApiPath, EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types';
+import { Telemetry } from '../../../src/telemetry';
import type { N8nApp } from '../../../src/UserManagement/Interfaces';
+import { set } from 'lodash';
+interface TriggerTime {
+ mode: string;
+ hour: number;
+ minute: number;
+ dayOfMonth: number;
+ weekeday: number;
+ [key: string]: string | number;
+}
import * as UserManagementMailer from '../../../src/UserManagement/email/UserManagementMailer';
/**
@@ -34,7 +64,7 @@ import * as UserManagementMailer from '../../../src/UserManagement/email/UserMan
* @param applyAuth Whether to apply auth middleware to test server.
* @param endpointGroups Groups of endpoints to apply to test server.
*/
-export function initTestServer({
+export async function initTestServer({
applyAuth,
endpointGroups,
}: {
@@ -44,6 +74,7 @@ export function initTestServer({
const testServer = {
app: express(),
restEndpoint: REST_PATH_SEGMENT,
+ publicApiEndpoint: PUBLIC_API_REST_PATH_SEGMENT,
...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}),
};
@@ -62,12 +93,18 @@ export function initTestServer({
const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups);
if (routerEndpoints.length) {
- const map: Record = {
+ const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint);
+ const map: Record = {
credentials: credentialsController,
+ publicApi: apiRouters
};
for (const group of routerEndpoints) {
- testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
+ if (group === 'publicApi') {
+ testServer.app.use(...(map[group] as express.Router[]));
+ } else {
+ testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
+ }
}
}
@@ -106,7 +143,9 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
const functionEndpoints: string[] = [];
endpointGroups.forEach((group) =>
- (group === 'credentials' ? routerEndpoints : functionEndpoints).push(group),
+ (group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(
+ group,
+ ),
);
return [routerEndpoints, functionEndpoints];
@@ -116,6 +155,598 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
// initializers
// ----------------------------------
+/**
+ * Initialize node types.
+ */
+export async function initActiveWorkflowRunner(): Promise {
+ const workflowRunner = ActiveWorkflowRunner.getInstance();
+ workflowRunner.init();
+ return workflowRunner;
+}
+
+export function gitHubCredentialType(): ICredentialType {
+ return {
+ name: 'githubApi',
+ displayName: 'Github API',
+ documentationUrl: 'github',
+ properties: [
+ {
+ displayName: 'Github Server',
+ name: 'server',
+ type: 'string',
+ default: 'https://api.github.com',
+ description: 'The server to connect to. Only has to be set if Github Enterprise is used.',
+ },
+ {
+ displayName: 'User',
+ name: 'user',
+ type: 'string',
+ default: '',
+ },
+ {
+ displayName: 'Access Token',
+ name: 'accessToken',
+ type: 'string',
+ default: '',
+ },
+ ],
+ };
+}
+
+/**
+ * Initialize node types.
+ */
+export async function initCredentialsTypes(): Promise {
+ const credentialTypes = CredentialTypes();
+ await credentialTypes.init({
+ githubApi: {
+ type: gitHubCredentialType(),
+ sourcePath: '',
+ },
+ });
+}
+
+/**
+ * Initialize node types.
+ */
+export async function initNodeTypes() {
+ const types: INodeTypeData = {
+ 'n8n-nodes-base.start': {
+ sourcePath: '',
+ type: {
+ description: {
+ displayName: 'Start',
+ name: 'start',
+ group: ['input'],
+ version: 1,
+ description: 'Starts the workflow execution from this node',
+ defaults: {
+ name: 'Start',
+ color: '#553399',
+ },
+ inputs: [],
+ outputs: ['main'],
+ properties: [],
+ },
+ execute(this: IExecuteFunctions): Promise {
+ const items = this.getInputData();
+
+ return this.prepareOutputData(items);
+ },
+ },
+ },
+ 'n8n-nodes-base.cron': {
+ sourcePath: '',
+ type: {
+ description: {
+ displayName: 'Cron',
+ name: 'cron',
+ icon: 'fa:calendar',
+ group: ['trigger', 'schedule'],
+ version: 1,
+ description: 'Triggers the workflow at a specific time',
+ eventTriggerDescription: '',
+ activationMessage:
+ 'Your cron trigger will now trigger executions on the schedule you have defined.',
+ defaults: {
+ name: 'Cron',
+ color: '#00FF00',
+ },
+ inputs: [],
+ outputs: ['main'],
+ properties: [
+ {
+ displayName: 'Trigger Times',
+ name: 'triggerTimes',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ multipleValueButtonText: 'Add Time',
+ },
+ default: {},
+ description: 'Triggers for the workflow',
+ placeholder: 'Add Cron Time',
+ options: [
+ {
+ name: 'item',
+ displayName: 'Item',
+ values: [
+ {
+ displayName: 'Mode',
+ name: 'mode',
+ type: 'options',
+ options: [
+ {
+ name: 'Every Minute',
+ value: 'everyMinute',
+ },
+ {
+ name: 'Every Hour',
+ value: 'everyHour',
+ },
+ {
+ name: 'Every Day',
+ value: 'everyDay',
+ },
+ {
+ name: 'Every Week',
+ value: 'everyWeek',
+ },
+ {
+ name: 'Every Month',
+ value: 'everyMonth',
+ },
+ {
+ name: 'Every X',
+ value: 'everyX',
+ },
+ {
+ name: 'Custom',
+ value: 'custom',
+ },
+ ],
+ default: 'everyDay',
+ description: 'How often to trigger.',
+ },
+ {
+ displayName: 'Hour',
+ name: 'hour',
+ type: 'number',
+ typeOptions: {
+ minValue: 0,
+ maxValue: 23,
+ },
+ displayOptions: {
+ hide: {
+ mode: ['custom', 'everyHour', 'everyMinute', 'everyX'],
+ },
+ },
+ default: 14,
+ description: 'The hour of the day to trigger (24h format).',
+ },
+ {
+ displayName: 'Minute',
+ name: 'minute',
+ type: 'number',
+ typeOptions: {
+ minValue: 0,
+ maxValue: 59,
+ },
+ displayOptions: {
+ hide: {
+ mode: ['custom', 'everyMinute', 'everyX'],
+ },
+ },
+ default: 0,
+ description: 'The minute of the day to trigger.',
+ },
+ {
+ displayName: 'Day of Month',
+ name: 'dayOfMonth',
+ type: 'number',
+ displayOptions: {
+ show: {
+ mode: ['everyMonth'],
+ },
+ },
+ typeOptions: {
+ minValue: 1,
+ maxValue: 31,
+ },
+ default: 1,
+ description: 'The day of the month to trigger.',
+ },
+ {
+ displayName: 'Weekday',
+ name: 'weekday',
+ type: 'options',
+ displayOptions: {
+ show: {
+ mode: ['everyWeek'],
+ },
+ },
+ options: [
+ {
+ name: 'Monday',
+ value: '1',
+ },
+ {
+ name: 'Tuesday',
+ value: '2',
+ },
+ {
+ name: 'Wednesday',
+ value: '3',
+ },
+ {
+ name: 'Thursday',
+ value: '4',
+ },
+ {
+ name: 'Friday',
+ value: '5',
+ },
+ {
+ name: 'Saturday',
+ value: '6',
+ },
+ {
+ name: 'Sunday',
+ value: '0',
+ },
+ ],
+ default: '1',
+ description: 'The weekday to trigger.',
+ },
+ {
+ displayName: 'Cron Expression',
+ name: 'cronExpression',
+ type: 'string',
+ displayOptions: {
+ show: {
+ mode: ['custom'],
+ },
+ },
+ default: '* * * * * *',
+ description:
+ 'Use custom cron expression. Values and ranges as follows:- Seconds: 0-59
- Minutes: 0 - 59
- Hours: 0 - 23
- Day of Month: 1 - 31
- Months: 0 - 11 (Jan - Dec)
- Day of Week: 0 - 6 (Sun - Sat)
.',
+ },
+ {
+ displayName: 'Value',
+ name: 'value',
+ type: 'number',
+ typeOptions: {
+ minValue: 0,
+ maxValue: 1000,
+ },
+ displayOptions: {
+ show: {
+ mode: ['everyX'],
+ },
+ },
+ default: 2,
+ description: 'All how many X minutes/hours it should trigger.',
+ },
+ {
+ displayName: 'Unit',
+ name: 'unit',
+ type: 'options',
+ displayOptions: {
+ show: {
+ mode: ['everyX'],
+ },
+ },
+ options: [
+ {
+ name: 'Minutes',
+ value: 'minutes',
+ },
+ {
+ name: 'Hours',
+ value: 'hours',
+ },
+ ],
+ default: 'hours',
+ description: 'If it should trigger all X minutes or hours.',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ async trigger(this: ITriggerFunctions): Promise {
+ const triggerTimes = this.getNodeParameter('triggerTimes') as unknown as {
+ item: TriggerTime[];
+ };
+
+ // Define the order the cron-time-parameter appear
+ const parameterOrder = [
+ 'second', // 0 - 59
+ 'minute', // 0 - 59
+ 'hour', // 0 - 23
+ 'dayOfMonth', // 1 - 31
+ 'month', // 0 - 11(Jan - Dec)
+ 'weekday', // 0 - 6(Sun - Sat)
+ ];
+
+ // Get all the trigger times
+ const cronTimes: string[] = [];
+ let cronTime: string[];
+ let parameterName: string;
+ if (triggerTimes.item !== undefined) {
+ for (const item of triggerTimes.item) {
+ cronTime = [];
+ if (item.mode === 'custom') {
+ cronTimes.push(item.cronExpression as string);
+ continue;
+ }
+ if (item.mode === 'everyMinute') {
+ cronTimes.push(`${Math.floor(Math.random() * 60).toString()} * * * * *`);
+ continue;
+ }
+ if (item.mode === 'everyX') {
+ if (item.unit === 'minutes') {
+ cronTimes.push(
+ `${Math.floor(Math.random() * 60).toString()} */${item.value} * * * *`,
+ );
+ } else if (item.unit === 'hours') {
+ cronTimes.push(
+ `${Math.floor(Math.random() * 60).toString()} 0 */${item.value} * * *`,
+ );
+ }
+ continue;
+ }
+
+ for (parameterName of parameterOrder) {
+ if (item[parameterName] !== undefined) {
+ // Value is set so use it
+ cronTime.push(item[parameterName] as string);
+ } else if (parameterName === 'second') {
+ // For seconds we use by default a random one to make sure to
+ // balance the load a little bit over time
+ cronTime.push(Math.floor(Math.random() * 60).toString());
+ } else {
+ // For all others set "any"
+ cronTime.push('*');
+ }
+ }
+
+ cronTimes.push(cronTime.join(' '));
+ }
+ }
+
+ // The trigger function to execute when the cron-time got reached
+ // or when manually triggered
+ const executeTrigger = () => {
+ this.emit([this.helpers.returnJsonArray([{}])]);
+ };
+
+ const timezone = this.getTimezone();
+
+ // Start the cron-jobs
+ const cronJobs: CronJob[] = [];
+ for (const cronTime of cronTimes) {
+ cronJobs.push(new CronJob(cronTime, executeTrigger, undefined, true, timezone));
+ }
+
+ // Stop the cron-jobs
+ async function closeFunction() {
+ for (const cronJob of cronJobs) {
+ cronJob.stop();
+ }
+ }
+
+ async function manualTriggerFunction() {
+ executeTrigger();
+ }
+
+ return {
+ closeFunction,
+ manualTriggerFunction,
+ };
+ },
+ },
+ },
+ 'n8n-nodes-base.set': {
+ sourcePath: '',
+ type: {
+ description: {
+ displayName: 'Set',
+ name: 'set',
+ icon: 'fa:pen',
+ group: ['input'],
+ version: 1,
+ description: 'Sets values on items and optionally remove other values',
+ defaults: {
+ name: 'Set',
+ color: '#0000FF',
+ },
+ inputs: ['main'],
+ outputs: ['main'],
+ properties: [
+ {
+ displayName: 'Keep Only Set',
+ name: 'keepOnlySet',
+ type: 'boolean',
+ default: false,
+ description:
+ 'If only the values set on this node should be kept and all others removed.',
+ },
+ {
+ displayName: 'Values to Set',
+ name: 'values',
+ placeholder: 'Add Value',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ sortable: true,
+ },
+ description: 'The value to set.',
+ default: {},
+ options: [
+ {
+ name: 'boolean',
+ displayName: 'Boolean',
+ values: [
+ {
+ displayName: 'Name',
+ name: 'name',
+ type: 'string',
+ default: 'propertyName',
+ description:
+ 'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
+ },
+ {
+ displayName: 'Value',
+ name: 'value',
+ type: 'boolean',
+ default: false,
+ description: 'The boolean value to write in the property.',
+ },
+ ],
+ },
+ {
+ name: 'number',
+ displayName: 'Number',
+ values: [
+ {
+ displayName: 'Name',
+ name: 'name',
+ type: 'string',
+ default: 'propertyName',
+ description:
+ 'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
+ },
+ {
+ displayName: 'Value',
+ name: 'value',
+ type: 'number',
+ default: 0,
+ description: 'The number value to write in the property.',
+ },
+ ],
+ },
+ {
+ name: 'string',
+ displayName: 'String',
+ values: [
+ {
+ displayName: 'Name',
+ name: 'name',
+ type: 'string',
+ default: 'propertyName',
+ description:
+ 'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
+ },
+ {
+ displayName: 'Value',
+ name: 'value',
+ type: 'string',
+ default: '',
+ description: 'The string value to write in the property.',
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ placeholder: 'Add Option',
+ default: {},
+ options: [
+ {
+ displayName: 'Dot Notation',
+ name: 'dotNotation',
+ type: 'boolean',
+ default: true,
+ description: `By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.
If that is not intended this can be deactivated, it will then set { "a.b": value } instead.
+ `,
+ },
+ ],
+ },
+ ],
+ },
+ execute(this: IExecuteFunctions): Promise {
+ const items = this.getInputData();
+
+ if (items.length === 0) {
+ items.push({ json: {} });
+ }
+
+ const returnData: INodeExecutionData[] = [];
+
+ let item: INodeExecutionData;
+ let keepOnlySet: boolean;
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
+ keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean;
+ item = items[itemIndex];
+ const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
+
+ const newItem: INodeExecutionData = {
+ json: {},
+ };
+
+ if (keepOnlySet !== true) {
+ if (item.binary !== undefined) {
+ // Create a shallow copy of the binary data so that the old
+ // data references which do not get changed still stay behind
+ // but the incoming data does not get changed.
+ newItem.binary = {};
+ Object.assign(newItem.binary, item.binary);
+ }
+
+ newItem.json = JSON.parse(JSON.stringify(item.json));
+ }
+
+ // Add boolean values
+ (this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach(
+ (setItem) => {
+ if (options.dotNotation === false) {
+ newItem.json[setItem.name as string] = !!setItem.value;
+ } else {
+ set(newItem.json, setItem.name as string, !!setItem.value);
+ }
+ },
+ );
+
+ // Add number values
+ (this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach(
+ (setItem) => {
+ if (options.dotNotation === false) {
+ newItem.json[setItem.name as string] = setItem.value;
+ } else {
+ set(newItem.json, setItem.name as string, setItem.value);
+ }
+ },
+ );
+
+ // Add string values
+ (this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach(
+ (setItem) => {
+ if (options.dotNotation === false) {
+ newItem.json[setItem.name as string] = setItem.value;
+ } else {
+ set(newItem.json, setItem.name as string, setItem.value);
+ }
+ },
+ );
+
+ returnData.push(newItem);
+ }
+
+ return this.prepareOutputData(returnData);
+ },
+ },
+ },
+ };
+ const nodeTypes = NodeTypes();
+ await nodeTypes.init(types);
+}
+
/**
* Initialize a logger for test runs.
*/
@@ -123,6 +754,14 @@ export function initTestLogger() {
LoggerProxy.init(getLogger());
}
+/**
+ * Initialize a BinaryManager for test runs.
+ */
+export async function initBinaryManager() {
+ const binaryDataConfig = config.getEnv('binaryDataManager');
+ await BinaryDataManager.init(binaryDataConfig, true);
+}
+
/**
* Initialize a user settings config file if non-existent.
*/
@@ -142,13 +781,26 @@ export function initConfigFile() {
/**
* Create a request agent, optionally with an auth cookie.
*/
-export function createAgent(app: express.Application, options?: { auth: true; user: User }) {
+export function createAgent(
+ app: express.Application,
+ options?: { apiPath?: ApiPath; version?: string | number; auth: boolean; user: User },
+) {
const agent = request.agent(app);
- agent.use(prefix(REST_PATH_SEGMENT));
- if (options?.auth && options?.user) {
- const { token } = issueJWT(options.user);
- agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
+ if (options?.apiPath === undefined || options?.apiPath === 'internal') {
+ agent.use(prefix(REST_PATH_SEGMENT));
+ if (options?.auth && options?.user) {
+ const { token } = issueJWT(options.user);
+ agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
+ }
+ }
+
+ if (options?.apiPath === 'public') {
+ agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${options?.version}`));
+
+ if (options?.auth && options?.user.apiKey) {
+ agent.set({ 'X-N8N-API-KEY': options.user.apiKey });
+ }
}
return agent;
@@ -170,7 +822,6 @@ export function prefix(pathSegment: string) {
url.pathname = pathSegment + url.pathname;
request.url = url.toString();
-
return request;
};
}
diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts
index a9143e4f74..a9cd270a1d 100644
--- a/packages/cli/test/integration/users.api.test.ts
+++ b/packages/cli/test/integration/users.api.test.ts
@@ -30,7 +30,7 @@ let credentialOwnerRole: Role;
let isSmtpAvailable = false;
beforeAll(async () => {
- app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
+ app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@@ -92,6 +92,7 @@ test('GET /users should return all users', async () => {
password,
resetPasswordToken,
isPending,
+ apiKey,
} = user;
expect(validator.isUUID(id)).toBe(true);
@@ -103,6 +104,7 @@ test('GET /users should return all users', async () => {
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
+ expect(apiKey).not.toBeDefined();
}),
);
});
@@ -357,6 +359,7 @@ test('POST /users/:id should fill out a user shell', async () => {
resetPasswordToken,
globalRole,
isPending,
+ apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@@ -368,6 +371,7 @@ test('POST /users/:id should fill out a user shell', async () => {
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
+ expect(apiKey).not.toBeDefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
@@ -427,6 +431,7 @@ test('POST /users/:id should fail with invalid inputs', async () => {
const storedUser = await Db.collections.User.findOneOrFail({
where: { email: memberShellEmail },
});
+
expect(storedUser.firstName).toBeNull();
expect(storedUser.lastName).toBeNull();
expect(storedUser.password).toBeNull();
diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue
index 4e6c0a5441..cc7a306a8c 100644
--- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue
+++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue
@@ -1,6 +1,6 @@
-
+
{{ props.heading }}
diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue
index 2e2c1fd3cd..3868d2839d 100644
--- a/packages/design-system/src/components/N8nButton/Button.vue
+++ b/packages/design-system/src/components/N8nButton/Button.vue
@@ -25,7 +25,11 @@
:size="props.size"
/>
-
{{ props.label }}
+
+
+ {{ props.label }}
+
+
diff --git a/packages/design-system/src/components/N8nCard/Card.stories.ts b/packages/design-system/src/components/N8nCard/Card.stories.ts
new file mode 100644
index 0000000000..754ae926d5
--- /dev/null
+++ b/packages/design-system/src/components/N8nCard/Card.stories.ts
@@ -0,0 +1,29 @@
+/* tslint:disable:variable-name */
+
+import N8nCard from './Card.vue';
+import {StoryFn} from "@storybook/vue";
+
+export default {
+ title: 'Atoms/Card',
+ component: N8nCard,
+};
+
+export const Default: StoryFn = (args, {argTypes}) => ({
+ props: Object.keys(argTypes),
+ components: {
+ N8nCard,
+ },
+ template: `
This is a card.`,
+});
+
+export const WithHeaderAndFooter: StoryFn = (args, {argTypes}) => ({
+ props: Object.keys(argTypes),
+ components: {
+ N8nCard,
+ },
+ template: `
+ Header
+ This is a card.
+ Footer
+ `,
+});
diff --git a/packages/design-system/src/components/N8nCard/Card.vue b/packages/design-system/src/components/N8nCard/Card.vue
new file mode 100644
index 0000000000..3d385f909f
--- /dev/null
+++ b/packages/design-system/src/components/N8nCard/Card.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nCard/Card.xpec.ts b/packages/design-system/src/components/N8nCard/Card.xpec.ts
new file mode 100644
index 0000000000..6d1a0f4cb8
--- /dev/null
+++ b/packages/design-system/src/components/N8nCard/Card.xpec.ts
@@ -0,0 +1,26 @@
+import {render} from '@testing-library/vue';
+import N8nCard from "../Card.vue";
+
+describe('components', () => {
+ describe('N8nCard', () => {
+ it('should render correctly', () => {
+ const wrapper = render(N8nCard, {
+ slots: {
+ default: 'This is a card.',
+ },
+ });
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('should render correctly with header and footer', () => {
+ const wrapper = render(N8nCard, {
+ slots: {
+ header: 'Header',
+ default: 'This is a card.',
+ footer: 'Footer',
+ },
+ });
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/packages/design-system/src/components/N8nCard/index.ts b/packages/design-system/src/components/N8nCard/index.ts
new file mode 100644
index 0000000000..a2bc0e903c
--- /dev/null
+++ b/packages/design-system/src/components/N8nCard/index.ts
@@ -0,0 +1,3 @@
+import N8nCard from './Card.vue';
+
+export default N8nCard;
diff --git a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue
index 2f0d44dbd2..eb4117f191 100644
--- a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue
+++ b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue
@@ -1,7 +1,7 @@
-
+
-
+
@@ -33,6 +33,10 @@ export default {
validator: (value: string): boolean =>
['note', 'tooltip'].includes(value),
},
+ bold: {
+ type: Boolean,
+ default: true,
+ },
tooltipPlacement: {
type: String,
default: 'top',
@@ -44,7 +48,6 @@ export default {
diff --git a/packages/design-system/src/styleguide/utilities.stories.ts b/packages/design-system/src/styleguide/utilities.stories.ts
new file mode 100644
index 0000000000..e5f9e2da16
--- /dev/null
+++ b/packages/design-system/src/styleguide/utilities.stories.ts
@@ -0,0 +1,47 @@
+/* tslint:disable:variable-name */
+
+import {StoryFn} from "@storybook/vue";
+import SpacingPreview from "../styleguide/SpacingPreview.vue";
+
+export default {
+ title: 'Utilities/Spacing',
+};
+
+const Template: StoryFn = (args, {argTypes}) => ({
+ props: Object.keys(argTypes),
+ components: {
+ SpacingPreview,
+ },
+ template: ``,
+});
+
+export const Padding = Template.bind({});
+Padding.args = { property: 'padding' };
+
+export const PaddingTop = Template.bind({});
+PaddingTop.args = { property: 'padding', side: 'top' };
+
+export const PaddingRight = Template.bind({});
+PaddingRight.args = { property: 'padding', side: 'right' };
+
+export const PaddingBottom = Template.bind({});
+PaddingBottom.args = { property: 'padding', side: 'bottom' };
+
+export const PaddingLeft = Template.bind({});
+PaddingLeft.args = { property: 'padding', side: 'left' };
+
+export const Margin = Template.bind({});
+Margin.args = { property: 'margin' };
+
+export const MarginTop = Template.bind({});
+MarginTop.args = { property: 'margin', side: 'top' };
+
+export const MarginRight = Template.bind({});
+MarginRight.args = { property: 'margin', side: 'right' };
+
+export const MarginBottom = Template.bind({});
+MarginBottom.args = { property: 'margin', side: 'bottom' };
+
+export const MarginLeft = Template.bind({});
+MarginLeft.args = { property: 'margin', side: 'left' };
+
diff --git a/packages/design-system/theme/src/index.scss b/packages/design-system/theme/src/index.scss
index 2bc1d28de4..2b467348de 100644
--- a/packages/design-system/theme/src/index.scss
+++ b/packages/design-system/theme/src/index.scss
@@ -82,3 +82,4 @@
// @use "./avatar.scss";
@use "./drawer.scss";
// @use "./popconfirm.scss";
+@use "./utilities.scss";
diff --git a/packages/design-system/theme/src/utilities.scss b/packages/design-system/theme/src/utilities.scss
new file mode 100644
index 0000000000..024f983815
--- /dev/null
+++ b/packages/design-system/theme/src/utilities.scss
@@ -0,0 +1,19 @@
+@use 'sass:string';
+
+$spacing-sizes: '5xs', '4xs', '3xs', '2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', '5xl';
+$spacing-properties: 'margin', 'padding';
+$spacing-sides: 'top', 'right', 'bottom', 'left';
+
+@each $size in $spacing-sizes {
+ @each $property in $spacing-properties {
+ @each $side in $spacing-sides {
+ .#{string.slice($property, 0, 1)}#{string.slice($side, 0, 1)}-#{$size} {
+ #{$property}-#{$side}: var(--spacing-#{$size}) !important;
+ }
+ }
+
+ .#{string.slice($property, 0, 1)}-#{$size} {
+ #{$property}: var(--spacing-#{$size}) !important;
+ }
+ }
+}
diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts
index 15cc4a96c9..dcd983794e 100644
--- a/packages/editor-ui/src/Interface.ts
+++ b/packages/editor-ui/src/Interface.ts
@@ -558,6 +558,7 @@ export interface IPermissionGroup {
loginStatus?: ILogInStatus[];
role?: IRole[];
um?: boolean;
+ api?: boolean;
}
export interface IPermissions {
@@ -660,6 +661,11 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
+ publicApi: {
+ enabled: boolean;
+ latestVersion: number;
+ path: string;
+ };
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@@ -875,6 +881,11 @@ export interface ISettingsState {
promptsData: IN8nPrompts;
userManagement: IUserManagementConfig;
templatesEndpointHealthy: boolean;
+ api: {
+ enabled: boolean;
+ latestVersion: number;
+ path: string;
+ };
}
export interface ITemplateState {
diff --git a/packages/editor-ui/src/api/api-keys.ts b/packages/editor-ui/src/api/api-keys.ts
new file mode 100644
index 0000000000..27598149be
--- /dev/null
+++ b/packages/editor-ui/src/api/api-keys.ts
@@ -0,0 +1,14 @@
+import {IRestApiContext} from "@/Interface";
+import {makeRestApiRequest} from "@/api/helpers";
+
+export function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
+ return makeRestApiRequest(context, 'GET', '/me/api-key');
+}
+
+export function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
+ return makeRestApiRequest(context, 'POST', '/me/api-key');
+}
+
+export function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> {
+ return makeRestApiRequest(context, 'DELETE', '/me/api-key');
+}
diff --git a/packages/editor-ui/src/components/CollectionCard.vue b/packages/editor-ui/src/components/CollectionCard.vue
index 8a74fc7ee9..534d2334ec 100644
--- a/packages/editor-ui/src/components/CollectionCard.vue
+++ b/packages/editor-ui/src/components/CollectionCard.vue
@@ -16,7 +16,7 @@
+
+
+