diff --git a/README.md b/README.md index 11e7469..eff3864 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,10 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` / | `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. | | `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. | | `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. | +| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. | | `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. | | `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. | -| `JFLOW_ROLE` | `all` | `all` (API + cron producer + worker), `api` (HTTP/admin/cron enqueue only), or `worker` (consume queue only). | +| `JFLOW_ROLE` | `all` | Which duties this process performs: `all` (HTTP/admin + cron producer + worker), `api` (HTTP/admin + cron enqueue only), or `worker` (consume queue only). Use separate processes in production when you want to scale workers independently. | | `JFLOW_LOG_LEVEL` | `debug` | Pino level | | `JFLOW_RETENTION_DAYS` | `30` | Run history prune | | `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev | @@ -81,8 +82,8 @@ Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { ```bash pnpm install pnpm build -# Redis must be reachable at REDIS_URL -JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 NODE_ENV=production pnpm start +# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH) +JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start ``` The server serves `packages/web/dist` when that folder exists. diff --git a/packages/server/runner.js b/packages/server/runner.js index b2bd3ca..8394fe2 100644 --- a/packages/server/runner.js +++ b/packages/server/runner.js @@ -26,7 +26,7 @@ import { closeRedis, createWorkflowQueue, createWorkflowWorker, - getRedisUrl, + getRedisUrlForLog, } from "./workflow-queue.js"; await migrate(); @@ -52,13 +52,13 @@ const role = (process.env.JFLOW_ROLE || "all").toLowerCase(); const runApi = role === "all" || role === "api"; const runWorker = role === "all" || role === "worker"; -log.info({ redis: getRedisUrl(), role }, "starting jerapah-flow"); +log.info({ redis: getRedisUrlForLog(), role }, "starting jerapah-flow"); const workflowQueue = createWorkflowQueue(); try { await workflowQueue.waitUntilReady(); } catch (err) { - log.error({ err, redis: getRedisUrl() }, "failed to connect to Redis"); + log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis"); process.exit(1); } diff --git a/packages/server/workflow-queue.js b/packages/server/workflow-queue.js index d969d62..47d371b 100644 --- a/packages/server/workflow-queue.js +++ b/packages/server/workflow-queue.js @@ -13,6 +13,28 @@ export function getRedisUrl() { return process.env.REDIS_URL || DEFAULT_REDIS_URL; } +/** + * Optional Redis AUTH password. Applied even when REDIS_URL has no embedded credentials. + * @returns {string | undefined} + */ +export function getRedisPassword() { + const pass = process.env.REDIS_PASS; + if (typeof pass !== "string" || pass.length === 0) return undefined; + return pass; +} + +/** Redact credentials for logs. */ +export function getRedisUrlForLog() { + try { + const url = new URL(getRedisUrl()); + if (url.password || getRedisPassword()) url.password = "***"; + if (url.username) url.username = url.username ? "***" : ""; + return url.toString(); + } catch { + return getRedisUrl(); + } +} + export function getQueueName() { return process.env.JFLOW_QUEUE_NAME || DEFAULT_QUEUE_NAME; } @@ -29,10 +51,15 @@ export function getWorkerConcurrency() { */ export function getSharedConnection() { if (sharedConnection) return sharedConnection; - sharedConnection = new IORedis(getRedisUrl(), { + /** @type {import("ioredis").RedisOptions} */ + const options = { maxRetriesPerRequest: null, enableReadyCheck: true, - }); + }; + const password = getRedisPassword(); + if (password) options.password = password; + + sharedConnection = new IORedis(getRedisUrl(), options); sharedConnection.on("error", (err) => { log.error({ err }, "redis connection error"); });