posts: n8n v1→v2 migration, self-healing entitlements, TTS sidecars
Deploy / build (push) Successful in 19s

Three new posts (EN + ZH twins, og + banner each):

- n8n-v1-to-v2-upgrade-gotchas (devops): the seven deprecations that
  surfaced upgrading 1.123.x → 2.40.1, decoded from the boot log —
  telemetry schema rejection, N8N_WEBHOOK_URL rename, internal runner
  deprecation, task timeout 300s→60s, two compression limits, v3
  storage rename, plus the DB override that silently disabled the
  AI sandbox.
- self-healing-digital-goods-entitlements (case-studies): the W1–W5
  NocoDB → n8n → AList entitlement lifecycle. Build-time code sharing
  for n8n Code nodes, MAX-expiry semantics, dry-run safety, daily
  drift repair, CORS-not-HMAC reasoning, and the public→internal
  NocoDB cascading-failure fix (504 → retry storm → 503).
- running-tts-as-a-service-with-token-sidecars (ai): a year-long TTS
  service built on two cron containers that refresh Azure/Google
  tokens into a shared file, with the speed/voice mapping layer.

banner-gen: center terminal body vertically so line counts shorter
than the fixed 690px panel don't leave a dead void at the bottom.
Verified via DOM measurement (gapAbove 104 / gapBelow 106).
This commit is contained in:
2026-09-18 01:51:46 +08:00
parent be37348a8c
commit fb66e17b50
15 changed files with 1539 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+63
View File
@@ -445,6 +445,69 @@ const BANNERS = {
],
};
BANNERS['n8n-v1-to-v2-upgrade-gotchas'] = {
titlebar: 'root@dsm — n8n v1 → v2 migration',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker pull n8nio/n8n:2.40.1 · container up in 90s' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'v1.123.x → v2.40.1 · 17 workflows · 7 active' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Telemetry failed schema validation: executions_data_save_on_error' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Failed to start Python task runner — Python 3 missing' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Sandbox: enabled=false (DB override; env was enabled=true)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'N8N_WEBHOOK_URL · pin TASK_TIMEOUT=300 · pin compression limits' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'storage rename flagged for v3 · migrate + remount together' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 deprecations resolved · downstream workflows intact ✓' },
],
flow: [
{ n: '1', label: 'pull v2' },
{ n: '2', label: 'read boot log' },
{ n: '3', label: 'schema reject', err: true },
{ n: '4', label: 'pin defaults' },
{ n: '5', label: 'verified ✓' },
],
};
BANNERS['self-healing-digital-goods-entitlements'] = {
titlebar: 'root@dsm — entitlement lifecycle (W1W5)',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'nocodb webhook → n8n compute → alist role scopes' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'desired = union(active purchases, direct grants) · MAX expiry wins' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'shared/effective-grants.js inlined at build · 4 workflows' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'public hostname: >60s → nginx 504 → 1s retries → pool → 503' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'http://nocodb:10380 (30ms, no tunnel) → cascade gone' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W3 sweeps every 5min · W4 repairs drift 03:00 + verifies' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W5 public: CORS-locked, minimal fields, no HMAC theatre' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ repair verified · reconcile log empty when healthy ✓' },
],
flow: [
{ n: '1', label: 'grant' },
{ n: '2', label: 'expire' },
{ n: '3', label: 'drift', err: true },
{ n: '4', label: 'repair' },
{ n: '5', label: 'verify ✓' },
],
};
BANNERS['running-tts-as-a-service-with-token-sidecars'] = {
titlebar: 'root@dsm — tts service · 1 year uptime',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'reader → GET /webhook/{mtts,gtts}?pass=…&text=…&speed=…' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'azure speech F0 · google cloud cmn-CN-Wavenet-A' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'token stored in workflow → 401 after 10min (azure) / 1h (google)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: '2 cron sidecars → accesstoken.txt on shared volume' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'refresh 570s / 3500s · margin before expiry' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'map speed 550 → -20%…+150% · strip trailing newline' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '8 neural voices selected by integer index' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ secrets in zero workflows · still running ✓' },
],
flow: [
{ n: '1', label: 'webhook' },
{ n: '2', label: 'read token' },
{ n: '3', label: 'synthesize' },
{ n: '4', label: 'audio/wav' },
{ n: '5', label: '1yr ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+1 -1
View File
@@ -64,7 +64,7 @@
.terminal .dot.g { background: #3fb950; }
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; }
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
.terminal .cmd { color: #e6edf3; }
+15
View File
@@ -144,6 +144,21 @@ const TERMINALS = {
<div class="line"><span class="prompt">$</span><span class="cmd">wp plugin list · hre-ai-remix 0.0.1 → 0.0.22</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">404 …/wp-json/hre/v1<span class="hl">admin</span>/photos — rest_url() has no trailing slash</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">fix = '/admin/…' · 58 commits · 4 were the AI</span><span class="fix">→ shipped ✓</span></div>`,
'n8n-v1-to-v2-upgrade-gotchas': `
<div class="line"><span class="prompt">$</span><span class="cmd">pull n8nio/n8n:2.40.1 · restart · upgrade took 90s</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">telemetry schema: executions_data_save_on_error rejected</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">read the boot log · pin timeouts + limits</span><span class="fix">→ 7 fixed ✓</span></div>`,
'self-healing-digital-goods-entitlements': `
<div class="line"><span class="prompt">$</span><span class="cmd">nocodb → n8n W1W5 → alist role scopes</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">public hostname: 60s latency → nginx 504 → retry storm → 503</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">http://nocodb:10380 · 30ms · W4 repairs drift 03:00</span><span class="fix">→ self-healing ✓</span></div>`,
'running-tts-as-a-service-with-token-sidecars': `
<div class="line"><span class="prompt">$</span><span class="cmd">reading app → GET /webhook/mtts?pass=…&text=…</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">azure token expires in ~10min · google in ~1h</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">2 cron sidecars write accesstoken.txt · 570s / 3500s</span><span class="fix">→ 1 year uptime ✓</span></div>`,
};
const DEFAULT_TERMINAL = `
@@ -0,0 +1,270 @@
---
title: "Upgrading n8n v1 to v2: Seven Deprecations in One Log File"
description: "My self-hosted n8n 1.x to 2.40.1 upgrade surfaced seven silent breakages at once — a telemetry schema rejection, deprecated webhook vars, and a DB override that quietly disabled the AI sandbox."
pubDate: 2026-09-18
category: devops
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
ogImage: /og/n8n-v1-to-v2-upgrade-gotchas.png
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.png
---
I run n8n as the automation backbone for my self-hosted stack — it handles
file-delivery permissions, database backups, and a text-to-speech API that a
reading app depends on. It had been sitting on the `1.123.x` line for the
better part of a year, quietly doing its job.
Then I pulled `n8nio/n8n:2.40.1` and restarted the container. The upgrade
itself took about ninety seconds. Understanding what it *broke* took the rest
of the evening — and almost all of it was already written down in a single log
file that n8n prints on boot. I just hadn't read it carefully enough the first
time.
This is that log file, decoded, so you can plan your own v1 → v2 jump instead
of discovering these at 11 PM.
## Why it matters
Major-version upgrades of an automation platform are different from upgrading
a leaf service. n8n is *the thing that runs everything else*: if it comes up
broken, your backups, your access-control syncs, and your internal APIs all
stop with it. Worse, most of what breaks in v2 doesn't throw an error — it
logs a deprecation notice once and then quietly does something different.
The seven items below are the ones that actually applied to a real, messy,
production-shaped install. Three of them changed behavior in my stack. One of
them silently turned a feature *off*.
## Start here: n8n tells you what's wrong at boot
Before touching a single workflow, read the container log from the top. On a
fresh v2 boot, n8n prints an explicit deprecation block:
```text
There are deprecations related to your n8n setup. Please take the recommended
actions to update your configuration:
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
both test and production webhooks.
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
change to `false` in a future version.
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
removed in a future version.
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
reduced from 2 GiB to 256 MiB in a future version.
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
5000 to 1000 in a future version.
```
That block is your migration checklist. Six of my seven gotchas are in it.
## 1. Your env values can now fail *schema validation*
Here is the one that confused me most, because it looked like a nonsense error:
```text
Telemetry event "Instance started" failed schema validation:
execution_variables.executions_data_save_on_error: Invalid option:
expected one of "all"|"none"
```
I had `EXECUTIONS_DATA_SAVE_ON_ERROR=error` set — a value that was perfectly
legal in v1, and that I'd chosen deliberately, because saving *only* failed
executions is the sane default for a busy instance. In v2 that value is no
longer in the allowed set, which is now `all` or `none`.
The failure mode is the interesting part. It didn't crash. It didn't even warn
in a way that reads like an error at a glance — it emitted a *telemetry schema
validation* message, which sounds like an n8n-internal problem, not a
configuration problem of mine. The setting was effectively ignored.
The fix is to move that intent somewhere coherent: pick a legal value and
control volume with pruning instead.
```env
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
```
**Lesson:** in v2, treat your environment variables as a typed interface with
a schema. An invalid value may be dropped silently rather than rejected loudly.
## 2. `WEBHOOK_URL` is deprecated for `N8N_WEBHOOK_URL`
If you publish webhooks behind a reverse proxy — which you almost certainly do,
because that's how they become reachable — the base URL variable is load-bearing.
It's what makes n8n report the *public* webhook path instead of
`http://localhost:5678/...`.
The old name still works today, so this one won't bite immediately. But note
the wording: the new variable sets the base URL for **both test and production
webhooks**. In my setup those had drifted apart in behavior, which is exactly
the class of bug this consolidation is meant to eliminate.
```env
# before (still functional, deprecated)
WEBHOOK_URL=https://auto.example.com/
# after
N8N_WEBHOOK_URL=https://auto.example.com/
```
## 3. Internal task runner mode is going away — and mine was already broken
This one was sitting in my logs the whole time, several lines above the
deprecation block, and I'd been reading past it for months:
```text
Failed to start Python task runner in internal mode. because Python 3 is
missing from this system. Launching a Python runner in internal mode is
intended only for debugging and is not recommended for production.
```
If any workflow of yours uses a **Python** Code node, it has not been running
in internal mode at all — there's no Python in the stock image. JavaScript
Code nodes are fine (a JS runner registers normally), which is why this can go
unnoticed indefinitely: everything *looks* healthy.
v2 makes the direction of travel explicit: switch to `external` mode and share
an auth token with a separate launcher process.
```env
N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=<a long random string>
```
**Lesson:** "internal mode is deprecated" is the headline, but the real
finding is that a runner type can be *silently non-functional* for months.
Check `docker logs` for the runner registration line, not just for up/down.
## 4. Task timeout drops from 300s to 60s
This is the one I'd flag hardest for anyone with slow workflows:
```text
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
```
Be honest about your own workloads. Do you have a Code node that loops over
thousands of records, or an HTTP call to a slow upstream? Mine do — a nightly
reconciliation walks every customer and calls an external API per record. On a
future upgrade, that stops at sixty seconds with no config change on my side.
Set it explicitly now, while you're already in the file:
```env
N8N_RUNNERS_TASK_TIMEOUT=300
```
The general principle for every deprecation of the form "the default will
change": **if you rely on the current default, pin it explicitly.** Otherwise
the upgrade is a silent behavior change, and you'll debug it as a bug rather
than recognise it as a stale default.
## 5 & 6. Two compression-node limits shrink (2 GiB → 256 MiB, 5000 → 1000 entries)
These two travel together and matter only if you use compression/decompression
nodes on large payloads — which is easy to end up doing when you're shuttling
database dumps or archives through a workflow.
```text
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
256 MiB in a future version.
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
future version.
```
An eighth of the memory ceiling and a fifth of the entry limit. Nothing errors;
the node just refuses at a threshold you didn't set. Pin both if you're near
either.
## 7. The storage path renames in v3 — and you have a volume mounted at the old one
Not a v2 breakage, but v2 warns about it, and it's the one with real data-planning
implications:
```text
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
path, update your mount configuration after migration.
```
Read that last sentence again: *if you have a volume mounted at the old path,
update your mount configuration after migration.* If you set the migration flag
and keep your old bind mount, you now have two directories and your binary data
lives in whichever one the container is actually pointed at. Do the rename and
the mount change in the same maintenance window — not one now, one "later".
## The one that wasn't in the log: my AI sandbox disabled itself
Here's the finding that had nothing to do with a deprecation notice, and that
I'd never have caught without reading the full boot sequence:
```text
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
provider=n8n-sandbox)
```
My environment said enabled. The database said otherwise. **The database won.**
The env var was `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`, and it was still set
correctly on the container. But a value persisted in n8n's own configuration
store overrode it at startup — and the only place that conflict is reported is
inside a parenthetical in a log line.
This is a genuinely valuable debugging lesson beyond n8n: when a feature is off
despite the env var being obviously right, suspect a **persisted settings layer
that outranks your environment**. The container config is not always the last
word. Grep for the feature name in the logs, and don't stop at the env value.
## What I'd do differently
1. **Read the boot log before declaring the upgrade done.** Every deprecation
that mattered to me was printed on startup, in one block, on the first run.
My v1 habit was to check "is it up, do the workflows run" — which is exactly
the check that misses all seven of these.
2. **Treat "the default will change" as a to-do, not a warning.** Five of the
seven items are future-default changes. Pinning them now costs one edit and
converts a mystery outage later into a config diff.
3. **Diff the container config against n8n's own stored config.** The sandbox
override taught me that env is one of two inputs, not the source of truth.
When behavior and configuration disagree, believe the behavior and go find
the higher-priority layer.
4. **Pin the image tag and keep the previous one.** I jumped v1 → `2.40.1`
directly. Having the old image on disk is what makes a rollback a
`docker run` instead of a rebuild.
## The result
n8n is on 2.40.1 with the full deprecation block resolved: the schema-invalid
value corrected, `N8N_WEBHOOK_URL` in place, the JS runner registering cleanly,
and the task timeout and compression limits pinned so the next upgrade is a
no-op rather than a surprise.
Everything downstream kept working — the file-permission syncs, the nightly
backups, the text-to-speech endpoint. That's the outcome worth aiming for with
an upgrade like this: not "it came back up", but "it came back up *and* the
next three upgrades are already paid for".
The uncomfortable part is how much of it I could have known in advance. n8n
handed me the entire list, unprompted, at startup. The upgrade was never the
hard part — reading the output was.
---
## Running automation you'd rather not babysit?
I build and maintain self-hosted automation — n8n workflows, Docker stacks,
and the glue between apps that were never designed to talk to each other. If
you're facing a major version upgrade, or you have automation that works until
it doesn't, I plan the migration, do it in a maintenance window, and document
every config decision so the next upgrade is boring.
Reach me at [[email protected]](mailto:[email protected]?subject=n8n%20upgrade) or
WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,307 @@
---
title: "I Ran a Text-to-Speech Service for a Year on Two Cron Containers"
description: "A reading app needed TTS. Cloud free tiers expire tokens in 10 minutes and 1 hour. Here's the sidecar pattern that kept Azure and Google voices working for a year with no secrets in any workflow."
pubDate: 2026-09-18
category: ai
tags: [n8n, tts, azure, google-cloud, docker, automation, sidecar]
ogImage: /og/running-tts-as-a-service-with-token-sidecars.png
banner: /banners/running-tts-as-a-service-with-token-sidecars.png
---
I run a self-hosted reading server for ebooks. It has a "read aloud" feature,
and the built-in engines are serviceable but robotic. So I wired it up to
proper neural voices — Azure Speech and Google Cloud TTS — through my n8n
instance.
That was about a year ago. It has been running ever since, and the design has
barely changed. This is how it works, and more usefully, why it's shaped the
way it is: the whole architecture exists to solve one specific problem that
breaks naive implementations within the hour.
## Why it matters
Text-to-speech is a nice feature to add and a surprisingly annoying one to
*keep* working. The voices are cheap or free at low volume, the quality is
excellent, and the APIs are straightforward — right up until you discover that
an OAuth access token is not a credential you can just store in a config file.
Both providers issue short-lived access tokens, and the lifetimes are
drastically different:
- **Azure Speech** free tier: roughly **10 minutes**.
- **Google Cloud**: roughly **1 hour**.
If you put a token in a workflow variable, the integration works beautifully
for ten minutes and then starts returning 401s forever. This is the classic
shape of an integration that demos perfectly and fails in production — and the
fix isn't "remember to refresh the token", because you will not.
## The architecture: token refresh is not the workflow's job
The design decision that makes this work is refusing to let the workflows
manage credentials at all. Instead, two tiny containers own token lifecycle
exclusively, and write the current token to a file on a shared volume. The
workflows just read the file.
```text
┌──────────────────┐ GET /webhook/{mtts|gtts}?pass=…&text=…&speed=…
│ Reading server │ ───────────────────────────────────────────────┐
│ (httpTTS engine)│ │
└──────────────────┘ ▼
┌───────────────────────────┐
│ n8n │
│ ├ /mtts (Microsoft) │
│ └ /gtts (Google) │
└───────┬───────────────────┘
reads accesstoken.txt
┌─────────────┴─────────────┐
▼ ▼
Azure Speech (F0) Google Cloud TTS
southeastasia region cmn-CN Wavenet
│ │
└──────── WAV audio ────────┘
back to the player
```
Two cron sidecars keep the tokens fresh:
| Container | Image | Interval | Writes |
|---|---|---|---|
| `cron-azure-refresh` | `curlimages/curl` | every ~570 s | `MicrosoftTTS/accesstoken.txt` |
| `cron-gcloud-refresh` | `google/cloud-sdk:slim` | every ~3500 s | `GoogleTTS/accesstoken.txt` |
Both bind-mount the **same host directory** that n8n reads through its
Read/Write Files node. That shared volume is the entire interface between the
credential layer and the workflow layer.
Why the intervals are what they are: 570 seconds against a ~600-second Azure
lifetime gives a 30-second safety margin, and refreshing slightly *early*
forever is far more robust than refreshing exactly on expiry. Same reasoning
for 3500 seconds against an hour.
```yaml
cron-azure-refresh:
image: curlimages/curl:8.10.1
restart: unless-stopped
volumes:
- /volume1/docker/n8n/file:/file
entrypoint: /bin/sh
command: >
-c 'while true; do
curl -s -X POST "https://southeastasia.api.cognitive.microsoft.com/sts/v1.0/issueToken"
-H "Ocp-Apim-Subscription-Key: $AZURE_SPEECH_KEY" > /file/MicrosoftTTS/accesstoken.txt;
sleep 570;
done'
```
## Why a file, and not the obvious alternatives
**Why not store the token in n8n credentials and refresh in-workflow?** Because
the refresh logic would then be copied into every workflow that needs a token,
and each copy would need its own error handling. When a refresh fails at 3 AM,
you want exactly one process to care.
**Why not have the workflow call the token endpoint on every request?** It
works, and it doubles the latency and the dependency surface of every read-aloud
request. Worse, it means a token-endpoint hiccup becomes a TTS outage.
**Why a file at all?** Because it's the simplest possible interface that both
sides already support. n8n has a built-in Read/Write Files node; cron
containers can write with `curl` and shell redirection. No queue, no database
table, no shared library — just a file whose contents are always the current
token.
The tradeoff is honest: reading a file per request is a disk read in the hot
path. At a reading app's request rate that is entirely free, and it buys a
complete decoupling between credential lifecycle and request handling.
## The n8n side: two workflows, one shape
Both TTS workflows have the same skeleton, and it's worth walking through
because the details are where it gets interesting.
**1. Webhook with `responseMode: responseNode`.** The workflow must return raw
audio bytes, not JSON, so the response is explicitly controlled by a Respond to
Webhook node rather than n8n's default.
**2. A password gate.** A query parameter is checked against an expected value,
and mismatches return a real 403 rather than an empty 200:
```text
Respond to Webhook → text: "403 unauthorized", responseCode: 403
```
The `pass` value sits in the URL, which I'll address honestly in a moment.
**3. Read the token file.** `Read/Write Files from Disk` reads
`/home/user/file/MicrosoftTTS/accesstoken.txt`. Then two nodes clean it up:
`Extract from File` (text mode) and a Set node that strips newlines, because a
trailing `\n` in an `Authorization` header produces a maddening 401 that looks
nothing like a whitespace problem:
```js
// Edit Fields node
{{ $json.data.replace(/(\r\n|\n|\r)/g, '') }}
```
**4. Call the provider.** For Azure, the body is SSML with the voice and rate
interpolated in:
```xml
<speak version='1.0' xmlns="http://www.w3.org/2001/10/synthesis"
xmlns:mstts="http://www.w3.org/2001/mstts" xml:lang="zh-CN">
<voice name='{{ $('Code in JavaScript').item.json.voice }}'>
<prosody rate="{{ $('Code in JavaScript').item.json.rate }}"
pitch="{{ $('Webhook').item.json.query.pitch }}">
{{ $('Webhook').item.json.query.text }}
</prosody>
</voice>
</speak>
```
**5. Return the audio as a binary response** with the right content type:
```text
Respond to Webhook → binary, set
Content-Type: audio/wav
Content-Disposition: filename="output.wav"
```
## The mapping problem: the client speaks a different language
Here's a detail that took more thought than the API calls. The reading app sends
a `speed` value on its own scale — 5 to 50, because that's what its UI slider
produces. Azure wants a prosody rate as a percentage, and Google wants a
`speakingRate` multiplier around 1.0.
Neither provider's scale matches the app's. So there's a deliberate translation
step, and this one is worth copying because mapping a UI control onto an API
parameter is a recurring chore:
```js
// Map the reader's 550 speed slider onto Azure's -20%…+150% rate range.
const inMin = 5, inMax = 50;
const outMin = -20, outMax = 150;
// Clamp the input before mapping, so an out-of-range client value can't
// produce an absurd prosody rate.
if (speed < inMin) speed = inMin;
if (speed > inMax) speed = inMax;
const mapped = ((speed - inMin) / (inMax - inMin)) * (outMax - outMin) + outMin;
// → rate: `${Math.round(mapped)}%`
```
The equivalent for Google is a straight division, since its scale is close to
linear in the same range:
```js
speakingRate: speed / 25 // Google expects ~1.0, not a percentage
```
Two different providers, two different unit systems, one client-side concept.
Keeping the mapping in the workflow (rather than asking the client to know
about Azure percentages) is what lets the reading app stay provider-agnostic —
and it's why I could add the second provider without touching the app at all.
There's also a voice table, because the client sends an integer index rather
than a voice name:
```js
const voices = [
"zh-CN-XiaochenMultilingualNeural", // 1
"zh-CN-XiaoxiaoMultilingualNeural", // 2
// ...
"zh-CN-XiaoshuangNeural", // 7 (female, child)
"zh-CN-XiaoyouNeural" // 8 (female, child)
];
```
Eight voices — six adult, two child — selectable from the reading app's UI.
The workflow clamps the index into range rather than trusting it, which is the
same defensive habit as the speed clamp.
## The security question, answered honestly
The gate is a `pass` query parameter compared against a fixed string. I'm not
going to dress that up: **it is a shared secret in a URL.** It stops casual
abuse of an endpoint that costs me money per request. It does not stop anyone
who can read the reading app's config, and it won't survive a serious attacker.
I'm at peace with that because of what it's protecting. The worst outcome is
that someone burns my free-tier TTS quota — an annoyance, not a data breach.
There's no customer data behind this endpoint and no privileged access to
anything. Paying for real authentication (OAuth, signed requests, per-user
limits) would cost far more complexity than the exposure is worth.
The transferable habit is being *explicit* about which tier a gate sits in:
this is **abuse deterrence**, not authorization. Systems get into trouble when
a deterrent is mistaken for a boundary. If this endpoint had touched customer
records or file access, it would need real auth — and I'd have built it
differently from the start.
## The hidden cost: it was pinned to a dead branch
The service ran for nearly a year with zero code changes. Which is the good
news and the bad news:
```text
n8nio/n8n:1.123.72
```
That's the image the TTS documentation specified. It kept working, which meant
nothing prompted me to revisit it — the classic failure mode of infrastructure
that's *too* reliable. When I finally upgraded n8n to v2, this was the last
1.x-era reference left in my stack, and the token-refresh containers are the
part of the system most sensitive to platform behaviour changing underneath
them.
The lesson isn't "upgrade more often" — it's that **a service with no moving
parts has no natural prompt to re-examine its assumptions.** Set a calendar
reminder to review pinned dependency versions, because the system itself will
never tell you.
## What I'd do differently
1. **Keep the mapping logic versioned with the app, not buried in a workflow.**
The speed mapping and the voice table encode the reading app's UI contract.
They live in JavaScript inside an n8n Code node, where they're invisible to
anyone working on the app — and if the slider range ever changes, nothing
will tell me.
2. **Add a health endpoint that exercises a real synthesis.** All I can
currently check is that the containers are up. A token can be present in the
file and *expired anyway* (if a refresh silently failed), and there's no
cheap way to see that before a user hits it.
3. **Track pinned versions somewhere I'll actually look.** `1.123.72` sat in a
markdown file for a year. A single "review pinned images" line in the
operations runbook would have surfaced it at the next maintenance window
instead of through a migration.
## The result
A year of uptime, two containers, one shared directory, and eight neural voices
that a reading app can select from a slider. Requests complete in a couple of
seconds including provider round-trip, and the whole thing costs nothing beyond
the free tiers — because the token lifecycle problem was solved once, in the
right place.
The pattern generalises to any short-lived credential: **don't teach every
caller to refresh a token — run one process whose only job is to keep a file
current, and let everyone else read the file.** It's not clever, and that's the
point. Clever credential handling is how you end up with four refresh
implementations and three of them wrong.
---
## Need an AI feature wired into an app you already have?
I build the unglamorous middle layer of AI integrations — the part that keeps
working after the demo: token refresh, provider failover, voice and model
mapping, and rate-limit handling. If you want text-to-speech, transcription, or
an LLM feature added to an existing application and you'd like it to still work
next year, that's the work I do.
Reach me at [[email protected]](mailto:[email protected]?subject=TTS%20integration)
or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,298 @@
---
title: "Self-Healing Access Control for Digital Goods: NocoDB, n8n, and AList"
description: "How I built a five-workflow entitlement system that grants, expires, and continuously repairs customer file access — with a build-time code-sharing trick and a public endpoint designed to leak nothing."
pubDate: 2026-09-18
category: case-studies
tags: [n8n, nocodb, alist, access-control, docker, automation, digital-goods]
ogImage: /og/self-healing-digital-goods-entitlements.png
banner: /banners/self-healing-digital-goods-entitlements.png
---
I sell digital products — files, artwork, licensed assets — and deliver them
through a self-hosted file portal. The hard problem was never storage. It was
**entitlement**: making sure that when someone buys, they get exactly the
folders they paid for, for exactly as long as they paid for, and that when
they stop paying, the access actually goes away.
Doing that by hand works for the first ten customers. It does not work for a
hundred, and it fails in a specific and nasty way: silently. Nothing errors
when a subscription expires and the customer keeps downloading. You just keep
serving files to someone who stopped paying months ago.
So I built it as five n8n workflows that treat access as **derived state**
computed from my business database, applied to the file server, and
continuously re-verified. Here's the architecture, the code-sharing trick that
makes it maintainable, and the security reasoning behind the one public
endpoint.
## Why it matters
For a digital-goods business, access control *is* the product. You're not
protecting a warehouse; you're protecting the thing you sold. Two failure modes
cost real money:
- **Under-granting** — a paying customer can't reach what they bought, and you
find out from an angry message rather than a monitoring alert.
- **Over-granting** — expired or revoked customers keep access, and the leak
is invisible until someone resells your catalog.
Manual administration guarantees both, eventually. The fix is to stop treating
a grant as a *thing you do* and start treating it as a *function of your data*:
given the customer's current purchases, what should they be able to reach right
now? Compute it, apply it, and then prove it's still true on a schedule.
## The shape of the system
Five workflows, each with one job:
| Workflow | Trigger | Responsibility |
|---|---|---|
| **W1** — Customer Provision & Status Lifecycle | NocoDB webhook (customer row) | Create/update the file-server user; enable or disable on status change |
| **W2** — CustomerProduct Sync | NocoDB webhook (purchase row) | Recompute and apply that customer's allowed paths |
| **W3** — Expiry Sync | Cron, every 5 minutes | Sweep expiring entitlements; deselect paths that lapsed |
| **W4** — Daily Full Reconciliation | Cron, 03:00 + manual webhook | Compare *desired* vs *actual* for everyone; repair drift; log |
| **W5** — Footer Purchase Check | Public GET webhook | Let the portal show a customer their own purchases; read-only |
The data lives in **NocoDB** (a self-hosted Airtable-style database) as
Customers, CustomerProducts, Products, Resources, and AccessGrants. The file
server is **AList**, which gives each customer a user and a role whose
`permission_scopes` is a list of paths.
The crucial design decision: **NocoDB is the business source of truth. AList is
only enforcement state.** Sync runs one way. A manual tweak in the AList admin
UI is not a configuration change — it's drift, and W4 repairs it back.
## The algorithm: what *should* this customer have?
Everything hinges on one function. Desired paths for a customer is the union of
two sources:
1. Every resource reachable from an **active, not-yet-expired** purchase.
2. Every resource granted directly via an **active, not-yet-expired** manual grant.
The interesting case is a resource reachable through *two* different products.
If a customer bought Product A (expires in 30 days) and Product B (expires in
200 days) and both include the same folder, the correct expiry is the **latest**
one — buying more of something should never shorten your access to it.
```js
// For each product's resources, keep the MAX expiry per path.
for (const c of cps) {
if (c.status && c.status !== 'active') continue;
if (c.expires_at && String(c.expires_at) <= today) continue; // expired
const pid = linkId(c.product);
if (!pid) continue;
const res = (await ncGet(ctx,
`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];
const exp = c.expires_at;
for (const r of res) {
const p = resPath[r.Id];
if (!p) continue;
const cur = desired[p];
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };
}
}
}
```
Note the boundary: `<= today`. **Expiring today counts as expired.** Off-by-one
on an expiry check is the difference between a subscription period and a free
extra day, and if you're not explicit about it you'll get it wrong in the
customer's favour.
## The problem: n8n Code nodes can't share code
Here's the constraint that shaped the whole codebase. n8n Code nodes are
self-contained: there's no `require`, no `import`, no filesystem access to a
shared module. So the obvious structure — one grants algorithm, called by W1,
W2, W3, and W4 — is exactly what the platform prevents.
Copy-pasting that function into four nodes would have been a guaranteed
maintenance disaster. Four copies of an expiry rule is four chances for them to
disagree, and a system that computes access four different ways is worse than
no system.
The fix was to make the sharing happen at **build time** instead of run time:
- `shared/effective-grants.js` is the single source of truth, and it's written
to be self-contained: no `require`, no `process.exit`, no top-level `return`
outside a function. It also takes its config through a `ctx` argument
(`{ $env, helpers }`) rather than reaching for globals — which has the happy
side effect that it's **unit-testable outside n8n entirely**.
- `gen_w1.js`, `gen_w2.js`, `gen_w3.js`, `gen_w4.js` read that file, inline it
into the workflow's Code node body, and update the workflow.
The result is one algorithm, four workflows, zero runtime dependencies — and a
version of the function that I can test with plain Node before it ever touches
a live system. The code is duplicated in *artifacts* but never in *sources*,
which is the same tradeoff a bundler makes.
One n8n-specific wrinkle worth knowing: **custom environment variables are only
reliably readable via `$env` inside a Code node — `process.env` is not
dependable there.** That's why every workflow has an explicit "Load Env" node
that lifts the values it needs onto the item, rather than reading config
wherever it's convenient.
## Making expiry safe to automate
W3 runs every five minutes and reconciles expiring entitlements. Two details
make it safe to let a cron job modify live access:
**It only disables when access genuinely ran out.** A naive "if no desired
paths, disable the user" rule is dangerous — it will happily disable a
brand-new customer who simply hasn't been granted anything yet. The guard is
explicit:
```js
// Only disable when a customer that HAD scopes now has none.
// currentPaths.length > 0 avoids killing a fresh, not-yet-granted customer.
const shouldDisable = AUTO_DISABLE && nowEmpty
&& cust.status === 'active' && currentPaths.length > 0;
```
**It has a dry-run mode.** `W3_DRY_RUN=true` on the container (or a query
parameter on the manual webhook for W4) makes the sweep compute and report its
plan while performing **zero writes**. Being able to ask "what would you do?"
before letting a schedule do it is the single most useful safety feature I've
added to any automation.
```js
const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';
```
## The daily repair: assuming you'll drift
W4 is the workflow I'd argue is the real product. It runs at 03:00, walks every
customer, and compares desired state against actual state — then repairs the
difference and **verifies the repair**.
The drift matrix it handles:
- **Should be active** → user must exist (looked up *by username first*, to
avoid creating duplicates if a stored ID was lost), be enabled, and carry
exactly the computed role scopes.
- **Should be inactive** → disabled, with scopes emptied. Pending customers
stay without a user entirely — no auto-creation.
- **Dangling references** → a stored user or role ID points at a record that no
longer exists. Look it up by name, adopt it if found, recreate if not.
- **Username drift** → detected and *logged*, never destructive-migrated.
Every repair is verified by re-reading the file server's state afterwards. A
failed verification is logged as `verify_failed` rather than assumed successful,
and the reconciliation log only receives **drift, repair, and error rows**
healthy customers produce nothing. That last choice is what makes the log
usable: if it's empty, everything is fine, and you don't have to read past a
thousand "no change" lines to find the one that matters.
## The public endpoint, and why there's no HMAC
W5 lets the file portal's footer show a signed-in customer their own purchases
and expiry dates. It's a **public** webhook, and the security reasoning is the
part I'm most deliberate about.
The obvious instinct is to sign requests with an HMAC. I didn't, and the reason
is worth stating plainly: **the key would have to ship to the browser, so the
signature would be theatre.** A shared secret that every client holds protects
nothing — it just adds ceremony that makes the endpoint *look* verified.
So the endpoint relies on things that actually hold:
- **CORS locked to one origin.** The response carries
`Access-Control-Allow-Origin: https://drive.example.com`, so only the portal's
own pages can read the response in a browser.
- **Minimal data by design.** The response returns product name, expiry date,
display state, and public folder paths. No internal database IDs, no customer
PII, nothing about other customers.
- **Rate limiting at the edge,** via a WAF rule on the request path, to blunt
username enumeration.
And one subtle architectural choice: **the username comes from the caller's own
session token, decoded client-side**, rather than from a parameter the client
can set freely. The endpoint never authenticates to the file server, which
avoids a whole class of connection state and device-registration side effects
that a server-side login would introduce on every footer render.
The honest framing: this endpoint is not a trust boundary, and I don't pretend
it is. It shows a customer what they already know about themselves, over a
response shape that's useless to anyone else.
## The bug that taught me the most: public path, cascading failure
W5 originally fetched data by calling NocoDB at its **public** hostname —
through a Cloudflare tunnel. It worked in testing. Under real load it produced
this chain:
1. Public round-trip latency exceeded 60 seconds under load.
2. nginx upstream timeout fired → **504**.
3. The footer's client-side fetch had an 8-second timeout and retried **every
second**.
4. Retries piled up connections.
5. Those connections exhausted the pool → **503** for unrelated requests.
A slow dependency became a cascading outage of a different service. The fix
was to stop crossing the internet to reach something on the same Docker
network:
```js
// n8n and NocoDB share the `bridge_hoelee` network; NocoDB listens on :10380.
// The public route added CF-tunnel jitter and could exceed the proxy timeout.
const NOCODB_URL = 'http://nocodb:10380';
```
**~30 ms internal versus 300 ms+ public, with no tunnel variance** — and the
entire failure chain disappeared, because the trigger condition (multi-second
latency) can no longer occur.
The lesson generalises well beyond this stack: **when a service and its
dependency are in the same container network, the public hostname is a bug
waiting for load.** And when you see a 503 downstream of a 504, look for a
client that retries aggressively — the retry loop is usually the amplifier, not
the original problem.
## What I'd do differently
1. **Build the drift repair first, not last.** I wrote the grant path, then the
expiry sweep, then the reconciliation. In retrospect the reconciliation is
what makes the other two safe to operate, and it should have existed from
day one — because "assume you will drift" is a design stance, not a feature.
2. **Write the desired-state function before any workflow.** Having it live in
`shared/` and be unit-testable *outside* n8n is why the whole system stayed
coherent across four workflows. If I'd started by pasting logic into nodes,
I'd have shipped four subtly different expiry rules.
3. **Never cross the public internet between two containers on the same host.**
This one cost me a genuine outage, and it's a rule I now apply by default
rather than discovering per-integration.
4. **Put the dry-run switch in from the beginning.** Adding `DRY_RUN` after the
fact was easy; operating a scheduled job that mutates access *without* one
was an unnecessary few weeks of nerves.
## The result
Five workflows run the full entitlement lifecycle: a purchase in the database
becomes usable access within seconds, expiries sweep every five minutes, and a
nightly full reconciliation repairs any drift and verifies each repair. The
reconciliation log is empty when everything is healthy — which, most days, is
what it says.
The design principle worth taking away is the one that made it tractable:
**stop administrating access, and start asserting it.** Define what a customer
should have as a pure function of your business data, apply that function
whenever the data changes, and re-assert it on a schedule to catch everything
else. Then the system doesn't need you to be careful — it just needs you to be
correct once, in one function.
---
## Want this for your business?
If you sell digital products and you're still granting file access by hand —
or you're not certain that expired customers lost access last month — I build
exactly this: self-hosted entitlement systems where access is computed from
your data, expires on its own, and repairs itself nightly. I work with
NocoDB, n8n, AList, and Docker, and I hand it back documented so you can
operate it without me.
Reach me at [[email protected]](mailto:[email protected]?subject=Access%20control%20for%20digital%20goods)
or WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -0,0 +1,190 @@
---
title: "n8n v1 升级到 v2:一个日志文件里的七项废弃警告"
description: "我的自建 n8n 从 1.x 升级到 2.40.1,一次性暴露出七处静默失效——包括一个被 schema 校验拒绝的遥测事件、废弃的 webhook 变量,以及一个悄悄把 AI 沙盒关掉的数据库覆盖值。"
pubDate: 2026-09-18
category: devops
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
ogImage: /og/n8n-v1-to-v2-upgrade-gotchas.png
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.png
---
n8n 是我整个自建技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
然后我拉了 `n8nio/n8n:2.40.1` 镜像并重启容器。升级本身只花了大约九十秒。而搞清楚它*弄坏了什么*花了我剩下的一整晚——而其中几乎所有信息,n8n 在启动时就已经写在一个日志文件里了。只是我第一次读得不够仔细。
这篇文章就是那个日志文件的解读,让你在规划自己的 v1 → v2 升级时能提前准备,而不是晚上十一点才发现问题。
## 为什么这件事重要
自动化平台的大版本升级,和其他服务升级的性质不一样。n8n 是*运行其他一切东西的那个东西*:它一旦起不来,你的备份、权限同步、内部 API 全部跟着停。更麻烦的是,v2 里坏掉的东西大多不会报错——它只打印一次废弃警告,然后就悄悄换了一种行为方式。
下面这七项,都是在一个真实的、有点混乱的、生产形态的安装上实际命中的。其中三项改变了我的技术栈的行为。还有一项,悄悄把一个功能*关掉了*。
## 从这里开始:n8n 在启动时会告诉你哪里不对
在动任何工作流之前,先从头读容器日志。在 v2 全新的启动过程中,n8n 会明确打印出一整块废弃警告:
```text
There are deprecations related to your n8n setup. Please take the recommended
actions to update your configuration:
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
both test and production webhooks.
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
change to `false` in a future version.
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
removed in a future version.
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
reduced from 2 GiB to 256 MiB in a future version.
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
5000 to 1000 in a future version.
```
这一块就是你的迁移清单。我那七项坑里的六项都在里面。
## 1. 你的环境变量值现在可能过不了 schema 校验
这是最让我困惑的一项,因为它看起来完全像一条无意义的报错:
```text
Telemetry event "Instance started" failed schema validation:
execution_variables.executions_data_save_on_error: Invalid option:
expected one of "all"|"none"
```
我当时设置的是 `EXECUTIONS_DATA_SAVE_ON_ERROR=error`——这个值在 v1 里完全合法,而且是我刻意选的,因为对一台繁忙的实例来说,「只保存失败的执行记录」是合理的默认策略。但在 v2 里这个值已经不在允许集合内了,现在的合法值是 `all``none`
失败方式才是关键。它没有崩溃。它甚至没有以一眼就能看出是错误的方式发出警告——它抛出的是一条 *telemetry schema validation* 消息,听起来像是 n8n 内部的问题,而不是我的配置问题。这个设置实际上被忽略了。
修法是把你的意图放到一个说得通的地方:选一个合法的值,然后用 pruning 控制数据量。
```env
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
```
**教训:** 在 v2 里,把你的环境变量当成一套有类型的、带 schema 的接口来看待。一个非法的值可能被静默丢弃,而不是大声拒绝。
## 2. `WEBHOOK_URL` 已被 `N8N_WEBHOOK_URL` 取代
如果你的 webhook 发布在反向代理后面——你几乎肯定是的,因为那是它们变得可访问的方式——那么这个 base URL 变量就是关键路径上的东西。它决定了 n8n 报告的是*公网* webhook 路径,还是 `http://localhost:5678/...`
旧名字目前还能用,所以这一项不会立刻咬你。但注意它的措辞:新变量为**测试和生产 webhook 同时**设置 base URL。在我的环境里这两者在行为上已经出现了分叉,而这次的合并正是为了消除这一类 bug。
```env
# 升级前(仍可用,但已废弃)
WEBHOOK_URL=https://auto.example.com/
# 升级后
N8N_WEBHOOK_URL=https://auto.example.com/
```
## 3. Internal task runner 模式要取消了——而我的其实早就坏了
这一项一直在我的日志里,就在废弃警告块上面几行,而我已经读过去好几个月了:
```text
Failed to start Python task runner in internal mode. because Python 3 is
missing from this system. Launching a Python runner in internal mode is
intended only for debugging and is not recommended for production.
```
如果你的任何工作流用了 **Python** 的 Code 节点,那它根本就没在 internal 模式下跑起来过——官方镜像里没有 Python。JavaScript 的 Code 节点是正常的(JS runner 会正常注册),所以这个问题可以无限期不被发现:一切*看起来*都是健康的。
v2 把方向挑明了:切到 `external` 模式,和一个独立启动器进程共享一个 auth token。
```env
N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=<一串足够长的随机字符串>
```
**教训:** 「internal 模式已废弃」是标题,但真正的发现是:某一类 runner 可能已经*静默失效*好几个月了。要检查 `docker logs` 里的 runner 注册那行,而不是只看容器起没起来。
## 4. Task 超时默认值从 300 秒降到 60 秒
这一项是我最想替所有有慢工作流的人标红的:
```text
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
```
对自己的负载诚实一点。你有没有 Code 节点要遍历几千条记录,或者要调一个很慢的上游接口?我有——一个每夜的对账流程会遍历每个客户,每条记录调一次外部 API。在未来的某次升级中,它会在六十秒处停下,而我这边没有任何配置变更。
趁你人还在这个文件里,现在就显式设置:
```env
N8N_RUNNERS_TASK_TIMEOUT=300
```
对所有「默认值将会改变」形式的废弃警告,通用原则是:**如果你依赖当前的默认值,就把它显式钉死。** 否则这次升级就是一个静默的行为变更,而你会把它当成 bug 来排查,而不是认出它是一个过期的默认值。
## 5 和 6. 两个压缩节点上限缩水(2 GiB → 256 MiB5000 → 1000 条)
这两项一起出现,只有当你在工作流里处理大负载的压缩/解压节点时才相关——而当你在工作流里搬运数据库导出或归档文件时,很容易就变成相关。
```text
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
256 MiB in a future version.
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
future version.
```
内存上限降到八分之一,条目上限降到五分之一。不会报错;节点只是在一个你没设定的阈值处拒绝执行。如果你接近任何一个上限,两个都钉死。
## 7. 存储路径将在 v3 改名——而你的卷正好挂在旧路径上
这不是 v2 的破坏性变更,但 v2 会警告它,而且是真正涉及数据规划的那一项:
```text
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
path, update your mount configuration after migration.
```
再读一遍最后一句:*如果你有一个卷挂载在旧路径上,请在迁移后更新你的挂载配置。* 如果你设了迁移标志却保留旧的 bind mount,你现在就有两个目录,而你的二进制数据住在容器实际指向的那一个里。把改名和挂载变更放在同一个维护窗口里做——不要一个现在、一个「以后」。
## 那个不在日志里的:我的 AI 沙盒把自己关掉了
这是跟废弃警告毫无关系的发现,也是如果我不读完整的启动序列就永远抓不到的一项:
```text
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
provider=n8n-sandbox)
```
我的环境变量说已启用。数据库说不是。**数据库赢了。**
环境变量是 `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`,在容器上依然设置正确。但有一个持久化在 n8n 自己的配置存储里的值,在启动时覆盖了它——而这个冲突唯一被报告的地方,就是一行日志里的一个括号。
这是一个超越 n8n 的、非常有价值的排查教训:当一个功能明明环境变量设对了却是关闭状态,就怀疑存在一个**优先级高于环境的持久化设置层**。容器配置不总是最终答案。在日志里 grep 那个功能名,不要看到环境变量就停下。
## 我会怎么做得不一样
1. **在宣布升级完成之前先读启动日志。** 所有对我重要的废弃警告,都在第一次启动时、在一个块里、清清楚楚打印出来了。我的 v1 习惯是检查「它起来了吗、工作流跑得动吗」——而这恰恰是漏掉全部七项的检查方式。
2. **把「默认值将会改变」当成待办事项,而不是警告。** 七项里有五项是未来的默认值变更。现在钉死它们只需要改一次配置,却能把以后的一次神秘故障转化为一次配置 diff。
3. **把容器配置和 n8n 自己存储的配置做对比。** 沙盒覆盖这件事教会我:env 只是两个输入之一,不是真相来源。当行为和配置不一致时,相信行为,然后去找那个优先级更高的层。
4. **钉死镜像 tag,并保留上一个。** 我是从 v1 直接跳到 `2.40.1` 的。本地留着旧镜像,是回滚能变成一条 `docker run` 而不是一次重新构建的原因。
## 结果
n8n 现在跑在 2.40.1 上,整块废弃警告都已处理:schema 非法的值已修正,`N8N_WEBHOOK_URL` 已就位,JS runner 正常注册,task 超时和压缩上限都已钉死,让下一次升级变成一次无操作而不是一次意外。
下游一切都还在工作——文件权限同步、每夜备份、语音合成接口。这才是这类升级值得追求的结果:不是「它起来了」,而是「它起来了,*而且*接下来三次升级的成本已经预付了」。
真正让人不舒服的地方在于,这些我本可以提前知道的信息占了多大比例。n8n 主动把整份清单递给了我,就在启动时。升级从来不是难的部分——读输出才是。
---
## 需要不用你天天盯着的自动化?
我搭建并维护自建自动化——n8n 工作流、Docker 技术栈,以及那些从来不是设计来互相通信的应用之间的胶水层。如果你正面临一次大版本升级,或者你有那种「能用,直到不能用」的自动化,我会规划迁移、在维护窗口执行、并把每一个配置决策都记录下来,让下一次升级变得无聊。
欢迎联系 [[email protected]](mailto:[email protected]?subject=n8n%20%E5%8D%87%E7%BA%A7)
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
[hoelee.com](https://hoelee.com) 做什么。
@@ -0,0 +1,214 @@
---
title: "我用两个 Cron 容器跑了一年的语音合成服务"
description: "一个阅读应用需要 TTS,而云服务免费版的 token 十分钟、一小时就过期。这是那套让 Azure 和 Google 语音稳定运行一年的边车模式——任何工作流里都没有存放密钥。"
pubDate: 2026-09-18
category: ai
tags: [n8n, tts, azure, google-cloud, docker, automation, sidecar]
ogImage: /og/running-tts-as-a-service-with-token-sidecars.png
banner: /banners/running-tts-as-a-service-with-token-sidecars.png
---
我自建了一个电子书阅读服务器。它有个「朗读」功能,内置的引擎能用但很机械。所以我把它接到了真正的神经网络语音上——Azure Speech 和 Google Cloud TTS——通过我的 n8n 实例。
那是大约一年前的事。它此后一直在运行,而且设计几乎没变过。这篇文章讲它怎么工作,更有用的是讲它为什么长成这样:整个架构的存在就是为了解决一个具体问题,而这个问题会在一小时之内击垮天真的实现方式。
## 为什么这件事重要
语音合成是个加进去很愉快、但维持起来出乎意料麻烦的功能。低用量下这些语音要么便宜要么免费,音质出色,API 也直截了当——直到你发现 OAuth access token 不是一种你可以直接存进配置文件的凭据。
两家服务商都会签发短期 access token,而有效期差别巨大:
- **Azure Speech** 免费层:大约 **10 分钟**
- **Google Cloud**:大约 **1 小时**
如果你把 token 放进一个工作流变量里,这个集成会漂亮地工作十分钟,然后开始永远返回 401。这就是那种「演示完美、生产失败」的集成的经典形状——而解法不是「记得刷新 token」,因为你不会记得。
## 架构:刷新 token 不是工作流的事
让这套东西能工作的设计决策,是拒绝让工作流管理凭据。取而代之,两个极小的容器独占 token 生命周期,把当前 token 写到一个共享卷上的文件里。工作流只负责读文件。
```text
┌──────────────────┐ GET /webhook/{mtts|gtts}?pass=…&text=…&speed=…
│ 阅读服务器 │ ───────────────────────────────────────────────┐
│ (httpTTS 引擎) │ │
└──────────────────┘ ▼
┌───────────────────────────┐
│ n8n │
│ ├ /mtts (Microsoft) │
│ └ /gtts (Google) │
└───────┬───────────────────┘
读取 accesstoken.txt
┌─────────────┴─────────────┐
▼ ▼
Azure Speech (F0) Google Cloud TTS
southeastasia 区域 cmn-CN Wavenet
│ │
└──────── WAV 音频 ─────────┘
回到播放器
```
两个 cron 边车容器负责保持 token 新鲜:
| 容器 | 镜像 | 间隔 | 写入 |
|---|---|---|---|
| `cron-azure-refresh` | `curlimages/curl` | 每 ~570 秒 | `MicrosoftTTS/accesstoken.txt` |
| `cron-gcloud-refresh` | `google/cloud-sdk:slim` | 每 ~3500 秒 | `GoogleTTS/accesstoken.txt` |
两者都 bind-mount 了**同一个宿主目录**n8n 通过它的 Read/Write Files 节点读取这个目录。这个共享卷就是凭据层与工作流层之间的完整接口。
为什么间隔是这个数字:570 秒对约 600 秒的 Azure 有效期,留出 30 秒安全边际;而永远略微*提前*刷新,远比卡着到期点刷新稳健得多。Google 的 3500 秒对一小时是同样的道理。
```yaml
cron-azure-refresh:
image: curlimages/curl:8.10.1
restart: unless-stopped
volumes:
- /volume1/docker/n8n/file:/file
entrypoint: /bin/sh
command: >
-c 'while true; do
curl -s -X POST "https://southeastasia.api.cognitive.microsoft.com/sts/v1.0/issueToken"
-H "Ocp-Apim-Subscription-Key: $AZURE_SPEECH_KEY" > /file/MicrosoftTTS/accesstoken.txt;
sleep 570;
done'
```
## 为什么用文件,而不是那些显而易见的替代方案
**为什么不把 token 存进 n8n 凭据、在工作流里刷新?** 因为刷新逻辑就会被复制进每一个需要 token 的工作流,而每一份拷贝都需要自己的错误处理。当凌晨三点刷新失败时,你希望只有一个进程需要关心这件事。
**为什么不让工作流每次请求都去调 token 接口?** 可行,而且它让每次朗读请求的延迟和依赖面都翻倍。更糟的是,它意味着 token 接口的一次抖动就变成一次 TTS 故障。
**那为什么用文件?** 因为它是双方都已经支持的最简单接口。n8n 有内置的 Read/Write Files 节点;cron 容器可以用 `curl` 和 shell 重定向写入。没有队列、没有数据库表、没有共享库——只有一个内容永远是当前 token 的文件。
这笔取舍是诚实的:每次请求读一次文件,是热路径上的一次磁盘读。在一个阅读应用的请求频率下,这完全是免费的,而它换来的是凭据生命周期与请求处理之间的彻底解耦。
## n8n 这一侧:两个工作流,一种形状
两个 TTS 工作流骨架相同,值得走一遍,因为细节才是有意思的地方。
**1. `responseMode: responseNode` 的 webhook。** 工作流必须返回原始音频字节而不是 JSON,所以响应由一个显式的 Respond to Webhook 节点控制,而不是 n8n 的默认行为。
**2. 一道密码闸门。** 一个查询参数会与期望值比对,不匹配时返回真正的 403,而不是一个空的 200:
```text
Respond to Webhook → text: "403 unauthorized", responseCode: 403
```
`pass` 值就在 URL 里,这一点我稍后会诚实交代。
**3. 读取 token 文件。** `Read/Write Files from Disk` 读取
`/home/user/file/MicrosoftTTS/accesstoken.txt`。然后两个节点做清理:
`Extract from File`(文本模式),以及一个删掉换行的 Set 节点——因为
`Authorization` 头里一个尾随的 `\n` 会产生一个令人抓狂、且看起来完全不像空白字符问题的 401:
```js
// Edit Fields 节点
{{ $json.data.replace(/(\r\n|\n|\r)/g, '') }}
```
**4. 调用服务商。** 对 Azure 来说,请求体是插入了语音和语速的 SSML:
```xml
<speak version='1.0' xmlns="http://www.w3.org/2001/10/synthesis"
xmlns:mstts="http://www.w3.org/2001/mstts" xml:lang="zh-CN">
<voice name='{{ $('Code in JavaScript').item.json.voice }}'>
<prosody rate="{{ $('Code in JavaScript').item.json.rate }}"
pitch="{{ $('Webhook').item.json.query.pitch }}">
{{ $('Webhook').item.json.query.text }}
</prosody>
</voice>
</speak>
```
**5. 以二进制响应返回音频**,并带上正确的内容类型:
```text
Respond to Webhook → binary, set
Content-Type: audio/wav
Content-Disposition: filename="output.wav"
```
## 映射问题:客户端说的是另一种语言
这个细节花的心思比 API 调用本身还多。阅读应用发送一个 `speed` 值,用的是它自己的刻度——5 到 50,因为那是它 UI 滑块产生的范围。Azure 想要的是百分比的 prosody rate,而 Google 想要的是一个约等于 1.0 的 `speakingRate` 乘数。
两家服务商的刻度都和应用的刻度不一致。所以这里有一个刻意的转换步骤,而这一步值得照抄,因为把 UI 控件映射到 API 参数是一个反复出现的琐事:
```js
// 把阅读器的 5–50 速度滑块映射到 Azure 的 -20%…+150% 语速区间。
const inMin = 5, inMax = 50;
const outMin = -20, outMax = 150;
// 映射之前先钳制输入,这样客户端一个越界的值不会产生荒谬的 prosody rate。
if (speed < inMin) speed = inMin;
if (speed > inMax) speed = inMax;
const mapped = ((speed - inMin) / (inMax - inMin)) * (outMax - outMin) + outMin;
// → rate: `${Math.round(mapped)}%`
```
Google 那边则是直接相除,因为它的刻度在同一区间里接近线性:
```js
speakingRate: speed / 25 // Google 期望约 1.0,而不是百分比
```
两家服务商、两套单位制、一个客户端概念。把映射留在工作流里(而不是要求客户端了解 Azure 的百分比),正是让阅读应用保持服务商无关的原因——也是我能在完全不动应用的情况下加上第二个服务商的原因。
此外还有一张语音表,因为客户端发送的是整数索引而不是语音名:
```js
const voices = [
"zh-CN-XiaochenMultilingualNeural", // 1
"zh-CN-XiaoxiaoMultilingualNeural", // 2
// ...
"zh-CN-XiaoshuangNeural", // 7(女声,儿童)
"zh-CN-XiaoyouNeural" // 8(女声,儿童)
];
```
八种语音——六种成人、两种儿童——可从阅读应用 UI 选择。工作流会把索引钳制进范围,而不是信任它,这和语速钳制是同一个防御习惯。
## 安全方面,诚实作答
闸门是一个 `pass` 查询参数,比对一个固定字符串。我不打算美化它:**这就是一个放在 URL 里的共享密钥。** 它阻止了针对一个每次请求都要花我钱的接口的随意滥用。它阻止不了任何能读到阅读应用配置的人,也扛不住认真的攻击者。
我能接受这一点,是因为它所保护的东西。最坏的结果是有人烧掉我的免费层 TTS 配额——一件烦人事,不是数据泄露。这个接口后面没有客户数据,也没有对任何东西的特权访问。为真正的认证(OAuth、签名请求、按用户限流)付出的代价,远超这点暴露所值。
可迁移的习惯是:**明确说出**一道闸门属于哪一层级——这是**滥用威慑**,不是授权。系统出问题,往往是因为把威慑误当成了边界。如果这个接口碰到客户记录或文件访问,它就需要真正的认证——而我会从一开始就设计得不一样。
## 隐形成本:它被钉在一个早已过时的分支上
这项服务跑了将近一年,零代码改动。这既是好消息也是坏消息:
```text
n8nio/n8n:1.123.72
```
那是 TTS 文档里写明的镜像。它一直能用,所以也就没有任何东西促使我重新审视它——这正是「*过于*可靠」的基础设施的经典失败模式。当我终于把 n8n 升到 v2 时,这是整个技术栈里最后一处 1.x 时代的引用,而 token 刷新容器恰恰是最容易受平台行为悄悄变化影响的那部分。
教训不是「升级更勤一点」——而是:**一个没有活动部件的服务,没有任何自然契机去重审它的假设。** 给自己设个日历提醒去复查钉死的依赖版本,因为系统本身永远不会告诉你。
## 我会怎么做得不一样
1. **把映射逻辑和应用一起版本化,而不是埋在某个工作流里。** 语速映射和语音表编码的是阅读应用的 UI 契约。它们活在 n8n Code 节点里的 JavaScript 中,任何做应用的人在那边都看不见——而如果滑块范围哪天变了,没有任何东西会告诉我。
2. **加一个真正做一次合成请求的健康检查端点。** 我目前能查的只有容器起没起来。token 可以存在于文件里但*依然*是过期的(如果某次刷新静默失败了),而在用户撞上之前,没有便宜的办法发现这一点。
3. **把钉死的版本记录在一个我真的会看的地方。** `1.123.72` 在一个 markdown 文件里躺了一年。运维手册里加一行「复查钉死的镜像」,就能让它在下一个维护窗口浮现,而不是通过一次迁移。
## 结果
一年可用性、两个容器、一个共享目录,以及八种阅读应用可以用滑块选择的神经语音。请求含服务商往返在内几秒内完成,而整个东西除免费层之外零成本——因为 token 生命周期问题被一次性解决在了正确的地方。
这个模式可以泛化到任何短期凭据:**别教会每一个调用方去刷新 token——跑一个唯一职责就是让某个文件保持最新的进程,让其他所有人读这个文件。** 它不聪明,而这就是重点。聪明的凭据处理,正是你最后会得到四份刷新实现、其中三份是错的的原因。
---
## 需要在现有应用里接上 AI 功能?
我搭建 AI 集成里那些不性感的中层——也就是演示之后还能继续工作的那部分:token 刷新、服务商故障转移、语音与模型映射、限流处理。如果你想把语音合成、转写或 LLM 功能加进一个现有应用,并且希望它明年还能用,那就是我在做的事。
欢迎联系 [[email protected]](mailto:[email protected]?subject=TTS%20%E9%9B%86%E6%88%90)
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
[hoelee.com](https://hoelee.com) 做什么。
@@ -0,0 +1,181 @@
---
title: "数字商品的自愈式访问控制:NocoDB、n8n 与 AList"
description: "我如何用五个工作流搭出一套权限系统,自动授予、到期失效并持续修复客户的文件访问——包括一个构建期代码共享技巧,以及一个设计上刻意不泄露任何信息的公开接口。"
pubDate: 2026-09-18
category: case-studies
tags: [n8n, nocodb, alist, access-control, docker, automation, digital-goods]
ogImage: /og/self-healing-digital-goods-entitlements.png
banner: /banners/self-healing-digital-goods-entitlements.png
---
我卖数字产品——文件、美术素材、授权资源——并通过一个自建的文件门户交付。真正的难题从来不是存储,而是**授权**:确保客户在购买时拿到恰好他们买下的目录,拿到恰好他们付费的时长,并且在停止付费时访问权限真的被收回。
手工做这件事,前十个客户没问题。一百个就不行了,而且失败的方式很具体、也很讨厌:静默失败。订阅到期而客户继续下载时,不会有任何报错。你只是一直在给一个几个月前就停止付费的人供文件。
所以我把它做成五个 n8n 工作流,把访问权限当作**派生状态**来处理——从我的业务数据库计算得出,应用到文件服务器上,并持续重新校验。下面是它的架构、让它可维护的代码共享技巧,以及那个公开接口背后的安全推理。
## 为什么这件事重要
对一个数字商品生意来说,访问控制*就是*产品本身。你保护的不是一座仓库,你保护的是你卖掉的那个东西。两种失败模式会真金白银地亏钱:
- **授予不足**——付费客户拿不到他买的东西,而你是从愤怒的消息里而不是监控告警里知道的。
- **授予过度**——已到期或已撤销的客户继续有访问权,而泄露会一直不可见,直到有人转卖你的目录。
手工管理最终会必然地导致这两种。解法是不要再把一次授权当成*你做的一件事*,而把它当成*你数据的一个函数*:给定客户当前的购买记录,他现在应该能访问什么?算出来、应用它,然后按计划证明它仍然成立。
## 系统的形状
五个工作流,每个只做一件事:
| 工作流 | 触发器 | 职责 |
|---|---|---|
| **W1** — Customer Provision & Status Lifecycle | NocoDB webhook(客户行) | 创建/更新文件服务器用户;状态变更时启用或禁用 |
| **W2** — CustomerProduct Sync | NocoDB webhook(购买行) | 重新计算并应用该客户被允许的路径 |
| **W3** — Expiry Sync | Cron,每 5 分钟 | 扫描即将到期的授权;移除已失效的路径 |
| **W4** — Daily Full Reconciliation | Cron 03:00 + 手动 webhook | 对比全体客户的*期望*与*实际*;修复漂移;记录日志 |
| **W5** — Footer Purchase Check | 公开 GET webhook | 让门户展示客户自己的购买记录;只读 |
数据存在 **NocoDB**(一个自建的 Airtable 类数据库)里,包括 Customers、CustomerProducts、Products、Resources 和 AccessGrants 几张表。文件服务器是 **AList**,它为每个客户提供一个用户和一个角色,角色的 `permission_scopes` 就是一串路径。
至关重要的设计决策:**NocoDB 是业务真相来源,AList 只是执行状态。** 同步是单向的。在 AList 管理后台手动改一笔不算配置变更——那叫漂移,W4 会把它修复回去。
## 算法:这个客户*应该*有什么?
一切都系于一个函数。客户的期望路径是两个来源的并集:
1. 所有可从**有效的、未过期**购买记录到达的资源。
2. 所有通过**有效的、未过期**手工授权直接授予的资源。
有意思的情况是:同一个资源可以通过*两个不同*产品到达。如果客户买了产品 A(30 天后到期)和产品 B(200 天后到期),而两者都包含同一个目录,那么正确的到期时间是**较晚**的那一个——多买一样东西永远不该缩短你对它的访问权。
```js
// 对每个产品的资源,按路径保留最大的 expires_at。
for (const c of cps) {
if (c.status && c.status !== 'active') continue;
if (c.expires_at && String(c.expires_at) <= today) continue; // 已过期
const pid = linkId(c.product);
if (!pid) continue;
const res = (await ncGet(ctx,
`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];
const exp = c.expires_at;
for (const r of res) {
const p = resPath[r.Id];
if (!p) continue;
const cur = desired[p];
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };
}
}
}
```
注意边界:`<= today`。**当天到期就算已过期。** 到期检查上的一个差一错误,就是订阅期与白送一天之间的区别;如果你不把它写明确,你一定会写错,而且会错在对客户有利的那一边。
## 问题所在:n8n 的 Code 节点无法共享代码
这个约束塑造了整个代码库。n8n 的 Code 节点是自包含的:没有 `require`、没有 `import`、也没有访问共享模块的文件系统权限。所以最自然的结构——一个授权算法,被 W1、W2、W3、W4 调用——恰恰是这个平台不让你做的事。
把那个函数复制粘贴进四个节点,注定是一场维护灾难。四份到期规则的拷贝,就是四次它们互相不一致的机会,而一个用四种方式计算访问权限的系统,比没有系统更糟。
解法是让共享发生在**构建期**而不是运行期:
- `shared/effective-grants.js` 是唯一真相来源,而且它被写成自包含的:没有 `require`、没有 `process.exit`、函数外没有顶层 `return`。它通过一个 `ctx` 参数(`{ $env, helpers }`)接收配置,而不是去抓全局变量——这带来一个令人愉快的副作用:它**可以在 n8n 之外做单元测试**。
- `gen_w1.js``gen_w2.js``gen_w3.js``gen_w4.js` 读取这个文件,把它内联进工作流的 Code 节点主体,并更新工作流。
结果是:一个算法、四个工作流、零运行期依赖——而且这个函数可以在碰到任何线上系统之前,用纯 Node 测试。代码在*产物*里重复,但在*源码*里从不重复,这和打包器做的是同一笔交易。
一个 n8n 特有的细节值得知道:**自定义环境变量只有在 Code 节点里通过 `$env` 才可靠可读——`process.env` 在那里不可靠。** 这就是为什么每个工作流都有一个显式的「Load Env」节点,把它需要的值提升到 item 上,而不是哪里方便就在哪里读配置。
## 让到期处理可以安全自动化
W3 每五分钟跑一次,对即将到期的授权做对账。有两个细节让一个 cron 任务可以安全地改动线上权限:
**只有当访问权限真的用完时才禁用。** 一个天真的「如果没有期望路径就禁用用户」规则是危险的——它会乐于禁用一位只是还没被授予任何东西的新客户。守卫条件是显式的:
```js
// 只在「曾经有权限、现在一个都没有」的客户身上禁用。
// currentPaths.length > 0 避免干掉一个全新的、尚未授权的客户。
const shouldDisable = AUTO_DISABLE && nowEmpty
&& cust.status === 'active' && currentPaths.length > 0;
```
**它有 dry-run 模式。** 容器上的 `W3_DRY_RUN=true`(或 W4 手动 webhook 上的查询参数)会让扫描计算并报告它的计划,同时**执行零写入**。能在让一个计划任务动手之前先问「你会做什么?」,是我在任何自动化里加过的最有用的安全功能。
```js
const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';
```
## 每日修复:假定你一定会漂移
W4 是那个我会说才是真正产品的工作流。它在 03:00 运行,遍历每个客户,对比期望状态与实际状态——然后修复差异并**验证修复**。
它处理的漂移矩阵:
- **应当是有效的** → 用户必须存在(**先用用户名查找**,以避免在存储的 ID 丢失时创建重复用户)、处于启用状态,并且恰好带有计算出的角色权限范围。
- **应当是无效的** → 禁用,并清空权限范围。待处理客户完全不建用户——不自动创建。
- **悬空引用** → 存储的用户或角色 ID 指向一个已不存在的记录。按名字查找,找到就收编,找不到就重建。
- **用户名漂移** → 只检测并*记录日志*,绝不做破坏性迁移。
每一次修复之后都会重新读取文件服务器状态来验证。失败验证会被记为 `verify_failed` 而不是假定成功,而对账日志只接收**漂移、修复和错误三类记录**——健康客户不产生任何行。最后这个选择正是让日志可用的原因:如果它是空的,一切正常,你不必读着一千行「无变化」去找那一条重要的。
## 那个公开接口,以及为什么没有 HMAC
W5 让文件门户的页脚能展示已登录客户自己的购买记录和到期日期。它是一个**公开** webhook,而它的安全推理是我最刻意对待的部分。
直觉是用 HMAC 给请求签名。我没这么做,理由值得直说:**密钥必须发到浏览器,所以签名只是表演。** 一个每个客户端都持有的共享密钥什么都保护不了——它只增加了一层仪式,让这个接口*看起来*经过验证。
所以这个接口依赖的是真正成立的东西:
- **CORS 锁定单一来源。** 响应带有
`Access-Control-Allow-Origin: https://drive.example.com`,所以只有门户自己的页面能在浏览器里读取响应。
- **数据最小化是设计出来的。** 响应只返回产品名、到期日期、展示状态和公开目录路径。没有内部数据库 ID、没有客户个人信息、没有任何其他客户的信息。
- **边缘限流**,通过请求路径上的一条 WAF 规则,削弱用户名枚举。
还有一个细微的架构选择:**用户名来自调用者自己的会话 token,在客户端解码**,而不是来自一个客户端可以自由设置的参数。这个接口从不向文件服务器认证,从而避开了一整类连接状态与设备注册副作用——否则每次页脚渲染都会引入这些副作用。
诚实的说法是:这个接口不是信任边界,我也不假装它是。它向客户展示的只是他们本来就知道的关于自己的信息,走的是一个对任何其他人无用的响应形状。
## 教会我最多的那个 bug:公网路径与级联故障
W5 最初是通过 NocoDB 的**公网**主机名——穿过一个 Cloudflare 隧道——去取数据的。测试中它工作良好。在真实负载下,它产生了这样一条链:
1. 公网往返延迟在负载下超过 60 秒。
2. nginx 上游超时触发 → **504**
3. 页脚客户端 fetch 的 8 秒超时**每秒重试一次**。
4. 重试堆积了连接。
5. 这些连接耗尽连接池 → 无关请求收到 **503**
一个慢依赖变成了一次不同服务的级联故障。修法是停止跨过整个互联网去访问同一个 Docker 网络上的东西:
```js
// n8n 与 NocoDB 同处 bridge_hoelee 网络;NocoDB 监听 :10380。
// 走公网路由会引入 CF 隧道抖动,且可能超过代理超时。
const NOCODB_URL = 'http://nocodb:10380';
```
**内部约 30 ms,公网 300 ms 以上,且没有隧道抖动**——整条故障链消失了,因为触发条件(数秒级延迟)已不可能出现。
这个教训可以很好地泛化到这个技术栈之外:**当一个服务和它的依赖在同一个容器网络里时,用公网主机名就是一个等着负载来触发的 bug。** 而当你看到一个 503 出现在一个 504 的下游时,去找那个激进重试的客户端——重试循环通常才是放大器,而不是原始问题。
## 我会怎么做得不一样
1. **先做漂移修复,而不是最后做。** 我先写授权路径,然后到期扫描,最后才对账。回头看,对账才是让另外两个可以安全运行的东西,它应该从第一天就存在——因为「假定你一定会漂移」是一种设计立场,不是一个功能。
2. **在任何工作流之前先写期望状态函数。** 它能待在 `shared/` 里、能在 n8n *之外*做单元测试,正是整个系统在四个工作流之间保持连贯的原因。如果我一开始就把逻辑粘贴进节点,我就会发布四份微妙的、彼此不同的到期规则。
3. **永远不要在同一台主机的两个容器之间走公网。** 这条让我真的经历了一次故障,而它现在是我默认应用的规则,而不是每个集成都要重新发现一次。
4. **从第一天就把 dry-run 开关放进去。** 事后加 `DRY_RUN` 很容易;而在没有它的情况下运行一个会改动权限的计划任务,是几周完全不必要的提心吊胆。
## 结果
五个工作流跑完整个授权生命周期:数据库里的一笔购买在几秒内变成可用访问,到期每五分钟扫一次,每夜的完整对账修复任何漂移并验证每次修复。一切健康时对账日志是空的——而大多数日子里,它说的就是这件事。
值得带走的设计原则,是让这件事变得可控的那一条:**别再「管理」访问权限,开始「断言」它。** 把客户应该拥有什么定义成业务数据的纯函数,在数据变化时应用这个函数,并按计划重新断言它以捕获其他所有情况。这样系统就不需要你小心翼翼——它只需要你在一个函数里,一次性地正确。
---
## 想让你的生意也用上这套?
如果你卖数字产品,还在手工授予文件访问权限——或者你不确定上个月到期的客户是否真的已经失去访问权——我搭的正是这套东西:自建授权系统,访问权限从你的数据计算得出、自动到期、每夜自我修复。我熟悉 NocoDB、n8n、AList 和 Docker,并且会把文档一并交付,让你不用依赖我也能运维。
欢迎联系 [[email protected]](mailto:[email protected]?subject=%E6%95%B0%E5%AD%97%E5%95%86%E5%93%81%E8%AE%BF%E9%97%AE%E6%8E%A7%E5%88%B6)
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
[hoelee.com](https://hoelee.com) 做什么。