posts: n8n v1→v2 migration, self-healing entitlements, TTS sidecars
Deploy / build (push) Successful in 19s
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:
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 |
@@ -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 (W1–W5)',
|
||||
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 5–50 → -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';
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"> </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"> </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 W1–W5 → alist role scopes</span></div>
|
||||
<div class="line"><span class="prompt"> </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"> </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 5–50 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 MiB,5000 → 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) 做什么。
|
||||
Reference in New Issue
Block a user