n8n-decanter 0.10.1 → 0.11.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,283 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.1] - 2026-09-10
11
+
12
+ ### Added
13
+
14
+ - **`N8N_DECANTER_AUTH=upstream` — an auth mode for instances behind a proxy
15
+ that attaches the n8n credentials itself.** In this mode decanter sends *no*
16
+ credential header at all: no `Authorization` on MCP, no `X-N8N-API-KEY` on
17
+ REST. That is the only thing that works — a proxy which adds its own key
18
+ **appends** it to whatever the client sent, and n8n answers `401` to the pair,
19
+ so the previous workaround (a placeholder value in `N8N_API_KEY`, to satisfy
20
+ the REST-verb guard) fails just like an empty one. `N8N_API_KEY` stops being
21
+ required, so `executions`, `data-tables` and `backup` work with no key of your
22
+ own. `mcp serve` drops the agent's session secret instead of swapping it for a
23
+ credential, so that local secret is never forwarded upstream. Set it in `.env`
24
+ or with the new **`init --auth upstream`**, which needs no token, no browser
25
+ and no TTY, and verifies both backends through the proxy. Existing credentials
26
+ are kept, ignored, and reported as unused. Any other value of the variable is
27
+ an error on every verb — a typo must not silently restore the header.
28
+
29
+ ### Fixed
30
+
31
+ - **A REST 401 now says what is wrong.** It used to fall through to a bare
32
+ `401 Unauthorized` status line; it now names `N8N_API_KEY` as invalid for the
33
+ host (or, in upstream mode, points at the proxy).
34
+
35
+ ## [0.11.0] - 2026-09-03
36
+
37
+ ### Changed
38
+
39
+ - **Breaking: a compiled `.ts` node now identifies itself on line 1, not on
40
+ its last line.** Open a bundled node in n8n and you used to see
41
+ `var __n8n_node = {}` followed by a few hundred lines of inlined helpers,
42
+ with the only trace of where it came from — `// @ts-n8n sha256:…` — at the
43
+ bottom, where nobody looks, naming *n8n* rather than the tool that put it
44
+ there. Push now writes one self-describing line where the editor opens:
45
+
46
+ ```js
47
+ // n8n-decanter · workflows/orders/code/normalize-lines.ts · do not edit here · @ts-n8n sha256:39af5ea6… · v0.10.1 ca3c201 2026-08-20T09:14Z
48
+ ```
49
+
50
+ **Nothing you already pushed has to change.** The old trailing form is read
51
+ forever and counts as fully in sync — `push`, `diff` and `preflight` say
52
+ nothing about it, and no write is queued just to relocate a marker. Nodes
53
+ adopt line 1 on their next real code push; there is no migration and no mass
54
+ re-push. The break runs the other way: **an older n8n-decanter will not
55
+ recognise a node pushed by this version** (its reader only looks at the last
56
+ line) and would pull such a node down as a plain `.js` file. Pre-1.0, that is
57
+ accepted rather than shimmed — upgrade the CLI everywhere that pulls the same
58
+ instance. The `@ts-n8n` token itself is unchanged, so one `grep -r @ts-n8n`
59
+ still finds both forms.
60
+
61
+ - **The layout guard now rejects a marker line in a node source file in either
62
+ position, and in `.ts` files too** (it was trailing-only and `.js`-only).
63
+ That line is written by `push`; it is never source. Surfaces as before via
64
+ `preflight`'s `layout` check.
65
+
66
+ ### Fixed
67
+
68
+ - **An auth failure no longer sends you in a circle, and throttling is no
69
+ longer called an expiry.** Every failed OAuth token refresh used to print
70
+ the same sentence — `MCP session expired … re-run: n8n-decanter init` — and
71
+ both halves of it could be wrong. `init` reuses `.decanter-auth.json`
72
+ whenever the host matches and never re-mints, so following the advice
73
+ re-probed with the same dead credentials and finished with "credentials
74
+ written anyway"; the only apparent way out was deleting a credential file
75
+ that was often perfectly fine. The three cases are now told apart:
76
+ - **the refresh token really is spent** (`invalid_grant`) — named as such,
77
+ and pointed at `init --reauth`, a command that actually re-mints;
78
+ - **n8n is rate-limiting** (429) — now **retried** with the same backoff the
79
+ MCP endpoint has always had (honouring `Retry-After`, five attempts), and
80
+ if it still fails it says the credentials are fine and never mentions
81
+ `init`. OAuth discovery got the same retry; both sat on bare `fetch` calls;
82
+ - **anything else** — the reason is named without a diagnosis, and nothing
83
+ is suggested for discarding.
84
+
85
+ - **`init`'s closing connection check acts on a spent token instead of
86
+ shrugging.** It had the failure in hand and printed "credentials written
87
+ anyway". On a terminal it now offers to re-authorize on the spot; off one it
88
+ names `init --reauth`. Deliberately narrow — a network error, a 401, or a
89
+ 403 "MCP access is disabled" still report as before, because none of them
90
+ says anything about your credentials.
91
+
92
+ - **A successful first OAuth consent no longer warns "no MCP credentials
93
+ yet".** The check looked for a *pre-existing* auth file, so a browser
94
+ authorization that had just succeeded still ended with advice to re-run
95
+ `init` with a token.
96
+
97
+ - **`<verb> --help` prints help instead of running the verb.** `--help` was
98
+ only recognised in argument slot 0, and every `--flag` is stripped before
99
+ dispatch, so `n8n-decanter init --help` was indistinguishable from a bare
100
+ `init` — a request for help **scaffolded a sync dir** into whatever directory
101
+ it was asked from. `--help` (and `-h`) now wins from any position, before any
102
+ verb, any namespace and the picker. It prints **that verb's own block** rather
103
+ than the whole listing, with only the notes that apply to it; `help <verb>` is
104
+ the same question, and a bare `help`/`--help` still prints everything.
105
+
106
+ ### Added
107
+
108
+ - **`init` refuses to scaffold on top of a sync dir that already exists below
109
+ the target.** Run from the root of a bigger repo whose sync dir lives in
110
+ `n8n/`, `init` used to drop a second `decanter.config.json`, the template,
111
+ `workflows/`, `shared/`, `tsconfig.json` and the agent configs into the root.
112
+ It now looks a few levels down first and stops, naming the sync dir it found
113
+ and the `--dir=`/`N8N_DECANTER_DIR` form that addresses it from where you are
114
+ — the same advice every read verb already gives. A terminal gets it as a
115
+ question (`y` scaffolds anyway); a piped or flag-driven run exits 1 having
116
+ written nothing, not even the target directory. Re-running `init` **inside**
117
+ an existing sync dir is unchanged.
118
+
119
+ - **The provenance line carries the build stamp, not just the hash**: the
120
+ node's source path relative to the sync dir, "do not edit here", the CLI
121
+ version, the git commit and the push time. The commit is HEAD at build time
122
+ and renders as `ca3c201+dirty` when the sync dir has uncommitted changes —
123
+ which, with `commitOnPush` on, is the normal case and reads correctly as
124
+ "built from working-tree state on top of `ca3c201`". Fields that cannot be
125
+ known (no git repo, unreadable `package.json`) are simply left out. **None of
126
+ it is hashed**, so a rename, a new commit or a CLI upgrade never makes a node
127
+ look changed — only the code below line 1 does.
128
+
129
+ - **Credentials now resolve in a git worktree.** Both `.env` and
130
+ `.decanter-auth.json` are gitignored, so a fresh linked worktree had neither
131
+ and every credentialed verb died on `N8N_HOST must be set` — including the
132
+ `mcp connect` guard, which left agents in a worktree with no `n8n-instance`
133
+ tools at all. A worktree without its own credentials now reads the main
134
+ checkout's copies (same path, resolved from git's own worktree pointer, no
135
+ `git` subprocess). **A local file still wins**, so a worktree deliberately
136
+ aimed at another instance keeps its own, and nothing else is redirected —
137
+ `workflows/`, `.decanter.json` and `decanter.config.json` stay worktree-local.
138
+ For `.decanter-auth.json` the shared file is the only correct shape: the
139
+ refresh token is single-use and rotates, so **copying** it into a worktree
140
+ (what Claude Code's `.worktreeinclude` would do) forks it into two token
141
+ chains and kills the loser. New [Git
142
+ worktrees](/docs/concepts/configuration/#git-worktrees) section covers the two
143
+ remaining worktree gaps decanter cannot fix — a missing `node_modules` and the
144
+ per-path MCP approval — and the troubleshooting entry for missing
145
+ `n8n-instance` tools gained the worktree case.
146
+
147
+ ## [0.10.2] - 2026-08-18
148
+
149
+ ### Changed
150
+
151
+ - **"Missing `n8n-instance` tools? Restart" is no longer the only answer we
152
+ give — in a nested sync dir it was a dead end.** Agent wiring loads at
153
+ startup *from the dir the agent was started in*, so tools declared in
154
+ `.mcp.json` can be absent for two reasons: the wiring is new (a restart fixes
155
+ it), or the wiring sits **below** the launch dir, where no restart will ever
156
+ load it. Every surface that taught only the first now teaches both, with the
157
+ discriminator you can apply yourself — *is that `.mcp.json` below the
158
+ directory you started the agent in?* — and the two working routes (start the
159
+ agent in the sync dir, or wire the root with `N8N_DECANTER_DIR` plus a
160
+ root-resolvable command): the scaffolded `AGENTS.md`, `init`'s own output
161
+ (which now prints the nested guidance **instead of** the restart line when it
162
+ scaffolds into a nested dir), [Working with coding
163
+ agents](/docs/agents/overview/), [init](/docs/cli/init/) and a new
164
+ [troubleshooting](/docs/faq/troubleshooting/) entry. The CLI never needed
165
+ that wiring, so `pull`/`push`/`preflight` keep working either way — the docs
166
+ now say so too.
167
+
168
+ - **The oversized-scenario warning now names a remedy you can actually take.**
169
+ Above 1 MB `scenario create` said "Trim it before that" — but there is no
170
+ trim flag, so the advice pointed nowhere. It now says what is true and what
171
+ to do: nothing is committed yet (`scenario create` never commits — only the
172
+ next `pull`/`push` sweeps the folder into history), so you can still cut
173
+ items out of `data.resultData.runData` by hand, or re-create the scenario
174
+ with `--scaffold` instead of `--execution` and author the pins yourself.
175
+ `docs/cli/scenario.md` spells out both, including which parts of a capture
176
+ are unsafe to delete.
177
+
178
+ - **The agent contract now says to orient *before* the first edit, not only
179
+ before the push.** `preflight` was always the read-only report of the
180
+ instance's side (`drift`, `CONFLICT`, a pending `parity`), but every surface
181
+ framed it as the pre-push gate — so an agent edited first and learned about a
182
+ colleague's UI edit afterwards. The scaffolded `AGENTS.md` and both agent doc
183
+ pages now open the loop with it: on drift, `pull` and carry on; on a
184
+ `CONFLICT`, show `diff` and ask before either side is overwritten.
185
+ - **The scaffold stopped advertising a deny rule it no longer has.** The
186
+ Claude Code `settings.json` denies `.decanter.json`, `.env` and
187
+ `push --force` — but the scaffolded `CLAUDE.md` and `opencode.json` still
188
+ claimed `*.remote.js` was blocked too. Those conflict artifacts were removed
189
+ in the MCP pivot; both files now describe the policy that actually ships.
190
+ - **Docs: `mcp connect` / `mcp serve` no longer read as if the guard obtained
191
+ credentials itself.** "decanter's own credentials", "the agent never holds an
192
+ n8n credential" and "no secret to manage" led readers (and agents) to believe
193
+ the guard handles the n8n login. It does not: it only *reads* what `init`
194
+ wrote to `.env` / `.decanter-auth.json`, and can at most refresh an OAuth
195
+ token. The mcp-connect / mcp-serve pages and the template `AGENTS.md` now say
196
+ so outright — obtaining credentials is exclusively `init`'s job, and a
197
+ "no MCP credentials" answer means run `init`, not retry.
198
+
199
+ - **"decanter.config.json not found" now points at `init` — with its flags.**
200
+ The classic half-setup is a hand-written `.env`: an agent that cannot run the
201
+ browser OAuth flow asks its human to paste `N8N_MCP_TOKEN` into a file and
202
+ stops there, leaving no config, template, `.gitignore` or agent wiring behind.
203
+ The error now says the dir is not a sync dir yet, that `.env` alone is not
204
+ enough, and prints the prompt-free command that fixes it
205
+ (`n8n-decanter init . --host <host-url> --token <mcp-token>`). The docs
206
+ (init, configuration, troubleshooting, README) say the same thing: headless is
207
+ not a reason to skip `init` — it takes the same token as a flag.
208
+
209
+ - **`init`'s restart reminder now covers everything it wires, not just
210
+ permission rules.** MCP servers (`.mcp.json` / `opencode.json` — including the
211
+ guarded `n8n-instance` server), permission rules and hooks are all read at
212
+ agent **startup**, and `init` normally runs inside the session it configures.
213
+ The reminder now fires when any of those files is newly scaffolded and says
214
+ what it means: this session is still unconfigured, restart the agent (or
215
+ `/reload`). README, init and the agents docs say the same, and the scaffolded
216
+ `AGENTS.md` tells the agent to **ask for a restart** when the `n8n-instance`
217
+ tools are missing instead of connecting to the instance directly.
218
+
219
+ ### Fixed
220
+
221
+ - **The scaffolded hooks now work when your sync dir is not where the agent was
222
+ started.** All three found the sync dir by assuming it was the current
223
+ directory, which only holds for an agent launched inside it. With the sync dir
224
+ nested in a bigger repo — a layout the docs explicitly allow — the agent runs
225
+ at the repo root and every one of them misbehaved: the rename-reference guard
226
+ became a **silent no-op**, so `$('Old Name')` references left behind by a
227
+ `renameNode` went unreported until a later `push` refused them; the verify hook
228
+ spawned the CLI without a directory, so it **blocked every node-file edit** with
229
+ a "not a sync dir" error; and the MCP routing check scanned the wrong tree.
230
+ Each hook now locates the sync dir from its own installed path, so it behaves
231
+ the same wherever the agent starts.
232
+ - **The routing check no longer misses direct-route servers in your user
233
+ config.** Its lookup for this project's entry in `~/.claude.json` matched the
234
+ current directory, but that file is keyed by the repository root — so in any
235
+ sync dir inside a git repo the check silently found nothing. It now matches the
236
+ project entry for the sync dir or any parent of it.
237
+ - **The verify hook finds a locally installed CLI.** It only ever looked for
238
+ `n8n-decanter` on `PATH`, so with a local (non-global) install it stayed quiet
239
+ and no verification ran at all. It now prefers the sync dir's
240
+ `node_modules/.bin` and falls back to `PATH`.
241
+ - **"decanter.config.json not found" no longer sends you to `init` when the sync
242
+ dir is simply somewhere else.** Run from *above* a perfectly good sync dir —
243
+ what happens whenever an agent starts at the repo root — the error read as if
244
+ nothing had ever been set up, and advised scaffolding a second sync dir on top
245
+ of the working one. It now looks below the directory it searched from, names
246
+ the sync dir it finds there, and prints the `--dir` / `N8N_DECANTER_DIR` form
247
+ that reaches it. When there really is no sync dir, the `init` advice is
248
+ unchanged.
249
+
250
+ ### Added
251
+
252
+ - **The routing check also looks at parent directories, up to your repository
253
+ root.** Agents merge `.mcp.json` from every directory above the one they start
254
+ in, so a server pointing straight at your n8n instance can sit in the repo
255
+ root's config and still route this session. The scan stops at the repository
256
+ boundary, so it never reaches into unrelated parent directories, and an
257
+ offender found above the sync dir is named by its relative path
258
+ (`../.mcp.json`) so you can tell which file it means.
259
+
260
+ - **`--dir <path>` (or `N8N_DECANTER_DIR`) points any verb at a sync dir that is
261
+ not the current directory.** The layout the docs allow but nothing supported:
262
+ the sync dir nested inside a bigger repo, with the agent started at the repo
263
+ root. The MCP entry `init` writes into the sync dir is invisible from up
264
+ there, and hoisting it to the repo root spawned the guard where no
265
+ `decanter.config.json` could be found — so the guard did not work at all. The
266
+ search still only walks *up*; this says where it starts. In an agent's server
267
+ entry the environment variable is the form to reach for
268
+ (`"env": { "N8N_DECANTER_DIR": "flows" }`), and relative values resolve
269
+ against the working directory, so a repo-relative one keeps working for
270
+ everyone who clones. `init` does not take `--dir` — it still takes the
271
+ directory to scaffold as an argument.
272
+
273
+ - **`init` now tells you how to wire an agent when your sync dir is nested in a
274
+ bigger project.** Agents look for `.mcp.json`, `opencode.json` and
275
+ `.claude/settings.json` from the directory they were *started* in and never in
276
+ one below it, so everything `init` scaffolds is inert for an agent started at
277
+ the repo root above the sync dir. When init sees a project around it (a `.git` or
278
+ `package.json` in a parent) it prints both shapes that work: starting the
279
+ agent inside the sync dir — recommended, nothing further to configure — or the
280
+ paste-ready MCP, opencode and hooks/permissions blocks for the project root,
281
+ with every path and glob already prefixed. That prefixing is the point: copied
282
+ up verbatim, `Read(.env)` / `Edit(.env)` guard the *root's* `.env` and quietly
283
+ stop protecting your credentials. `init` prints this; it never writes into a
284
+ parent directory. Only on the run that first scaffolds the agent files, and
285
+ never for a standalone sync dir.
286
+
10
287
  ## [0.10.1] - 2026-08-16
11
288
 
12
289
  ### Added
package/README.md CHANGED
@@ -37,6 +37,10 @@ clean git diffs.
37
37
  - **Shared code and small libraries** — `.ts` nodes import helper files from
38
38
  anywhere in the sync dir (`shared/` by default) and opted-in npm packages,
39
39
  bundled on push to run anywhere, n8n Cloud included.
40
+ - **The deployed node says where it came from** — a compiled `.ts` node opens
41
+ in n8n with its source path, "do not edit here", the content hash and the
42
+ build stamp (CLI version, git commit, push time) on line 1, so a bundle full
43
+ of inlined helpers is never a mystery artifact.
40
44
  - **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs;
41
45
  `preflight --offline` and `node run` give agents a credential-free loop.
42
46
  - **Guarded agent access to n8n's MCP, wired by default** — the scaffolded
@@ -73,6 +77,13 @@ n8n-decanter init [dir] # OAuth in your browser (or a pasted MCP token)
73
77
  n8n-decanter pull # pick a workflow from the list → it pulls
74
78
  ```
75
79
 
80
+ **Restart your coding agent after `init`** — the MCP servers, permission rules
81
+ and hooks it scaffolds are read at agent startup, so the session that ran `init`
82
+ is still unconfigured. If the sync dir is nested in a bigger repo and you start
83
+ the agent at the repo root, a restart is *not* enough — that wiring loads only
84
+ from the dir the agent starts in
85
+ ([which file loads from where](docs/agents/overview.md#where-the-agent-wiring-loads-from)).
86
+
76
87
  On a terminal, `pull` with no argument lists your n8n workflows — pick one and
77
88
  it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
78
89
  <id-or-name>` pulls it directly (scriptable, no TTY needed).
@@ -80,8 +91,12 @@ it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
80
91
  set a bare `pull`/`push`/`diff` acts on.)
81
92
 
82
93
  **Credentials:** OAuth by default (via `init`); `N8N_MCP_TOKEN` for
83
- headless/CI; `N8N_API_KEY` is optional, needed only for `executions`,
84
- `data-tables`, and `backup`. Details: [Installation](docs/getting-started/installation.md),
94
+ headless/CI — pass it to `init --host <url> --token <mcp-token>` rather than
95
+ hand-writing `.env`, which leaves the config, template, `.gitignore` and agent
96
+ configs unscaffolded; `N8N_API_KEY` is optional, needed only for `executions`,
97
+ `data-tables`, and `backup`. Behind a proxy that attaches the n8n credentials
98
+ itself, `init --auth upstream` sets `N8N_DECANTER_AUTH=upstream` and decanter
99
+ sends none of its own. Details: [Installation](docs/getting-started/installation.md),
85
100
  [init](docs/cli/init.md), [Configuration](docs/concepts/configuration.md).
86
101
 
87
102
  ## Works with n8n's official skills
@@ -109,7 +124,7 @@ Full flag reference: `n8n-decanter --help`, or the
109
124
 
110
125
  | Verb | What it does |
111
126
  |---|---|
112
- | `init [dir]` | Bootstrap a sync dir — OAuth/token, template, config, agent tooling (`--host`/`--token`/`--api-key` drive it non-interactively) |
127
+ | `init [dir]` | Bootstrap a sync dir — OAuth/token, template, config, agent tooling (`--host`/`--token`/`--api-key` drive it non-interactively; `--reauth` re-consents after a spent token) |
113
128
  | `completion zsh\|bash` | Print a shell completion script |
114
129
  | `pull [workflow…]` | Code + structure snapshot → `workflows/<kebab>/` |
115
130
  | `push [workflow…]` | Push Code-node source to the workflow's **draft** |
@@ -132,6 +147,17 @@ renaming, and archiving workflows — and adding or renaming nodes — are
132
147
  **n8n's acts**: do them in the editor or let your agent do it over n8n's MCP
133
148
  tools (through the guard above); the next `pull` reconciles the local mirror.
134
149
 
150
+ **Sync dir nested in a bigger repo?** A global `--dir <path>` (or
151
+ `N8N_DECANTER_DIR`) starts the upward `decanter.config.json` search there
152
+ instead of in the working directory — every verb honours it except `init`,
153
+ which takes the directory to scaffold as its argument. A coding agent launched
154
+ at the repo root needs it too — agents look for `.mcp.json` and
155
+ `.claude/settings.json` from where they were *started*, never in a directory
156
+ below it, so either start the agent in the sync dir or wire the root
157
+ explicitly:
158
+ [Working with coding agents](docs/agents/overview.md#where-the-agent-wiring-loads-from),
159
+ [mcp connect](docs/cli/mcp-connect.md#when-your-sync-dir-is-not-your-project-root).
160
+
135
161
  ## How it compares
136
162
 
137
163
  n8n-decanter is **Code-node-first**: it optimizes writing, typing, verifying and
package/dist/lib/api.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { AUTH_MODE_ENV } from "./config.mjs";
1
2
  /**
2
3
  * n8n public REST API client — since Plan 33 only the surfaces MCP cannot
3
4
  * serve: executions (no MCP read of full run data), data-table rows (MCP is
@@ -16,7 +17,19 @@
16
17
  * fold into `dataTable:read`, so a key that lists tables fine still 403s on
17
18
  * `/columns`.
18
19
  */
19
- function scopeHint(method, pathname) {
20
+ function scopeHint(method, pathname, authMode) {
21
+ const scope = scopeName(method, pathname);
22
+ // In upstream mode the under-scoped key belongs to the proxy, so the scope
23
+ // name alone would send the reader to an `.env` that holds no key (Plan 92).
24
+ // Credentials mode keeps its exact previous wording.
25
+ if (authMode === "upstream") {
26
+ const where = "The key belongs to the upstream auth proxy, not to your .env — fix it where the proxy mints it.";
27
+ return scope === "" ? where : `${scope} ${where}`;
28
+ }
29
+ return scope === "" ? "Check the key's scopes in n8n → Settings → n8n API." : scope;
30
+ }
31
+ /** The scope a refused path needed, or `""` when the path is not one we map. */
32
+ function scopeName(method, pathname) {
20
33
  const readOnly = " decanter only ever READS data tables — no write scope is needed.";
21
34
  if (pathname.includes("/data-tables/") && pathname.includes("/columns"))
22
35
  return `Add \`dataTableColumn:read\` (listing tables uses \`dataTable:list\`, which does NOT cover columns).${readOnly}`;
@@ -32,16 +45,18 @@ function scopeHint(method, pathname) {
32
45
  return "Add `workflow:create` (`backup restore` redeploys a backup as a new workflow).";
33
46
  if (pathname.startsWith("/api/v1/workflows"))
34
47
  return "Add `workflow:read` (and `workflow:list` for init's connection check).";
35
- return "Check the key's scopes in n8n → Settings → n8n API.";
48
+ return "";
36
49
  }
37
50
  export class N8nApi {
38
51
  #host;
39
52
  #apiKey;
40
53
  #timeoutMs;
41
- constructor({ host, apiKey, requestTimeoutMs = 30_000 }) {
54
+ #authMode;
55
+ constructor({ host, apiKey, requestTimeoutMs = 30_000, authMode = "credentials" }) {
42
56
  this.#host = host;
43
57
  this.#apiKey = apiKey;
44
58
  this.#timeoutMs = requestTimeoutMs;
59
+ this.#authMode = authMode;
45
60
  }
46
61
  /**
47
62
  * Recent executions, newest first. Read-only by design — the executions API is
@@ -134,7 +149,10 @@ export class N8nApi {
134
149
  const res = await fetch(this.#host + pathname, {
135
150
  method,
136
151
  headers: {
137
- "X-N8N-API-KEY": this.#apiKey,
152
+ // OMITTED, not blanked, in upstream mode: the proxy in front of n8n
153
+ // APPENDS its key to whatever the client sent, and n8n answers 401 to
154
+ // the pair (measured 2026-09-09). An empty header is still a header.
155
+ ...(this.#authMode === "credentials" && { "X-N8N-API-KEY": this.#apiKey }),
138
156
  accept: "application/json",
139
157
  ...(body !== undefined && { "content-type": "application/json" }),
140
158
  },
@@ -147,8 +165,18 @@ export class N8nApi {
147
165
  // and n8n does not say which scope is missing. Mapped HERE rather than
148
166
  // in each caller so every REST surface (executions, data-tables, backup)
149
167
  // gets it: `pathname` already identifies what was refused.
168
+ // 401 used to fall through to the bare status line below — the least
169
+ // helpful place to land, and the failure upstream mode makes most
170
+ // likely (a proxy that forwards but attaches nothing).
171
+ if (res.status === 401) {
172
+ throw new Error(this.#authMode === "upstream"
173
+ ? `${method} ${pathname} was rejected (401) — decanter sent no API key by design (${AUTH_MODE_ENV}=upstream); the upstream auth proxy did not attach a valid one`
174
+ : `${method} ${pathname} was rejected (401) — N8N_API_KEY is not valid for ${this.#host} (n8n → Settings → n8n API)`);
175
+ }
150
176
  if (res.status === 403) {
151
- throw new Error(`${method} ${pathname} was refused (403) — N8N_API_KEY is valid but lacks a scope. ${scopeHint(method, pathname)}`);
177
+ throw new Error(this.#authMode === "upstream"
178
+ ? `${method} ${pathname} was refused (403) — the API key the upstream auth proxy attaches is valid but lacks a scope. ${scopeHint(method, pathname, this.#authMode)}`
179
+ : `${method} ${pathname} was refused (403) — N8N_API_KEY is valid but lacks a scope. ${scopeHint(method, pathname, this.#authMode)}`);
152
180
  }
153
181
  throw new Error(`${method} ${pathname} failed: ${res.status} ${res.statusText}\n${text.slice(0, 2000)}`);
154
182
  }