@still-running/health-check 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/adapters/make.d.ts +14 -0
- package/dist/adapters/make.js +213 -0
- package/dist/adapters/n8n.d.ts +9 -0
- package/dist/adapters/n8n.js +273 -0
- package/dist/core/checks/others.d.ts +10 -0
- package/dist/core/checks/others.js +160 -0
- package/dist/core/checks/protections.d.ts +11 -0
- package/dist/core/checks/protections.js +96 -0
- package/dist/core/checks/zero-write.d.ts +27 -0
- package/dist/core/checks/zero-write.js +245 -0
- package/dist/core/model.d.ts +141 -0
- package/dist/core/model.js +19 -0
- package/dist/core/providers.d.ts +48 -0
- package/dist/core/providers.js +181 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +68 -0
- package/dist/package.json +1 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ali Alsamraay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Workflow Health Check — parser core
|
|
2
|
+
|
|
3
|
+
Static analysis of n8n workflow JSON and Make blueprint JSON. No execution, no
|
|
4
|
+
network, no storage. Runs in the browser.
|
|
5
|
+
|
|
6
|
+
## Architecture
|
|
7
|
+
|
|
8
|
+
src/core/model.ts the neutral shape — the only thing checks may know about
|
|
9
|
+
src/core/providers.ts credential expiry table (data, with sources)
|
|
10
|
+
src/core/checks/ four checks, none of which know a platform exists
|
|
11
|
+
src/adapters/n8n.ts all n8n knowledge
|
|
12
|
+
src/adapters/make.ts all Make knowledge
|
|
13
|
+
src/index.ts detect format, run checks, return findings
|
|
14
|
+
|
|
15
|
+
The rule that keeps platform three cheap: **a check may never import an adapter,
|
|
16
|
+
and an adapter may never import a check.** Adding Zapier or Workato later is one
|
|
17
|
+
new file in `adapters/` and one line in `parseWorkflow`. Nothing in `core/`
|
|
18
|
+
changes. If a check ever needs `if (platform === ...)` for anything except
|
|
19
|
+
wording a sentence, the model is missing a field — add the field instead.
|
|
20
|
+
|
|
21
|
+
## Check order
|
|
22
|
+
|
|
23
|
+
Deliberately inverted from the original spec:
|
|
24
|
+
|
|
25
|
+
1. **zero-write** — writes that can be skipped while the run reports success
|
|
26
|
+
2. **credential-expiry** — stated as a conditional, never as a prediction
|
|
27
|
+
3. **no-cadence** — nothing declares how often this should run
|
|
28
|
+
4. **error-handling** — table stakes; six free tools ship it already
|
|
29
|
+
|
|
30
|
+
## Build and test
|
|
31
|
+
|
|
32
|
+
Tests run straight off TypeScript source, no compile step:
|
|
33
|
+
|
|
34
|
+
npm test # from the repo root
|
|
35
|
+
npm run corpus -- ./workflows # analyzer over a folder of exports
|
|
36
|
+
|
|
37
|
+
`npm run build` compiles `src/` to `dist/` and stamps `dist/package.json` with
|
|
38
|
+
`{"type":"commonjs"}` so Node parses the output correctly even though this
|
|
39
|
+
package's own `type` is `"module"`. This is the form that actually gets
|
|
40
|
+
published to npm (see `files` in `package.json` — only `dist/` and this
|
|
41
|
+
README ship; `src/` does not) and the form every consumer resolves through
|
|
42
|
+
`exports`, including this monorepo's own `apps/dashboard` and `apps/free-tool`.
|
|
43
|
+
That's deliberate: a package whose `exports.import` condition points at raw
|
|
44
|
+
`.ts` only works by accident, for whichever bundler happens to transpile
|
|
45
|
+
`node_modules` — it would silently break for a real npm install. So the repo
|
|
46
|
+
root's `build`/`dev:tool`/`dev:dash` scripts all build this package first;
|
|
47
|
+
editing `src/` here requires re-running that build (or the app's dev script,
|
|
48
|
+
which does it for you) before the change shows up downstream. Type *checking*
|
|
49
|
+
still resolves straight to `src/index.ts` for both apps, unaffected — see the
|
|
50
|
+
`paths` override in `tsconfig.base.json` and each app's own `tsconfig.json`.
|
|
51
|
+
|
|
52
|
+
## Results on a real corpus
|
|
53
|
+
|
|
54
|
+
2,043 workflows from the public n8n template library plus Make's own top-ten
|
|
55
|
+
templates. 2,054 parsed, 12 skipped as not-a-workflow, **0 crashes**.
|
|
56
|
+
|
|
57
|
+
| measure | result |
|
|
58
|
+
|---|---|
|
|
59
|
+
| workflows with at least one write step | 54.0% |
|
|
60
|
+
| workflows carrying an OAuth credential | 34.3% |
|
|
61
|
+
| workflows with any zero-write finding | 35.5% |
|
|
62
|
+
| workflows the tool actually shouts about (high/critical) | 13.5% |
|
|
63
|
+
|
|
64
|
+
## How the zero-write check works
|
|
65
|
+
|
|
66
|
+
For each write step, compute the steps that **dominate** it — the ones every
|
|
67
|
+
path from a trigger must pass through. If a dominator can emit zero items, the
|
|
68
|
+
write is skipped and the run still finishes green.
|
|
69
|
+
|
|
70
|
+
Dominators rather than path enumeration, for two reasons: a 246-node workflow
|
|
71
|
+
has too many paths to walk, and a gate only truly starves a write when there is
|
|
72
|
+
no way around it. That is exactly what dominance means. `test-dominators.mjs`
|
|
73
|
+
pins the case that matters — a write reachable by a second path is **not**
|
|
74
|
+
flagged.
|
|
75
|
+
|
|
76
|
+
Severity depends on whether anything in the workflow could notice an empty run:
|
|
77
|
+
an alert branch, a known cadence, or an error handler. With none of those, and
|
|
78
|
+
one write, it is critical. With any of them, it drops. This is what took the
|
|
79
|
+
shout rate from 33% to 13.5% — without it the check is true and unreadable.
|
|
80
|
+
|
|
81
|
+
## Known limits — read before writing any copy
|
|
82
|
+
|
|
83
|
+
- **"Can", never "did".** Static JSON cannot tell you a workflow wrote nothing
|
|
84
|
+
yesterday. It tells you it can, and that nobody would know. Run history is the
|
|
85
|
+
paid product, and every message here keeps that line visible.
|
|
86
|
+
- **Triggers returning zero are not flagged.** A scheduled scenario that finds
|
|
87
|
+
no new rows is normal behaviour, and static analysis cannot separate "nothing
|
|
88
|
+
new today" from "the sheet was renamed". That separation needs a volume
|
|
89
|
+
baseline. Deliberately out of scope for the free tool.
|
|
90
|
+
- **Credential expiry is unknowable from an export.** The file names the
|
|
91
|
+
connection, not the consent screen behind it. Every finding is a conditional
|
|
92
|
+
plus a way to check in under a minute.
|
|
93
|
+
- **Make templates carry no connections.** Published templates have them
|
|
94
|
+
stripped; the tool says so rather than reporting a clean bill of health.
|
|
95
|
+
- **Make blueprints from the API carry no schedule.** The schedule lives on the
|
|
96
|
+
scenario. The tool says which kind of export it received.
|
|
97
|
+
- **Code nodes are opaque.** A Code step is marked "possible", never
|
|
98
|
+
"structural", and never drives a high severity on its own.
|
|
99
|
+
|
|
100
|
+
The page that consumes this lives in `apps/free-tool`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make adapter. All Make knowledge lives here.
|
|
3
|
+
*
|
|
4
|
+
* Make differs from n8n in three ways that matter, and all three are absorbed
|
|
5
|
+
* here so the checks never learn about them:
|
|
6
|
+
* 1. The flow is a nested tree (routes, branches, onerror), not a flat node
|
|
7
|
+
* list plus a connections map. We flatten it and synthesise the edges.
|
|
8
|
+
* 2. Filters sit on the LINK, not in a module. They become edge gates.
|
|
9
|
+
* 3. Scheduling sits outside the blueprint, next to it. Present in a template
|
|
10
|
+
* export, absent from an API blueprint fetch — so we say which we got.
|
|
11
|
+
*/
|
|
12
|
+
import type { Workflow } from '../core/model.js';
|
|
13
|
+
export declare function isMakeBlueprint(raw: any): boolean;
|
|
14
|
+
export declare function parseMake(raw: any): Workflow;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Make adapter. All Make knowledge lives here.
|
|
4
|
+
*
|
|
5
|
+
* Make differs from n8n in three ways that matter, and all three are absorbed
|
|
6
|
+
* here so the checks never learn about them:
|
|
7
|
+
* 1. The flow is a nested tree (routes, branches, onerror), not a flat node
|
|
8
|
+
* list plus a connections map. We flatten it and synthesise the edges.
|
|
9
|
+
* 2. Filters sit on the LINK, not in a module. They become edge gates.
|
|
10
|
+
* 3. Scheduling sits outside the blueprint, next to it. Present in a template
|
|
11
|
+
* export, absent from an API blueprint fetch — so we say which we got.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.isMakeBlueprint = isMakeBlueprint;
|
|
15
|
+
exports.parseMake = parseMake;
|
|
16
|
+
const providers_js_1 = require("../core/providers.js");
|
|
17
|
+
const WRITE_VERBS = [
|
|
18
|
+
[/:add(row|record|item)?/i, 'append'],
|
|
19
|
+
[/:create/i, 'create'],
|
|
20
|
+
[/:update/i, 'update'],
|
|
21
|
+
[/:upsert|:addupdate/i, 'upsert'],
|
|
22
|
+
[/:delete|:remove/i, 'delete'],
|
|
23
|
+
[/:send|:createmessage|:createatweet|:createpost|:post/i, 'send'],
|
|
24
|
+
[/:uploadfile|:upload/i, 'create'],
|
|
25
|
+
];
|
|
26
|
+
const EMPTY_READ = /:(search|list|get[a-z]*|retrieve|watch[a-z]*|iterate)/i;
|
|
27
|
+
function moduleApp(module) {
|
|
28
|
+
return (module.split(':')[0] ?? module).replace(/-/g, ' ');
|
|
29
|
+
}
|
|
30
|
+
/** 'google-sheets:addRow' -> 'Add Row (google sheets)'. Modules are often unnamed. */
|
|
31
|
+
function prettyLabel(module) {
|
|
32
|
+
const [app, action] = module.split(':');
|
|
33
|
+
if (!action)
|
|
34
|
+
return module;
|
|
35
|
+
const words = action
|
|
36
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
37
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
38
|
+
return `${words} (${(app ?? '').replace(/-/g, ' ')})`;
|
|
39
|
+
}
|
|
40
|
+
function classify(m) {
|
|
41
|
+
const module = String(m.module ?? '');
|
|
42
|
+
const lower = module.toLowerCase();
|
|
43
|
+
if (/^builtin:basicrouter/i.test(module)) {
|
|
44
|
+
return {
|
|
45
|
+
role: 'gate',
|
|
46
|
+
zeroEmit: { cause: 'Every route out of this router can filter everything out.', certainty: 'possible' },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (/^builtin:basicifelse/i.test(module)) {
|
|
50
|
+
return { role: 'gate', zeroEmit: { cause: 'The condition can be false for every bundle.', certainty: 'structural' } };
|
|
51
|
+
}
|
|
52
|
+
if (/^builtin:basicfeeder|:iterate/i.test(module)) {
|
|
53
|
+
return { role: 'loop', zeroEmit: { cause: 'The array being iterated can be empty, so nothing downstream runs.', certainty: 'structural' } };
|
|
54
|
+
}
|
|
55
|
+
if (/^builtin:basicaggregator|:aggregate/i.test(module))
|
|
56
|
+
return { role: 'transform', zeroEmit: null };
|
|
57
|
+
// A watch* module at the head of a flow is the trigger.
|
|
58
|
+
if (/^[a-z0-9-]+:watch/i.test(module) || /gateway:customwebhook/i.test(module)) {
|
|
59
|
+
return { role: 'trigger', zeroEmit: null };
|
|
60
|
+
}
|
|
61
|
+
for (const [re, kind] of WRITE_VERBS) {
|
|
62
|
+
if (re.test(lower)) {
|
|
63
|
+
return { role: 'write', writeKind: kind, writeTarget: moduleApp(module), zeroEmit: null };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (EMPTY_READ.test(lower)) {
|
|
67
|
+
return {
|
|
68
|
+
role: 'read',
|
|
69
|
+
zeroEmit: { cause: `The ${moduleApp(module)} search can return no bundles.`, certainty: 'structural' },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { role: 'other', zeroEmit: null };
|
|
73
|
+
}
|
|
74
|
+
/** Connections hide in two places depending on how the blueprint was produced. */
|
|
75
|
+
function readConnections(m) {
|
|
76
|
+
const out = [];
|
|
77
|
+
const restore = m?.metadata?.restore?.parameters?.__IMTCONN__;
|
|
78
|
+
if (restore) {
|
|
79
|
+
const slug = restore?.data?.connection;
|
|
80
|
+
const rawType = slug ? `account:${slug}` : 'account:unknown';
|
|
81
|
+
out.push({
|
|
82
|
+
rawType,
|
|
83
|
+
providerId: (0, providers_js_1.resolveProvider)(rawType, 'make')?.id ?? null,
|
|
84
|
+
authKind: (0, providers_js_1.guessAuthKind)(rawType),
|
|
85
|
+
label: typeof restore.label === 'string' ? restore.label : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
for (const p of m?.metadata?.parameters ?? []) {
|
|
89
|
+
if (p?.name === '__IMTCONN__' && typeof p.type === 'string') {
|
|
90
|
+
if (out.some((c) => c.rawType === p.type))
|
|
91
|
+
continue;
|
|
92
|
+
out.push({
|
|
93
|
+
rawType: p.type,
|
|
94
|
+
providerId: (0, providers_js_1.resolveProvider)(p.type, 'make')?.id ?? null,
|
|
95
|
+
authKind: (0, providers_js_1.guessAuthKind)(p.type),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Walk the nested flow, emitting nodes and the edges implied by sequence. */
|
|
102
|
+
function flatten(flow, prevId, acc, channel = 'main') {
|
|
103
|
+
let last = prevId;
|
|
104
|
+
for (const m of flow ?? []) {
|
|
105
|
+
const id = String(m.id ?? `${acc.nodes.length}`);
|
|
106
|
+
const c = classify(m);
|
|
107
|
+
const label = m?.metadata?.designer?.name || prettyLabel(String(m.module ?? id));
|
|
108
|
+
acc.nodes.push({
|
|
109
|
+
id,
|
|
110
|
+
label,
|
|
111
|
+
platformType: String(m.module ?? ''),
|
|
112
|
+
role: c.role,
|
|
113
|
+
writeKind: c.writeKind,
|
|
114
|
+
writeTarget: c.writeTarget,
|
|
115
|
+
zeroEmit: c.zeroEmit,
|
|
116
|
+
credentials: readConnections(m),
|
|
117
|
+
errorHandling: {
|
|
118
|
+
hasErrorBranch: Array.isArray(m.onerror) && m.onerror.length > 0,
|
|
119
|
+
retries: false,
|
|
120
|
+
continueOnFail: false,
|
|
121
|
+
alwaysOutputData: false,
|
|
122
|
+
},
|
|
123
|
+
disabled: m.disabled === true,
|
|
124
|
+
});
|
|
125
|
+
if (last) {
|
|
126
|
+
acc.edges.push({
|
|
127
|
+
from: last,
|
|
128
|
+
to: id,
|
|
129
|
+
channel,
|
|
130
|
+
gate: m.filter
|
|
131
|
+
? {
|
|
132
|
+
label: String(m.filter.name ?? 'filter'),
|
|
133
|
+
cause: `The filter "${m.filter.name ?? 'unnamed'}" can match nothing.`,
|
|
134
|
+
}
|
|
135
|
+
: null,
|
|
136
|
+
});
|
|
137
|
+
channel = 'main';
|
|
138
|
+
}
|
|
139
|
+
for (const route of m.routes ?? []) {
|
|
140
|
+
flatten(route.flow ?? [], id, acc, 'route');
|
|
141
|
+
}
|
|
142
|
+
for (const branch of m.branches ?? []) {
|
|
143
|
+
flatten(branch.flow ?? [], id, acc, branch.type === 'else' ? 'else' : 'branch');
|
|
144
|
+
}
|
|
145
|
+
for (const handler of m.onerror ?? []) {
|
|
146
|
+
const hFlow = Array.isArray(handler?.flow) ? handler.flow : [handler];
|
|
147
|
+
flatten(hFlow, id, acc, 'error');
|
|
148
|
+
}
|
|
149
|
+
// A router or if/else hands off through its branches, not in sequence.
|
|
150
|
+
if ((m.routes ?? []).length || (m.branches ?? []).length)
|
|
151
|
+
last = null;
|
|
152
|
+
else
|
|
153
|
+
last = id;
|
|
154
|
+
}
|
|
155
|
+
return last;
|
|
156
|
+
}
|
|
157
|
+
function isMakeBlueprint(raw) {
|
|
158
|
+
if (!raw || typeof raw !== 'object')
|
|
159
|
+
return false;
|
|
160
|
+
if (Array.isArray(raw.flow))
|
|
161
|
+
return true;
|
|
162
|
+
if (raw.blueprint && Array.isArray(raw.blueprint.flow))
|
|
163
|
+
return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
function parseMake(raw) {
|
|
167
|
+
const bp = raw.blueprint ?? raw;
|
|
168
|
+
const parseNotes = [];
|
|
169
|
+
const acc = { nodes: [], edges: [] };
|
|
170
|
+
flatten(bp.flow ?? [], null, acc);
|
|
171
|
+
const scenarioMeta = bp?.metadata?.scenario ?? {};
|
|
172
|
+
const scheduling = raw.scheduling ?? bp.scheduling ?? null;
|
|
173
|
+
let cadence;
|
|
174
|
+
if (scheduling && typeof scheduling.interval === 'number') {
|
|
175
|
+
cadence = {
|
|
176
|
+
kind: 'schedule',
|
|
177
|
+
description: scheduling.interval >= 3600
|
|
178
|
+
? `every ${Math.round(scheduling.interval / 3600)} hour${scheduling.interval >= 7200 ? 's' : ''}`
|
|
179
|
+
: `every ${Math.round(scheduling.interval / 60)} minute${scheduling.interval >= 120 ? 's' : ''}`,
|
|
180
|
+
intervalSeconds: scheduling.interval,
|
|
181
|
+
expectedIntervalKnown: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
else if (bp?.metadata?.instant === true) {
|
|
185
|
+
cadence = { kind: 'event', description: 'an instant trigger (webhook)', intervalSeconds: null, expectedIntervalKnown: false };
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
cadence = { kind: 'unknown', description: 'no scheduling in this export', intervalSeconds: null, expectedIntervalKnown: false };
|
|
189
|
+
parseNotes.push('This blueprint carries no scheduling block. Blueprints fetched from the Make API contain the flow only — the schedule lives on the scenario. Export from the scenario menu to include it.');
|
|
190
|
+
}
|
|
191
|
+
const withConnections = acc.nodes.filter((n) => n.credentials.length > 0).length;
|
|
192
|
+
if (acc.nodes.length > 0 && withConnections === 0) {
|
|
193
|
+
parseNotes.push('No connection data in this blueprint, so the credential check found nothing to look at. Published templates have connections stripped out; your own scenario export will have them.');
|
|
194
|
+
}
|
|
195
|
+
const triggerIds = acc.nodes.filter((n) => n.role === 'trigger').map((n) => n.id);
|
|
196
|
+
if (triggerIds.length === 0 && acc.nodes.length > 0)
|
|
197
|
+
triggerIds.push(acc.nodes[0].id);
|
|
198
|
+
return {
|
|
199
|
+
platform: 'make',
|
|
200
|
+
name: String(raw.name ?? bp.name ?? 'Untitled scenario'),
|
|
201
|
+
nodes: acc.nodes,
|
|
202
|
+
edges: acc.edges,
|
|
203
|
+
triggerIds,
|
|
204
|
+
cadence,
|
|
205
|
+
errorPolicy: {
|
|
206
|
+
workflowLevelHandler: acc.nodes.some((n) => n.errorHandling.hasErrorBranch),
|
|
207
|
+
storesFailedRuns: typeof scenarioMeta.dlq === 'boolean' ? scenarioMeta.dlq : null,
|
|
208
|
+
maxErrors: typeof scenarioMeta.maxErrors === 'number' ? scenarioMeta.maxErrors : null,
|
|
209
|
+
notes: [],
|
|
210
|
+
},
|
|
211
|
+
parseNotes,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* n8n adapter. All n8n knowledge lives here and nowhere else.
|
|
3
|
+
*
|
|
4
|
+
* Classification tables below are ranked by what actually appears in 2,043 real
|
|
5
|
+
* workflows from the public template library, not by guesswork.
|
|
6
|
+
*/
|
|
7
|
+
import type { Workflow } from '../core/model.js';
|
|
8
|
+
export declare function isN8nWorkflow(raw: any): boolean;
|
|
9
|
+
export declare function parseN8n(raw: any): Workflow;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* n8n adapter. All n8n knowledge lives here and nowhere else.
|
|
4
|
+
*
|
|
5
|
+
* Classification tables below are ranked by what actually appears in 2,043 real
|
|
6
|
+
* workflows from the public template library, not by guesswork.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.isN8nWorkflow = isN8nWorkflow;
|
|
10
|
+
exports.parseN8n = parseN8n;
|
|
11
|
+
const providers_js_1 = require("../core/providers.js");
|
|
12
|
+
const TRIGGER_HINTS = [
|
|
13
|
+
'trigger',
|
|
14
|
+
'webhook',
|
|
15
|
+
'cron',
|
|
16
|
+
'interval',
|
|
17
|
+
'formtrigger',
|
|
18
|
+
'chattrigger',
|
|
19
|
+
'localfiletrigger',
|
|
20
|
+
];
|
|
21
|
+
/** Nodes that produce writes. Keyed by short type; value maps operation -> kind. */
|
|
22
|
+
const WRITE_OPS = {
|
|
23
|
+
googleSheets: { append: 'append', update: 'update', appendOrUpdate: 'upsert', delete: 'delete' },
|
|
24
|
+
airtable: { create: 'create', update: 'update', upsert: 'upsert', append: 'append', deleteRecord: 'delete' },
|
|
25
|
+
notion: { create: 'create', update: 'update', append: 'append' },
|
|
26
|
+
supabase: { create: 'create', update: 'update', upsert: 'upsert' },
|
|
27
|
+
mongoDb: { insert: 'create', update: 'update', upsert: 'upsert' },
|
|
28
|
+
redis: { set: 'update' },
|
|
29
|
+
telegram: 'send',
|
|
30
|
+
slack: 'send',
|
|
31
|
+
gmail: { send: 'send', reply: 'send', sendAndWait: 'send' },
|
|
32
|
+
emailSend: 'send',
|
|
33
|
+
whatsApp: 'send',
|
|
34
|
+
discord: 'send',
|
|
35
|
+
twilio: 'send',
|
|
36
|
+
hubspot: { create: 'create', upsert: 'upsert', update: 'update' },
|
|
37
|
+
pipedrive: { create: 'create', update: 'update' },
|
|
38
|
+
salesforce: { create: 'create', upsert: 'upsert', update: 'update' },
|
|
39
|
+
wordpress: { create: 'create', update: 'update' },
|
|
40
|
+
googleDrive: { upload: 'create', createFromText: 'create', copy: 'create' },
|
|
41
|
+
readWriteFile: { write: 'create' },
|
|
42
|
+
googleCalendar: { create: 'create', update: 'update' },
|
|
43
|
+
clickUp: { create: 'create', update: 'update' },
|
|
44
|
+
trello: { create: 'create', update: 'update' },
|
|
45
|
+
monday: { create: 'create', update: 'update' },
|
|
46
|
+
jira: { create: 'create', update: 'update' },
|
|
47
|
+
baserow: { create: 'create', update: 'update' },
|
|
48
|
+
nocoDb: { create: 'create', update: 'update' },
|
|
49
|
+
};
|
|
50
|
+
const WRITE_TARGETS = {
|
|
51
|
+
googleSheets: 'Google Sheets',
|
|
52
|
+
airtable: 'Airtable',
|
|
53
|
+
notion: 'Notion',
|
|
54
|
+
supabase: 'Supabase',
|
|
55
|
+
mongoDb: 'MongoDB',
|
|
56
|
+
postgres: 'Postgres',
|
|
57
|
+
mySql: 'MySQL',
|
|
58
|
+
hubspot: 'HubSpot',
|
|
59
|
+
salesforce: 'Salesforce',
|
|
60
|
+
slack: 'Slack',
|
|
61
|
+
telegram: 'Telegram',
|
|
62
|
+
gmail: 'Gmail',
|
|
63
|
+
whatsApp: 'WhatsApp',
|
|
64
|
+
};
|
|
65
|
+
/** Steps whose whole job is to let some items through and stop the rest. */
|
|
66
|
+
const STRUCTURAL_GATES = {
|
|
67
|
+
filter: 'The filter can match nothing.',
|
|
68
|
+
if: 'The condition can be false for every item.',
|
|
69
|
+
switch: 'Every item can fall through without matching a branch.',
|
|
70
|
+
removeDuplicates: 'Everything can be a duplicate of a previous run, leaving nothing new.',
|
|
71
|
+
limit: 'The limit can resolve to zero items.',
|
|
72
|
+
splitOut: 'The field being split can be an empty list.',
|
|
73
|
+
splitInBatches: 'The list being looped over can be empty, so the loop body never runs.',
|
|
74
|
+
compareDatasets: 'The comparison can find nothing on either side.',
|
|
75
|
+
};
|
|
76
|
+
/** Reads that legitimately return nothing. Type -> operations that can be empty. */
|
|
77
|
+
const EMPTY_READS = {
|
|
78
|
+
googleSheets: ['read', 'getAll', 'lookup'],
|
|
79
|
+
airtable: ['search', 'list', 'getAll'],
|
|
80
|
+
notion: ['getAll', 'search'],
|
|
81
|
+
gmail: ['getAll', 'get'],
|
|
82
|
+
postgres: ['executeQuery', 'select'],
|
|
83
|
+
mySql: ['executeQuery', 'select'],
|
|
84
|
+
mongoDb: ['find'],
|
|
85
|
+
hubspot: ['getAll', 'search'],
|
|
86
|
+
googleCalendar: ['getAll'],
|
|
87
|
+
googleDrive: ['list', 'search'],
|
|
88
|
+
rssFeedRead: ['*'],
|
|
89
|
+
supabase: ['getAll', 'get'],
|
|
90
|
+
};
|
|
91
|
+
function shortType(type) {
|
|
92
|
+
return type.split('.').pop() ?? type;
|
|
93
|
+
}
|
|
94
|
+
function classify(node) {
|
|
95
|
+
const type = node.type ?? '';
|
|
96
|
+
const st = shortType(type);
|
|
97
|
+
const lower = type.toLowerCase();
|
|
98
|
+
const params = node.parameters ?? {};
|
|
99
|
+
const op = typeof params.operation === 'string' ? params.operation : undefined;
|
|
100
|
+
if (st === 'stickyNote')
|
|
101
|
+
return { role: 'note', zeroEmit: null };
|
|
102
|
+
if (TRIGGER_HINTS.some((h) => lower.includes(h)))
|
|
103
|
+
return { role: 'trigger', zeroEmit: null };
|
|
104
|
+
if (st === 'stopAndError')
|
|
105
|
+
return { role: 'error-handler', zeroEmit: null };
|
|
106
|
+
if (STRUCTURAL_GATES[st]) {
|
|
107
|
+
return { role: 'gate', zeroEmit: { cause: STRUCTURAL_GATES[st], certainty: 'structural' } };
|
|
108
|
+
}
|
|
109
|
+
const writeSpec = WRITE_OPS[st];
|
|
110
|
+
if (writeSpec) {
|
|
111
|
+
if (typeof writeSpec === 'string') {
|
|
112
|
+
return { role: 'write', writeKind: writeSpec, writeTarget: WRITE_TARGETS[st] ?? st, zeroEmit: null };
|
|
113
|
+
}
|
|
114
|
+
if (op && writeSpec[op]) {
|
|
115
|
+
return { role: 'write', writeKind: writeSpec[op], writeTarget: WRITE_TARGETS[st] ?? st, zeroEmit: null };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Raw SQL: read the verb rather than the operation name.
|
|
119
|
+
if (st === 'postgres' || st === 'mySql') {
|
|
120
|
+
const q = String(params.query ?? '').trim().toLowerCase();
|
|
121
|
+
if (/^(insert|update|upsert|merge|delete)/.test(q)) {
|
|
122
|
+
return { role: 'write', writeKind: 'create', writeTarget: WRITE_TARGETS[st], zeroEmit: null };
|
|
123
|
+
}
|
|
124
|
+
if (/^(select|with)/.test(q) || !q) {
|
|
125
|
+
return { role: 'read', zeroEmit: { cause: 'The query can return no rows.', certainty: 'structural' } };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (st === 'httpRequest') {
|
|
129
|
+
const method = String(params.method ?? 'GET').toUpperCase();
|
|
130
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
131
|
+
return { role: 'write', writeKind: 'send', writeTarget: 'an API', zeroEmit: null };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
role: 'read',
|
|
135
|
+
zeroEmit: { cause: 'The request can come back with an empty list.', certainty: 'possible' },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const empties = EMPTY_READS[st];
|
|
139
|
+
if (empties && (empties.includes('*') || (op && empties.includes(op)))) {
|
|
140
|
+
return {
|
|
141
|
+
role: 'read',
|
|
142
|
+
zeroEmit: { cause: `"${node.name}" can find no matching records.`, certainty: 'structural' },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (st === 'code' || st === 'function' || st === 'functionItem') {
|
|
146
|
+
return {
|
|
147
|
+
role: 'transform',
|
|
148
|
+
zeroEmit: { cause: 'The code step can return an empty list.', certainty: 'possible' },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { role: 'other', zeroEmit: null };
|
|
152
|
+
}
|
|
153
|
+
function readCadence(nodes) {
|
|
154
|
+
for (const n of nodes) {
|
|
155
|
+
const st = shortType(n.type ?? '');
|
|
156
|
+
const p = n.parameters ?? {};
|
|
157
|
+
if (st === 'scheduleTrigger') {
|
|
158
|
+
const rule = p.rule?.interval?.[0];
|
|
159
|
+
if (rule) {
|
|
160
|
+
const field = rule.field ?? 'unknown';
|
|
161
|
+
const every = rule.minutesInterval ?? rule.hoursInterval ?? rule.daysInterval ?? 1;
|
|
162
|
+
const secs = { minutes: 60, hours: 3600, days: 86400, weeks: 604800 };
|
|
163
|
+
const unit = String(field).replace(/s$/, '');
|
|
164
|
+
const phrase = Number(every) === 1 ? `every ${unit}` : `every ${every} ${unit}s`;
|
|
165
|
+
return {
|
|
166
|
+
kind: 'schedule',
|
|
167
|
+
description: phrase,
|
|
168
|
+
intervalSeconds: (secs[field] ?? 0) * Number(every) || null,
|
|
169
|
+
expectedIntervalKnown: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { kind: 'schedule', description: 'a schedule with no interval set', intervalSeconds: null, expectedIntervalKnown: false };
|
|
173
|
+
}
|
|
174
|
+
if (st === 'cron') {
|
|
175
|
+
return { kind: 'schedule', description: 'a cron expression', intervalSeconds: null, expectedIntervalKnown: true };
|
|
176
|
+
}
|
|
177
|
+
if (st === 'intervalTrigger') {
|
|
178
|
+
return { kind: 'schedule', description: 'a fixed interval', intervalSeconds: null, expectedIntervalKnown: true };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const n of nodes) {
|
|
182
|
+
const lower = String(n.type ?? '').toLowerCase();
|
|
183
|
+
if (lower.includes('manualtrigger')) {
|
|
184
|
+
return { kind: 'manual', description: 'the Execute Workflow button', intervalSeconds: null, expectedIntervalKnown: false };
|
|
185
|
+
}
|
|
186
|
+
if (lower.includes('webhook') || lower.includes('formtrigger') || lower.includes('chattrigger')) {
|
|
187
|
+
return { kind: 'event', description: 'an incoming call from outside', intervalSeconds: null, expectedIntervalKnown: false };
|
|
188
|
+
}
|
|
189
|
+
if (lower.includes('trigger')) {
|
|
190
|
+
return { kind: 'event', description: `the ${shortType(n.type)} event`, intervalSeconds: null, expectedIntervalKnown: false };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { kind: 'unknown', description: 'no trigger found', intervalSeconds: null, expectedIntervalKnown: false };
|
|
194
|
+
}
|
|
195
|
+
function isN8nWorkflow(raw) {
|
|
196
|
+
return !!raw && typeof raw === 'object' && Array.isArray(raw.nodes) && typeof raw.connections === 'object';
|
|
197
|
+
}
|
|
198
|
+
function parseN8n(raw) {
|
|
199
|
+
const rawNodes = raw.nodes ?? [];
|
|
200
|
+
const parseNotes = [];
|
|
201
|
+
const nodes = rawNodes.map((n) => {
|
|
202
|
+
const c = classify(n);
|
|
203
|
+
const credentials = Object.entries(n.credentials ?? {}).map(([rawType, val]) => ({
|
|
204
|
+
rawType,
|
|
205
|
+
providerId: (0, providers_js_1.resolveProvider)(rawType, 'n8n')?.id ?? null,
|
|
206
|
+
authKind: (0, providers_js_1.guessAuthKind)(rawType),
|
|
207
|
+
label: typeof val?.name === 'string' ? val.name : undefined,
|
|
208
|
+
}));
|
|
209
|
+
return {
|
|
210
|
+
id: String(n.id ?? n.name),
|
|
211
|
+
label: String(n.name ?? n.id ?? 'unnamed step'),
|
|
212
|
+
platformType: String(n.type ?? ''),
|
|
213
|
+
role: c.role,
|
|
214
|
+
writeKind: c.writeKind,
|
|
215
|
+
writeTarget: c.writeTarget,
|
|
216
|
+
zeroEmit: c.zeroEmit,
|
|
217
|
+
credentials,
|
|
218
|
+
errorHandling: {
|
|
219
|
+
hasErrorBranch: n.onError === 'continueErrorOutput',
|
|
220
|
+
retries: n.retryOnFail === true,
|
|
221
|
+
continueOnFail: n.continueOnFail === true || n.onError === 'continueRegularOutput',
|
|
222
|
+
alwaysOutputData: n.alwaysOutputData === true,
|
|
223
|
+
},
|
|
224
|
+
disabled: n.disabled === true,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
// n8n keys connections by node NAME; the model keys by id. Bridge it.
|
|
228
|
+
const idByName = new Map(rawNodes.map((n) => [String(n.name), String(n.id ?? n.name)]));
|
|
229
|
+
const edges = [];
|
|
230
|
+
for (const [sourceName, outputs] of Object.entries(raw.connections ?? {})) {
|
|
231
|
+
const from = idByName.get(sourceName);
|
|
232
|
+
if (!from)
|
|
233
|
+
continue;
|
|
234
|
+
for (const [channel, groups] of Object.entries(outputs ?? {})) {
|
|
235
|
+
(groups ?? []).forEach((group, outputIndex) => {
|
|
236
|
+
(group ?? []).forEach((conn) => {
|
|
237
|
+
const to = idByName.get(String(conn?.node));
|
|
238
|
+
if (!to)
|
|
239
|
+
return;
|
|
240
|
+
const sourceNode = rawNodes.find((n) => String(n.name) === sourceName);
|
|
241
|
+
const isIf = shortType(sourceNode?.type ?? '') === 'if';
|
|
242
|
+
edges.push({
|
|
243
|
+
from,
|
|
244
|
+
to,
|
|
245
|
+
channel: channel === 'main' && isIf ? (outputIndex === 0 ? 'true' : 'false') : channel,
|
|
246
|
+
gate: null,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const triggerIds = nodes.filter((n) => n.role === 'trigger').map((n) => n.id);
|
|
253
|
+
const hasErrorTrigger = rawNodes.some((n) => shortType(n.type ?? '') === 'errorTrigger');
|
|
254
|
+
const settings = raw.settings ?? {};
|
|
255
|
+
if (!raw.id && !raw.versionId) {
|
|
256
|
+
parseNotes.push('This looks like a template export rather than a live workflow export.');
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
platform: 'n8n',
|
|
260
|
+
name: String(raw.name ?? 'Untitled workflow'),
|
|
261
|
+
nodes,
|
|
262
|
+
edges,
|
|
263
|
+
triggerIds,
|
|
264
|
+
cadence: readCadence(rawNodes),
|
|
265
|
+
errorPolicy: {
|
|
266
|
+
workflowLevelHandler: Boolean(settings.errorWorkflow) || hasErrorTrigger,
|
|
267
|
+
storesFailedRuns: null,
|
|
268
|
+
maxErrors: null,
|
|
269
|
+
notes: [],
|
|
270
|
+
},
|
|
271
|
+
parseNotes,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHECK 3 — credential expiry risk.
|
|
3
|
+
* CHECK 2 — triggers with no expected cadence.
|
|
4
|
+
* CHECK 1 — error handling. Table stakes: six free tools already do this, so it
|
|
5
|
+
* is a short footer, never the headline.
|
|
6
|
+
*/
|
|
7
|
+
import type { Finding, Workflow } from '../model.js';
|
|
8
|
+
export declare function checkCredentialExpiry(wf: Workflow): Finding[];
|
|
9
|
+
export declare function checkCadence(wf: Workflow): Finding[];
|
|
10
|
+
export declare function checkErrorHandling(wf: Workflow): Finding[];
|